Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add pre and post invocation hooks #548

Merged
merged 13 commits into from
Mar 24, 2022
1 change: 1 addition & 0 deletions .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"@typescript-eslint/restrict-template-expressions": "off",
"@typescript-eslint/unbound-method": "off",
"no-empty": "off",
"prefer-const": ["error", { "destructuring": "all" }],
"prefer-rest-params": "off",
"prefer-spread": "off"
},
Expand Down
92 changes: 16 additions & 76 deletions src/Context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,12 @@ import {
import { FunctionInfo } from './FunctionInfo';
import { Request } from './http/Request';
import { Response } from './http/Response';
import EventEmitter = require('events');
import LogLevel = rpc.RpcLog.Level;
import LogCategory = rpc.RpcLog.RpcLogCategory;

export function CreateContextAndInputs(
info: FunctionInfo,
request: rpc.IInvocationRequest,
logCallback: LogCallback,
callback: ResultCallback
) {
const context = new InvocationContext(info, request, logCallback, callback);
export function CreateContextAndInputs(info: FunctionInfo, request: rpc.IInvocationRequest, logCallback: LogCallback) {
const doneEmitter = new EventEmitter();
const context = new InvocationContext(info, request, logCallback, doneEmitter);

const bindings: ContextBindings = {};
const inputs: any[] = [];
Expand Down Expand Up @@ -76,6 +72,7 @@ export function CreateContextAndInputs(
return {
context: <Context>context,
inputs: inputs,
doneEmitter,
};
}

Expand All @@ -95,7 +92,7 @@ class InvocationContext implements Context {
info: FunctionInfo,
request: rpc.IInvocationRequest,
logCallback: LogCallback,
callback: ResultCallback
doneEmitter: EventEmitter
) {
this.invocationId = <string>request.invocationId;
this.traceContext = fromRpcTraceContext(request.traceContext);
Expand All @@ -107,89 +104,32 @@ class InvocationContext implements Context {
};
this.executionContext = executionContext;
this.bindings = {};
let _done = false;
let _promise = false;

// Log message that is tied to function invocation
this.log = Object.assign(
(...args: any[]) => logWithAsyncCheck(_done, logCallback, LogLevel.Information, executionContext, ...args),
{
error: (...args: any[]) =>
logWithAsyncCheck(_done, logCallback, LogLevel.Error, executionContext, ...args),
warn: (...args: any[]) =>
logWithAsyncCheck(_done, logCallback, LogLevel.Warning, executionContext, ...args),
info: (...args: any[]) =>
logWithAsyncCheck(_done, logCallback, LogLevel.Information, executionContext, ...args),
verbose: (...args: any[]) =>
logWithAsyncCheck(_done, logCallback, LogLevel.Trace, executionContext, ...args),
}
);
this.log = Object.assign((...args: any[]) => logCallback(LogLevel.Information, ...args), {
error: (...args: any[]) => logCallback(LogLevel.Error, ...args),
warn: (...args: any[]) => logCallback(LogLevel.Warning, ...args),
info: (...args: any[]) => logCallback(LogLevel.Information, ...args),
verbose: (...args: any[]) => logCallback(LogLevel.Trace, ...args),
});

this.bindingData = getNormalizedBindingData(request);
this.bindingDefinitions = getBindingDefinitions(info);

// isPromise is a hidden parameter that we set to true in the event of a returned promise
this.done = (err?: any, result?: any, isPromise?: boolean) => {
_promise = isPromise === true;
if (_done) {
if (_promise) {
logCallback(
LogLevel.Error,
LogCategory.User,
"Error: Choose either to return a promise or call 'done'. Do not use both in your script."
);
} else {
logCallback(
LogLevel.Error,
LogCategory.User,
"Error: 'done' has already been called. Please check your script for extraneous calls to 'done'."
);
}
return;
}
_done = true;

// Allow HTTP response from context.res if HTTP response is not defined from the context.bindings object
if (info.httpOutputName && this.res && this.bindings[info.httpOutputName] === undefined) {
this.bindings[info.httpOutputName] = this.res;
}

callback(err, {
return: result,
bindings: this.bindings,
});
this.done = (err?: unknown, result?: any) => {
doneEmitter.emit('done', err, result);
};
}
}

// Emit warning if trying to log after function execution is done.
function logWithAsyncCheck(
done: boolean,
log: LogCallback,
level: LogLevel,
executionContext: ExecutionContext,
...args: any[]
) {
if (done) {
let badAsyncMsg =
"Warning: Unexpected call to 'log' on the context object after function execution has completed. Please check for asynchronous calls that are not awaited or calls to 'done' made before function execution completes. ";
badAsyncMsg += `Function name: ${executionContext.functionName}. Invocation Id: ${executionContext.invocationId}. `;
badAsyncMsg += `Learn more: https://go.microsoft.com/fwlink/?linkid=2097909 `;
log(LogLevel.Warning, LogCategory.System, badAsyncMsg);
}
return log(level, LogCategory.User, ...args);
}

export interface InvocationResult {
return: any;
bindings: ContextBindings;
}

export type DoneCallback = (err?: Error | string, result?: any) => void;

export type LogCallback = (level: LogLevel, category: rpc.RpcLog.RpcLogCategory, ...args: any[]) => void;
export type DoneCallback = (err?: unknown, result?: any) => void;

export type ResultCallback = (err?: any, result?: InvocationResult) => void;
export type LogCallback = (level: LogLevel, ...args: any[]) => void;

export interface Dict<T> {
[key: string]: T;
Expand Down
35 changes: 35 additions & 0 deletions src/Disposable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT License.

/**
* Based off of VS Code
* https://github.com/microsoft/vscode/blob/a64e8e5673a44e5b9c2d493666bde684bd5a135c/src/vs/workbench/api/common/extHostTypes.ts#L32
*/
export class Disposable {
static from(...inDisposables: { dispose(): any }[]): Disposable {
let disposables: ReadonlyArray<{ dispose(): any }> | undefined = inDisposables;
return new Disposable(function () {
if (disposables) {
for (const disposable of disposables) {
if (disposable && typeof disposable.dispose === 'function') {
disposable.dispose();
}
}
disposables = undefined;
}
});
}

#callOnDispose?: () => any;

constructor(callOnDispose: () => any) {
this.#callOnDispose = callOnDispose;
}

dispose(): any {
if (this.#callOnDispose instanceof Function) {
this.#callOnDispose();
this.#callOnDispose = undefined;
}
}
}
2 changes: 2 additions & 0 deletions src/Worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import * as parseArgs from 'minimist';
import { FunctionLoader } from './FunctionLoader';
import { CreateGrpcEventStream } from './GrpcClient';
import { setupEventStream } from './setupEventStream';
import { setupWorkerModule } from './setupWorkerModule';
import { ensureErrorType } from './utils/ensureErrorType';
import { InternalException } from './utils/InternalException';
import { systemError, systemLog } from './utils/Logger';
Expand Down Expand Up @@ -42,6 +43,7 @@ export function startNodeWorker(args) {

const channel = new WorkerChannel(eventStream, new FunctionLoader());
setupEventStream(workerId, channel);
setupWorkerModule(channel);

eventStream.write({
requestId: requestId,
Expand Down
54 changes: 25 additions & 29 deletions src/WorkerChannel.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,21 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT License.

import { Context } from '@azure/functions';
import { HookCallback, HookContext } from '@azure/functions-worker';
import { AzureFunctionsRpcMessages as rpc } from '../azure-functions-language-worker-protobuf/src/rpc';
import { Disposable } from './Disposable';
import { IFunctionLoader } from './FunctionLoader';
import { IEventStream } from './GrpcClient';

type InvocationRequestBefore = (context: Context, userFn: Function) => Function;
type InvocationRequestAfter = (context: Context) => void;

export class WorkerChannel {
public eventStream: IEventStream;
public functionLoader: IFunctionLoader;
private _invocationRequestBefore: InvocationRequestBefore[];
private _invocationRequestAfter: InvocationRequestAfter[];
private _preInvocationHooks: HookCallback[] = [];
private _postInvocationHooks: HookCallback[] = [];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the interest of type safety, I would support adding separate types for pre and post invocation hook callbacks, and reflecting that here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added the separate types, but I ran into too many build/lint errors if I tried to reference them here. If you can come up with a simple/clean way to do that let me know, otherwise I don't think this is worth the effort

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I gave it a try but I couldn't get them to work either, without having to use different functions to retrieve/execute each hook. I guess it's another argument for having separate functions 😋


constructor(eventStream: IEventStream, functionLoader: IFunctionLoader) {
this.eventStream = eventStream;
this.functionLoader = functionLoader;
this._invocationRequestBefore = [];
this._invocationRequestAfter = [];
}

/**
Expand All @@ -33,32 +29,32 @@ export class WorkerChannel {
});
}

/**
* Register a patching function to be run before User Function is executed.
* Hook should return a patched version of User Function.
*/
public registerBeforeInvocationRequest(beforeCb: InvocationRequestBefore): void {
this._invocationRequestBefore.push(beforeCb);
}

/**
* Register a function to be run after User Function resolves.
*/
public registerAfterInvocationRequest(afterCb: InvocationRequestAfter): void {
this._invocationRequestAfter.push(afterCb);
public registerHook(hookName: string, callback: HookCallback): Disposable {
const hooks = this.getHooks(hookName);
hooks.push(callback);
return new Disposable(() => {
const index = hooks.indexOf(callback);
if (index > -1) {
hooks.splice(index, 1);
}
});
}

public runInvocationRequestBefore(context: Context, userFunction: Function): Function {
let wrappedFunction = userFunction;
for (const before of this._invocationRequestBefore) {
wrappedFunction = before(context, wrappedFunction);
public async executeHooks(hookName: string, context: HookContext): Promise<void> {
const callbacks = this.getHooks(hookName);
for (const callback of callbacks) {
await callback(context);
}
return wrappedFunction;
}

public runInvocationRequestAfter(context: Context) {
for (const after of this._invocationRequestAfter) {
after(context);
private getHooks(hookName: string): HookCallback[] {
switch (hookName) {
case 'preInvocation':
return this._preInvocationHooks;
case 'postInvocation':
return this._postInvocationHooks;
default:
throw new RangeError(`Unrecognized hook "${hookName}"`);
}
}
}
Loading