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