diff --git a/.changeset/rare-comics-tan.md b/.changeset/rare-comics-tan.md new file mode 100644 index 0000000000..338dfba7c2 --- /dev/null +++ b/.changeset/rare-comics-tan.md @@ -0,0 +1,11 @@ +--- +'@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. + +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 d63416571d..f53fb9d6e4 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'; @@ -13,6 +14,7 @@ import { BitbucketIntegration } from '@backstage/integration'; import { Config } from '@backstage/config'; import cors from 'cors'; import Docker from 'dockerode'; +import { Duration } from 'luxon'; import { ErrorRequestHandler } from 'express'; import express from 'express'; import { GithubCredentialsProvider } from '@backstage/integration'; @@ -145,6 +147,29 @@ export interface ContainerRunner { runContainer(opts: RunContainerOptions): Promise; } +// @alpha +export interface Context { + readonly abortSignal: AbortSignal_2; + readonly deadline: Date | undefined; + value(key: string): T | undefined; +} + +// @alpha +export class Contexts { + static root(): Context; + 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), + ): Context; +} + // @public @deprecated export const createDatabase: typeof createDatabaseClient; diff --git a/packages/backend-common/package.json b/packages/backend-common/package.json index b8f56c1fbe..f8e445465e 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", @@ -41,6 +42,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 +61,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", @@ -109,7 +112,8 @@ }, "files": [ "dist", - "config.d.ts" + "config.d.ts", + "alpha" ], "configSchema": "config.d.ts" } 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..f47e1d4beb --- /dev/null +++ b/packages/backend-common/src/context/AbortContext.test.ts @@ -0,0 +1,345 @@ +/* + * 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 { 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('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(); + + 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); + }); + }); +}); diff --git a/packages/backend-common/src/context/AbortContext.ts b/packages/backend-common/src/context/AbortContext.ts new file mode 100644 index 0000000000..8a119358a6 --- /dev/null +++ b/packages/backend-common/src/context/AbortContext.ts @@ -0,0 +1,133 @@ +/* + * 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, AbortSignal } from 'node-abort-controller'; +import { Context } 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. + * + * @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); + 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(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 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 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(); + + 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); + } + + private constructor( + private readonly parent: Context, + readonly abortSignal: AbortSignal, + readonly deadline: Date | undefined, + ) {} + + value(key: string): T | undefined { + return this.parent.value(key); + } +} 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..cbd61d20a9 --- /dev/null +++ b/packages/backend-common/src/context/Contexts.test.ts @@ -0,0 +1,90 @@ +/* + * 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 = Contexts.withAbort(parent, 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 = Contexts.withAbort(parent, 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 = Contexts.withTimeoutDuration( + parent, + 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 = Contexts.withTimeoutMillis(parent, 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 = 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 new file mode 100644 index 0000000000..e22a76f673 --- /dev/null +++ b/packages/backend-common/src/context/Contexts.ts @@ -0,0 +1,114 @@ +/* + * 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, AbortSignal } from 'node-abort-controller'; +import { AbortContext } from './AbortContext'; +import { RootContext } from './RootContext'; +import { Context } from './types'; +import { ValueContext } from './ValueContext'; + +/** + * Common context decorators. + * + * @alpha + */ +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 source is aborted. + * + * @remarks + * + * If the parent context was already aborted, then it is returned as-is. + * + * 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 new {@link Context} + */ + static withAbort( + parentCtx: Context, + source: AbortController | AbortSignal, + ): Context { + return 'aborted' in source + ? AbortContext.forSignal(parentCtx, source) + : AbortContext.forController(parentCtx, source); + } + + /** + * 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 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 new {@link Context} + */ + static withTimeoutDuration(parentCtx: Context, timeout: Duration): Context { + return AbortContext.forTimeoutMillis(parentCtx, 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 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 new {@link Context} + */ + 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 new {@link Context} + */ + static withValue( + parentCtx: Context, + key: string, + value: unknown | ((previous: unknown | undefined) => unknown), + ): 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 new file mode 100644 index 0000000000..395786e3dc --- /dev/null +++ b/packages/backend-common/src/context/RootContext.test.ts @@ -0,0 +1,27 @@ +/* + * 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 { 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(); + }); +}); diff --git a/packages/backend-common/src/context/RootContext.ts b/packages/backend-common/src/context/RootContext.ts new file mode 100644 index 0000000000..61962380a4 --- /dev/null +++ b/packages/backend-common/src/context/RootContext.ts @@ -0,0 +1,45 @@ +/* + * 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 } 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 = dummyAbortSignal; + readonly deadline = 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 new file mode 100644 index 0000000000..6a216d4548 --- /dev/null +++ b/packages/backend-common/src/context/ValueContext.test.ts @@ -0,0 +1,44 @@ +/* + * 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 { RootContext } from './RootContext'; +import { ValueContext } from './ValueContext'; + +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, '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('x')).toBeUndefined(); + + expect(b.value('a')).toBe(1); + expect(b.value('b')).toBeUndefined(); + expect(b.value('x')).toBe(2); + + expect(c.value('a')).toBe(3); + expect(c.value('b')).toBeUndefined(); + expect(c.value('x')).toBe(2); + + expect(d.value('a')).toBe(3); + expect(d.value('b')).toBe(4); + expect(d.value('x')).toBe(2); + }); +}); diff --git a/packages/backend-common/src/context/ValueContext.ts b/packages/backend-common/src/context/ValueContext.ts new file mode 100644 index 0000000000..441aaec984 --- /dev/null +++ b/packages/backend-common/src/context/ValueContext.ts @@ -0,0 +1,46 @@ +/* + * 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 } 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, value: unknown): Context { + return new ValueContext(ctx, key, value); + } + + constructor( + private readonly _parent: Context, + private readonly _key: string, + private readonly _value: unknown, + ) {} + + get abortSignal(): AbortSignal { + return this._parent.abortSignal; + } + + get deadline(): Date | undefined { + return this._parent.deadline; + } + + 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/index.ts b/packages/backend-common/src/context/index.ts new file mode 100644 index 0000000000..37a6e29c8c --- /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 { Contexts } from './Contexts'; +export type { Context } 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..664745bbf6 --- /dev/null +++ b/packages/backend-common/src/context/types.ts @@ -0,0 +1,45 @@ +/* + * 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'; + +/** + * A context that is meant to be passed as a ctx variable down the call chain, + * to pass along scoped information and abort signals. + * + * @alpha + */ +export interface Context { + /** + * Returns an abort signal that triggers when the current context or any of + * its parents signal for it. + */ + readonly abortSignal: AbortSignal; + + /** + * The point in time when the current context shall time out and abort, if + * applicable. + */ + readonly deadline: Date | undefined; + + /** + * 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): 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';