mirror of
https://github.com/CommunitySolidServer/CommunitySolidServer.git
synced 2024-10-03 14:55:10 +00:00
feat: initial proposal for multithreaded execution
This commit is contained in:
committed by
Joachim Van Herwegen
parent
32245fc604
commit
236bbc6e5d
@@ -1,3 +1,4 @@
|
||||
import type { ClusterManager } from '../../../src';
|
||||
import { App } from '../../../src/init/App';
|
||||
import type { Finalizable } from '../../../src/init/final/Finalizable';
|
||||
import type { Initializer } from '../../../src/init/Initializer';
|
||||
@@ -5,12 +6,14 @@ import type { Initializer } from '../../../src/init/Initializer';
|
||||
describe('An App', (): void => {
|
||||
let initializer: Initializer;
|
||||
let finalizer: Finalizable;
|
||||
let clusterManager: ClusterManager;
|
||||
let app: App;
|
||||
|
||||
beforeEach(async(): Promise<void> => {
|
||||
initializer = { handleSafe: jest.fn() } as any;
|
||||
finalizer = { finalize: jest.fn() };
|
||||
app = new App(initializer, finalizer);
|
||||
clusterManager = {} as any;
|
||||
app = new App(initializer, finalizer, clusterManager);
|
||||
});
|
||||
|
||||
it('can start with the initializer.', async(): Promise<void> => {
|
||||
@@ -22,4 +25,9 @@ describe('An App', (): void => {
|
||||
await expect(app.stop()).resolves.toBeUndefined();
|
||||
expect(finalizer.finalize).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('can check its clusterManager for the threading mode.', async(): Promise<void> => {
|
||||
await expect(app.start()).resolves.toBeUndefined();
|
||||
expect(app.clusterManager).toBe(clusterManager);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ComponentsManager } from 'componentsjs';
|
||||
import type { ClusterManager } from '../../../src';
|
||||
import type { App } from '../../../src/init/App';
|
||||
import { AppRunner } from '../../../src/init/AppRunner';
|
||||
import type { CliExtractor } from '../../../src/init/cli/CliExtractor';
|
||||
@@ -6,10 +7,6 @@ import type { SettingsResolver } from '../../../src/init/variables/SettingsResol
|
||||
import { joinFilePath } from '../../../src/util/PathUtil';
|
||||
import { flushPromises } from '../../util/Util';
|
||||
|
||||
const app: jest.Mocked<App> = {
|
||||
start: jest.fn(),
|
||||
} as any;
|
||||
|
||||
const defaultParameters = {
|
||||
port: 3000,
|
||||
logLevel: 'info',
|
||||
@@ -26,6 +23,31 @@ const settingsResolver: jest.Mocked<SettingsResolver> = {
|
||||
handleSafe: jest.fn().mockResolvedValue(defaultVariables),
|
||||
} as any;
|
||||
|
||||
const mockLogger = {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
silly: jest.fn(),
|
||||
error: jest.fn(),
|
||||
verbose: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
log: jest.fn(),
|
||||
};
|
||||
|
||||
const clusterManager: jest.Mocked<ClusterManager> = {
|
||||
isSingleThreaded: jest.fn().mockReturnValue(false),
|
||||
spawnWorkers: jest.fn(),
|
||||
isPrimary: jest.fn().mockReturnValue(true),
|
||||
isWorker: jest.fn().mockReturnValue(false),
|
||||
logger: mockLogger,
|
||||
workers: 1,
|
||||
clusterMode: 1,
|
||||
} as any;
|
||||
|
||||
const app: jest.Mocked<App> = {
|
||||
start: jest.fn(),
|
||||
clusterManager,
|
||||
} as any;
|
||||
|
||||
const manager: jest.Mocked<ComponentsManager<App>> = {
|
||||
instantiate: jest.fn(async(iri: string): Promise<any> => {
|
||||
switch (iri) {
|
||||
@@ -39,6 +61,12 @@ const manager: jest.Mocked<ComponentsManager<App>> = {
|
||||
},
|
||||
} as any;
|
||||
|
||||
const listSingleThreadedComponentsMock = jest.fn().mockResolvedValue([]);
|
||||
|
||||
jest.mock('../../../src/init/cluster/SingleThreaded', (): any => ({
|
||||
listSingleThreadedComponents: (): any => listSingleThreadedComponentsMock(),
|
||||
}));
|
||||
|
||||
jest.mock('componentsjs', (): any => ({
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
ComponentsManager: {
|
||||
@@ -90,6 +118,75 @@ describe('AppRunner', (): void => {
|
||||
expect(cliExtractor.handleSafe).toHaveBeenCalledTimes(0);
|
||||
expect(settingsResolver.handleSafe).toHaveBeenCalledTimes(0);
|
||||
expect(app.start).toHaveBeenCalledTimes(0);
|
||||
expect(app.clusterManager.isSingleThreaded()).toBeFalsy();
|
||||
});
|
||||
|
||||
it('throws an error if threading issues are detected with 1 class.', async(): Promise<void> => {
|
||||
listSingleThreadedComponentsMock.mockImplementationOnce((): string[] => [ 'ViolatingClass' ]);
|
||||
const variables = {
|
||||
'urn:solid-server:default:variable:port': 3000,
|
||||
'urn:solid-server:default:variable:loggingLevel': 'info',
|
||||
'urn:solid-server:default:variable:rootFilePath': '/var/cwd/',
|
||||
'urn:solid-server:default:variable:showStackTrace': false,
|
||||
'urn:solid-server:default:variable:podConfigJson': '/var/cwd/pod-config.json',
|
||||
'urn:solid-server:default:variable:seededPodConfigJson': '/var/cwd/seeded-pod-config.json',
|
||||
};
|
||||
|
||||
let caughtError: Error | undefined;
|
||||
try {
|
||||
await new AppRunner().create(
|
||||
{
|
||||
mainModulePath: joinFilePath(__dirname, '../../../'),
|
||||
dumpErrorState: true,
|
||||
logLevel: 'info',
|
||||
},
|
||||
joinFilePath(__dirname, '../../../config/default.json'),
|
||||
variables,
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
caughtError = error as Error;
|
||||
}
|
||||
expect(caughtError?.message).toMatch(/^Cannot run a singlethreaded-only component in a multithreaded setup!/mu);
|
||||
expect(caughtError?.message).toMatch(
|
||||
/\[ViolatingClass\] is not threadsafe and should not be run in multithreaded setups!/mu,
|
||||
);
|
||||
|
||||
expect(write).toHaveBeenCalledTimes(0);
|
||||
expect(exit).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('throws an error if threading issues are detected with 2 class.', async(): Promise<void> => {
|
||||
listSingleThreadedComponentsMock.mockImplementationOnce((): string[] => [ 'ViolatingClass1', 'ViolatingClass2' ]);
|
||||
const variables = {
|
||||
'urn:solid-server:default:variable:port': 3000,
|
||||
'urn:solid-server:default:variable:loggingLevel': 'info',
|
||||
'urn:solid-server:default:variable:rootFilePath': '/var/cwd/',
|
||||
'urn:solid-server:default:variable:showStackTrace': false,
|
||||
'urn:solid-server:default:variable:podConfigJson': '/var/cwd/pod-config.json',
|
||||
'urn:solid-server:default:variable:seededPodConfigJson': '/var/cwd/seeded-pod-config.json',
|
||||
};
|
||||
|
||||
let caughtError: Error | undefined;
|
||||
try {
|
||||
await new AppRunner().create(
|
||||
{
|
||||
mainModulePath: joinFilePath(__dirname, '../../../'),
|
||||
dumpErrorState: true,
|
||||
logLevel: 'info',
|
||||
},
|
||||
joinFilePath(__dirname, '../../../config/default.json'),
|
||||
variables,
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
caughtError = error as Error;
|
||||
}
|
||||
expect(caughtError?.message).toMatch(/^Cannot run a singlethreaded-only component in a multithreaded setup!/mu);
|
||||
expect(caughtError?.message).toMatch(
|
||||
/\[ViolatingClass1, ViolatingClass2\] are not threadsafe and should not be run in multithreaded setups!/mu,
|
||||
);
|
||||
|
||||
expect(write).toHaveBeenCalledTimes(0);
|
||||
expect(exit).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -128,6 +225,7 @@ describe('AppRunner', (): void => {
|
||||
expect(settingsResolver.handleSafe).toHaveBeenCalledTimes(0);
|
||||
expect(app.start).toHaveBeenCalledTimes(1);
|
||||
expect(app.start).toHaveBeenCalledWith();
|
||||
expect(app.clusterManager.isSingleThreaded()).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -154,6 +252,7 @@ describe('AppRunner', (): void => {
|
||||
expect(manager.instantiate).toHaveBeenNthCalledWith(2,
|
||||
'urn:solid-server:default:App',
|
||||
{ variables: defaultVariables });
|
||||
expect(app.clusterManager.isSingleThreaded()).toBeFalsy();
|
||||
expect(app.start).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
@@ -171,6 +270,7 @@ describe('AppRunner', (): void => {
|
||||
'-t',
|
||||
'--podConfigJson', '/different-path.json',
|
||||
'--seededPodConfigJson', '/different-path.json',
|
||||
'-w', '1',
|
||||
];
|
||||
process.argv = argvParameters;
|
||||
|
||||
@@ -195,10 +295,35 @@ describe('AppRunner', (): void => {
|
||||
'urn:solid-server:default:App',
|
||||
{ variables: defaultVariables });
|
||||
expect(app.start).toHaveBeenCalledTimes(0);
|
||||
expect(app.clusterManager.isSingleThreaded()).toBeFalsy();
|
||||
|
||||
process.argv = argv;
|
||||
});
|
||||
|
||||
it('checks for threading issues when starting in multithreaded mode.', async(): Promise<void> => {
|
||||
const createdApp = await new AppRunner().createCli();
|
||||
expect(createdApp).toBe(app);
|
||||
expect(listSingleThreadedComponentsMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws an error if there are threading issues detected.', async(): Promise<void> => {
|
||||
listSingleThreadedComponentsMock.mockImplementationOnce((): string[] => [ 'ViolatingClass' ]);
|
||||
|
||||
let caughtError: Error = new Error('should disappear');
|
||||
try {
|
||||
await new AppRunner().createCli([ 'node', 'script' ]);
|
||||
} catch (error: unknown) {
|
||||
caughtError = error as Error;
|
||||
}
|
||||
expect(caughtError.message).toMatch(/^Cannot run a singlethreaded-only component in a multithreaded setup!/mu);
|
||||
expect(caughtError?.message).toMatch(
|
||||
/\[ViolatingClass\] is not threadsafe and should not be run in multithreaded setups!/mu,
|
||||
);
|
||||
|
||||
expect(write).toHaveBeenCalledTimes(0);
|
||||
expect(exit).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('throws an error if creating a ComponentsManager fails.', async(): Promise<void> => {
|
||||
(manager.configRegistry.register as jest.Mock).mockRejectedValueOnce(new Error('Fatal'));
|
||||
|
||||
@@ -291,6 +416,7 @@ describe('AppRunner', (): void => {
|
||||
{ variables: defaultVariables });
|
||||
expect(app.start).toHaveBeenCalledTimes(1);
|
||||
expect(app.start).toHaveBeenLastCalledWith();
|
||||
expect(app.clusterManager.isSingleThreaded()).toBeFalsy();
|
||||
});
|
||||
|
||||
it('throws an error if the server could not start.', async(): Promise<void> => {
|
||||
@@ -342,6 +468,7 @@ describe('AppRunner', (): void => {
|
||||
{ variables: defaultVariables });
|
||||
expect(app.start).toHaveBeenCalledTimes(1);
|
||||
expect(app.start).toHaveBeenLastCalledWith();
|
||||
expect(app.clusterManager.isSingleThreaded()).toBeFalsy();
|
||||
});
|
||||
|
||||
it('exits the process and writes to stderr if there was an error.', async(): Promise<void> => {
|
||||
|
||||
106
test/unit/init/cluster/ClusterManager.test.ts
Normal file
106
test/unit/init/cluster/ClusterManager.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import cluster from 'cluster';
|
||||
import EventEmitter from 'events';
|
||||
import { cpus } from 'os';
|
||||
import { ClusterManager } from '../../../../src';
|
||||
import * as LogUtil from '../../../../src/logging/LogUtil';
|
||||
|
||||
jest.mock('cluster');
|
||||
jest.mock('os', (): any => ({
|
||||
...jest.requireActual('os'),
|
||||
cpus: jest.fn().mockImplementation((): any => [{}, {}, {}, {}, {}, {}]),
|
||||
}));
|
||||
|
||||
const mockWorker = new EventEmitter() as any;
|
||||
mockWorker.process = { pid: 666 };
|
||||
|
||||
describe('A ClusterManager', (): void => {
|
||||
const emitter = new EventEmitter();
|
||||
const mockCluster = jest.requireMock('cluster');
|
||||
const mockLogger = { info: jest.fn(), warn: jest.fn() };
|
||||
jest.spyOn(LogUtil, 'getLoggerFor').mockImplementation((): any => mockLogger);
|
||||
|
||||
beforeAll((): void => {
|
||||
Object.assign(mockCluster, {
|
||||
fork: jest.fn().mockImplementation((): any => mockWorker),
|
||||
on: jest.fn().mockImplementation(emitter.on),
|
||||
emit: jest.fn().mockImplementation(emitter.emit),
|
||||
isMaster: true,
|
||||
isWorker: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('can handle workers input as string.', (): void => {
|
||||
const cm = new ClusterManager('4');
|
||||
expect(cm.isSingleThreaded()).toBeFalsy();
|
||||
});
|
||||
|
||||
it('can distinguish between ClusterModes.', (): void => {
|
||||
const cm1 = new ClusterManager(-1);
|
||||
const cm2 = new ClusterManager(0);
|
||||
const cm3 = new ClusterManager(1);
|
||||
const cm4 = new ClusterManager(2);
|
||||
expect(cm1.isSingleThreaded()).toBeFalsy();
|
||||
expect(cm2.isSingleThreaded()).toBeFalsy();
|
||||
expect(cm3.isSingleThreaded()).toBeTruthy();
|
||||
expect(cm4.isSingleThreaded()).toBeFalsy();
|
||||
});
|
||||
|
||||
it('errors on invalid workers amount.', (): void => {
|
||||
expect((): ClusterManager => new ClusterManager('10')).toBeDefined();
|
||||
expect((): ClusterManager => new ClusterManager('2')).toBeDefined();
|
||||
expect((): ClusterManager => new ClusterManager('1')).toBeDefined();
|
||||
expect((): ClusterManager => new ClusterManager('0')).toBeDefined();
|
||||
expect((): ClusterManager => new ClusterManager('-1')).toBeDefined();
|
||||
expect((): ClusterManager => new ClusterManager('-5')).toBeDefined();
|
||||
expect((): ClusterManager => new ClusterManager('-6')).toThrow('Invalid workers value');
|
||||
expect((): ClusterManager => new ClusterManager('-10')).toThrow('Invalid workers value');
|
||||
});
|
||||
|
||||
it('has an isPrimary() that works.', (): void => {
|
||||
const cm = new ClusterManager(-1);
|
||||
expect(cm.isPrimary()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('has an isWorker() that works.', (): void => {
|
||||
const cm = new ClusterManager(-1);
|
||||
expect(cm.isWorker()).toBeFalsy();
|
||||
});
|
||||
|
||||
it('can autoscale to num_cpu and applies proper logging.', (): void => {
|
||||
const cm = new ClusterManager(-1);
|
||||
const workers = cpus().length - 1;
|
||||
expect(cpus()).toHaveLength(workers + 1);
|
||||
Object.assign(cm, { logger: mockLogger });
|
||||
|
||||
cm.spawnWorkers();
|
||||
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(`Setting up ${workers} workers`);
|
||||
|
||||
for (let i = 0; i < workers; i++) {
|
||||
mockCluster.emit('online', mockWorker);
|
||||
}
|
||||
|
||||
expect(cluster.on).toHaveBeenCalledWith('online', expect.any(Function));
|
||||
expect(cluster.fork).toHaveBeenCalledTimes(workers);
|
||||
expect(mockLogger.info).toHaveBeenLastCalledWith(`All ${workers} requested workers have been started.`);
|
||||
|
||||
expect(cluster.on).toHaveBeenCalledWith('exit', expect.any(Function));
|
||||
const code = 333;
|
||||
const signal = 'exiting';
|
||||
mockCluster.emit('exit', mockWorker, code, signal);
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
`Worker ${mockWorker.process.pid} died with code ${code} and signal ${signal}`,
|
||||
);
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(`Starting a new worker`);
|
||||
});
|
||||
|
||||
it('can receive message from spawned workers.', (): void => {
|
||||
const cm = new ClusterManager(2);
|
||||
Object.assign(cm, { logger: mockLogger });
|
||||
|
||||
cm.spawnWorkers();
|
||||
const msg = 'Hi from worker!';
|
||||
mockWorker.emit('message', msg);
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(msg);
|
||||
});
|
||||
});
|
||||
61
test/unit/init/cluster/SingleThreaded.test.ts
Normal file
61
test/unit/init/cluster/SingleThreaded.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { ComponentsManager } from 'componentsjs';
|
||||
import type { Resource } from 'rdf-object';
|
||||
import { listSingleThreadedComponents } from '../../../../src';
|
||||
|
||||
const moduleState = {
|
||||
contexts: {
|
||||
'https://linkedsoftwaredependencies.org/bundles/npm/@solid/community-server/^5.0.0/components/context.jsonld': 'dist/components/context.jsonld',
|
||||
},
|
||||
};
|
||||
|
||||
const mockResource: Resource = {
|
||||
isA: jest.fn().mockReturnValue(true),
|
||||
value: '#ViolatingClass',
|
||||
property: { type: { value: '#ViolatingClass' }},
|
||||
} as any;
|
||||
|
||||
const myExpandTerm = jest.fn().mockImplementation((): any => 'http://myFullIRI');
|
||||
|
||||
function mockComponentsManagerFn(length: number): jest.Mocked<ComponentsManager<any>> {
|
||||
const resources: Resource[] = Array.from<Resource>({ length }).fill(mockResource);
|
||||
return { moduleState, getInstantiatedResources: jest.fn((): any => resources) } as any;
|
||||
}
|
||||
|
||||
jest.mock('jsonld-context-parser/lib/ContextParser', (): any => ({
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
ContextParser: jest.fn().mockImplementation((): any => ({
|
||||
parse: jest.fn(async(): Promise<any> => ({
|
||||
expandTerm: jest.fn((): any => myExpandTerm()),
|
||||
})),
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('componentsjs', (): any => ({
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
ComponentsManager: {
|
||||
build: jest.fn(async(props: any): Promise<ComponentsManager<any>> => mockComponentsManagerFn(props.length)),
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
PrefetchedDocumentLoader: jest.fn().mockImplementation((): any => ({
|
||||
load: jest.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('A SingleThreaded', (): void => {
|
||||
it('has a listSingleThreadedComponents that works with 1 resource.', async(): Promise<void> => {
|
||||
const comp = await ComponentsManager.build({ length: 1 } as any);
|
||||
await expect(listSingleThreadedComponents(comp)).resolves.toEqual([ 'ViolatingClass' ]);
|
||||
});
|
||||
|
||||
it('has a listSingleThreadedComponents that works with multiple resources.', async(): Promise<void> => {
|
||||
const comp = await ComponentsManager.build({ length: 2 } as any);
|
||||
await expect(listSingleThreadedComponents(comp)).resolves.toEqual([ 'ViolatingClass', 'ViolatingClass' ]);
|
||||
});
|
||||
|
||||
it('errors when the interface IRI cannot be expanded.', async(): Promise<void> => {
|
||||
myExpandTerm.mockReturnValueOnce(null);
|
||||
const comp = await ComponentsManager.build({} as any);
|
||||
await expect(listSingleThreadedComponents(comp)).rejects
|
||||
.toThrow(/^Could not expand .* to IRI!/u);
|
||||
});
|
||||
});
|
||||
15
test/unit/init/cluster/WorkerManager.test.ts
Normal file
15
test/unit/init/cluster/WorkerManager.test.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { ClusterManager, WorkerManager } from '../../../../src';
|
||||
|
||||
describe('A WorkerManager', (): void => {
|
||||
it('can be created from a ClusterManager.', (): void => {
|
||||
expect((): WorkerManager => new WorkerManager(new ClusterManager(4))).toBeDefined();
|
||||
});
|
||||
|
||||
it('can call handle.', async(): Promise<void> => {
|
||||
const cm = new ClusterManager(4);
|
||||
const wm = new WorkerManager(cm);
|
||||
Object.assign(cm, { spawnWorkers: jest.fn() });
|
||||
await wm.handle();
|
||||
expect(cm.spawnWorkers).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user