From 50d039577a13ad8ff992b7be09c92e1c965a1e09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Fri, 12 Nov 2021 16:54:20 +0100 Subject: [PATCH 1/6] Introduce the Context to the backend again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .changeset/rare-comics-tan.md | 5 + packages/backend-common/api-report.md | 42 ++++++ packages/backend-common/package.json | 2 + .../src/context/RootContext.test.ts | 60 ++++++++ .../backend-common/src/context/RootContext.ts | 123 ++++++++++++++++ .../src/context/features/abort.test.ts | 134 ++++++++++++++++++ .../src/context/features/abort.ts | 75 ++++++++++ .../src/context/features/values.test.ts | 52 +++++++ .../src/context/features/values.ts | 73 ++++++++++ packages/backend-common/src/context/index.ts | 18 +++ packages/backend-common/src/context/types.ts | 104 ++++++++++++++ packages/backend-common/src/index.ts | 1 + 12 files changed, 689 insertions(+) create mode 100644 .changeset/rare-comics-tan.md create mode 100644 packages/backend-common/src/context/RootContext.test.ts create mode 100644 packages/backend-common/src/context/RootContext.ts create mode 100644 packages/backend-common/src/context/features/abort.test.ts create mode 100644 packages/backend-common/src/context/features/abort.ts create mode 100644 packages/backend-common/src/context/features/values.test.ts create mode 100644 packages/backend-common/src/context/features/values.ts create mode 100644 packages/backend-common/src/context/index.ts create mode 100644 packages/backend-common/src/context/types.ts diff --git a/.changeset/rare-comics-tan.md b/.changeset/rare-comics-tan.md new file mode 100644 index 0000000000..a067ab2eb4 --- /dev/null +++ b/.changeset/rare-comics-tan.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-common': patch +--- + +Added a Context class for the backend, that handles aborting, timeouts, api resolution etc diff --git a/packages/backend-common/api-report.md b/packages/backend-common/api-report.md index d63416571d..8b925c441c 100644 --- a/packages/backend-common/api-report.md +++ b/packages/backend-common/api-report.md @@ -12,7 +12,9 @@ import { AzureIntegration } from '@backstage/integration'; import { BitbucketIntegration } from '@backstage/integration'; import { Config } from '@backstage/config'; import cors from 'cors'; +import { DateTime } from 'luxon'; import Docker from 'dockerode'; +import { Duration } from 'luxon'; import { ErrorRequestHandler } from 'express'; import express from 'express'; import { GithubCredentialsProvider } from '@backstage/integration'; @@ -145,6 +147,27 @@ export interface ContainerRunner { runContainer(opts: RunContainerOptions): Promise; } +// @public +export interface Context { + readonly abortPromise: Promise; + readonly abortSignal: AbortSignal; + readonly deadline: DateTime | undefined; + value(key: string | symbol): T | undefined; + with(...decorators: ContextDecorator[]): Context; + withAbort(): { + ctx: Context; + abort: () => void; + }; + withTimeout(timeout: Duration): Context; + withValue( + key: string | symbol, + value: T | ((previous: T | undefined) => T), + ): Context; +} + +// @public +export type ContextDecorator = (ctx: Context) => Context; + // @public @deprecated export const createDatabase: typeof createDatabaseClient; @@ -456,6 +479,25 @@ export function resolvePackagePath(name: string, ...paths: string[]): string; // @public export function resolveSafeChildPath(base: string, path: string): string; +// @public +export class RootContext implements Context { + get abortPromise(): Promise; + get abortSignal(): AbortSignal_2; + static create(): Context; + get deadline(): DateTime | undefined; + value(key: string | symbol): T | undefined; + with(...items: ContextDecorator[]): Context; + withAbort(): { + ctx: Context; + abort: () => void; + }; + withTimeout(timeout: Duration): Context; + withValue( + key: string | symbol, + value: T | ((previous: T | undefined) => T), + ): Context; +} + // @public export type RunContainerOptions = { imageName: string; diff --git a/packages/backend-common/package.json b/packages/backend-common/package.json index a5963bc77d..3008d915f9 100644 --- a/packages/backend-common/package.json +++ b/packages/backend-common/package.json @@ -41,6 +41,7 @@ "@types/cors": "^2.8.6", "@types/dockerode": "^3.3.0", "@types/express": "^4.17.6", + "@types/luxon": "^2.0.4", "archiver": "^5.0.2", "aws-sdk": "^2.840.0", "compression": "^1.7.4", @@ -59,6 +60,7 @@ "knex": "^0.95.1", "lodash": "^4.17.21", "logform": "^2.3.2", + "luxon": "^2.0.2", "minimatch": "^3.0.4", "minimist": "^1.2.5", "morgan": "^1.10.0", diff --git a/packages/backend-common/src/context/RootContext.test.ts b/packages/backend-common/src/context/RootContext.test.ts new file mode 100644 index 0000000000..d83485a78b --- /dev/null +++ b/packages/backend-common/src/context/RootContext.test.ts @@ -0,0 +1,60 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Duration } from 'luxon'; +import { RootContext } from './RootContext'; + +describe('RootContext', () => { + it('can perform a manual abort', async () => { + const { ctx, abort } = RootContext.create().withAbort(); + + const cb = jest.fn(); + ctx.abortSignal.addEventListener('abort', cb); + ctx.abortPromise.then(cb); + + abort(); + + await ctx.abortPromise; + expect(cb).toBeCalledTimes(2); + }); + + it('can abort on a timeout', async () => { + const ctx = RootContext.create().withTimeout(Duration.fromMillis(200)); + const start = Date.now(); + + const cb = jest.fn(); + ctx.abortSignal.addEventListener('abort', cb); + ctx.abortPromise.then(cb); + + await ctx.abortPromise; + const delta = Date.now() - start; + + expect(delta).toBeGreaterThan(100); + expect(delta).toBeLessThan(300); + expect(cb).toBeCalledTimes(2); + }); + + it('can apply behaviors', () => { + const ctx = RootContext.create().with( + c => c.withValue('a', 1), + c => c.withValue('a', p => p! + 1), + c => c.withValue('b', 3), + ); + + expect(ctx.value('a')).toBe(2); + expect(ctx.value('b')).toBe(3); + }); +}); diff --git a/packages/backend-common/src/context/RootContext.ts b/packages/backend-common/src/context/RootContext.ts new file mode 100644 index 0000000000..889f9d3f5a --- /dev/null +++ b/packages/backend-common/src/context/RootContext.ts @@ -0,0 +1,123 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { DateTime, Duration } from 'luxon'; +import { AbortSignal } from 'node-abort-controller'; +import { + abortManually, + abortOnTimeout, + ContextAbortState, +} from './features/abort'; +import { + ContextValues, + findInContextValues, + unshiftContextValues, +} from './features/values'; +import { Context, ContextDecorator } from './types'; + +// The context value key used for holding abort related state +const abortKey = Symbol('Context.abort'); + +/** + * A context that is meant to be passed as a ctx variable down the call chain, + * to pass along scoped information and abort signals. + * + * @public + */ +export class RootContext implements Context { + /** + * Creates a root context. + * + * @remarks + * + * This should normally only be called near the root of an application. The + * created context is meant to be passed down into deeper levels, which may + * or may not make derived contexts out of it. + */ + static create() { + return new RootContext(undefined).withValue( + abortKey, + abortManually(), + ); + } + + /** + * {@inheritdoc Context.abortSignal} + */ + public get abortSignal(): AbortSignal { + return this.value(abortKey)!.signal; + } + + /** + * {@inheritdoc Context.abortPromise} + */ + public get abortPromise(): Promise { + return this.value(abortKey)!.promise; + } + + /** + * {@inheritdoc Context.deadline} + */ + public get deadline(): DateTime | undefined { + return this.value(abortKey)!.deadline; + } + + private constructor(private readonly values: ContextValues) {} + + /** + * {@inheritdoc Context.withAbort} + */ + withAbort(): { ctx: Context; abort: () => void } { + const state = abortManually(this.value(abortKey)); + return { + ctx: this.withValue(abortKey, state), + abort: state.abort, + }; + } + + /** + * {@inheritdoc Context.withTimeout} + */ + withTimeout(timeout: Duration): Context { + return this.withValue(abortKey, previous => + abortOnTimeout(timeout, previous), + ); + } + + /** + * {@inheritdoc Context.with} + */ + with(...items: ContextDecorator[]): Context { + return items.reduce((prev, curr) => curr(prev), this); + } + + /** + * {@inheritdoc Context.withValue} + */ + withValue( + key: string | symbol, + value: T | ((previous: T | undefined) => T), + ): Context { + return new RootContext(unshiftContextValues(this.values, key, value)); + } + + /** + * {@inheritdoc Context.value} + */ + value(key: string | symbol): T | undefined { + return findInContextValues(this.values, key); + } +} diff --git a/packages/backend-common/src/context/features/abort.test.ts b/packages/backend-common/src/context/features/abort.test.ts new file mode 100644 index 0000000000..74cd8cbfe2 --- /dev/null +++ b/packages/backend-common/src/context/features/abort.test.ts @@ -0,0 +1,134 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Duration } from 'luxon'; +import { abortManually, abortOnTimeout } from './abort'; + +describe('ContextAbortState', () => { + describe('abortManually', () => { + it('can perform a manual abort', async () => { + const state = abortManually(); + + const cb = jest.fn(); + state.signal.addEventListener('abort', cb); + state.promise.then(cb); + + state.abort(); + + await state.promise; + expect(cb).toBeCalledTimes(2); + }); + + it('triggers child when parent is aborted', async () => { + const parent = abortManually(); + const child = abortManually(parent); + + const parentCb = jest.fn(); + parent.signal.addEventListener('abort', parentCb); + parent.promise.then(parentCb); + + const childCb = jest.fn(); + child.signal.addEventListener('abort', childCb); + child.promise.then(childCb); + + parent.abort(); + + await child.promise; + expect(parentCb).toBeCalledTimes(2); + expect(childCb).toBeCalledTimes(2); + }); + + it('does not trigger parent when child is aborted', async () => { + const parent = abortManually(); + const child = abortManually(parent); + + const parentCb = jest.fn(); + parent.signal.addEventListener('abort', parentCb); + parent.promise.then(parentCb); + + const childCb = jest.fn(); + child.signal.addEventListener('abort', childCb); + child.promise.then(childCb); + + child.abort(); + + await child.promise; + expect(parentCb).toBeCalledTimes(0); + expect(childCb).toBeCalledTimes(2); + }); + + it('only triggers once', async () => { + const state = abortManually(); + + const cb = jest.fn(); + state.signal.addEventListener('abort', cb); + state.promise.then(cb); + + state.abort(); + + await state.promise; + expect(cb).toBeCalledTimes(2); + + state.abort(); + + await state.promise; + expect(cb).toBeCalledTimes(2); + }); + }); + + describe('abortOnTimeout', () => { + it('can abort on a timeout', async () => { + const state = abortOnTimeout(Duration.fromMillis(200)); + const start = Date.now(); + + const cb = jest.fn(); + state.signal.addEventListener('abort', cb); + state.promise.then(cb); + + await state.promise; + const delta = Date.now() - start; + + expect(delta).toBeGreaterThan(100); + expect(delta).toBeLessThan(300); + expect(cb).toBeCalledTimes(2); + }); + + it('aborts early if parent triggers first', async () => { + const parent = abortManually(); + const child = abortOnTimeout(Duration.fromMillis(200), parent); + + const parentCb = jest.fn(); + parent.signal.addEventListener('abort', parentCb); + parent.promise.then(parentCb); + + const childCb = jest.fn(); + child.signal.addEventListener('abort', childCb); + child.promise.then(childCb); + + expect(parentCb).toBeCalledTimes(0); + expect(childCb).toBeCalledTimes(0); + + const start = Date.now(); + + parent.abort(); + + await child.promise; + expect(parentCb).toBeCalledTimes(2); + expect(childCb).toBeCalledTimes(2); + expect(Date.now() - start).toBeLessThan(100); + }); + }); +}); diff --git a/packages/backend-common/src/context/features/abort.ts b/packages/backend-common/src/context/features/abort.ts new file mode 100644 index 0000000000..1a37de960b --- /dev/null +++ b/packages/backend-common/src/context/features/abort.ts @@ -0,0 +1,75 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { DateTime, Duration } from 'luxon'; +import { AbortController, AbortSignal } from 'node-abort-controller'; + +export type ContextAbortState = { + signal: AbortSignal; + promise: Promise; + deadline: DateTime | undefined; + abort: () => void; +}; + +export function abortManually( + previous?: ContextAbortState | undefined, +): ContextAbortState { + const controller = new AbortController(); + const abort = controller.abort.bind(controller); + previous?.signal.addEventListener('abort', abort); + + return { + signal: controller.signal, + promise: new Promise(resolve => { + controller.signal.addEventListener('abort', resolve); + }), + deadline: previous?.deadline, + abort, + }; +} + +export function abortOnTimeout( + timeout: Duration, + previous?: ContextAbortState | undefined, +): ContextAbortState { + const deadline = DateTime.now().plus(timeout); + if (previous?.deadline && deadline > previous.deadline) { + return previous; + } + + const controller = new AbortController(); + + const timeoutHandle = setTimeout(() => { + controller.abort(); + }, timeout.as('milliseconds')); + + const abort = () => { + previous?.signal.removeEventListener('abort', abort); + clearTimeout(timeoutHandle); + controller.abort(); + }; + + previous?.signal.addEventListener('abort', abort); + + return { + signal: controller.signal, + promise: new Promise(resolve => { + controller.signal.addEventListener('abort', resolve); + }), + deadline, + abort, + }; +} diff --git a/packages/backend-common/src/context/features/values.test.ts b/packages/backend-common/src/context/features/values.test.ts new file mode 100644 index 0000000000..63d2da80de --- /dev/null +++ b/packages/backend-common/src/context/features/values.test.ts @@ -0,0 +1,52 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + ContextValues, + findInContextValues, + unshiftContextValues, +} from './values'; + +describe('ContextValues', () => { + it('can start from the empty list', () => { + let list: ContextValues = undefined; + expect(findInContextValues(list, 'a')).toBeUndefined(); + list = unshiftContextValues(list, 'a', 'b'); + expect(findInContextValues(list, 'a')).toBe('b'); + expect(findInContextValues(list, 'x')).toBeUndefined(); + }); + + it('always fetches the most recent value', () => { + let list: ContextValues = undefined; + expect(findInContextValues(list, 'a')).toBeUndefined(); + list = unshiftContextValues(list, 'a', 1); + expect(findInContextValues(list, 'a')).toBe(1); + list = unshiftContextValues(list, 'a', 2); + expect(findInContextValues(list, 'a')).toBe(2); + }); + + it('handles all key types', () => { + let list: ContextValues = undefined; + const symbol1 = Symbol('str'); + const symbol2 = Symbol('str'); + list = unshiftContextValues(list, 'str', 'str'); + list = unshiftContextValues(list, symbol1, 'sym'); + expect(findInContextValues(list, 'str')).toBe('str'); + expect(findInContextValues(list, symbol1)).toBe('sym'); + expect(findInContextValues(list, 'blah')).toBeUndefined(); + expect(findInContextValues(list, symbol2)).toBeUndefined(); + }); +}); diff --git a/packages/backend-common/src/context/features/values.ts b/packages/backend-common/src/context/features/values.ts new file mode 100644 index 0000000000..b7ab062e32 --- /dev/null +++ b/packages/backend-common/src/context/features/values.ts @@ -0,0 +1,73 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * An immutable key-value list. The only operations possible are to add a new + * node at the start forming a new list, and to find by key from the start and + * backwards through the list. + */ +export type ContextValues = ContextValueNode | undefined; + +type ContextValueNode = { + key: string | symbol; + value: unknown; + next: ContextValueNode | undefined; +}; + +/** + * Creates a new list with the given key-value pair as its first element. + * + * @param list - The original list + * @param key - The key of the pair + * @param value - The value of the pair, or a function that accepts the + * previously stored value (or undefined if not found) and + * computes the new value + * @returns A new list with this pair as its first element + */ +export function unshiftContextValues( + list: ContextValues, + key: string | symbol, + value: unknown | ((previous: unknown | undefined) => unknown), +): ContextValues { + return { + key, + value: + typeof value === 'function' + ? value(findInContextValues(list, key)) + : value, + next: list, + }; +} + +/** + * Attempts to find the value associated with a given key, starting from the + * most recently added element. + * + * @param list - The list + * @param key - The key to search for + * @returns The first such value, or undefined if no match was found + */ +export function findInContextValues( + list: ContextValues, + key: string | symbol, +): T | undefined { + for (let current = list; current; current = current.next) { + if (key === current.key) { + return current.value as T; + } + } + return undefined; +} diff --git a/packages/backend-common/src/context/index.ts b/packages/backend-common/src/context/index.ts new file mode 100644 index 0000000000..a09c32bac5 --- /dev/null +++ b/packages/backend-common/src/context/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { RootContext } from './RootContext'; +export type { Context, ContextDecorator } from './types'; diff --git a/packages/backend-common/src/context/types.ts b/packages/backend-common/src/context/types.ts new file mode 100644 index 0000000000..e3c880b4a8 --- /dev/null +++ b/packages/backend-common/src/context/types.ts @@ -0,0 +1,104 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { DateTime, Duration } from 'luxon'; + +/** + * A function that accepts a context and produces a new, derived context from, + * decorated with some specific behavior. + * + * @public + */ +export type ContextDecorator = (ctx: Context) => Context; + +/** + * A context that is meant to be passed as a ctx variable down the call chain, + * to pass along scoped information and abort signals. + * + * @public + */ +export interface Context { + /** + * Returns an abort signal that triggers when the current context or any of + * its parents signal for it. + */ + readonly abortSignal: AbortSignal; + + /** + * Returns a promise that resolves when the current context or any of its + * parents signal to abort. + */ + readonly abortPromise: Promise; + + /** + * The point in time when the current context shall time out and abort, if + * applicable. + */ + readonly deadline: DateTime | undefined; + + /** + * Creates a derived context, which signals to abort operations either when + * any parent context signals, or when the current layer calls the returned + * abort function. + * + * @returns A derived context, and the function that triggers it to abort. + */ + withAbort(): { ctx: Context; abort: () => void }; + + /** + * Creates a derived context, which signals to abort operations either when + * any parent context signals, or when the given amount of time has passed. + * This may affect the deadline. + * + * @param timeout - The duration of time, after which the derived context + * will signal to abort. + * @returns A derived context with an updated deadline + */ + withTimeout(timeout: Duration): Context; + + /** + * Decorates this context with one or more behaviors. + * + * @remarks + * + * The decorators are applied in the order that they are given. + * + * @param decorators - The decorators to apply + * @returns A derived context with the relevant behaviors + */ + with(...decorators: ContextDecorator[]): Context; + + /** + * Creates a derived context, which has a specific key-value pair set as well + * as all key-value pairs set in the original context. + * + * @param key - The key of the value to set + * @param value - The value, or a function that accepts the previous value (or + * undefined if not set yet) and computes the new value + */ + withValue( + key: string | symbol, + value: T | ((previous: T | undefined) => T), + ): Context; + + /** + * Attempts to get a stored value by key from the context. + * + * @param key - The key of the value to get + * @returns The associated value, or undefined if not set + */ + value(key: string | symbol): T | undefined; +} diff --git a/packages/backend-common/src/index.ts b/packages/backend-common/src/index.ts index e430ddc1a4..238e58b2a8 100644 --- a/packages/backend-common/src/index.ts +++ b/packages/backend-common/src/index.ts @@ -22,6 +22,7 @@ export * from './cache'; export { loadBackendConfig } from './config'; +export * from './context'; export * from './database'; export * from './discovery'; export * from './hot'; From 5ae840e4f69445a2ef70433597eebac4666899ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Sat, 27 Nov 2021 12:35:45 +0100 Subject: [PATCH 2/6] Use the simpler abortSignal + deadline + value structure instead MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .changeset/rare-comics-tan.md | 2 +- packages/backend-common/api-report.md | 46 +-- .../src/context/AbortContext.test.ts | 265 ++++++++++++++++++ .../src/context/AbortContext.ts | 96 +++++++ .../backend-common/src/context/Contexts.ts | 107 +++++++ .../src/context/RootContext.test.ts | 48 +--- .../backend-common/src/context/RootContext.ts | 107 +------ .../src/context/ValueContext.test.ts | 57 ++++ .../src/context/ValueContext.ts | 53 ++++ .../src/context/features/abort.test.ts | 134 --------- .../src/context/features/abort.ts | 75 ----- .../src/context/features/values.test.ts | 52 ---- .../src/context/features/values.ts | 73 ----- packages/backend-common/src/context/index.ts | 2 +- packages/backend-common/src/context/types.ts | 53 +--- 15 files changed, 622 insertions(+), 548 deletions(-) create mode 100644 packages/backend-common/src/context/AbortContext.test.ts create mode 100644 packages/backend-common/src/context/AbortContext.ts create mode 100644 packages/backend-common/src/context/Contexts.ts create mode 100644 packages/backend-common/src/context/ValueContext.test.ts create mode 100644 packages/backend-common/src/context/ValueContext.ts delete mode 100644 packages/backend-common/src/context/features/abort.test.ts delete mode 100644 packages/backend-common/src/context/features/abort.ts delete mode 100644 packages/backend-common/src/context/features/values.test.ts delete mode 100644 packages/backend-common/src/context/features/values.ts diff --git a/.changeset/rare-comics-tan.md b/.changeset/rare-comics-tan.md index a067ab2eb4..12d615d3f3 100644 --- a/.changeset/rare-comics-tan.md +++ b/.changeset/rare-comics-tan.md @@ -2,4 +2,4 @@ '@backstage/backend-common': patch --- -Added a Context class for the backend, that handles aborting, timeouts, api resolution etc +Added a `Context` type for the backend, that can propagate an abort signal, a deadline, and contextual values through the call stack. The main entrypoint is the `Contexts` utility class that provides a root context creator and commonly used decorators. diff --git a/packages/backend-common/api-report.md b/packages/backend-common/api-report.md index 8b925c441c..e77beef556 100644 --- a/packages/backend-common/api-report.md +++ b/packages/backend-common/api-report.md @@ -12,7 +12,6 @@ import { AzureIntegration } from '@backstage/integration'; import { BitbucketIntegration } from '@backstage/integration'; import { Config } from '@backstage/config'; import cors from 'cors'; -import { DateTime } from 'luxon'; import Docker from 'dockerode'; import { Duration } from 'luxon'; import { ErrorRequestHandler } from 'express'; @@ -149,25 +148,27 @@ export interface ContainerRunner { // @public export interface Context { - readonly abortPromise: Promise; readonly abortSignal: AbortSignal; - readonly deadline: DateTime | undefined; + readonly deadline: Date | undefined; + use(...decorators: ContextDecorator[]): Context; value(key: string | symbol): T | undefined; - with(...decorators: ContextDecorator[]): Context; - withAbort(): { - ctx: Context; - abort: () => void; - }; - withTimeout(timeout: Duration): Context; - withValue( - key: string | symbol, - value: T | ((previous: T | undefined) => T), - ): Context; } // @public export type ContextDecorator = (ctx: Context) => Context; +// @public +export class Contexts { + static root(): Context; + static setAbort(signal: AbortSignal_2): ContextDecorator; + static setTimeoutDuration(timeout: Duration): ContextDecorator; + static setTimeoutMillis(timeout: number): ContextDecorator; + static setValue( + key: string | symbol, + value: unknown | ((previous: unknown | undefined) => unknown), + ): ContextDecorator; +} + // @public @deprecated export const createDatabase: typeof createDatabaseClient; @@ -479,25 +480,6 @@ export function resolvePackagePath(name: string, ...paths: string[]): string; // @public export function resolveSafeChildPath(base: string, path: string): string; -// @public -export class RootContext implements Context { - get abortPromise(): Promise; - get abortSignal(): AbortSignal_2; - static create(): Context; - get deadline(): DateTime | undefined; - value(key: string | symbol): T | undefined; - with(...items: ContextDecorator[]): Context; - withAbort(): { - ctx: Context; - abort: () => void; - }; - withTimeout(timeout: Duration): Context; - withValue( - key: string | symbol, - value: T | ((previous: T | undefined) => T), - ): Context; -} - // @public export type RunContainerOptions = { imageName: string; diff --git a/packages/backend-common/src/context/AbortContext.test.ts b/packages/backend-common/src/context/AbortContext.test.ts new file mode 100644 index 0000000000..fc7b366c23 --- /dev/null +++ b/packages/backend-common/src/context/AbortContext.test.ts @@ -0,0 +1,265 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AbortController } from 'node-abort-controller'; +import { AbortContext } from './AbortContext'; +import { Contexts } from './Contexts'; +import { RootContext } from './RootContext'; + +describe('AbortContext', () => { + afterEach(() => { + jest.useRealTimers(); + }); + + describe('forTimeoutMillis', () => { + it('can abort on a timeout', async () => { + jest.useFakeTimers(); + const timeout = 200; + const deadline = Date.now() + timeout; + + const root = new RootContext(); + + const child = AbortContext.forTimeoutMillis(root, timeout); + const childListener = jest.fn(); + child.abortSignal.addEventListener('abort', childListener); + + expect(child.abortSignal.aborted).toBe(false); + expect(Math.abs(+child.deadline! - deadline)).toBeLessThan(50); + expect(childListener).toBeCalledTimes(0); + + jest.advanceTimersByTime(timeout + 1); + + expect(child.abortSignal.aborted).toBe(true); + expect(childListener).toBeCalledTimes(1); + }); + + it('results in minimum deadline when parent triggers sooner', async () => { + jest.useFakeTimers(); + const parentTimeout = 200; + const childTimeout = 300; + const parentDeadline = Date.now() + parentTimeout; + const childDeadline = parentDeadline; // clamped + + const root = new RootContext(); + + const parent = AbortContext.forTimeoutMillis(root, parentTimeout); + const parentListener = jest.fn(); + parent.abortSignal.addEventListener('abort', parentListener); + + const child = AbortContext.forTimeoutMillis(parent, childTimeout); + const childListener = jest.fn(); + child.abortSignal.addEventListener('abort', childListener); + + expect(parent.abortSignal.aborted).toBe(false); + expect(child.abortSignal.aborted).toBe(false); + expect(Math.abs(+parent.deadline! - parentDeadline)).toBeLessThan(50); + expect(Math.abs(+child.deadline! - childDeadline)).toBeLessThan(50); + expect(parentListener).toBeCalledTimes(0); + expect(childListener).toBeCalledTimes(0); + + jest.advanceTimersByTime(parentTimeout + 1); + + expect(parent.abortSignal.aborted).toBe(true); + expect(child.abortSignal.aborted).toBe(true); + expect(parentListener).toBeCalledTimes(1); + expect(childListener).toBeCalledTimes(1); + }); + + it('results in minimum deadline when child triggers sooner', async () => { + jest.useFakeTimers(); + const parentTimeout = 300; + const childTimeout = 200; + const parentDeadline = Date.now() + parentTimeout; + const childDeadline = Date.now() + childTimeout; + + const root = new RootContext(); + + const parent = AbortContext.forTimeoutMillis(root, parentTimeout); + const parentListener = jest.fn(); + parent.abortSignal.addEventListener('abort', parentListener); + + const child = AbortContext.forTimeoutMillis(parent, childTimeout); + const childListener = jest.fn(); + child.abortSignal.addEventListener('abort', childListener); + + expect(parent.abortSignal.aborted).toBe(false); + expect(child.abortSignal.aborted).toBe(false); + expect(Math.abs(+parent.deadline! - parentDeadline)).toBeLessThan(50); + expect(Math.abs(+child.deadline! - childDeadline)).toBeLessThan(50); + expect(parentListener).toBeCalledTimes(0); + expect(childListener).toBeCalledTimes(0); + + jest.advanceTimersByTime(childTimeout + 1); + + expect(parent.abortSignal.aborted).toBe(false); + expect(child.abortSignal.aborted).toBe(true); + expect(parentListener).toBeCalledTimes(0); + expect(childListener).toBeCalledTimes(1); + + jest.advanceTimersByTime(parentTimeout - childTimeout + 1); + + expect(parent.abortSignal.aborted).toBe(true); + expect(child.abortSignal.aborted).toBe(true); + expect(parentListener).toBeCalledTimes(1); + expect(childListener).toBeCalledTimes(1); + }); + + it('child carries over parent signal state if parent was already aborted and had no deadline', async () => { + jest.useFakeTimers(); + const childTimeout = 200; + const childDeadline = Date.now() + childTimeout; + + const root = new RootContext(); + + const parentController = new AbortController(); + const parent = AbortContext.forSignal(root, parentController.signal); + + parentController.abort(); + + const child = AbortContext.forTimeoutMillis(parent, childTimeout); + const childListener = jest.fn(); + child.abortSignal.addEventListener('abort', childListener); + + expect(child.abortSignal.aborted).toBe(true); + expect(childListener).toBeCalledTimes(0); + expect(Math.abs(+child.deadline! - childDeadline)).toBeLessThan(50); + + jest.advanceTimersByTime(childTimeout + 1); + + expect(child.abortSignal.aborted).toBe(true); + expect(childListener).toBeCalledTimes(0); // still + }); + + it('child carries over parent signal state if parent was already aborted and had a deadline', async () => { + jest.useFakeTimers(); + const first = new RootContext(); + + const secondController = new AbortController(); + const second = AbortContext.forSignal(first, secondController.signal); + secondController.abort(); + + const third = AbortContext.forTimeoutMillis(second, 200); + const fourth = AbortContext.forTimeoutMillis(third, 300); + + expect(third.abortSignal.aborted).toBe(true); + expect(fourth.abortSignal.aborted).toBe(true); + expect(Math.abs(+fourth.deadline! - Date.now() - 200)).toBeLessThan(50); + }); + }); + + describe('forSignal', () => { + it('signals child when parent is aborted', async () => { + const root = new RootContext(); + + const parentController = new AbortController(); + const parent = AbortContext.forSignal(root, parentController.signal); + const parentListener = jest.fn(); + parent.abortSignal.addEventListener('abort', parentListener); + + const childController = new AbortController(); + const child = AbortContext.forSignal(parent, childController.signal); + const childListener = jest.fn(); + child.abortSignal.addEventListener('abort', childListener); + + expect(parent.abortSignal.aborted).toBe(false); + expect(child.abortSignal.aborted).toBe(false); + expect(parentListener).toBeCalledTimes(0); + expect(childListener).toBeCalledTimes(0); + + parentController.abort(); + + expect(parent.abortSignal.aborted).toBe(true); + expect(child.abortSignal.aborted).toBe(true); + expect(parentListener).toBeCalledTimes(1); + expect(childListener).toBeCalledTimes(1); + }); + + it('does not signal parent when child is aborted', async () => { + const root = new RootContext(); + + const parentController = new AbortController(); + const parent = AbortContext.forSignal(root, parentController.signal); + const parentListener = jest.fn(); + parent.abortSignal.addEventListener('abort', parentListener); + + const childController = new AbortController(); + const child = AbortContext.forSignal(parent, childController.signal); + const childListener = jest.fn(); + child.abortSignal.addEventListener('abort', childListener); + + expect(parent.abortSignal.aborted).toBe(false); + expect(child.abortSignal.aborted).toBe(false); + expect(parentListener).toBeCalledTimes(0); + expect(childListener).toBeCalledTimes(0); + + childController.abort(); + + expect(parent.abortSignal.aborted).toBe(false); + expect(child.abortSignal.aborted).toBe(true); + expect(parentListener).toBeCalledTimes(0); + expect(childListener).toBeCalledTimes(1); + }); + + it('child carries over parent signal state if parent was already aborted', async () => { + const root = new RootContext(); + + const parentController = new AbortController(); + const parent = AbortContext.forSignal(root, parentController.signal); + + parentController.abort(); + + const childController = new AbortController(); + const child = AbortContext.forSignal(parent, childController.signal); + const childListener = jest.fn(); + child.abortSignal.addEventListener('abort', childListener); + + expect(parent.abortSignal.aborted).toBe(true); + expect(child.abortSignal.aborted).toBe(true); + expect(childListener).toBeCalledTimes(0); + + childController.abort(); + + expect(parent.abortSignal.aborted).toBe(true); + expect(child.abortSignal.aborted).toBe(true); + expect(childListener).toBeCalledTimes(0); + }); + + it('child carries over given signal state if it was already aborted', async () => { + const root = new RootContext(); + + const childController = new AbortController(); + childController.abort(); + + const child = AbortContext.forSignal(root, childController.signal); + const childListener = jest.fn(); + child.abortSignal.addEventListener('abort', childListener); + + expect(child.abortSignal.aborted).toBe(true); + expect(childListener).toBeCalledTimes(0); + }); + }); + + it('can decorate', () => { + const root = new RootContext(); + const controller = new AbortController(); + const parent = AbortContext.forSignal(root, controller.signal); + const child = parent.use( + Contexts.setValue('a', 2), + Contexts.setValue('a', 3), + ); + expect(child.value('a')).toBe(3); + }); +}); diff --git a/packages/backend-common/src/context/AbortContext.ts b/packages/backend-common/src/context/AbortContext.ts new file mode 100644 index 0000000000..a328241de2 --- /dev/null +++ b/packages/backend-common/src/context/AbortContext.ts @@ -0,0 +1,96 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AbortSignal } from 'node-abort-controller'; +import { Context, ContextDecorator } from './types'; + +/** + * A context that implements various abort related functionality. + */ +export class AbortContext implements Context { + /** + * Abort either when the parent aborts, or after the given timeout has + * expired. + */ + static forTimeoutMillis(ctx: Context, timeout: number): Context { + const desiredDeadline = new Date(Date.now() + timeout); + const actualDeadline = + ctx.deadline && ctx.deadline < desiredDeadline + ? ctx.deadline + : desiredDeadline; + + if (ctx.abortSignal.aborted) { + if (ctx.deadline && desiredDeadline === actualDeadline) { + return ctx; + } + return new AbortContext(ctx, ctx.abortSignal, actualDeadline); + } + + const controller = new AbortController(); + + const timeoutHandle = setTimeout(() => { + controller.abort(); + }, timeout); + + const abort = () => { + ctx.abortSignal.removeEventListener('abort', abort); + clearTimeout(timeoutHandle); + controller.abort(); + }; + + ctx.abortSignal.addEventListener('abort', abort); + + return new AbortContext(ctx, controller.signal, actualDeadline); + } + + /** + * Abort either when the parent aborts, or when the given signal is triggered. + */ + static forSignal(ctx: Context, signal: AbortSignal): Context { + // If the parent context was already aborted, it is fine to reuse as-is + if (ctx.abortSignal.aborted) { + return ctx; + } + + const controller = new AbortController(); + const abort = controller.abort.bind(controller); + + // If the incoming signal was already aborted, let's trigger the new one as + // well + if (signal.aborted) { + abort(); + } else { + ctx.abortSignal.addEventListener('abort', abort); + signal.addEventListener('abort', abort); + } + + return new AbortContext(ctx, controller.signal, ctx.deadline); + } + + private constructor( + private readonly parent: Context, + readonly abortSignal: AbortSignal, + readonly deadline: Date | undefined, + ) {} + + value(key: string | symbol): T | undefined { + return this.parent.value(key); + } + + use(...items: ContextDecorator[]): Context { + return items.reduce((prev, curr) => curr(prev), this as Context); + } +} diff --git a/packages/backend-common/src/context/Contexts.ts b/packages/backend-common/src/context/Contexts.ts new file mode 100644 index 0000000000..775f6f730d --- /dev/null +++ b/packages/backend-common/src/context/Contexts.ts @@ -0,0 +1,107 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Duration } from 'luxon'; +import { AbortSignal } from 'node-abort-controller'; +import { AbortContext } from './AbortContext'; +import { RootContext } from './RootContext'; +import { Context, ContextDecorator } from './types'; +import { ValueContext } from './ValueContext'; + +/** + * Common context decorators. + * + * @public + */ +export class Contexts { + /** + * Creates a root context. + * + * @remarks + * + * This should normally only be called near the root of an application. The + * created context is meant to be passed down into deeper levels, which may or + * may not make derived contexts out of it. + */ + static root(): Context { + return new RootContext(); + } + + /** + * Creates a derived context, which signals to abort operations either when + * any parent context signals, or when the given controller is aborted. + * + * @remarks + * + * If the parent context was already aborted, then it is returned as-is. + * + * If the given signal was already aborted, then a new already-aborted context + * is returned. + * + * @param signal - An abort signal that you intend to perhaps trigger at some + * later point in time. + * @returns A decorator that can be passed to {@link Context.use} + */ + static setAbort(signal: AbortSignal): ContextDecorator { + return ctx => AbortContext.forSignal(ctx, signal); + } + + /** + * Creates a derived context, which signals to abort operations either when + * any parent context signals, or when the given amount of time has passed. + * This may affect the deadline. + * + * @param timeout - The duration of time, after which the derived context will + * signal to abort. + * @returns A decorator that can be passed to {@link Context.use} + */ + static setTimeoutDuration(timeout: Duration): ContextDecorator { + return ctx => + AbortContext.forTimeoutMillis(ctx, timeout.as('milliseconds')); + } + + /** + * Creates a derived context, which signals to abort operations either when + * any parent context signals, or when the given amount of time has passed. + * This may affect the deadline. + * + * @param timeout - The number of milliseconds, after which the derived + * context will signal to abort. + * @returns A decorator that can be passed to {@link Context.use} + */ + static setTimeoutMillis(timeout: number): ContextDecorator { + return ctx => AbortContext.forTimeoutMillis(ctx, timeout); + } + + /** + * Creates a derived context, which has a specific key-value pair set as well + * as all key-value pairs that were set in the original context. + * + * @param key - The key of the value to set + * @param value - The value, or a function that accepts the previous value (or + * undefined if not set yet) and computes the new value + * @returns A decorator that can be passed to {@link Context.use} + */ + static setValue( + key: string | symbol, + value: unknown | ((previous: unknown | undefined) => unknown), + ): ContextDecorator { + return ctx => { + const v = typeof value === 'function' ? value(ctx.value(key)) : value; + return ValueContext.forConstantValue(ctx, key, v); + }; + } +} diff --git a/packages/backend-common/src/context/RootContext.test.ts b/packages/backend-common/src/context/RootContext.test.ts index d83485a78b..28a3af0a52 100644 --- a/packages/backend-common/src/context/RootContext.test.ts +++ b/packages/backend-common/src/context/RootContext.test.ts @@ -14,47 +14,23 @@ * limitations under the License. */ -import { Duration } from 'luxon'; +import { Contexts } from './Contexts'; import { RootContext } from './RootContext'; describe('RootContext', () => { - it('can perform a manual abort', async () => { - const { ctx, abort } = RootContext.create().withAbort(); - - const cb = jest.fn(); - ctx.abortSignal.addEventListener('abort', cb); - ctx.abortPromise.then(cb); - - abort(); - - await ctx.abortPromise; - expect(cb).toBeCalledTimes(2); + it('returns empty values', async () => { + const ctx = new RootContext(); + expect(ctx.abortSignal).toBeDefined(); + expect(ctx.deadline).toBeUndefined(); + expect(ctx.value('a')).toBeUndefined(); }); - it('can abort on a timeout', async () => { - const ctx = RootContext.create().withTimeout(Duration.fromMillis(200)); - const start = Date.now(); - - const cb = jest.fn(); - ctx.abortSignal.addEventListener('abort', cb); - ctx.abortPromise.then(cb); - - await ctx.abortPromise; - const delta = Date.now() - start; - - expect(delta).toBeGreaterThan(100); - expect(delta).toBeLessThan(300); - expect(cb).toBeCalledTimes(2); - }); - - it('can apply behaviors', () => { - const ctx = RootContext.create().with( - c => c.withValue('a', 1), - c => c.withValue('a', p => p! + 1), - c => c.withValue('b', 3), + it('can decorate', () => { + const parent = new RootContext(); + const child = parent.use( + Contexts.setValue('a', 2), + Contexts.setValue('a', 3), ); - - expect(ctx.value('a')).toBe(2); - expect(ctx.value('b')).toBe(3); + expect(child.value('a')).toBe(3); }); }); diff --git a/packages/backend-common/src/context/RootContext.ts b/packages/backend-common/src/context/RootContext.ts index 889f9d3f5a..ae7c18a905 100644 --- a/packages/backend-common/src/context/RootContext.ts +++ b/packages/backend-common/src/context/RootContext.ts @@ -14,110 +14,23 @@ * limitations under the License. */ -import { DateTime, Duration } from 'luxon'; -import { AbortSignal } from 'node-abort-controller'; -import { - abortManually, - abortOnTimeout, - ContextAbortState, -} from './features/abort'; -import { - ContextValues, - findInContextValues, - unshiftContextValues, -} from './features/values'; +import { AbortController } from 'node-abort-controller'; import { Context, ContextDecorator } from './types'; -// The context value key used for holding abort related state -const abortKey = Symbol('Context.abort'); +const neverAborts = new AbortController().signal; /** - * A context that is meant to be passed as a ctx variable down the call chain, - * to pass along scoped information and abort signals. - * - * @public + * An empty root context. */ export class RootContext implements Context { - /** - * Creates a root context. - * - * @remarks - * - * This should normally only be called near the root of an application. The - * created context is meant to be passed down into deeper levels, which may - * or may not make derived contexts out of it. - */ - static create() { - return new RootContext(undefined).withValue( - abortKey, - abortManually(), - ); + readonly abortSignal = neverAborts; + readonly deadline = undefined; + + value(_key: string | symbol): T | undefined { + return undefined; } - /** - * {@inheritdoc Context.abortSignal} - */ - public get abortSignal(): AbortSignal { - return this.value(abortKey)!.signal; - } - - /** - * {@inheritdoc Context.abortPromise} - */ - public get abortPromise(): Promise { - return this.value(abortKey)!.promise; - } - - /** - * {@inheritdoc Context.deadline} - */ - public get deadline(): DateTime | undefined { - return this.value(abortKey)!.deadline; - } - - private constructor(private readonly values: ContextValues) {} - - /** - * {@inheritdoc Context.withAbort} - */ - withAbort(): { ctx: Context; abort: () => void } { - const state = abortManually(this.value(abortKey)); - return { - ctx: this.withValue(abortKey, state), - abort: state.abort, - }; - } - - /** - * {@inheritdoc Context.withTimeout} - */ - withTimeout(timeout: Duration): Context { - return this.withValue(abortKey, previous => - abortOnTimeout(timeout, previous), - ); - } - - /** - * {@inheritdoc Context.with} - */ - with(...items: ContextDecorator[]): Context { - return items.reduce((prev, curr) => curr(prev), this); - } - - /** - * {@inheritdoc Context.withValue} - */ - withValue( - key: string | symbol, - value: T | ((previous: T | undefined) => T), - ): Context { - return new RootContext(unshiftContextValues(this.values, key, value)); - } - - /** - * {@inheritdoc Context.value} - */ - value(key: string | symbol): T | undefined { - return findInContextValues(this.values, key); + use(...items: ContextDecorator[]): Context { + return items.reduce((prev, curr) => curr(prev), this as Context); } } diff --git a/packages/backend-common/src/context/ValueContext.test.ts b/packages/backend-common/src/context/ValueContext.test.ts new file mode 100644 index 0000000000..99d6b2b60a --- /dev/null +++ b/packages/backend-common/src/context/ValueContext.test.ts @@ -0,0 +1,57 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Contexts } from './Contexts'; +import { RootContext } from './RootContext'; +import { ValueContext } from './ValueContext'; + +const s = Symbol(); + +describe('ValueContext', () => { + it('returns its own values, or delegates to the parent', async () => { + const root = new RootContext(); + const a = ValueContext.forConstantValue(root, 'a', 1); + const b = ValueContext.forConstantValue(a, s, 2); + const c = ValueContext.forConstantValue(b, 'a', 3); + const d = ValueContext.forConstantValue(c, 'b', 4); + + expect(a.value('a')).toBe(1); + expect(a.value('b')).toBeUndefined(); + expect(a.value(s)).toBeUndefined(); + + expect(b.value('a')).toBe(1); + expect(b.value('b')).toBeUndefined(); + expect(b.value(s)).toBe(2); + + expect(c.value('a')).toBe(3); + expect(c.value('b')).toBeUndefined(); + expect(c.value(s)).toBe(2); + + expect(d.value('a')).toBe(3); + expect(d.value('b')).toBe(4); + expect(d.value(s)).toBe(2); + }); + + it('can decorate', () => { + const root = new RootContext(); + const parent = ValueContext.forConstantValue(root, 'a', 1); + const child = parent.use( + Contexts.setValue('a', 2), + Contexts.setValue('a', 3), + ); + expect(child.value('a')).toBe(3); + }); +}); diff --git a/packages/backend-common/src/context/ValueContext.ts b/packages/backend-common/src/context/ValueContext.ts new file mode 100644 index 0000000000..1ed5e2dba5 --- /dev/null +++ b/packages/backend-common/src/context/ValueContext.ts @@ -0,0 +1,53 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Context, ContextDecorator } from './types'; + +/** + * A context that just holds a single value, and delegates the rest to its + * parent. + */ +export class ValueContext implements Context { + static forConstantValue( + ctx: Context, + key: string | symbol, + value: unknown, + ): Context { + return new ValueContext(ctx, key, value); + } + + constructor( + private readonly _parent: Context, + private readonly _key: string | symbol, + private readonly _value: unknown, + ) {} + + get abortSignal(): AbortSignal { + return this._parent.abortSignal; + } + + get deadline(): Date | undefined { + return this._parent.deadline; + } + + value(key: string | symbol): T | undefined { + return key === this._key ? (this._value as T) : this._parent.value(key); + } + + use(...items: ContextDecorator[]): Context { + return items.reduce((prev, curr) => curr(prev), this as Context); + } +} diff --git a/packages/backend-common/src/context/features/abort.test.ts b/packages/backend-common/src/context/features/abort.test.ts deleted file mode 100644 index 74cd8cbfe2..0000000000 --- a/packages/backend-common/src/context/features/abort.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -/* - * Copyright 2021 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Duration } from 'luxon'; -import { abortManually, abortOnTimeout } from './abort'; - -describe('ContextAbortState', () => { - describe('abortManually', () => { - it('can perform a manual abort', async () => { - const state = abortManually(); - - const cb = jest.fn(); - state.signal.addEventListener('abort', cb); - state.promise.then(cb); - - state.abort(); - - await state.promise; - expect(cb).toBeCalledTimes(2); - }); - - it('triggers child when parent is aborted', async () => { - const parent = abortManually(); - const child = abortManually(parent); - - const parentCb = jest.fn(); - parent.signal.addEventListener('abort', parentCb); - parent.promise.then(parentCb); - - const childCb = jest.fn(); - child.signal.addEventListener('abort', childCb); - child.promise.then(childCb); - - parent.abort(); - - await child.promise; - expect(parentCb).toBeCalledTimes(2); - expect(childCb).toBeCalledTimes(2); - }); - - it('does not trigger parent when child is aborted', async () => { - const parent = abortManually(); - const child = abortManually(parent); - - const parentCb = jest.fn(); - parent.signal.addEventListener('abort', parentCb); - parent.promise.then(parentCb); - - const childCb = jest.fn(); - child.signal.addEventListener('abort', childCb); - child.promise.then(childCb); - - child.abort(); - - await child.promise; - expect(parentCb).toBeCalledTimes(0); - expect(childCb).toBeCalledTimes(2); - }); - - it('only triggers once', async () => { - const state = abortManually(); - - const cb = jest.fn(); - state.signal.addEventListener('abort', cb); - state.promise.then(cb); - - state.abort(); - - await state.promise; - expect(cb).toBeCalledTimes(2); - - state.abort(); - - await state.promise; - expect(cb).toBeCalledTimes(2); - }); - }); - - describe('abortOnTimeout', () => { - it('can abort on a timeout', async () => { - const state = abortOnTimeout(Duration.fromMillis(200)); - const start = Date.now(); - - const cb = jest.fn(); - state.signal.addEventListener('abort', cb); - state.promise.then(cb); - - await state.promise; - const delta = Date.now() - start; - - expect(delta).toBeGreaterThan(100); - expect(delta).toBeLessThan(300); - expect(cb).toBeCalledTimes(2); - }); - - it('aborts early if parent triggers first', async () => { - const parent = abortManually(); - const child = abortOnTimeout(Duration.fromMillis(200), parent); - - const parentCb = jest.fn(); - parent.signal.addEventListener('abort', parentCb); - parent.promise.then(parentCb); - - const childCb = jest.fn(); - child.signal.addEventListener('abort', childCb); - child.promise.then(childCb); - - expect(parentCb).toBeCalledTimes(0); - expect(childCb).toBeCalledTimes(0); - - const start = Date.now(); - - parent.abort(); - - await child.promise; - expect(parentCb).toBeCalledTimes(2); - expect(childCb).toBeCalledTimes(2); - expect(Date.now() - start).toBeLessThan(100); - }); - }); -}); diff --git a/packages/backend-common/src/context/features/abort.ts b/packages/backend-common/src/context/features/abort.ts deleted file mode 100644 index 1a37de960b..0000000000 --- a/packages/backend-common/src/context/features/abort.ts +++ /dev/null @@ -1,75 +0,0 @@ -/* - * Copyright 2021 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { DateTime, Duration } from 'luxon'; -import { AbortController, AbortSignal } from 'node-abort-controller'; - -export type ContextAbortState = { - signal: AbortSignal; - promise: Promise; - deadline: DateTime | undefined; - abort: () => void; -}; - -export function abortManually( - previous?: ContextAbortState | undefined, -): ContextAbortState { - const controller = new AbortController(); - const abort = controller.abort.bind(controller); - previous?.signal.addEventListener('abort', abort); - - return { - signal: controller.signal, - promise: new Promise(resolve => { - controller.signal.addEventListener('abort', resolve); - }), - deadline: previous?.deadline, - abort, - }; -} - -export function abortOnTimeout( - timeout: Duration, - previous?: ContextAbortState | undefined, -): ContextAbortState { - const deadline = DateTime.now().plus(timeout); - if (previous?.deadline && deadline > previous.deadline) { - return previous; - } - - const controller = new AbortController(); - - const timeoutHandle = setTimeout(() => { - controller.abort(); - }, timeout.as('milliseconds')); - - const abort = () => { - previous?.signal.removeEventListener('abort', abort); - clearTimeout(timeoutHandle); - controller.abort(); - }; - - previous?.signal.addEventListener('abort', abort); - - return { - signal: controller.signal, - promise: new Promise(resolve => { - controller.signal.addEventListener('abort', resolve); - }), - deadline, - abort, - }; -} diff --git a/packages/backend-common/src/context/features/values.test.ts b/packages/backend-common/src/context/features/values.test.ts deleted file mode 100644 index 63d2da80de..0000000000 --- a/packages/backend-common/src/context/features/values.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright 2021 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { - ContextValues, - findInContextValues, - unshiftContextValues, -} from './values'; - -describe('ContextValues', () => { - it('can start from the empty list', () => { - let list: ContextValues = undefined; - expect(findInContextValues(list, 'a')).toBeUndefined(); - list = unshiftContextValues(list, 'a', 'b'); - expect(findInContextValues(list, 'a')).toBe('b'); - expect(findInContextValues(list, 'x')).toBeUndefined(); - }); - - it('always fetches the most recent value', () => { - let list: ContextValues = undefined; - expect(findInContextValues(list, 'a')).toBeUndefined(); - list = unshiftContextValues(list, 'a', 1); - expect(findInContextValues(list, 'a')).toBe(1); - list = unshiftContextValues(list, 'a', 2); - expect(findInContextValues(list, 'a')).toBe(2); - }); - - it('handles all key types', () => { - let list: ContextValues = undefined; - const symbol1 = Symbol('str'); - const symbol2 = Symbol('str'); - list = unshiftContextValues(list, 'str', 'str'); - list = unshiftContextValues(list, symbol1, 'sym'); - expect(findInContextValues(list, 'str')).toBe('str'); - expect(findInContextValues(list, symbol1)).toBe('sym'); - expect(findInContextValues(list, 'blah')).toBeUndefined(); - expect(findInContextValues(list, symbol2)).toBeUndefined(); - }); -}); diff --git a/packages/backend-common/src/context/features/values.ts b/packages/backend-common/src/context/features/values.ts deleted file mode 100644 index b7ab062e32..0000000000 --- a/packages/backend-common/src/context/features/values.ts +++ /dev/null @@ -1,73 +0,0 @@ -/* - * Copyright 2021 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** - * An immutable key-value list. The only operations possible are to add a new - * node at the start forming a new list, and to find by key from the start and - * backwards through the list. - */ -export type ContextValues = ContextValueNode | undefined; - -type ContextValueNode = { - key: string | symbol; - value: unknown; - next: ContextValueNode | undefined; -}; - -/** - * Creates a new list with the given key-value pair as its first element. - * - * @param list - The original list - * @param key - The key of the pair - * @param value - The value of the pair, or a function that accepts the - * previously stored value (or undefined if not found) and - * computes the new value - * @returns A new list with this pair as its first element - */ -export function unshiftContextValues( - list: ContextValues, - key: string | symbol, - value: unknown | ((previous: unknown | undefined) => unknown), -): ContextValues { - return { - key, - value: - typeof value === 'function' - ? value(findInContextValues(list, key)) - : value, - next: list, - }; -} - -/** - * Attempts to find the value associated with a given key, starting from the - * most recently added element. - * - * @param list - The list - * @param key - The key to search for - * @returns The first such value, or undefined if no match was found - */ -export function findInContextValues( - list: ContextValues, - key: string | symbol, -): T | undefined { - for (let current = list; current; current = current.next) { - if (key === current.key) { - return current.value as T; - } - } - return undefined; -} diff --git a/packages/backend-common/src/context/index.ts b/packages/backend-common/src/context/index.ts index a09c32bac5..64fe635de9 100644 --- a/packages/backend-common/src/context/index.ts +++ b/packages/backend-common/src/context/index.ts @@ -14,5 +14,5 @@ * limitations under the License. */ -export { RootContext } from './RootContext'; +export { Contexts } from './Contexts'; export type { Context, ContextDecorator } from './types'; diff --git a/packages/backend-common/src/context/types.ts b/packages/backend-common/src/context/types.ts index e3c880b4a8..569a133c87 100644 --- a/packages/backend-common/src/context/types.ts +++ b/packages/backend-common/src/context/types.ts @@ -14,8 +14,6 @@ * limitations under the License. */ -import { DateTime, Duration } from 'luxon'; - /** * A function that accepts a context and produces a new, derived context from, * decorated with some specific behavior. @@ -37,37 +35,19 @@ export interface Context { */ readonly abortSignal: AbortSignal; - /** - * Returns a promise that resolves when the current context or any of its - * parents signal to abort. - */ - readonly abortPromise: Promise; - /** * The point in time when the current context shall time out and abort, if * applicable. */ - readonly deadline: DateTime | undefined; + readonly deadline: Date | undefined; /** - * Creates a derived context, which signals to abort operations either when - * any parent context signals, or when the current layer calls the returned - * abort function. + * Attempts to get a stored value by key from the context. * - * @returns A derived context, and the function that triggers it to abort. + * @param key - The key of the value to get + * @returns The associated value, or undefined if not set */ - withAbort(): { ctx: Context; abort: () => void }; - - /** - * Creates a derived context, which signals to abort operations either when - * any parent context signals, or when the given amount of time has passed. - * This may affect the deadline. - * - * @param timeout - The duration of time, after which the derived context - * will signal to abort. - * @returns A derived context with an updated deadline - */ - withTimeout(timeout: Duration): Context; + value(key: string | symbol): T | undefined; /** * Decorates this context with one or more behaviors. @@ -79,26 +59,5 @@ export interface Context { * @param decorators - The decorators to apply * @returns A derived context with the relevant behaviors */ - with(...decorators: ContextDecorator[]): Context; - - /** - * Creates a derived context, which has a specific key-value pair set as well - * as all key-value pairs set in the original context. - * - * @param key - The key of the value to set - * @param value - The value, or a function that accepts the previous value (or - * undefined if not set yet) and computes the new value - */ - withValue( - key: string | symbol, - value: T | ((previous: T | undefined) => T), - ): Context; - - /** - * Attempts to get a stored value by key from the context. - * - * @param key - The key of the value to get - * @returns The associated value, or undefined if not set - */ - value(key: string | symbol): T | undefined; + use(...decorators: ContextDecorator[]): Context; } From 95e702e50d40b9801fcd3d91a62edbd191b9747a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Sun, 28 Nov 2021 18:40:22 +0100 Subject: [PATCH 3/6] ability to abort on controllers instead of just signals, plus more tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- packages/backend-common/api-report.md | 5 +- .../src/context/AbortContext.test.ts | 92 +++++++++++++++++++ .../src/context/AbortContext.ts | 87 +++++++++++++----- .../src/context/Contexts.test.ts | 89 ++++++++++++++++++ .../backend-common/src/context/Contexts.ts | 17 ++-- .../backend-common/src/context/RootContext.ts | 4 +- .../src/context/ValueContext.ts | 1 + packages/backend-common/src/context/types.ts | 2 + 8 files changed, 262 insertions(+), 35 deletions(-) create mode 100644 packages/backend-common/src/context/Contexts.test.ts diff --git a/packages/backend-common/api-report.md b/packages/backend-common/api-report.md index e77beef556..d63eeaf11b 100644 --- a/packages/backend-common/api-report.md +++ b/packages/backend-common/api-report.md @@ -6,6 +6,7 @@ /// /// +import { AbortController as AbortController_2 } from 'node-abort-controller'; import { AbortSignal as AbortSignal_2 } from 'node-abort-controller'; import { AwsS3Integration } from '@backstage/integration'; import { AzureIntegration } from '@backstage/integration'; @@ -148,7 +149,7 @@ export interface ContainerRunner { // @public export interface Context { - readonly abortSignal: AbortSignal; + readonly abortSignal: AbortSignal_2; readonly deadline: Date | undefined; use(...decorators: ContextDecorator[]): Context; value(key: string | symbol): T | undefined; @@ -160,7 +161,7 @@ export type ContextDecorator = (ctx: Context) => Context; // @public export class Contexts { static root(): Context; - static setAbort(signal: AbortSignal_2): ContextDecorator; + static setAbort(source: AbortController_2 | AbortSignal_2): ContextDecorator; static setTimeoutDuration(timeout: Duration): ContextDecorator; static setTimeoutMillis(timeout: number): ContextDecorator; static setValue( diff --git a/packages/backend-common/src/context/AbortContext.test.ts b/packages/backend-common/src/context/AbortContext.test.ts index fc7b366c23..aa01c2209a 100644 --- a/packages/backend-common/src/context/AbortContext.test.ts +++ b/packages/backend-common/src/context/AbortContext.test.ts @@ -160,6 +160,98 @@ describe('AbortContext', () => { }); }); + describe('forController', () => { + it('signals child when parent is aborted', () => { + const root = new RootContext(); + + const parentController = new AbortController(); + const parent = AbortContext.forController(root, parentController); + const parentListener = jest.fn(); + parent.abortSignal.addEventListener('abort', parentListener); + + const childController = new AbortController(); + const child = AbortContext.forController(parent, childController); + const childListener = jest.fn(); + child.abortSignal.addEventListener('abort', childListener); + + expect(parent.abortSignal.aborted).toBe(false); + expect(child.abortSignal.aborted).toBe(false); + expect(parentListener).toBeCalledTimes(0); + expect(childListener).toBeCalledTimes(0); + + parentController.abort(); + + expect(parent.abortSignal.aborted).toBe(true); + expect(child.abortSignal.aborted).toBe(true); + expect(parentListener).toBeCalledTimes(1); + expect(childListener).toBeCalledTimes(1); + }); + + it('does not signal parent when child is aborted', async () => { + const root = new RootContext(); + + const parentController = new AbortController(); + const parent = AbortContext.forController(root, parentController); + const parentListener = jest.fn(); + parent.abortSignal.addEventListener('abort', parentListener); + + const childController = new AbortController(); + const child = AbortContext.forController(parent, childController); + const childListener = jest.fn(); + child.abortSignal.addEventListener('abort', childListener); + + expect(parent.abortSignal.aborted).toBe(false); + expect(child.abortSignal.aborted).toBe(false); + expect(parentListener).toBeCalledTimes(0); + expect(childListener).toBeCalledTimes(0); + + childController.abort(); + + expect(parent.abortSignal.aborted).toBe(false); + expect(child.abortSignal.aborted).toBe(true); + expect(parentListener).toBeCalledTimes(0); + expect(childListener).toBeCalledTimes(1); + }); + + it('child carries over parent signal state if parent was already aborted', async () => { + const root = new RootContext(); + + const parentController = new AbortController(); + const parent = AbortContext.forController(root, parentController); + + parentController.abort(); + + const childController = new AbortController(); + const child = AbortContext.forController(parent, childController); + const childListener = jest.fn(); + child.abortSignal.addEventListener('abort', childListener); + + expect(parent.abortSignal.aborted).toBe(true); + expect(child.abortSignal.aborted).toBe(true); + expect(childListener).toBeCalledTimes(0); + + childController.abort(); + + expect(parent.abortSignal.aborted).toBe(true); + expect(child.abortSignal.aborted).toBe(true); + expect(childListener).toBeCalledTimes(0); + }); + + it('child carries over given signal state if it was already aborted', async () => { + const root = new RootContext(); + + const childController = new AbortController(); + childController.abort(); + + const child = AbortContext.forController(root, childController); + const childListener = jest.fn(); + child.abortSignal.addEventListener('abort', childListener); + + expect(child.abortSignal.aborted).toBe(true); + expect(childListener).toBeCalledTimes(0); + }); + }); + describe('forSignal', () => { it('signals child when parent is aborted', async () => { const root = new RootContext(); diff --git a/packages/backend-common/src/context/AbortContext.ts b/packages/backend-common/src/context/AbortContext.ts index a328241de2..78ad8646ed 100644 --- a/packages/backend-common/src/context/AbortContext.ts +++ b/packages/backend-common/src/context/AbortContext.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { AbortSignal } from 'node-abort-controller'; +import { AbortController, AbortSignal } from 'node-abort-controller'; import { Context, ContextDecorator } from './types'; /** @@ -24,6 +24,10 @@ export class AbortContext implements Context { /** * Abort either when the parent aborts, or after the given timeout has * expired. + * + * @param ctx - The parent context + * @param timeout - A timeout value, in milliseconds + * @returns A new context */ static forTimeoutMillis(ctx: Context, timeout: number): Context { const desiredDeadline = new Date(Date.now() + timeout); @@ -40,43 +44,80 @@ export class AbortContext implements Context { } const controller = new AbortController(); - - const timeoutHandle = setTimeout(() => { - controller.abort(); - }, timeout); - - const abort = () => { - ctx.abortSignal.removeEventListener('abort', abort); - clearTimeout(timeoutHandle); - controller.abort(); - }; - + const timeoutHandle = setTimeout(abort, timeout); ctx.abortSignal.addEventListener('abort', abort); + function abort() { + ctx.abortSignal.removeEventListener('abort', abort); + clearTimeout(timeoutHandle!); + controller.abort(); + } + return new AbortContext(ctx, controller.signal, actualDeadline); } /** - * Abort either when the parent aborts, or when the given signal is triggered. + * Abort either when the parent aborts, or when the given controller is + * triggered. + * + * @remarks + * + * If you have access to the controller, this function is more efficient than + * {@link AbortContext#forSignal}. + * + * @param ctx - The parent context + * @param controller - An abort controller + * @returns A new context */ - static forSignal(ctx: Context, signal: AbortSignal): Context { - // If the parent context was already aborted, it is fine to reuse as-is + static forController(ctx: Context, controller: AbortController): Context { + // Already aborted context / signal are fine to reuse as-is if (ctx.abortSignal.aborted) { return ctx; + } else if (controller.signal.aborted) { + return new AbortContext(ctx, controller.signal, ctx.deadline); + } + + function abort() { + ctx.abortSignal.removeEventListener('abort', abort); + controller.abort(); + } + + ctx.abortSignal.addEventListener('abort', abort); + + return new AbortContext(ctx, controller.signal, ctx.deadline); + } + + /** + * Abort either when the parent aborts, or when the given signal is triggered. + * + * @remarks + * + * If you have access to the controller and not just the signal, + * {@link AbortContext#forController} is slightly more efficient to use. + * + * @param ctx - The parent context + * @param signal - An abort signal + * @returns A new context + */ + static forSignal(ctx: Context, signal: AbortSignal): Context { + // Already aborted context / signal are fine to reuse as-is + if (ctx.abortSignal.aborted) { + return ctx; + } else if (signal.aborted) { + return new AbortContext(ctx, signal, ctx.deadline); } const controller = new AbortController(); - const abort = controller.abort.bind(controller); - // If the incoming signal was already aborted, let's trigger the new one as - // well - if (signal.aborted) { - abort(); - } else { - ctx.abortSignal.addEventListener('abort', abort); - signal.addEventListener('abort', abort); + function abort() { + ctx.abortSignal.removeEventListener('abort', abort); + signal.removeEventListener('abort', abort); + controller.abort(); } + ctx.abortSignal.addEventListener('abort', abort); + signal.addEventListener('abort', abort); + return new AbortContext(ctx, controller.signal, ctx.deadline); } diff --git a/packages/backend-common/src/context/Contexts.test.ts b/packages/backend-common/src/context/Contexts.test.ts new file mode 100644 index 0000000000..6c05319a4f --- /dev/null +++ b/packages/backend-common/src/context/Contexts.test.ts @@ -0,0 +1,89 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Duration } from 'luxon'; +import { AbortController } from 'node-abort-controller'; +import { Contexts } from './Contexts'; + +describe('Contexts', () => { + afterEach(() => { + jest.useRealTimers(); + }); + + describe('root', () => { + it('can create a root', () => { + const ctx = Contexts.root(); + expect(ctx.abortSignal).toBeDefined(); + expect(ctx.deadline).toBeUndefined(); + }); + }); + + describe('setAbort', () => { + it('works for controllers', () => { + const controller = new AbortController(); + const parent = Contexts.root(); + const child = parent.use(Contexts.setAbort(controller)); + expect(child.abortSignal.aborted).toBe(false); + controller.abort(); + expect(child.abortSignal.aborted).toBe(true); + }); + + it('works for signals', () => { + const controller = new AbortController(); + const parent = Contexts.root(); + const child = parent.use(Contexts.setAbort(controller.signal)); + expect(child.abortSignal.aborted).toBe(false); + controller.abort(); + expect(child.abortSignal.aborted).toBe(true); + }); + }); + + describe('setTimeoutDuration', () => { + it('works', () => { + jest.useFakeTimers(); + const parent = Contexts.root(); + const child = parent.use( + Contexts.setTimeoutDuration(Duration.fromMillis(200)), + ); + expect(child.abortSignal.aborted).toBe(false); + jest.advanceTimersByTime(100); + expect(child.abortSignal.aborted).toBe(false); + jest.advanceTimersByTime(101); + expect(child.abortSignal.aborted).toBe(true); + }); + }); + + describe('setTimeoutMillis', () => { + it('works', () => { + jest.useFakeTimers(); + const parent = Contexts.root(); + const child = parent.use(Contexts.setTimeoutMillis(200)); + expect(child.abortSignal.aborted).toBe(false); + jest.advanceTimersByTime(100); + expect(child.abortSignal.aborted).toBe(false); + jest.advanceTimersByTime(101); + expect(child.abortSignal.aborted).toBe(true); + }); + }); + + describe('setValue', () => { + it('works', () => { + const parent = Contexts.root(); + const child = parent.use(Contexts.setValue('k', 'v')); + expect(child.value('k')).toBe('v'); + }); + }); +}); diff --git a/packages/backend-common/src/context/Contexts.ts b/packages/backend-common/src/context/Contexts.ts index 775f6f730d..7bab26f05a 100644 --- a/packages/backend-common/src/context/Contexts.ts +++ b/packages/backend-common/src/context/Contexts.ts @@ -15,7 +15,7 @@ */ import { Duration } from 'luxon'; -import { AbortSignal } from 'node-abort-controller'; +import { AbortController, AbortSignal } from 'node-abort-controller'; import { AbortContext } from './AbortContext'; import { RootContext } from './RootContext'; import { Context, ContextDecorator } from './types'; @@ -42,21 +42,24 @@ export class Contexts { /** * Creates a derived context, which signals to abort operations either when - * any parent context signals, or when the given controller is aborted. + * any parent context signals, or when the given source is aborted. * * @remarks * * If the parent context was already aborted, then it is returned as-is. * - * If the given signal was already aborted, then a new already-aborted context + * If the given source was already aborted, then a new already-aborted context * is returned. * - * @param signal - An abort signal that you intend to perhaps trigger at some - * later point in time. + * @param source - An abort controller or signal that you intend to perhaps + * trigger at some later point in time. * @returns A decorator that can be passed to {@link Context.use} */ - static setAbort(signal: AbortSignal): ContextDecorator { - return ctx => AbortContext.forSignal(ctx, signal); + static setAbort(source: AbortController | AbortSignal): ContextDecorator { + return ctx => + 'aborted' in source + ? AbortContext.forSignal(ctx, source) + : AbortContext.forController(ctx, source); } /** diff --git a/packages/backend-common/src/context/RootContext.ts b/packages/backend-common/src/context/RootContext.ts index ae7c18a905..48d24514fa 100644 --- a/packages/backend-common/src/context/RootContext.ts +++ b/packages/backend-common/src/context/RootContext.ts @@ -17,13 +17,11 @@ import { AbortController } from 'node-abort-controller'; import { Context, ContextDecorator } from './types'; -const neverAborts = new AbortController().signal; - /** * An empty root context. */ export class RootContext implements Context { - readonly abortSignal = neverAborts; + readonly abortSignal = new AbortController().signal; readonly deadline = undefined; value(_key: string | symbol): T | undefined { diff --git a/packages/backend-common/src/context/ValueContext.ts b/packages/backend-common/src/context/ValueContext.ts index 1ed5e2dba5..368e73fa05 100644 --- a/packages/backend-common/src/context/ValueContext.ts +++ b/packages/backend-common/src/context/ValueContext.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { AbortSignal } from 'node-abort-controller'; import { Context, ContextDecorator } from './types'; /** diff --git a/packages/backend-common/src/context/types.ts b/packages/backend-common/src/context/types.ts index 569a133c87..716a03308b 100644 --- a/packages/backend-common/src/context/types.ts +++ b/packages/backend-common/src/context/types.ts @@ -14,6 +14,8 @@ * limitations under the License. */ +import { AbortSignal } from 'node-abort-controller'; + /** * A function that accepts a context and produces a new, derived context from, * decorated with some specific behavior. From c78bf4acd49802ac3d6fa151f4e3c21e7167e2d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Mon, 24 Jan 2022 14:26:08 +0100 Subject: [PATCH 4/6] only allow string keys for context values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- packages/backend-common/api-report.md | 4 ++-- packages/backend-common/src/context/AbortContext.ts | 2 +- packages/backend-common/src/context/Contexts.ts | 2 +- packages/backend-common/src/context/RootContext.ts | 2 +- .../backend-common/src/context/ValueContext.test.ts | 12 +++++------- packages/backend-common/src/context/ValueContext.ts | 10 +++------- packages/backend-common/src/context/types.ts | 2 +- 7 files changed, 14 insertions(+), 20 deletions(-) diff --git a/packages/backend-common/api-report.md b/packages/backend-common/api-report.md index d63eeaf11b..e22195c2b3 100644 --- a/packages/backend-common/api-report.md +++ b/packages/backend-common/api-report.md @@ -152,7 +152,7 @@ export interface Context { readonly abortSignal: AbortSignal_2; readonly deadline: Date | undefined; use(...decorators: ContextDecorator[]): Context; - value(key: string | symbol): T | undefined; + value(key: string): T | undefined; } // @public @@ -165,7 +165,7 @@ export class Contexts { static setTimeoutDuration(timeout: Duration): ContextDecorator; static setTimeoutMillis(timeout: number): ContextDecorator; static setValue( - key: string | symbol, + key: string, value: unknown | ((previous: unknown | undefined) => unknown), ): ContextDecorator; } diff --git a/packages/backend-common/src/context/AbortContext.ts b/packages/backend-common/src/context/AbortContext.ts index 78ad8646ed..86069d290b 100644 --- a/packages/backend-common/src/context/AbortContext.ts +++ b/packages/backend-common/src/context/AbortContext.ts @@ -127,7 +127,7 @@ export class AbortContext implements Context { readonly deadline: Date | undefined, ) {} - value(key: string | symbol): T | undefined { + value(key: string): T | undefined { return this.parent.value(key); } diff --git a/packages/backend-common/src/context/Contexts.ts b/packages/backend-common/src/context/Contexts.ts index 7bab26f05a..692248028a 100644 --- a/packages/backend-common/src/context/Contexts.ts +++ b/packages/backend-common/src/context/Contexts.ts @@ -99,7 +99,7 @@ export class Contexts { * @returns A decorator that can be passed to {@link Context.use} */ static setValue( - key: string | symbol, + key: string, value: unknown | ((previous: unknown | undefined) => unknown), ): ContextDecorator { return ctx => { diff --git a/packages/backend-common/src/context/RootContext.ts b/packages/backend-common/src/context/RootContext.ts index 48d24514fa..96f55dd76a 100644 --- a/packages/backend-common/src/context/RootContext.ts +++ b/packages/backend-common/src/context/RootContext.ts @@ -24,7 +24,7 @@ export class RootContext implements Context { readonly abortSignal = new AbortController().signal; readonly deadline = undefined; - value(_key: string | symbol): T | undefined { + value(_key: string): T | undefined { return undefined; } diff --git a/packages/backend-common/src/context/ValueContext.test.ts b/packages/backend-common/src/context/ValueContext.test.ts index 99d6b2b60a..792893833d 100644 --- a/packages/backend-common/src/context/ValueContext.test.ts +++ b/packages/backend-common/src/context/ValueContext.test.ts @@ -18,31 +18,29 @@ import { Contexts } from './Contexts'; import { RootContext } from './RootContext'; import { ValueContext } from './ValueContext'; -const s = Symbol(); - describe('ValueContext', () => { it('returns its own values, or delegates to the parent', async () => { const root = new RootContext(); const a = ValueContext.forConstantValue(root, 'a', 1); - const b = ValueContext.forConstantValue(a, s, 2); + const b = ValueContext.forConstantValue(a, 'x', 2); const c = ValueContext.forConstantValue(b, 'a', 3); const d = ValueContext.forConstantValue(c, 'b', 4); expect(a.value('a')).toBe(1); expect(a.value('b')).toBeUndefined(); - expect(a.value(s)).toBeUndefined(); + expect(a.value('x')).toBeUndefined(); expect(b.value('a')).toBe(1); expect(b.value('b')).toBeUndefined(); - expect(b.value(s)).toBe(2); + expect(b.value('x')).toBe(2); expect(c.value('a')).toBe(3); expect(c.value('b')).toBeUndefined(); - expect(c.value(s)).toBe(2); + expect(c.value('x')).toBe(2); expect(d.value('a')).toBe(3); expect(d.value('b')).toBe(4); - expect(d.value(s)).toBe(2); + expect(d.value('x')).toBe(2); }); it('can decorate', () => { diff --git a/packages/backend-common/src/context/ValueContext.ts b/packages/backend-common/src/context/ValueContext.ts index 368e73fa05..789741e45f 100644 --- a/packages/backend-common/src/context/ValueContext.ts +++ b/packages/backend-common/src/context/ValueContext.ts @@ -22,17 +22,13 @@ import { Context, ContextDecorator } from './types'; * parent. */ export class ValueContext implements Context { - static forConstantValue( - ctx: Context, - key: string | symbol, - value: unknown, - ): Context { + static forConstantValue(ctx: Context, key: string, value: unknown): Context { return new ValueContext(ctx, key, value); } constructor( private readonly _parent: Context, - private readonly _key: string | symbol, + private readonly _key: string, private readonly _value: unknown, ) {} @@ -44,7 +40,7 @@ export class ValueContext implements Context { return this._parent.deadline; } - value(key: string | symbol): T | undefined { + value(key: string): T | undefined { return key === this._key ? (this._value as T) : this._parent.value(key); } diff --git a/packages/backend-common/src/context/types.ts b/packages/backend-common/src/context/types.ts index 716a03308b..25a6d2354b 100644 --- a/packages/backend-common/src/context/types.ts +++ b/packages/backend-common/src/context/types.ts @@ -49,7 +49,7 @@ export interface Context { * @param key - The key of the value to get * @returns The associated value, or undefined if not set */ - value(key: string | symbol): T | undefined; + value(key: string): T | undefined; /** * Decorates this context with one or more behaviors. From 5045671d32c8a8fde99bf53170da36fbf7d67c7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Mon, 24 Jan 2022 15:23:37 +0100 Subject: [PATCH 5/6] remove the middleware part of context composition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- packages/backend-common/api-report.md | 18 ++++---- .../src/context/AbortContext.test.ts | 12 ----- .../src/context/AbortContext.ts | 6 +-- .../src/context/Contexts.test.ts | 13 +++--- .../backend-common/src/context/Contexts.ts | 46 ++++++++++--------- .../src/context/RootContext.test.ts | 11 +---- .../backend-common/src/context/RootContext.ts | 25 +++++++--- .../src/context/ValueContext.test.ts | 11 ----- .../src/context/ValueContext.ts | 6 +-- packages/backend-common/src/context/index.ts | 2 +- packages/backend-common/src/context/types.ts | 20 -------- 11 files changed, 63 insertions(+), 107 deletions(-) diff --git a/packages/backend-common/api-report.md b/packages/backend-common/api-report.md index e22195c2b3..478a17d1fd 100644 --- a/packages/backend-common/api-report.md +++ b/packages/backend-common/api-report.md @@ -151,23 +151,23 @@ export interface ContainerRunner { export interface Context { readonly abortSignal: AbortSignal_2; readonly deadline: Date | undefined; - use(...decorators: ContextDecorator[]): Context; value(key: string): T | undefined; } -// @public -export type ContextDecorator = (ctx: Context) => Context; - // @public export class Contexts { static root(): Context; - static setAbort(source: AbortController_2 | AbortSignal_2): ContextDecorator; - static setTimeoutDuration(timeout: Duration): ContextDecorator; - static setTimeoutMillis(timeout: number): ContextDecorator; - static setValue( + static withAbort( + parentCtx: Context, + source: AbortController_2 | AbortSignal_2, + ): Context; + static withTimeoutDuration(parentCtx: Context, timeout: Duration): Context; + static withTimeoutMillis(parentCtx: Context, timeout: number): Context; + static withValue( + parentCtx: Context, key: string, value: unknown | ((previous: unknown | undefined) => unknown), - ): ContextDecorator; + ): Context; } // @public @deprecated diff --git a/packages/backend-common/src/context/AbortContext.test.ts b/packages/backend-common/src/context/AbortContext.test.ts index aa01c2209a..f47e1d4beb 100644 --- a/packages/backend-common/src/context/AbortContext.test.ts +++ b/packages/backend-common/src/context/AbortContext.test.ts @@ -16,7 +16,6 @@ import { AbortController } from 'node-abort-controller'; import { AbortContext } from './AbortContext'; -import { Contexts } from './Contexts'; import { RootContext } from './RootContext'; describe('AbortContext', () => { @@ -343,15 +342,4 @@ describe('AbortContext', () => { expect(childListener).toBeCalledTimes(0); }); }); - - it('can decorate', () => { - const root = new RootContext(); - const controller = new AbortController(); - const parent = AbortContext.forSignal(root, controller.signal); - const child = parent.use( - Contexts.setValue('a', 2), - Contexts.setValue('a', 3), - ); - expect(child.value('a')).toBe(3); - }); }); diff --git a/packages/backend-common/src/context/AbortContext.ts b/packages/backend-common/src/context/AbortContext.ts index 86069d290b..8a119358a6 100644 --- a/packages/backend-common/src/context/AbortContext.ts +++ b/packages/backend-common/src/context/AbortContext.ts @@ -15,7 +15,7 @@ */ import { AbortController, AbortSignal } from 'node-abort-controller'; -import { Context, ContextDecorator } from './types'; +import { Context } from './types'; /** * A context that implements various abort related functionality. @@ -130,8 +130,4 @@ export class AbortContext implements Context { value(key: string): T | undefined { return this.parent.value(key); } - - use(...items: ContextDecorator[]): Context { - return items.reduce((prev, curr) => curr(prev), this as Context); - } } diff --git a/packages/backend-common/src/context/Contexts.test.ts b/packages/backend-common/src/context/Contexts.test.ts index 6c05319a4f..cbd61d20a9 100644 --- a/packages/backend-common/src/context/Contexts.test.ts +++ b/packages/backend-common/src/context/Contexts.test.ts @@ -35,7 +35,7 @@ describe('Contexts', () => { it('works for controllers', () => { const controller = new AbortController(); const parent = Contexts.root(); - const child = parent.use(Contexts.setAbort(controller)); + const child = Contexts.withAbort(parent, controller); expect(child.abortSignal.aborted).toBe(false); controller.abort(); expect(child.abortSignal.aborted).toBe(true); @@ -44,7 +44,7 @@ describe('Contexts', () => { it('works for signals', () => { const controller = new AbortController(); const parent = Contexts.root(); - const child = parent.use(Contexts.setAbort(controller.signal)); + const child = Contexts.withAbort(parent, controller.signal); expect(child.abortSignal.aborted).toBe(false); controller.abort(); expect(child.abortSignal.aborted).toBe(true); @@ -55,8 +55,9 @@ describe('Contexts', () => { it('works', () => { jest.useFakeTimers(); const parent = Contexts.root(); - const child = parent.use( - Contexts.setTimeoutDuration(Duration.fromMillis(200)), + const child = Contexts.withTimeoutDuration( + parent, + Duration.fromMillis(200), ); expect(child.abortSignal.aborted).toBe(false); jest.advanceTimersByTime(100); @@ -70,7 +71,7 @@ describe('Contexts', () => { it('works', () => { jest.useFakeTimers(); const parent = Contexts.root(); - const child = parent.use(Contexts.setTimeoutMillis(200)); + const child = Contexts.withTimeoutMillis(parent, 200); expect(child.abortSignal.aborted).toBe(false); jest.advanceTimersByTime(100); expect(child.abortSignal.aborted).toBe(false); @@ -82,7 +83,7 @@ describe('Contexts', () => { describe('setValue', () => { it('works', () => { const parent = Contexts.root(); - const child = parent.use(Contexts.setValue('k', 'v')); + const child = Contexts.withValue(parent, 'k', 'v'); expect(child.value('k')).toBe('v'); }); }); diff --git a/packages/backend-common/src/context/Contexts.ts b/packages/backend-common/src/context/Contexts.ts index 692248028a..8bb086168a 100644 --- a/packages/backend-common/src/context/Contexts.ts +++ b/packages/backend-common/src/context/Contexts.ts @@ -18,7 +18,7 @@ import { Duration } from 'luxon'; import { AbortController, AbortSignal } from 'node-abort-controller'; import { AbortContext } from './AbortContext'; import { RootContext } from './RootContext'; -import { Context, ContextDecorator } from './types'; +import { Context } from './types'; import { ValueContext } from './ValueContext'; /** @@ -51,15 +51,18 @@ export class Contexts { * If the given source was already aborted, then a new already-aborted context * is returned. * + * @param parentCtx - A parent context that shall be used as a base * @param source - An abort controller or signal that you intend to perhaps * trigger at some later point in time. - * @returns A decorator that can be passed to {@link Context.use} + * @returns A new {@link Context} */ - static setAbort(source: AbortController | AbortSignal): ContextDecorator { - return ctx => - 'aborted' in source - ? AbortContext.forSignal(ctx, source) - : AbortContext.forController(ctx, source); + static withAbort( + parentCtx: Context, + source: AbortController | AbortSignal, + ): Context { + return 'aborted' in source + ? AbortContext.forSignal(parentCtx, source) + : AbortContext.forController(parentCtx, source); } /** @@ -67,13 +70,13 @@ export class Contexts { * any parent context signals, or when the given amount of time has passed. * This may affect the deadline. * + * @param parentCtx - A parent context that shall be used as a base * @param timeout - The duration of time, after which the derived context will * signal to abort. - * @returns A decorator that can be passed to {@link Context.use} + * @returns A new {@link Context} */ - static setTimeoutDuration(timeout: Duration): ContextDecorator { - return ctx => - AbortContext.forTimeoutMillis(ctx, timeout.as('milliseconds')); + static withTimeoutDuration(parentCtx: Context, timeout: Duration): Context { + return AbortContext.forTimeoutMillis(parentCtx, timeout.as('milliseconds')); } /** @@ -81,30 +84,31 @@ export class Contexts { * any parent context signals, or when the given amount of time has passed. * This may affect the deadline. * + * @param parentCtx - A parent context that shall be used as a base * @param timeout - The number of milliseconds, after which the derived * context will signal to abort. - * @returns A decorator that can be passed to {@link Context.use} + * @returns A new {@link Context} */ - static setTimeoutMillis(timeout: number): ContextDecorator { - return ctx => AbortContext.forTimeoutMillis(ctx, timeout); + static withTimeoutMillis(parentCtx: Context, timeout: number): Context { + return AbortContext.forTimeoutMillis(parentCtx, timeout); } /** * Creates a derived context, which has a specific key-value pair set as well * as all key-value pairs that were set in the original context. * + * @param parentCtx - A parent context that shall be used as a base * @param key - The key of the value to set * @param value - The value, or a function that accepts the previous value (or * undefined if not set yet) and computes the new value - * @returns A decorator that can be passed to {@link Context.use} + * @returns A new {@link Context} */ - static setValue( + static withValue( + parentCtx: Context, key: string, value: unknown | ((previous: unknown | undefined) => unknown), - ): ContextDecorator { - return ctx => { - const v = typeof value === 'function' ? value(ctx.value(key)) : value; - return ValueContext.forConstantValue(ctx, key, v); - }; + ): Context { + const v = typeof value === 'function' ? value(parentCtx.value(key)) : value; + return ValueContext.forConstantValue(parentCtx, key, v); } } diff --git a/packages/backend-common/src/context/RootContext.test.ts b/packages/backend-common/src/context/RootContext.test.ts index 28a3af0a52..395786e3dc 100644 --- a/packages/backend-common/src/context/RootContext.test.ts +++ b/packages/backend-common/src/context/RootContext.test.ts @@ -14,23 +14,14 @@ * limitations under the License. */ -import { Contexts } from './Contexts'; import { RootContext } from './RootContext'; describe('RootContext', () => { it('returns empty values', async () => { const ctx = new RootContext(); expect(ctx.abortSignal).toBeDefined(); + expect(ctx.abortSignal.aborted).toBe(false); expect(ctx.deadline).toBeUndefined(); expect(ctx.value('a')).toBeUndefined(); }); - - it('can decorate', () => { - const parent = new RootContext(); - const child = parent.use( - Contexts.setValue('a', 2), - Contexts.setValue('a', 3), - ); - expect(child.value('a')).toBe(3); - }); }); diff --git a/packages/backend-common/src/context/RootContext.ts b/packages/backend-common/src/context/RootContext.ts index 96f55dd76a..61962380a4 100644 --- a/packages/backend-common/src/context/RootContext.ts +++ b/packages/backend-common/src/context/RootContext.ts @@ -14,21 +14,32 @@ * limitations under the License. */ -import { AbortController } from 'node-abort-controller'; -import { Context, ContextDecorator } from './types'; +import { AbortSignal } from 'node-abort-controller'; +import { Context } from './types'; + +/** + * Since the root context can never abort, and since nobody is every meant to + * dispatch events through it, we can use a static dummy instance for + * efficiency. + */ +const dummyAbortSignal: AbortSignal = Object.freeze({ + aborted: false, + addEventListener() {}, + removeEventListener() {}, + dispatchEvent() { + return true; + }, + onabort: null, +}); /** * An empty root context. */ export class RootContext implements Context { - readonly abortSignal = new AbortController().signal; + readonly abortSignal = dummyAbortSignal; readonly deadline = undefined; value(_key: string): T | undefined { return undefined; } - - use(...items: ContextDecorator[]): Context { - return items.reduce((prev, curr) => curr(prev), this as Context); - } } diff --git a/packages/backend-common/src/context/ValueContext.test.ts b/packages/backend-common/src/context/ValueContext.test.ts index 792893833d..6a216d4548 100644 --- a/packages/backend-common/src/context/ValueContext.test.ts +++ b/packages/backend-common/src/context/ValueContext.test.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import { Contexts } from './Contexts'; import { RootContext } from './RootContext'; import { ValueContext } from './ValueContext'; @@ -42,14 +41,4 @@ describe('ValueContext', () => { expect(d.value('b')).toBe(4); expect(d.value('x')).toBe(2); }); - - it('can decorate', () => { - const root = new RootContext(); - const parent = ValueContext.forConstantValue(root, 'a', 1); - const child = parent.use( - Contexts.setValue('a', 2), - Contexts.setValue('a', 3), - ); - expect(child.value('a')).toBe(3); - }); }); diff --git a/packages/backend-common/src/context/ValueContext.ts b/packages/backend-common/src/context/ValueContext.ts index 789741e45f..441aaec984 100644 --- a/packages/backend-common/src/context/ValueContext.ts +++ b/packages/backend-common/src/context/ValueContext.ts @@ -15,7 +15,7 @@ */ import { AbortSignal } from 'node-abort-controller'; -import { Context, ContextDecorator } from './types'; +import { Context } from './types'; /** * A context that just holds a single value, and delegates the rest to its @@ -43,8 +43,4 @@ export class ValueContext implements Context { value(key: string): T | undefined { return key === this._key ? (this._value as T) : this._parent.value(key); } - - use(...items: ContextDecorator[]): Context { - return items.reduce((prev, curr) => curr(prev), this as Context); - } } diff --git a/packages/backend-common/src/context/index.ts b/packages/backend-common/src/context/index.ts index 64fe635de9..37a6e29c8c 100644 --- a/packages/backend-common/src/context/index.ts +++ b/packages/backend-common/src/context/index.ts @@ -15,4 +15,4 @@ */ export { Contexts } from './Contexts'; -export type { Context, ContextDecorator } from './types'; +export type { Context } from './types'; diff --git a/packages/backend-common/src/context/types.ts b/packages/backend-common/src/context/types.ts index 25a6d2354b..57bf836560 100644 --- a/packages/backend-common/src/context/types.ts +++ b/packages/backend-common/src/context/types.ts @@ -16,14 +16,6 @@ import { AbortSignal } from 'node-abort-controller'; -/** - * A function that accepts a context and produces a new, derived context from, - * decorated with some specific behavior. - * - * @public - */ -export type ContextDecorator = (ctx: Context) => Context; - /** * A context that is meant to be passed as a ctx variable down the call chain, * to pass along scoped information and abort signals. @@ -50,16 +42,4 @@ export interface Context { * @returns The associated value, or undefined if not set */ value(key: string): T | undefined; - - /** - * Decorates this context with one or more behaviors. - * - * @remarks - * - * The decorators are applied in the order that they are given. - * - * @param decorators - The decorators to apply - * @returns A derived context with the relevant behaviors - */ - use(...decorators: ContextDecorator[]): Context; } From c35e52cfcd204ac0d63bbd49829e5cc6a0900d84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Mon, 24 Jan 2022 15:50:13 +0100 Subject: [PATCH 6/6] switch to alpha MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .changeset/rare-comics-tan.md | 8 +++++++- packages/backend-common/api-report.md | 4 ++-- packages/backend-common/package.json | 8 +++++--- packages/backend-common/src/context/Contexts.ts | 2 +- packages/backend-common/src/context/types.ts | 2 +- 5 files changed, 16 insertions(+), 8 deletions(-) diff --git a/.changeset/rare-comics-tan.md b/.changeset/rare-comics-tan.md index 12d615d3f3..338dfba7c2 100644 --- a/.changeset/rare-comics-tan.md +++ b/.changeset/rare-comics-tan.md @@ -2,4 +2,10 @@ '@backstage/backend-common': patch --- -Added a `Context` type for the backend, that can propagate an abort signal, a deadline, and contextual values through the call stack. The main entrypoint is the `Contexts` utility class that provides a root context creator and commonly used decorators. +Added a `Context` type for the backend, that can propagate an abort signal, a +deadline, and contextual values through the call stack. The main entrypoint is +the `Contexts` utility class that provides a root context creator and commonly +used decorators. + +These are marked as `@alpha` for now, and are therefore only accessible via +`@backstage/backend-common/alpha`. diff --git a/packages/backend-common/api-report.md b/packages/backend-common/api-report.md index 478a17d1fd..f53fb9d6e4 100644 --- a/packages/backend-common/api-report.md +++ b/packages/backend-common/api-report.md @@ -147,14 +147,14 @@ export interface ContainerRunner { runContainer(opts: RunContainerOptions): Promise; } -// @public +// @alpha export interface Context { readonly abortSignal: AbortSignal_2; readonly deadline: Date | undefined; value(key: string): T | undefined; } -// @public +// @alpha export class Contexts { static root(): Context; static withAbort( diff --git a/packages/backend-common/package.json b/packages/backend-common/package.json index 3008d915f9..7bde82db0d 100644 --- a/packages/backend-common/package.json +++ b/packages/backend-common/package.json @@ -8,7 +8,8 @@ "publishConfig": { "access": "public", "main": "dist/index.cjs.js", - "types": "dist/index.d.ts" + "types": "dist/index.d.ts", + "alphaTypes": "dist/index.alpha.d.ts" }, "homepage": "https://backstage.io", "repository": { @@ -21,7 +22,7 @@ ], "license": "Apache-2.0", "scripts": { - "build": "backstage-cli build --outputs cjs,types", + "build": "backstage-cli build --experimental-type-build --outputs cjs,types", "lint": "backstage-cli lint", "test": "backstage-cli test", "prepack": "backstage-cli prepack", @@ -111,7 +112,8 @@ }, "files": [ "dist", - "config.d.ts" + "config.d.ts", + "alpha" ], "configSchema": "config.d.ts" } diff --git a/packages/backend-common/src/context/Contexts.ts b/packages/backend-common/src/context/Contexts.ts index 8bb086168a..e22a76f673 100644 --- a/packages/backend-common/src/context/Contexts.ts +++ b/packages/backend-common/src/context/Contexts.ts @@ -24,7 +24,7 @@ import { ValueContext } from './ValueContext'; /** * Common context decorators. * - * @public + * @alpha */ export class Contexts { /** diff --git a/packages/backend-common/src/context/types.ts b/packages/backend-common/src/context/types.ts index 57bf836560..664745bbf6 100644 --- a/packages/backend-common/src/context/types.ts +++ b/packages/backend-common/src/context/types.ts @@ -20,7 +20,7 @@ import { AbortSignal } from 'node-abort-controller'; * A context that is meant to be passed as a ctx variable down the call chain, * to pass along scoped information and abort signals. * - * @public + * @alpha */ export interface Context { /**