Skip to content

Add app-level startup hooks #577

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

Merged
merged 39 commits into from
Jun 28, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
abfdf60
add startup and teardown hook support
hossam-nasr Apr 23, 2022
3cd4888
add initial types
hossam-nasr Apr 23, 2022
4189d7f
move hook data handling to wokrer channel
hossam-nasr Apr 25, 2022
5b778f5
add app startup hooks to worker init handler
hossam-nasr Apr 25, 2022
b94a3ba
run new script file on functionEnvironmentReloadRequest
hossam-nasr Apr 25, 2022
9a099b2
add logger to base hook
hossam-nasr Apr 25, 2022
d3405b0
refactor
hossam-nasr Apr 25, 2022
be07aee
update comment
hossam-nasr Apr 25, 2022
0e4502b
create copies of hook data for invocation contexts
hossam-nasr Apr 26, 2022
aeec3ea
don't copy for app startup hook data + add function app directory
hossam-nasr Apr 26, 2022
e3f855c
function environment reload logic
hossam-nasr Apr 26, 2022
43fb5a0
implement logger interface in hook context
hossam-nasr Apr 26, 2022
477c506
add host version to startup hooks
hossam-nasr Apr 26, 2022
d54b6cd
fix tests
hossam-nasr Apr 26, 2022
7411dc5
rename app startup function
hossam-nasr Jun 2, 2022
651d34c
remove teardown hooks
hossam-nasr Jun 3, 2022
255fb5c
proper message category and functionInvocationId in hook logs
hossam-nasr Jun 3, 2022
30a773f
make sure it compiles
hossam-nasr Jun 3, 2022
b012b0f
remove logger
hossam-nasr Jun 8, 2022
497da2b
refactor app startup code
hossam-nasr Jun 8, 2022
6093ed8
don't reset the hook data
hossam-nasr Jun 8, 2022
8601eeb
simplify copying hook data
hossam-nasr Jun 8, 2022
6755f26
add specialization test for environment reload handler
hossam-nasr Jun 8, 2022
1711e80
add test for loading entry point in specialization scenario
hossam-nasr Jun 8, 2022
6b38e56
add test for running app startup hook in non-specialization scenario
hossam-nasr Jun 8, 2022
174e5ab
assert called with right context
hossam-nasr Jun 9, 2022
797c9e5
add test for specialization scenario
hossam-nasr Jun 9, 2022
1914121
add test for persisting hook data from startup hooks
hossam-nasr Jun 9, 2022
164f85d
add comment
hossam-nasr Jun 10, 2022
5065c1e
allow functionAppDirectory to be undefined in workerInitRequest
hossam-nasr Jun 23, 2022
1747d51
rename to appStart
hossam-nasr Jun 23, 2022
502e3e8
one more file rename
hossam-nasr Jun 23, 2022
c273e61
add appHookData and hookData distinction + tests
hossam-nasr Jun 24, 2022
32da711
fix tests
hossam-nasr Jun 24, 2022
21092d8
remove outdated comment
hossam-nasr Jun 24, 2022
b8152ce
non null hostVersion and remove log warning
hossam-nasr Jun 24, 2022
6b8c298
fix tests
hossam-nasr Jun 27, 2022
bbeb916
old hookData behavior
hossam-nasr Jun 27, 2022
0564e9d
address PR comments
hossam-nasr Jun 28, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion src/WorkerChannel.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT License.

import { HookCallback, HookContext } from '@azure/functions-core';
import { HookCallback, HookContext, HookData } from '@azure/functions-core';
import { AzureFunctionsRpcMessages as rpc } from '../azure-functions-language-worker-protobuf/src/rpc';
import { Disposable } from './Disposable';
import { IFunctionLoader } from './FunctionLoader';
Expand All @@ -15,8 +15,21 @@ export class WorkerChannel {
eventStream: IEventStream;
functionLoader: IFunctionLoader;
packageJson: PackageJson;
/**
* This will only be set after worker init request is received
*/
hostVersion?: string;
/**
* this hook data will be passed to (and set by) all hooks in all scopes
*/
appHookData: HookData = {};
/**
* this hook data is limited to the app-level scope and persisted only for app-level hooks
*/
appLevelOnlyHookData: HookData = {};
#preInvocationHooks: HookCallback[] = [];
#postInvocationHooks: HookCallback[] = [];
#appStartHooks: HookCallback[] = [];

constructor(eventStream: IEventStream, functionLoader: IFunctionLoader) {
this.eventStream = eventStream;
Expand Down Expand Up @@ -80,6 +93,8 @@ export class WorkerChannel {
return this.#preInvocationHooks;
case 'postInvocation':
return this.#postInvocationHooks;
case 'appStart':
return this.#appStartHooks;
default:
throw new RangeError(`Unrecognized hook "${hookName}"`);
}
Expand Down
4 changes: 3 additions & 1 deletion src/eventHandlers/FunctionEnvironmentReloadHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Licensed under the MIT License.

import { AzureFunctionsRpcMessages as rpc } from '../../azure-functions-language-worker-protobuf/src/rpc';
import { startApp } from '../startApp';
import { WorkerChannel } from '../WorkerChannel';
import { EventHandler } from './EventHandler';
import LogCategory = rpc.RpcLog.RpcLogCategory;
Expand Down Expand Up @@ -35,6 +36,7 @@ export class FunctionEnvironmentReloadHandler extends EventHandler<
});

process.env = Object.assign({}, msg.environmentVariables);

// Change current working directory
if (msg.functionAppDirectory) {
channel.log({
Expand All @@ -43,7 +45,7 @@ export class FunctionEnvironmentReloadHandler extends EventHandler<
logCategory: LogCategory.System,
});
process.chdir(msg.functionAppDirectory);
await channel.updatePackageJson(msg.functionAppDirectory);
await startApp(msg.functionAppDirectory, channel);
}

return response;
Expand Down
13 changes: 8 additions & 5 deletions src/eventHandlers/InvocationHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,16 +90,18 @@ export class InvocationHandler extends EventHandler<'invocationRequest', 'invoca
});
});

const hookData: HookData = {};
let userFunction = channel.functionLoader.getFunc(functionId);

const invocationHookData: HookData = {};

const preInvocContext: PreInvocationContext = {
hookData,
hookData: invocationHookData,
appHookData: channel.appHookData,
invocationContext: context,
functionCallback: <AzureFunction>userFunction,
inputs,
};

await channel.executeHooks('preInvocation', preInvocContext, msg.invocationId, msgCategory);
await channel.executeHooks('preInvocation', preInvocContext, invocationId, msgCategory);
inputs = preInvocContext.inputs;
userFunction = preInvocContext.functionCallback;

Expand All @@ -117,7 +119,8 @@ export class InvocationHandler extends EventHandler<'invocationRequest', 'invoca
}

const postInvocContext: PostInvocationContext = {
hookData,
hookData: invocationHookData,
appHookData: channel.appHookData,
invocationContext: context,
inputs,
result: null,
Expand Down
10 changes: 7 additions & 3 deletions src/eventHandlers/WorkerInitHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
import { access, constants } from 'fs';
import * as path from 'path';
import { AzureFunctionsRpcMessages as rpc } from '../../azure-functions-language-worker-protobuf/src/rpc';
import { startApp } from '../startApp';
import { isError } from '../utils/ensureErrorType';
import { nonNullProp } from '../utils/nonNull';
import { WorkerChannel } from '../WorkerChannel';
import { EventHandler } from './EventHandler';
import LogCategory = rpc.RpcLog.RpcLogCategory;
Expand All @@ -30,9 +32,11 @@ export class WorkerInitHandler extends EventHandler<'workerInitRequest', 'worker
});

logColdStartWarning(channel);
const functionAppDirectory = msg.functionAppDirectory;
if (functionAppDirectory) {
await channel.updatePackageJson(functionAppDirectory);

channel.hostVersion = nonNullProp(msg, 'hostVersion');

if (msg.functionAppDirectory) {
await startApp(msg.functionAppDirectory, channel);
}

response.capabilities = {
Expand Down
62 changes: 62 additions & 0 deletions src/startApp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT License.

import { AppStartContext } from '@azure/functions-core';
import { pathExists } from 'fs-extra';
import { AzureFunctionsRpcMessages as rpc } from '../azure-functions-language-worker-protobuf/src/rpc';
import { loadScriptFile } from './loadScriptFile';
import { ensureErrorType } from './utils/ensureErrorType';
import { nonNullProp } from './utils/nonNull';
import { WorkerChannel } from './WorkerChannel';
import path = require('path');
import LogLevel = rpc.RpcLog.Level;
import LogCategory = rpc.RpcLog.RpcLogCategory;

/**
* Starting an app can happen in two places, depending on if the worker was specialized or not
* 1. The worker can start in "normal" mode, meaning `workerInitRequest` will reference the user's app
* 2. The worker can start in "placeholder" mode, meaning `workerInitRequest` will reference a dummy app to "warm up" the worker and `functionEnvironmentReloadRequest` will be sent with the user's actual app.
* This process is called worker specialization and it helps with cold start times.
* The dummy app should never have actual startup code, so it should be safe to call `startApp` twice in this case
* Worker specialization happens only once, so we don't need to worry about cleaning up resources from previous `functionEnvironmentReloadRequest`s.
*/
export async function startApp(functionAppDirectory: string, channel: WorkerChannel): Promise<void> {
await channel.updatePackageJson(functionAppDirectory);
await loadEntryPointFile(functionAppDirectory, channel);
const appStartContext: AppStartContext = {
hookData: channel.appLevelOnlyHookData,
appHookData: channel.appHookData,
functionAppDirectory,
hostVersion: nonNullProp(channel, 'hostVersion'),
};
await channel.executeHooks('appStart', appStartContext);
}

async function loadEntryPointFile(functionAppDirectory: string, channel: WorkerChannel): Promise<void> {
const entryPointFile = channel.packageJson.main;
if (entryPointFile) {
channel.log({
message: `Loading entry point "${entryPointFile}"`,
level: LogLevel.Debug,
logCategory: LogCategory.System,
});
try {
const entryPointFullPath = path.join(functionAppDirectory, entryPointFile);
if (!(await pathExists(entryPointFullPath))) {
throw new Error(`file does not exist`);
}

await loadScriptFile(entryPointFullPath, channel.packageJson);
channel.log({
message: `Loaded entry point "${entryPointFile}"`,
level: LogLevel.Debug,
logCategory: LogCategory.System,
});
} catch (err) {
const error = ensureErrorType(err);
error.isAzureFunctionsInternalException = true;
error.message = `Worker was unable to load entry point "${entryPointFile}": ${error.message}`;
throw error;
}
}
}
88 changes: 85 additions & 3 deletions test/eventHandlers/FunctionEnvironmentReloadHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@ import { AzureFunctionsRpcMessages as rpc } from '../../azure-functions-language
import { WorkerChannel } from '../../src/WorkerChannel';
import { beforeEventHandlerSuite } from './beforeEventHandlerSuite';
import { TestEventStream } from './TestEventStream';
import { Msg as WorkerInitMsg } from './WorkerInitHandler.test';
import path = require('path');
import LogCategory = rpc.RpcLog.RpcLogCategory;
import LogLevel = rpc.RpcLog.Level;

namespace Msg {
export namespace Msg {
export function reloadEnvVarsLog(numVars: number): rpc.IStreamingMessage {
return {
rpcLog: {
Expand Down Expand Up @@ -69,11 +70,14 @@ describe('FunctionEnvironmentReloadHandler', () => {
let stream: TestEventStream;
let channel: WorkerChannel;

// Reset `process.env` after this test suite so it doesn't affect other tests
// Reset `process.env` and process.cwd() after this test suite so it doesn't affect other tests
let originalEnv: NodeJS.ProcessEnv;
let originalCwd: string;
before(() => {
originalCwd = process.cwd();
originalEnv = process.env;
({ stream, channel } = beforeEventHandlerSuite());
channel.hostVersion = '2.7.0';
});

after(() => {
Expand All @@ -82,6 +86,7 @@ describe('FunctionEnvironmentReloadHandler', () => {

afterEach(async () => {
mock.restore();
process.chdir(originalCwd);
await stream.afterEachEventHandlerTest();
});

Expand Down Expand Up @@ -229,6 +234,83 @@ describe('FunctionEnvironmentReloadHandler', () => {
});
await stream.assertCalledWith(Msg.reloadEnvVarsLog(0), Msg.changingCwdLog(newDirAbsolute), Msg.reloadSuccess);
expect(channel.packageJson).to.deep.equal(newPackageJson);
process.chdir(cwd);
});

it('correctly loads package.json in specialization scenario', async () => {
const cwd = process.cwd();
const tempDir = 'temp';
const appDir = 'app';
const packageJson = {
type: 'module',
hello: 'world',
};

mock({
[tempDir]: {},
[appDir]: {
'package.json': JSON.stringify(packageJson),
},
});

stream.addTestMessage(WorkerInitMsg.init(path.join(cwd, tempDir)));
await stream.assertCalledWith(
WorkerInitMsg.receivedInitLog,
WorkerInitMsg.warning(`Worker failed to load package.json: file does not exist`),
WorkerInitMsg.response
);
expect(channel.packageJson).to.be.empty;

stream.addTestMessage({
requestId: 'id',
functionEnvironmentReloadRequest: {
functionAppDirectory: path.join(cwd, appDir),
},
});
await stream.assertCalledWith(
Msg.reloadEnvVarsLog(0),
Msg.changingCwdLog(path.join(cwd, appDir)),
Msg.reloadSuccess
);
expect(channel.packageJson).to.deep.equal(packageJson);
});

for (const extension of ['.js', '.mjs', '.cjs']) {
it(`Loads entry point (${extension}) in specialization scenario`, async () => {
const cwd = process.cwd();
const tempDir = 'temp';
const fileName = `entryPointFiles/doNothing${extension}`;
const expectedPackageJson = {
main: fileName,
};
mock({
[tempDir]: {},
[__dirname]: {
'package.json': JSON.stringify(expectedPackageJson),
// 'require' and 'mockFs' don't play well together so we need these files in both the mock and real file systems
entryPointFiles: mock.load(path.join(__dirname, 'entryPointFiles')),
},
});

stream.addTestMessage(WorkerInitMsg.init(path.join(cwd, tempDir)));
await stream.assertCalledWith(
WorkerInitMsg.receivedInitLog,
WorkerInitMsg.warning('Worker failed to load package.json: file does not exist'),
WorkerInitMsg.response
);

stream.addTestMessage({
requestId: 'id',
functionEnvironmentReloadRequest: {
functionAppDirectory: __dirname,
},
});
await stream.assertCalledWith(
Msg.reloadEnvVarsLog(0),
Msg.changingCwdLog(__dirname),
WorkerInitMsg.loadingEntryPoint(fileName),
WorkerInitMsg.loadedEntryPoint(fileName),
Msg.reloadSuccess
);
});
}
});
Loading