Introduce the Context to the backend again

Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
Fredrik Adelöw
2021-11-12 16:54:20 +01:00
parent 64700a10e4
commit 50d039577a
12 changed files with 689 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-common': patch
---
Added a Context class for the backend, that handles aborting, timeouts, api resolution etc
+42
View File
@@ -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<void>;
}
// @public
export interface Context {
readonly abortPromise: Promise<void>;
readonly abortSignal: AbortSignal;
readonly deadline: DateTime | undefined;
value<T = unknown>(key: string | symbol): T | undefined;
with(...decorators: ContextDecorator[]): Context;
withAbort(): {
ctx: Context;
abort: () => void;
};
withTimeout(timeout: Duration): Context;
withValue<T = unknown>(
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<void>;
get abortSignal(): AbortSignal_2;
static create(): Context;
get deadline(): DateTime | undefined;
value<T = unknown>(key: string | symbol): T | undefined;
with(...items: ContextDecorator[]): Context;
withAbort(): {
ctx: Context;
abort: () => void;
};
withTimeout(timeout: Duration): Context;
withValue<T = unknown>(
key: string | symbol,
value: T | ((previous: T | undefined) => T),
): Context;
}
// @public
export type RunContainerOptions = {
imageName: string;
+2
View File
@@ -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",
@@ -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<number>('a', p => p! + 1),
c => c.withValue('b', 3),
);
expect(ctx.value('a')).toBe(2);
expect(ctx.value('b')).toBe(3);
});
});
@@ -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<ContextAbortState>(
abortKey,
abortManually(),
);
}
/**
* {@inheritdoc Context.abortSignal}
*/
public get abortSignal(): AbortSignal {
return this.value<ContextAbortState>(abortKey)!.signal;
}
/**
* {@inheritdoc Context.abortPromise}
*/
public get abortPromise(): Promise<void> {
return this.value<ContextAbortState>(abortKey)!.promise;
}
/**
* {@inheritdoc Context.deadline}
*/
public get deadline(): DateTime | undefined {
return this.value<ContextAbortState>(abortKey)!.deadline;
}
private constructor(private readonly values: ContextValues) {}
/**
* {@inheritdoc Context.withAbort}
*/
withAbort(): { ctx: Context; abort: () => void } {
const state = abortManually(this.value<ContextAbortState>(abortKey));
return {
ctx: this.withValue(abortKey, state),
abort: state.abort,
};
}
/**
* {@inheritdoc Context.withTimeout}
*/
withTimeout(timeout: Duration): Context {
return this.withValue<ContextAbortState>(abortKey, previous =>
abortOnTimeout(timeout, previous),
);
}
/**
* {@inheritdoc Context.with}
*/
with(...items: ContextDecorator[]): Context {
return items.reduce<Context>((prev, curr) => curr(prev), this);
}
/**
* {@inheritdoc Context.withValue}
*/
withValue<T = unknown>(
key: string | symbol,
value: T | ((previous: T | undefined) => T),
): Context {
return new RootContext(unshiftContextValues(this.values, key, value));
}
/**
* {@inheritdoc Context.value}
*/
value<T = unknown>(key: string | symbol): T | undefined {
return findInContextValues<T>(this.values, key);
}
}
@@ -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);
});
});
});
@@ -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<void>;
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<void>(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<void>(resolve => {
controller.signal.addEventListener('abort', resolve);
}),
deadline,
abort,
};
}
@@ -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();
});
});
@@ -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<T = unknown>(
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;
}
@@ -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';
@@ -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<void>;
/**
* 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<T = unknown>(
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<T = unknown>(key: string | symbol): T | undefined;
}
+1
View File
@@ -22,6 +22,7 @@
export * from './cache';
export { loadBackendConfig } from './config';
export * from './context';
export * from './database';
export * from './discovery';
export * from './hot';