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';