feat: initial proposal for multithreaded execution

This commit is contained in:
Thomas Dupont
2022-05-13 11:27:31 +02:00
committed by Joachim Van Herwegen
parent 32245fc604
commit 236bbc6e5d
40 changed files with 880 additions and 97 deletions

View File

@@ -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);
});
});

View File

@@ -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> => {

View 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);
});
});

View 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);
});
});

View 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();
});
});