Refactor runnables (#6528)

* Convert runManyFor to use composite

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* Convert runManySyncFor to use composite and to enfore sync-ness

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* Remove dead code

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* Convert getStartableStoppable to be always sync as it is never used in an async fashion

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* Convert all sync runnables to comply with new checks for sync-ness

Signed-off-by: Sebastian Malton <sebastian@malton.name>

Signed-off-by: Sebastian Malton <sebastian@malton.name>
This commit is contained in:
Sebastian Malton authored and GitHub committed 2022-11-07 19:04:56 +02:00
1 parent 82ab60d544
commit b498f12186
42 files changed
+191 -401

No files matched your search

+1 -1
View File
@@ -223,7 +223,7 @@ describe("runManyFor", () => {
);
return expect(() => runMany()).rejects.toThrow(
'Tried to run runnable "some-runnable-1" after the runnable "some-runnable-2" which does not share the "some-injection-token" injection token.',
/Tried to get a composite but encountered missing parent ids: "some-runnable-2".\n\nAvailable parent ids are:\n"[0-9a-z-]+",\n"some-runnable-1"/,
);
});
+29 -33
View File
@@ -2,13 +2,10 @@
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { pipeline } from "@ogre-tools/fp";
import type {
DiContainerForInjection,
InjectionToken,
} from "@ogre-tools/injectable";
import { filter, forEach, map, tap } from "lodash/fp";
import { throwWithIncorrectHierarchyFor } from "./throw-with-incorrect-hierarchy-for";
import type { DiContainerForInjection, InjectionToken } from "@ogre-tools/injectable";
import type { Composite } from "../utils/composite/get-composite/get-composite";
import { getCompositeFor } from "../utils/composite/get-composite/get-composite";
import * as uuid from "uuid";
export interface Runnable<TParameter = void> {
id: string;
@@ -18,35 +15,34 @@ export interface Runnable<TParameter = void> {
type Run<Param> = (parameter: Param) => Promise<void> | void;
export type RunMany = <Param>(
injectionToken: InjectionToken<Runnable<Param>, void>
) => Run<Param>;
export type RunMany = <Param>(injectionToken: InjectionToken<Runnable<Param>, void>) => Run<Param>;
async function runCompositeRunnables<Param>(param: Param, composite: Composite<Runnable<Param>>) {
await composite.value.run(param);
await Promise.all(composite.children.map(composite => runCompositeRunnables(param, composite)));
}
export function runManyFor(di: DiContainerForInjection): RunMany {
return (injectionToken) => async (parameter) => {
return <Param>(injectionToken: InjectionToken<Runnable<Param>, void>) => async (param: Param) => {
const allRunnables = di.injectMany(injectionToken);
const rootId = uuid.v4();
const getCompositeRunnables = getCompositeFor<Runnable<Param>>({
getId: (runnable) => runnable.id,
getParentId: (runnable) => (
runnable.id === rootId
? undefined
: runnable.runAfter?.id ?? rootId
),
});
const composite = getCompositeRunnables([
// This is a dummy runnable to conform to the requirements of `getCompositeFor` to only have one root
{
id: rootId,
run: () => {},
},
...allRunnables,
]);
const throwWithIncorrectHierarchy = throwWithIncorrectHierarchyFor((injectionToken as any).id, allRunnables);
const recursedRun = async (
runAfterRunnable: Runnable<any> | undefined = undefined,
) =>
await pipeline(
allRunnables,
tap(runnables => forEach(throwWithIncorrectHierarchy, runnables)),
filter((runnable) => runnable.runAfter === runAfterRunnable),
map(async (runnable) => {
await runnable.run(parameter);
await recursedRun(runnable);
}),
(promises) => Promise.all(promises),
);
await recursedRun();
await runCompositeRunnables(param, composite);
};
}
@@ -69,7 +69,7 @@ describe("runManySyncFor", () => {
instantiate: (di) => ({
id: "some-injectable-1",
run: () => runMock("third-level-run"),
run: () => void runMock("third-level-run"),
runAfter: di.inject(someInjectable2),
}),
@@ -81,7 +81,7 @@ describe("runManySyncFor", () => {
instantiate: (di) => ({
id: "some-injectable-2",
run: () => runMock("second-level-run"),
run: () => void runMock("second-level-run"),
runAfter: di.inject(someInjectable3),
}),
@@ -92,7 +92,7 @@ describe("runManySyncFor", () => {
id: "some-injectable-3",
instantiate: () => ({
id: "some-injectable-3",
run: () => runMock("first-level-run"),
run: () => void runMock("first-level-run"),
}),
injectionToken: someInjectionTokenForRunnables,
});
@@ -151,13 +151,13 @@ describe("runManySyncFor", () => {
someInjectionToken,
);
return expect(() => runMany()).rejects.toThrow(
'Tried to run runnable "some-runnable-1" after the runnable "some-runnable-2" which does not share the "some-injection-token" injection token.',
return expect(() => runMany()).toThrow(
/Tried to get a composite but encountered missing parent ids: "some-runnable-2".\n\nAvailable parent ids are:\n"[0-9a-z-]+",\n"some-runnable-1"/,
);
});
describe("when running many with parameter", () => {
let runMock: jest.Mock<(arg: string, arg2: string) => void>;
let runMock: jest.Mock<(arg: string, arg2: string) => undefined>;
beforeEach(() => {
const rootDi = createContainer("irrelevant");
@@ -175,7 +175,7 @@ describe("runManySyncFor", () => {
instantiate: () => ({
id: "some-runnable-1",
run: (parameter) => runMock("run-of-some-runnable-1", parameter),
run: (parameter) => void runMock("run-of-some-runnable-1", parameter),
}),
injectionToken: someInjectionTokenForRunnablesWithParameter,
@@ -186,7 +186,7 @@ describe("runManySyncFor", () => {
instantiate: () => ({
id: "some-runnable-2",
run: (parameter) => runMock("run-of-some-runnable-2", parameter),
run: (parameter) => void runMock("run-of-some-runnable-2", parameter),
}),
injectionToken: someInjectionTokenForRunnablesWithParameter,
+36 -29
View File
@@ -2,11 +2,10 @@
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { pipeline } from "@ogre-tools/fp";
import type { DiContainerForInjection, InjectionToken } from "@ogre-tools/injectable";
import { filter, forEach, map, tap } from "lodash/fp";
import type { Runnable } from "./run-many-for";
import { throwWithIncorrectHierarchyFor } from "./throw-with-incorrect-hierarchy-for";
import type { Composite } from "../utils/composite/get-composite/get-composite";
import { getCompositeFor } from "../utils/composite/get-composite/get-composite";
import * as uuid from "uuid";
export interface RunnableSync<TParameter = void> {
id: string;
@@ -14,35 +13,43 @@ export interface RunnableSync<TParameter = void> {
runAfter?: RunnableSync<TParameter>;
}
type RunSync<Param> = (parameter: Param) => void;
/**
* NOTE: this is the worse of two evils. This makes sure that `RunnableSync` always is sync.
* If the return type is `void` instead then async functions (those return `Promise<T>`) can
* coerce to it.
*/
type RunSync<Param> = (parameter: Param) => undefined;
export type RunManySync = <Param>(
injectionToken: InjectionToken<Runnable<Param>, void>
) => RunSync<Param>;
export type RunManySync = <Param>(injectionToken: InjectionToken<RunnableSync<Param>, void>) => RunSync<Param>;
function runCompositeRunnableSyncs<Param>(param: Param, composite: Composite<RunnableSync<Param>>): undefined {
composite.value.run(param);
composite.children.map(composite => runCompositeRunnableSyncs(param, composite));
return undefined;
}
export function runManySyncFor(di: DiContainerForInjection): RunManySync {
return (injectionToken) => async (parameter) => {
return <Param>(injectionToken: InjectionToken<RunnableSync<Param>, void>) => (param: Param): undefined => {
const allRunnables = di.injectMany(injectionToken);
const rootId = uuid.v4();
const getCompositeRunnables = getCompositeFor<RunnableSync<Param>>({
getId: (runnable) => runnable.id,
getParentId: (runnable) => (
runnable.id === rootId
? undefined
: runnable.runAfter?.id ?? rootId
),
});
const composite = getCompositeRunnables([
// This is a dummy runnable to conform to the requirements of `getCompositeFor` to only have one root
{
id: rootId,
run: () => undefined,
},
...allRunnables,
]);
const throwWithIncorrectHierarchy = throwWithIncorrectHierarchyFor((injectionToken as any).id, allRunnables);
const recursedRun = (
runAfterRunnable: RunnableSync<any> | undefined = undefined,
) =>
pipeline(
allRunnables,
tap(runnables => forEach(throwWithIncorrectHierarchy, runnables)),
filter((runnable) => runnable.runAfter === runAfterRunnable),
map((runnable) => {
runnable.run(parameter);
recursedRun(runnable);
}),
);
recursedRun();
return runCompositeRunnableSyncs(param, composite);
};
}
@@ -1,14 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { Runnable } from "./run-many-for";
import type { RunnableSync } from "./run-many-sync-for";
export const throwWithIncorrectHierarchyFor = (injectionTokenId: string, allRunnables: Runnable<any>[] | RunnableSync<any>[]) => (
(runnable: Runnable<any> | RunnableSync<any>) => {
if (runnable.runAfter && !allRunnables.includes(runnable.runAfter)) {
throw new Error(`Tried to run runnable "${runnable.id}" after the runnable "${runnable.runAfter.id}" which does not share the "${injectionTokenId}" injection token.`);
}
}
);
+16 -189
View File
@@ -2,21 +2,17 @@
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { AsyncFnMock } from "@async-fn/jest";
import asyncFn from "@async-fn/jest";
import type { StartableStoppable } from "./get-startable-stoppable";
import { getStartableStoppable } from "./get-startable-stoppable";
import { getPromiseStatus } from "../test-utils/get-promise-status";
import { flushPromises } from "../test-utils/flush-promises";
describe("getStartableStoppable", () => {
let stopMock: AsyncFnMock<() => Promise<void>>;
let startMock: AsyncFnMock<() => Promise<() => Promise<void>>>;
let actual: { stop: () => Promise<void>; start: () => Promise<void>; started: boolean };
let stopMock: jest.MockedFunction<() => void>;
let startMock: jest.MockedFunction<() => () => void>;
let actual: StartableStoppable;
beforeEach(() => {
stopMock = asyncFn();
startMock = asyncFn();
stopMock = jest.fn();
startMock = jest.fn().mockImplementation(() => stopMock);
actual = getStartableStoppable("some-id", startMock);
});
@@ -29,7 +25,7 @@ describe("getStartableStoppable", () => {
});
it("when stopping before ever starting, throws", () => {
expect(actual.stop).rejects.toThrow("Tried to stop \"some-id\", but it has not started yet.");
expect(() => actual.stop()).toThrow("Tried to stop \"some-id\", but it is already stopped.");
});
it("is not started", () => {
@@ -37,199 +33,30 @@ describe("getStartableStoppable", () => {
});
describe("when started", () => {
let startPromise: Promise<void>;
beforeEach(() => {
startPromise = actual.start();
actual.start();
});
it("starts starting", () => {
it("calls start function", () => {
expect(startMock).toHaveBeenCalled();
});
it("starting does not resolve yet", async () => {
const promiseStatus = await getPromiseStatus(startPromise);
expect(promiseStatus.fulfilled).toBe(false);
it("is started", () => {
expect(actual.started).toBe(true);
});
it("is not started yet", () => {
expect(actual.started).toBe(false);
});
describe("when started again before the start has finished", () => {
let error: Error;
describe("when stopped", () => {
beforeEach(() => {
startMock.mockClear();
actual.start().catch((e) => { error = e; });
actual.stop();
});
it("does not start starting again", () => {
expect(startMock).not.toHaveBeenCalled();
it("calls stop function", () => {
expect(stopMock).toBeCalled();
});
it("throws", () => {
expect(error.message).toBe("Tried to start \"some-id\", but it is already being started.");
});
});
describe("when starting finishes", () => {
beforeEach(async () => {
await startMock.resolve(stopMock);
});
it("is started", () => {
expect(actual.started).toBe(true);
});
it("starting resolves", async () => {
const promiseStatus = await getPromiseStatus(startPromise);
expect(promiseStatus.fulfilled).toBe(true);
});
it("when started again, throws", () => {
expect(actual.start).rejects.toThrow("Tried to start \"some-id\", but it has already started.");
});
it("does not stop yet", () => {
expect(stopMock).not.toHaveBeenCalled();
});
describe("when stopped", () => {
let stopPromise: Promise<void>;
beforeEach(() => {
stopPromise = actual.stop();
});
it("starts stopping", () => {
expect(stopMock).toHaveBeenCalled();
});
it("stopping does not resolve yet", async () => {
const promiseStatus = await getPromiseStatus(stopPromise);
expect(promiseStatus.fulfilled).toBe(false);
});
it("is not stopped yet", () => {
expect(actual.started).toBe(true);
});
describe("when stopping finishes", () => {
beforeEach(async () => {
await stopMock.resolve();
});
it("is not started", () => {
expect(actual.started).toBe(false);
});
it("stopping resolves", async () => {
const promiseStatus = await getPromiseStatus(stopPromise);
expect(promiseStatus.fulfilled).toBe(true);
});
it("when stopped again, throws", () => {
expect(actual.stop).rejects.toThrow("Tried to stop \"some-id\", but it has already stopped.");
});
describe("when started again", () => {
beforeEach(
() => {
startMock.mockClear();
actual.start();
});
it("starts", () => {
expect(startMock).toHaveBeenCalled();
});
it("is not started yet", () => {
expect(actual.started).toBe(false);
});
describe("when starting finishes", () => {
beforeEach(async () => {
await startMock.resolve(stopMock);
});
it("is started", () => {
expect(actual.started).toBe(true);
});
it("when stopped again, starts stopping again", async () => {
stopMock.mockClear();
actual.stop();
await flushPromises();
expect(stopMock).toHaveBeenCalled();
});
});
});
});
});
});
describe("when stopped before starting finishes", () => {
let stopPromise: Promise<void>;
beforeEach(() => {
stopPromise = actual.stop();
});
it("does not resolve yet", async () => {
const promiseStatus = await getPromiseStatus(stopPromise);
expect(promiseStatus.fulfilled).toBe(false);
});
it("is not started yet", () => {
it("is stopped", () => {
expect(actual.started).toBe(false);
});
describe("when starting finishes", () => {
beforeEach(async () => {
await startMock.resolve(stopMock);
});
it("starts stopping", () => {
expect(stopMock).toHaveBeenCalled();
});
it("is not stopped yet", () => {
expect(actual.started).toBe(true);
});
it("does not resolve yet", async () => {
const promiseStatus = await getPromiseStatus(stopPromise);
expect(promiseStatus.fulfilled).toBe(false);
});
describe("when stopping finishes", () => {
beforeEach(async () => {
await stopMock.resolve();
});
it("is stopped", () => {
expect(actual.started).toBe(false);
});
it("resolves", async () => {
const promiseStatus = await getPromiseStatus(stopPromise);
expect(promiseStatus.fulfilled).toBe(true);
});
});
});
});
});
});
+25 -40
View File
@@ -3,58 +3,43 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
type Stopper = () => Promise<void> | void;
type Starter = () => Promise<Stopper> | Stopper;
export type Stopper = () => void;
export type Starter = () => Stopper;
export const getStartableStoppable = (
id: string,
startAndGetStopCallback: Starter,
) => {
export interface StartableStoppable {
readonly started: boolean;
start: () => void;
stop: () => void;
}
type StartableStoppableState = "stopped" | "started" | "starting";
export function getStartableStoppable(id: string, startAndGetStopper: Starter): StartableStoppable {
let stop: Stopper;
let stopped = false;
let started = false;
let starting = false;
let startingPromise: Promise<Stopper> | Stopper;
let state: StartableStoppableState = "stopped";
return {
get started() {
return started;
return state === "started";
},
start: async () => {
if (starting) {
throw new Error(`Tried to start "${id}", but it is already being started.`);
start: () => {
if (state !== "stopped") {
throw new Error(`Tried to start "${id}", but it is already ${state}.`);
}
starting = true;
if (started) {
throw new Error(`Tried to start "${id}", but it has already started.`);
}
startingPromise = startAndGetStopCallback();
stop = await startingPromise;
stopped = false;
started = true;
starting = false;
state = "starting";
stop = startAndGetStopper();
state = "started";
},
stop: async () => {
await startingPromise;
if (stopped) {
throw new Error(`Tried to stop "${id}", but it has already stopped.`);
stop: () => {
if (state !== "started") {
throw new Error(`Tried to stop "${id}", but it is already ${state}.`);
}
if (!started) {
throw new Error(`Tried to stop "${id}", but it has not started yet.`);
}
await stop();
started = false;
stopped = true;
stop();
state = "stopped";
},
};
};
}