@morojs/moro 1.8.8 → 1.8.10
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/dist/core/cluster/thread-cluster.d.ts +178 -0
- package/dist/core/cluster/thread-cluster.js +389 -0
- package/dist/core/cluster/thread-cluster.js.map +1 -0
- package/dist/core/framework.js.map +1 -1
- package/dist/core/http/moro-engine-server.d.ts +29 -1
- package/dist/core/http/moro-engine-server.js +375 -68
- package/dist/core/http/moro-engine-server.js.map +1 -1
- package/dist/core/http/utils/compression.js +12 -6
- package/dist/core/http/utils/compression.js.map +1 -1
- package/dist/core/http/utils/uws-worker-clustering.js +10 -6
- package/dist/core/http/utils/uws-worker-clustering.js.map +1 -1
- package/dist/core/http/uws-http-server.d.ts +1 -0
- package/dist/core/http/uws-http-server.js +105 -22
- package/dist/core/http/uws-http-server.js.map +1 -1
- package/dist/core/jobs/leader-election.js +16 -5
- package/dist/core/jobs/leader-election.js.map +1 -1
- package/dist/core/networking/websocket-manager.js +4 -2
- package/dist/core/networking/websocket-manager.js.map +1 -1
- package/dist/core/routing/unified-router.js +60 -29
- package/dist/core/routing/unified-router.js.map +1 -1
- package/dist/core/runtime/node-adapter.d.ts +1 -1
- package/dist/core/runtime/node-adapter.js.map +1 -1
- package/dist/core/utilities/builtin.d.ts +1 -0
- package/dist/core/utilities/builtin.js +25 -0
- package/dist/core/utilities/builtin.js.map +1 -0
- package/dist/core/utilities/package-utils.d.ts +28 -3
- package/dist/core/utilities/package-utils.js +24 -12
- package/dist/core/utilities/package-utils.js.map +1 -1
- package/dist/core/workers/worker-manager.js +4 -3
- package/dist/core/workers/worker-manager.js.map +1 -1
- package/dist/moro.d.ts +5 -0
- package/dist/moro.js +364 -204
- package/dist/moro.js.map +1 -1
- package/dist/types/http.d.ts +1 -1
- package/dist/types/http.js.map +1 -1
- package/package.json +1 -1
package/dist/moro.js
CHANGED
|
@@ -9,11 +9,16 @@ import { UnifiedRouter, } from './core/routing/unified-router.js';
|
|
|
9
9
|
import { PathMatcher } from './core/routing/path-matcher.js';
|
|
10
10
|
import { AppDocumentationManager } from './core/docs/index.js';
|
|
11
11
|
import { EventEmitter } from 'events';
|
|
12
|
-
import
|
|
13
|
-
|
|
12
|
+
import { requireBuiltin } from './core/utilities/builtin.js';
|
|
13
|
+
// node:cluster (it drags in child_process, dgram and net) is loaded only when
|
|
14
|
+
// process clustering actually runs; worker_threads only on the clustering
|
|
15
|
+
// path. See core/utilities/builtin.ts.
|
|
16
|
+
const nodeCluster = () => requireBuiltin('cluster');
|
|
17
|
+
const wt = () => requireBuiltin('worker_threads');
|
|
18
|
+
import { ThreadClusterPrimary, ThreadClusterWorker, computeWorkerCount, isUserTunedYoungGen, resolveClusterTransport, threadWorkerInfo, YOUNG_GEN_MB_FOR_SEMI_SPACE_4, } from './core/cluster/thread-cluster.js';
|
|
14
19
|
import { normalizeValidationError } from './core/validation/schema-interface.js';
|
|
15
20
|
import { buildModuleBasePath } from './core/utilities/module-path.js';
|
|
16
|
-
import { filePathToImportURL } from './core/utilities/package-utils.js';
|
|
21
|
+
import { filePathToImportURL, loadNativeEngine } from './core/utilities/package-utils.js';
|
|
17
22
|
// Configuration System Integration
|
|
18
23
|
import { initializeConfig, initializeConfigAsync } from './core/config/index.js';
|
|
19
24
|
// Runtime System Integration
|
|
@@ -778,7 +783,8 @@ export class Moro extends EventEmitter {
|
|
|
778
783
|
}
|
|
779
784
|
// Check if clustering is enabled for massive performance gains
|
|
780
785
|
if (this.config.performance?.clustering?.enabled) {
|
|
781
|
-
|
|
786
|
+
// Worker threads (engine backend, POSIX, engine >= 1.1.6) or node:cluster
|
|
787
|
+
// processes - startWithClustering picks and logs the transport.
|
|
782
788
|
this.startWithClustering(port, host, callback);
|
|
783
789
|
return;
|
|
784
790
|
}
|
|
@@ -1652,150 +1658,275 @@ export class Moro extends EventEmitter {
|
|
|
1652
1658
|
* - BetterStack Node.js Clustering: https://betterstack.com/community/guides/scaling-nodejs/node-clustering/
|
|
1653
1659
|
*/
|
|
1654
1660
|
clusterWorkers = new Map();
|
|
1661
|
+
threadCluster = undefined;
|
|
1662
|
+
// Entry point for performance.clustering: decides whether THIS instance is
|
|
1663
|
+
// a worker (thread or process) or the primary, and which transport the
|
|
1664
|
+
// primary uses. Config keys are unchanged; the transport is automatic.
|
|
1655
1665
|
startWithClustering(port, host, callback) {
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1666
|
+
const { count: workerCount, detail } = computeWorkerCount(this.config.performance?.clustering);
|
|
1667
|
+
const tinfo = threadWorkerInfo();
|
|
1668
|
+
if (tinfo) {
|
|
1669
|
+
this.startClusterWorker({ kind: 'thread', info: tinfo }, port, host);
|
|
1670
|
+
return;
|
|
1671
|
+
}
|
|
1672
|
+
if (nodeCluster().isWorker) {
|
|
1673
|
+
this.startClusterWorker({ kind: 'process' }, port, host);
|
|
1674
|
+
return;
|
|
1675
|
+
}
|
|
1676
|
+
this.logger.info(detail, 'Cluster');
|
|
1677
|
+
const engineKind = this.engine;
|
|
1678
|
+
const transport = resolveClusterTransport({
|
|
1679
|
+
platform: process.platform,
|
|
1680
|
+
isMainThread: wt().isMainThread,
|
|
1681
|
+
argv1: process.argv[1],
|
|
1682
|
+
engineServer: engineKind.server,
|
|
1683
|
+
enginePackage: engineKind.enginePackage,
|
|
1684
|
+
workerThreadsCapable: loadNativeEngine()?.capabilities?.workerThreads === true,
|
|
1685
|
+
});
|
|
1686
|
+
if (transport.kind === 'threads') {
|
|
1687
|
+
this.startThreadPrimary(transport.entry, workerCount, port, host, callback);
|
|
1688
|
+
return;
|
|
1689
|
+
}
|
|
1690
|
+
this.logger.info(`Clustering: ${workerCount} worker processes (node:cluster; ${transport.reason})`, 'Cluster');
|
|
1691
|
+
this.startProcessPrimary(workerCount, port, host, callback);
|
|
1692
|
+
}
|
|
1693
|
+
// Services that must run exactly ONCE per cluster, in the primary: the job
|
|
1694
|
+
// scheduler (leader election already treats the main thread of a
|
|
1695
|
+
// non-cluster-worker process as a participant) and the gRPC server (its own
|
|
1696
|
+
// port). GraphQL registers HTTP/WS routes, so it initialises in every
|
|
1697
|
+
// worker instead. Before 1.9.0 none of these started under clustering.
|
|
1698
|
+
startPrimaryServices() {
|
|
1699
|
+
this.startJobScheduler().catch(err => {
|
|
1700
|
+
this.logger.error(`Failed to start job scheduler: ${String(err)}`);
|
|
1701
|
+
});
|
|
1702
|
+
if (this.grpcManager && !this.grpcStarted) {
|
|
1703
|
+
this.startGrpc().catch(error => {
|
|
1704
|
+
this.logger.error(`Failed to start gRPC server: ${error}`, 'GRPC');
|
|
1705
|
+
});
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
startThreadPrimary(entry, workerCount, port, host, callback) {
|
|
1709
|
+
// The libuv threadpool is process-wide and sized once at first use;
|
|
1710
|
+
// the process path set this per worker process. Only if the operator
|
|
1711
|
+
// did not choose a size.
|
|
1712
|
+
if (!process.env.UV_THREADPOOL_SIZE)
|
|
1713
|
+
process.env.UV_THREADPOOL_SIZE = '64';
|
|
1714
|
+
// Same young-generation bound the process path applies through execArgv
|
|
1715
|
+
// (see YOUNG_GEN_MB_FOR_SEMI_SPACE_4), unless the operator tuned GC.
|
|
1716
|
+
const resourceLimits = isUserTunedYoungGen()
|
|
1717
|
+
? undefined
|
|
1718
|
+
: { maxYoungGenerationSizeMb: YOUNG_GEN_MB_FOR_SEMI_SPACE_4 };
|
|
1719
|
+
const engineKind = this.engine;
|
|
1720
|
+
this.logger.info(`Clustering: ${workerCount} worker threads (worker_threads + SO_REUSEPORT, ` +
|
|
1721
|
+
`${engineKind.enginePackage}${engineKind.engineVersion ? ` v${engineKind.engineVersion}` : ''})`, 'Cluster');
|
|
1722
|
+
let fellBack = false;
|
|
1723
|
+
const fallBackToProcesses = (why) => {
|
|
1724
|
+
if (fellBack)
|
|
1725
|
+
return;
|
|
1726
|
+
fellBack = true;
|
|
1727
|
+
this.logger.warn(`Worker threads unavailable (${why}); falling back to worker processes`, 'Cluster');
|
|
1728
|
+
const primary = this.threadCluster;
|
|
1729
|
+
this.threadCluster = undefined;
|
|
1730
|
+
void primary?.shutdown(1000).finally(() => {
|
|
1731
|
+
this.startProcessPrimary(workerCount, port, host, callback);
|
|
1705
1732
|
});
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
process.
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1733
|
+
};
|
|
1734
|
+
const primary = new ThreadClusterPrimary({
|
|
1735
|
+
entry,
|
|
1736
|
+
argv: process.argv.slice(2),
|
|
1737
|
+
workerCount,
|
|
1738
|
+
logger: this.logger,
|
|
1739
|
+
...(resourceLimits ? { resourceLimits } : {}),
|
|
1740
|
+
onReady: () => {
|
|
1741
|
+
this.startPrimaryServices();
|
|
1742
|
+
if (callback)
|
|
1743
|
+
callback();
|
|
1744
|
+
},
|
|
1745
|
+
onFatal: (error, kind) => {
|
|
1746
|
+
if (kind === 'bind') {
|
|
1747
|
+
// Processes would fail identically: surface it like the single-process
|
|
1748
|
+
// path does (Node's server convention; rethrows without a listener).
|
|
1749
|
+
this.emit('error', error);
|
|
1750
|
+
return;
|
|
1719
1751
|
}
|
|
1720
|
-
|
|
1721
|
-
|
|
1752
|
+
fallBackToProcesses(error.message);
|
|
1753
|
+
},
|
|
1754
|
+
});
|
|
1755
|
+
this.threadCluster = primary;
|
|
1756
|
+
try {
|
|
1757
|
+
primary.start();
|
|
1758
|
+
}
|
|
1759
|
+
catch (error) {
|
|
1760
|
+
fallBackToProcesses(error instanceof Error ? error.message : String(error));
|
|
1761
|
+
return;
|
|
1762
|
+
}
|
|
1763
|
+
// Graceful shutdown: drain every worker thread (each closes its server),
|
|
1764
|
+
// then the primary's own services; a second signal exits at once.
|
|
1765
|
+
let shuttingDown = false;
|
|
1766
|
+
const gracefulShutdown = (code) => {
|
|
1767
|
+
if (shuttingDown) {
|
|
1768
|
+
process.exit(code);
|
|
1769
|
+
return;
|
|
1770
|
+
}
|
|
1771
|
+
shuttingDown = true;
|
|
1772
|
+
this.logger.info('Gracefully shutting down cluster...', 'Cluster');
|
|
1773
|
+
this.close()
|
|
1774
|
+
.then(() => process.exit(code))
|
|
1775
|
+
.catch(() => process.exit(1));
|
|
1776
|
+
};
|
|
1777
|
+
process.on('SIGINT', () => gracefulShutdown(0));
|
|
1778
|
+
process.on('SIGTERM', () => gracefulShutdown(0));
|
|
1779
|
+
}
|
|
1780
|
+
startProcessPrimary(workerCount, port, host, callback) {
|
|
1781
|
+
void port;
|
|
1782
|
+
void host;
|
|
1783
|
+
this.logger.info(`Starting ${workerCount} workers`, 'Cluster');
|
|
1784
|
+
// Optimize cluster scheduling for high concurrency
|
|
1785
|
+
// Round-robin is the default on all platforms except Windows (Node.js docs)
|
|
1786
|
+
// Provides better load distribution than shared socket approach
|
|
1787
|
+
nodeCluster().schedulingPolicy = nodeCluster().SCHED_RR;
|
|
1788
|
+
// Workers inherit the primary's execArgv; additionally bound each
|
|
1789
|
+
// worker's V8 young generation unless the user tuned it themselves.
|
|
1790
|
+
// On big machines V8 sizes the nursery from total RAM and grows it
|
|
1791
|
+
// under sustained load (~10-34 MB extra RSS per process, measured on a
|
|
1792
|
+
// 64 GB box) - and in cluster mode that cost multiplies by the worker
|
|
1793
|
+
// count. A/B-benched 2026-08-01: identical throughput at 4 MB semi-space
|
|
1794
|
+
// in both the realistic profile (~92k vs ~90k req/s) and the pipelined
|
|
1795
|
+
// x10 microbenchmark (~560k both ways), while sustained-load RSS drops
|
|
1796
|
+
// ~30 MB per worker (115 -> 81 MB on the 25-route POST soak).
|
|
1797
|
+
const userTunedGC = isUserTunedYoungGen();
|
|
1798
|
+
// Set cluster settings for better performance
|
|
1799
|
+
nodeCluster().setupMaster({
|
|
1800
|
+
exec: process.argv[1] || process.execPath,
|
|
1801
|
+
args: process.argv.slice(2),
|
|
1802
|
+
silent: false,
|
|
1803
|
+
...(userTunedGC ? {} : { execArgv: [...process.execArgv, '--max-semi-space-size=4'] }),
|
|
1804
|
+
});
|
|
1805
|
+
// IPC Optimization: Reduce communication overhead between master and workers
|
|
1806
|
+
// Research shows excessive IPC can create bottlenecks in clustered applications
|
|
1807
|
+
// (Source: BetterStack - Node.js Clustering Guide)
|
|
1808
|
+
process.env.NODE_CLUSTER_SCHED_POLICY = 'rr'; // Ensure round-robin
|
|
1809
|
+
process.env.NODE_DISABLE_COLORS = '1'; // Reduce IPC message size by disabling color codes
|
|
1810
|
+
// Graceful shutdown: SIGTERM every worker (each stops accepting, drains
|
|
1811
|
+
// its in-flight responses, then exits), wait for their exits up to a
|
|
1812
|
+
// bounded deadline, and only then exit. Exiting right after the kill
|
|
1813
|
+
// used to close the cluster IPC channel, and a node:cluster worker whose
|
|
1814
|
+
// primary disappears exits immediately - cutting the very responses the
|
|
1815
|
+
// worker was draining. A second signal while draining exits at once.
|
|
1816
|
+
let shuttingDown = false;
|
|
1817
|
+
const gracefulShutdown = () => {
|
|
1818
|
+
if (shuttingDown)
|
|
1819
|
+
process.exit(0);
|
|
1820
|
+
shuttingDown = true;
|
|
1821
|
+
this.logger.info('Gracefully shutting down cluster...', 'Cluster');
|
|
1822
|
+
if (this.clusterWorkers.size === 0) {
|
|
1823
|
+
nodeCluster().removeAllListeners();
|
|
1824
|
+
process.exit(0);
|
|
1825
|
+
}
|
|
1826
|
+
const pending = new Set(this.clusterWorkers.keys());
|
|
1827
|
+
const timer = setTimeout(() => {
|
|
1828
|
+
this.logger.warn(`Shutdown deadline reached; terminating ${pending.size} worker process(es)`, 'Cluster');
|
|
1829
|
+
for (const pid of pending)
|
|
1830
|
+
this.clusterWorkers.get(pid)?.kill('SIGKILL');
|
|
1831
|
+
finish();
|
|
1832
|
+
}, 3000);
|
|
1833
|
+
const finish = () => {
|
|
1834
|
+
clearTimeout(timer);
|
|
1835
|
+
nodeCluster().removeAllListeners();
|
|
1722
1836
|
process.exit(0);
|
|
1723
1837
|
};
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1838
|
+
for (const [pid, worker] of this.clusterWorkers) {
|
|
1839
|
+
worker.removeAllListeners(); // no restart on these exits
|
|
1840
|
+
worker.once('exit', () => {
|
|
1841
|
+
pending.delete(pid);
|
|
1842
|
+
if (pending.size === 0)
|
|
1843
|
+
finish();
|
|
1844
|
+
});
|
|
1845
|
+
worker.kill('SIGTERM');
|
|
1846
|
+
}
|
|
1847
|
+
};
|
|
1848
|
+
// Handle process signals for graceful shutdown
|
|
1849
|
+
process.on('SIGINT', gracefulShutdown);
|
|
1850
|
+
process.on('SIGTERM', gracefulShutdown);
|
|
1851
|
+
// Fork workers with basic tracking
|
|
1852
|
+
for (let i = 0; i < workerCount; i++) {
|
|
1853
|
+
const worker = nodeCluster().fork();
|
|
1854
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
1855
|
+
this.clusterWorkers.set(worker.process.pid, worker);
|
|
1856
|
+
this.logger.info(`Worker ${worker.process.pid} started`, 'Cluster');
|
|
1857
|
+
// Handle individual worker messages
|
|
1858
|
+
worker.on('message', this.handleWorkerMessage.bind(this));
|
|
1859
|
+
}
|
|
1860
|
+
// Primary-only services start once the first worker is serving.
|
|
1861
|
+
let servicesStarted = false;
|
|
1862
|
+
nodeCluster().on('listening', () => {
|
|
1863
|
+
if (servicesStarted)
|
|
1864
|
+
return;
|
|
1865
|
+
servicesStarted = true;
|
|
1866
|
+
this.startPrimaryServices();
|
|
1867
|
+
});
|
|
1868
|
+
// Simple worker exit handling
|
|
1869
|
+
nodeCluster().on('exit', (worker, code, signal) => {
|
|
1870
|
+
const pid = worker.process.pid;
|
|
1871
|
+
this.clusterWorkers.delete(pid);
|
|
1872
|
+
if (code !== 0 && !worker.exitedAfterDisconnect) {
|
|
1873
|
+
this.logger.warn(`Worker ${pid} died unexpectedly (${signal || code}). Restarting...`, 'Cluster');
|
|
1874
|
+
// Simple restart
|
|
1875
|
+
const newWorker = nodeCluster().fork();
|
|
1730
1876
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
1731
|
-
this.clusterWorkers.set(
|
|
1732
|
-
this.logger.info(`Worker ${
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1877
|
+
this.clusterWorkers.set(newWorker.process.pid, newWorker);
|
|
1878
|
+
this.logger.info(`Worker ${newWorker.process.pid} restarted`, 'Cluster');
|
|
1879
|
+
}
|
|
1880
|
+
});
|
|
1881
|
+
// Master process callback
|
|
1882
|
+
if (callback)
|
|
1883
|
+
callback();
|
|
1884
|
+
}
|
|
1885
|
+
// One worker, either a worker_threads thread or a node:cluster process:
|
|
1886
|
+
// boot the app exactly like the single-process path (auto-discovery,
|
|
1887
|
+
// file-based routes, WebSocket registrations, GraphQL) and listen with
|
|
1888
|
+
// SO_REUSEPORT. Signals/exit are the differences: a thread gets 'shutdown'
|
|
1889
|
+
// from the primary over parentPort and never calls process.exit().
|
|
1890
|
+
startClusterWorker(mode, port, host) {
|
|
1891
|
+
const label = mode.kind === 'thread' ? `Worker thread ${wt().threadId}` : `Worker ${process.pid}`;
|
|
1892
|
+
const workerRef = mode.kind === 'thread' ? wt().threadId : process.pid;
|
|
1893
|
+
this.logger.info(`${label} initializing`, 'Worker');
|
|
1894
|
+
if (mode.kind === 'process') {
|
|
1895
|
+
// Worker-specific optimizations for high concurrency (a thread shares
|
|
1896
|
+
// the primary's pool, sized before it was spawned)
|
|
1897
|
+
process.env.UV_THREADPOOL_SIZE = '64';
|
|
1898
|
+
}
|
|
1899
|
+
// Reduce logging contention in workers (major bottleneck)
|
|
1900
|
+
// Multiple workers writing to same log files creates I/O contention
|
|
1901
|
+
// ONLY reduce log level if user didn't explicitly set one
|
|
1902
|
+
if (!this.userSetLogger) {
|
|
1903
|
+
// Workers log less frequently to reduce I/O contention (only if not explicitly configured)
|
|
1904
|
+
applyLoggingConfiguration(undefined, { level: 'warn' }); // Only warnings and errors
|
|
1905
|
+
}
|
|
1906
|
+
// Optimize garbage collection for workers
|
|
1907
|
+
// eslint-disable-next-line no-undef
|
|
1908
|
+
if (global.gc) {
|
|
1909
|
+
setInterval(() => {
|
|
1910
|
+
// eslint-disable-next-line no-undef
|
|
1911
|
+
if (global.gc)
|
|
1912
|
+
global.gc();
|
|
1913
|
+
}, 60000); // GC every 60 seconds (less frequent)
|
|
1914
|
+
}
|
|
1915
|
+
let tcw = null;
|
|
1916
|
+
if (mode.kind === 'thread') {
|
|
1917
|
+
tcw = new ThreadClusterWorker(this.logger);
|
|
1918
|
+
// 'shutdown' from the primary: drain and close; the thread then exits
|
|
1919
|
+
// on its own once the engine's handles are gone.
|
|
1920
|
+
tcw.onShutdown(async () => {
|
|
1921
|
+
this.eventBus.removeAllListeners();
|
|
1922
|
+
this.removeAllListeners();
|
|
1923
|
+
await this.close();
|
|
1748
1924
|
});
|
|
1749
|
-
// Master process callback
|
|
1750
|
-
if (callback)
|
|
1751
|
-
callback();
|
|
1752
1925
|
}
|
|
1753
1926
|
else {
|
|
1754
|
-
// Worker process - start the actual server with proper cleanup
|
|
1755
|
-
this.logger.info(`Worker ${process.pid} initializing`, 'Worker');
|
|
1756
|
-
// Worker-specific optimizations for high concurrency
|
|
1757
|
-
process.env.UV_THREADPOOL_SIZE = '64';
|
|
1758
|
-
// Reduce logging contention in workers (major bottleneck)
|
|
1759
|
-
// Multiple workers writing to same log files creates I/O contention
|
|
1760
|
-
// ONLY reduce log level if user didn't explicitly set one
|
|
1761
|
-
if (!this.userSetLogger) {
|
|
1762
|
-
// Workers log less frequently to reduce I/O contention (only if not explicitly configured)
|
|
1763
|
-
applyLoggingConfiguration(undefined, { level: 'warn' }); // Only warnings and errors
|
|
1764
|
-
}
|
|
1765
|
-
// Research-based memory optimization for workers
|
|
1766
|
-
const totalMemoryGB = os.totalmem() / (1024 * 1024 * 1024);
|
|
1767
|
-
const workerCount = Object.keys(cluster.workers || {}).length || 1;
|
|
1768
|
-
// Conservative memory allocation
|
|
1769
|
-
const heapSizePerWorkerMB = Math.min(Math.floor(((totalMemoryGB * 1024) / workerCount) * 0.8), // 80% of available memory
|
|
1770
|
-
1536 // Cap at 1.5GB (GC efficiency threshold from research)
|
|
1771
|
-
);
|
|
1772
|
-
process.env.NODE_OPTIONS = `--max-old-space-size=${heapSizePerWorkerMB}`;
|
|
1773
|
-
this.logger.debug(`Worker memory allocated: ${heapSizePerWorkerMB}MB heap (${workerCount} workers, ${totalMemoryGB.toFixed(1)}GB total)`, 'Worker');
|
|
1774
|
-
// Optimize V8 flags for better performance
|
|
1775
|
-
if (process.env.NODE_ENV === 'production') {
|
|
1776
|
-
// Aggressive V8 optimizations for maximum performance
|
|
1777
|
-
const v8Flags = [
|
|
1778
|
-
'--optimize-for-size', // Trade memory for speed
|
|
1779
|
-
'--always-opt', // Always optimize functions
|
|
1780
|
-
'--turbo-fast-api-calls', // Optimize API calls
|
|
1781
|
-
'--turbo-escape-analysis', // Escape analysis optimization
|
|
1782
|
-
'--turbo-inline-api-calls', // Inline API calls
|
|
1783
|
-
`--max-old-space-size=${heapSizePerWorkerMB}`, // Limit memory to prevent GC pressure
|
|
1784
|
-
];
|
|
1785
|
-
process.env.NODE_OPTIONS = (process.env.NODE_OPTIONS || '') + ' ' + v8Flags.join(' ');
|
|
1786
|
-
}
|
|
1787
|
-
// Optimize garbage collection for workers
|
|
1788
|
-
// eslint-disable-next-line no-undef
|
|
1789
|
-
if (global.gc) {
|
|
1790
|
-
setInterval(() => {
|
|
1791
|
-
// eslint-disable-next-line no-undef
|
|
1792
|
-
if (global.gc)
|
|
1793
|
-
global.gc();
|
|
1794
|
-
}, 60000); // GC every 60 seconds (less frequent)
|
|
1795
|
-
}
|
|
1796
1927
|
// Graceful shutdown for worker
|
|
1797
1928
|
const workerShutdown = () => {
|
|
1798
|
-
this.logger.info(
|
|
1929
|
+
this.logger.info(`${label} shutting down gracefully...`, 'Worker');
|
|
1799
1930
|
// Clean up event listeners
|
|
1800
1931
|
this.eventBus.removeAllListeners();
|
|
1801
1932
|
this.removeAllListeners();
|
|
@@ -1812,79 +1943,96 @@ export class Moro extends EventEmitter {
|
|
|
1812
1943
|
// Handle worker shutdown signals
|
|
1813
1944
|
process.on('SIGTERM', workerShutdown);
|
|
1814
1945
|
process.on('SIGINT', workerShutdown);
|
|
1815
|
-
|
|
1816
|
-
|
|
1946
|
+
}
|
|
1947
|
+
const fatal = (code, message) => {
|
|
1948
|
+
this.logger.error(`${label} ${message}`, 'Worker');
|
|
1949
|
+
if (tcw) {
|
|
1950
|
+
tcw.fatal(code, message);
|
|
1951
|
+
void this.close().finally(() => tcw?.close());
|
|
1952
|
+
}
|
|
1953
|
+
else {
|
|
1954
|
+
process.exit(1);
|
|
1955
|
+
}
|
|
1956
|
+
};
|
|
1957
|
+
// Continue with normal server startup for this worker
|
|
1958
|
+
void this.eventBus.emit('server:starting', {
|
|
1959
|
+
port,
|
|
1960
|
+
runtime: this.runtimeType,
|
|
1961
|
+
worker: workerRef,
|
|
1962
|
+
});
|
|
1963
|
+
// Add documentation middleware first (if enabled)
|
|
1964
|
+
try {
|
|
1965
|
+
const docsMiddleware = this.documentation.getDocsMiddleware();
|
|
1966
|
+
this.coreFramework.addMiddleware(docsMiddleware);
|
|
1967
|
+
}
|
|
1968
|
+
catch {
|
|
1969
|
+
// Documentation not enabled, that's fine
|
|
1970
|
+
}
|
|
1971
|
+
// Attach the unified router (handles both chainable and direct routes)
|
|
1972
|
+
this.attachUnifiedRouter();
|
|
1973
|
+
// Register legacy direct routes with the HTTP server (for backward compatibility)
|
|
1974
|
+
if (this.routes.length > 0) {
|
|
1975
|
+
this.registerDirectRoutes();
|
|
1976
|
+
}
|
|
1977
|
+
const workerCallback = () => {
|
|
1978
|
+
const displayHost = host || 'localhost';
|
|
1979
|
+
this.logger.info(`${label} ready on ${displayHost}:${port}`, 'Worker');
|
|
1980
|
+
void this.eventBus.emit('server:started', {
|
|
1817
1981
|
port,
|
|
1818
1982
|
runtime: this.runtimeType,
|
|
1819
|
-
worker:
|
|
1983
|
+
worker: workerRef,
|
|
1984
|
+
engine: this.engine,
|
|
1820
1985
|
});
|
|
1821
|
-
//
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1986
|
+
// GraphQL registers HTTP/WS routes on this app: every worker.
|
|
1987
|
+
this.ensureGraphQLInitialized().catch(error => {
|
|
1988
|
+
this.logger.error(`Failed to initialize GraphQL: ${error}`, 'GraphQL');
|
|
1989
|
+
});
|
|
1990
|
+
tcw?.ready(port);
|
|
1991
|
+
};
|
|
1992
|
+
const startWorkerListening = () => {
|
|
1993
|
+
if (host) {
|
|
1994
|
+
this.coreFramework.listen(port, host, workerCallback);
|
|
1825
1995
|
}
|
|
1826
|
-
|
|
1827
|
-
|
|
1996
|
+
else {
|
|
1997
|
+
this.coreFramework.listen(port, workerCallback);
|
|
1828
1998
|
}
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1999
|
+
};
|
|
2000
|
+
// Same init as the single-process path: auto-discovery, file-based
|
|
2001
|
+
// routes, queued WebSocket registrations - before 1.9.0 workers skipped
|
|
2002
|
+
// discovery/route loading entirely.
|
|
2003
|
+
Promise.all([
|
|
2004
|
+
this.ensureAutoDiscoveryComplete().then(() => this.ensureRoutesLoaded()),
|
|
2005
|
+
this.processQueuedWebSocketRegistrations(),
|
|
2006
|
+
])
|
|
2007
|
+
.then(startWorkerListening)
|
|
2008
|
+
.catch(error => {
|
|
2009
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2010
|
+
const code = error?.code;
|
|
2011
|
+
// listen() itself failed (EADDRINUSE etc.): retrying is guaranteed to
|
|
2012
|
+
// fail again and would surface as an unhandled rejection crash-loop.
|
|
2013
|
+
// Report fatally so the primary sees a clean death.
|
|
2014
|
+
if (code === 'EADDRINUSE' ||
|
|
2015
|
+
code === 'EACCES' ||
|
|
2016
|
+
/EADDRINUSE|failed to bind/i.test(message)) {
|
|
2017
|
+
fatal(code === 'EACCES' ? 'EACCES' : 'EADDRINUSE', `could not bind port ${port}: ${message}`);
|
|
2018
|
+
return;
|
|
1834
2019
|
}
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
this.coreFramework.listen(port, workerCallback);
|
|
1852
|
-
}
|
|
1853
|
-
};
|
|
1854
|
-
this.processQueuedWebSocketRegistrations()
|
|
1855
|
-
.then(startWorkerListening)
|
|
1856
|
-
.catch(error => {
|
|
1857
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
1858
|
-
const code = error?.code;
|
|
1859
|
-
// listen() itself failed (EADDRINUSE etc.): retrying is guaranteed to
|
|
1860
|
-
// fail again and would surface as an unhandled rejection crash-loop.
|
|
1861
|
-
// Log fatally and exit so the cluster primary sees a clean death.
|
|
1862
|
-
// Prefer the code the native/Node servers set; fall back to message.
|
|
1863
|
-
if (code === 'EADDRINUSE' ||
|
|
1864
|
-
code === 'EACCES' ||
|
|
1865
|
-
/EADDRINUSE|failed to bind/i.test(message)) {
|
|
1866
|
-
this.logger.error(`Worker ${process.pid} could not bind port ${port}: ${message}`, 'Worker');
|
|
1867
|
-
process.exit(1);
|
|
1868
|
-
}
|
|
1869
|
-
this.logger.error('WebSocket initialization failed in worker', 'Worker', {
|
|
1870
|
-
error: message,
|
|
1871
|
-
});
|
|
1872
|
-
// For WebSocket failures with queued registrations, error will propagate
|
|
1873
|
-
if (error instanceof Error &&
|
|
1874
|
-
error.message.includes('WebSocket features require a WebSocket adapter')) {
|
|
1875
|
-
throw error;
|
|
1876
|
-
}
|
|
1877
|
-
// WebSocket setup failed but the HTTP server can still serve: start
|
|
1878
|
-
// anyway, and surface (not swallow) a listen failure from this path.
|
|
1879
|
-
try {
|
|
1880
|
-
startWorkerListening();
|
|
1881
|
-
}
|
|
1882
|
-
catch (listenError) {
|
|
1883
|
-
this.logger.error(`Worker ${process.pid} failed to start: ${listenError instanceof Error ? listenError.message : String(listenError)}`, 'Worker');
|
|
1884
|
-
process.exit(1);
|
|
1885
|
-
}
|
|
1886
|
-
});
|
|
1887
|
-
}
|
|
2020
|
+
this.logger.error('Initialization failed in worker', 'Worker', { error: message });
|
|
2021
|
+
// For WebSocket failures with queued registrations, error will propagate
|
|
2022
|
+
if (error instanceof Error &&
|
|
2023
|
+
error.message.includes('WebSocket features require a WebSocket adapter')) {
|
|
2024
|
+
throw error;
|
|
2025
|
+
}
|
|
2026
|
+
// Init failed but the HTTP server can still serve: start anyway, and
|
|
2027
|
+
// surface (not swallow) a listen failure from this path.
|
|
2028
|
+
try {
|
|
2029
|
+
startWorkerListening();
|
|
2030
|
+
}
|
|
2031
|
+
catch (listenError) {
|
|
2032
|
+
const lm = listenError instanceof Error ? listenError.message : String(listenError);
|
|
2033
|
+
fatal(listenError?.code === 'EACCES' ? 'EACCES' : 'EADDRINUSE', `failed to start: ${lm}`);
|
|
2034
|
+
}
|
|
2035
|
+
});
|
|
1888
2036
|
}
|
|
1889
2037
|
// Simple worker message handler
|
|
1890
2038
|
handleWorkerMessage(message) {
|
|
@@ -1999,6 +2147,18 @@ export class Moro extends EventEmitter {
|
|
|
1999
2147
|
}
|
|
2000
2148
|
}
|
|
2001
2149
|
}
|
|
2150
|
+
// Cluster worker threads first: each drains its server via its own
|
|
2151
|
+
// close(); the primary's services below stop after the traffic did.
|
|
2152
|
+
if (this.threadCluster) {
|
|
2153
|
+
const primary = this.threadCluster;
|
|
2154
|
+
this.threadCluster = undefined;
|
|
2155
|
+
try {
|
|
2156
|
+
await primary.shutdown();
|
|
2157
|
+
}
|
|
2158
|
+
catch (err) {
|
|
2159
|
+
this.logger.error(`Error shutting down cluster worker threads: ${String(err)}`);
|
|
2160
|
+
}
|
|
2161
|
+
}
|
|
2002
2162
|
// Shutdown worker threads first (fastest)
|
|
2003
2163
|
try {
|
|
2004
2164
|
this.logger.info('Shutting down worker threads...');
|