From 4350d88235397b5531ef9aded2f116e384807e16 Mon Sep 17 00:00:00 2001 From: Nikita Savchenko Date: Mon, 26 Oct 2020 01:37:51 +0200 Subject: [PATCH] Fix the dependency handling logic (now it's async graph instead of layers) --- index.js | 162 +++++++++++++++++++++++++++------------------------ package.json | 2 +- readme.md | 27 ++++++++- test.js | 84 +++++++++++++++++--------- 4 files changed, 170 insertions(+), 105 deletions(-) diff --git a/index.js b/index.js index 2e6940e..79c8df0 100644 --- a/index.js +++ b/index.js @@ -4,8 +4,6 @@ const dependencyTree = new Map(); // name => [dependency name, ...] const handlers = new Map(); // name => [handler, ...] const shutdownErrorHandlers = []; -let shuttingDown = false; - /** * Gracefully terminate application's modules on shutdown. * @param {string} [name] - Name of the handler. @@ -13,100 +11,112 @@ let shuttingDown = false; * @param {function} handler - Async or sync function which handles shutdown. */ module.exports.onShutdown = function (name, dependencies, handler) { - handler = typeof name === "function" ? name : typeof dependencies === "function" ? dependencies : handler; - dependencies = name instanceof Array ? name : dependencies instanceof Array ? dependencies : []; - name = typeof name === "string" ? name : "default"; - dependencies.forEach(dependency => addDependency(name, dependency)); - if (!handlers.has(name)) { - handlers.set(name, []); - } - handlers.get(name).push(handler); + handler = + typeof name === "function" + ? name + : typeof dependencies === "function" + ? dependencies + : handler; + dependencies = + name instanceof Array + ? name + : dependencies instanceof Array + ? dependencies + : []; + name = typeof name === "string" ? name : Math.random().toString(36); + + if (dependencies.reduce((acc, dep) => acc || testForCycles(dep), false)) { + throw new Error( + `Adding shutdown handler "${name}" will create a dependency loop: aborting` + ); + } + + dependencyTree.set( + name, + Array.from(new Set((dependencyTree.get(name) || []).concat(dependencies))) + ); + if (!handlers.has(name)) { + handlers.set(name, []); + } + handlers.get(name).push(handler); }; /** * Optional export to handle shutdown errors. - * @param {function} callback + * @param {function} callback */ module.exports.onShutdownError = function (callback) { - shutdownErrorHandlers.push(callback); + shutdownErrorHandlers.push(callback); }; -async function gracefullyHandleShutdown () { - const leveledHandlers = getLeveledHandlers(); - for (const handlers of leveledHandlers) { - await Promise.all(handlers.map(handler => handler())); +async function shutdown(name, promisesMap) { + if (promisesMap.has(name)) { + return await promisesMap.get(name); + } + + const nodeCompletedPromise = (async function () { + const dependencies = dependencyTree.get(name) || []; + + // Wait for all dependencies to shut down. + await Promise.all(dependencies.map((dep) => shutdown(dep, promisesMap))); + + // Shutdown this item. + const allHandlers = handlers.get(name) || []; + if (allHandlers.length) { + await Promise.all(allHandlers.map((f) => f())); } - exit(0); + })(); + + promisesMap.set(name, nodeCompletedPromise); + await nodeCompletedPromise; } -handledEvents.forEach(event => process.removeAllListeners(event).addListener(event, () => { +let shuttingDown = false; +handledEvents.forEach((event) => + process.removeAllListeners(event).addListener(event, () => { if (shuttingDown) { - return; + return; } shuttingDown = true; - gracefullyHandleShutdown().catch((e) => { - Promise.all(shutdownErrorHandlers.map(f => f(e))) - .then(() => exit(42759)) - .catch(() => exit(42758)); - }); -})); -// -------- Utility functions -------- \\ + // Get all unreferenced nodes. + const unreferencedNames = getAllUnreferencedNames(); -function checkDependencyLoop (node, visited = new Set()) { - if (visited.has(node)) { - throw new Error( - `node-graceful-shutdown, circular dependency defined: ${ Array.from(visited).join("->") }->${ node }. Check your code.` - ); - } - visited.add(node); - const dependencies = dependencyTree.get(node) || []; - for (const dependency of dependencies) { - checkDependencyLoop(dependency, visited); - } -} + const visited = new Map(); + Promise.all(unreferencedNames.map((name) => shutdown(name, visited))) + .then(() => exit(0)) + .catch((e) => { + Promise.all(shutdownErrorHandlers.map((f) => f(e))) + .then(() => exit(42759)) + .catch(() => exit(42758)); + }); + }) +); -function addDependency (child, parent) { - if (!dependencyTree.has(child)) { - dependencyTree.set(child, []); - } - dependencyTree.get(child).push(parent); - checkDependencyLoop(child); -} +// -------- Utility functions -------- \\ -function getDependencyTreeLeaves () { - return Array.from(handlers.keys()).filter((name) => // Leave only those hander names - !dependencyTree.has(name) // Which have no dependencies - || dependencyTree.get(name).reduce((r, dep) => r && !handlers.has(dep), true) // And all their deps w/o handlers - ); +function testForCycles(name, visitedSet = new Set()) { + // Return true if the cycle is found. + if (visitedSet.has(name)) { + return true; + } + visitedSet.add(name); + // If any of the cycles found in dependencies, return true. + return (dependencyTree.get(name) || []).reduce( + (acc, name) => acc || testForCycles(name), + false + ); } -function getAllDependents (name) { - return new Set(Array.from(dependencyTree.entries()) - .filter(([_, dependencies]) => dependencies.indexOf(name) !== -1) - .filter(([parent]) => handlers.has(parent)) - .map(([parent]) => parent)); +function getAllUnreferencedNames() { + const allNodes = new Set(Array.from(dependencyTree.keys())); + Array.from(dependencyTree.values()).forEach((deps) => + deps.forEach((dep) => allNodes.delete(dep)) + ); + return Array.from(allNodes); } -function getLeveledHandlers () { - const levels = [getDependencyTreeLeaves()]; - while (levels[levels.length - 1].length > 0) { - const dependents = new Set(); - for (const task of levels[levels.length - 1]) { - for (const dependent of getAllDependents(task)) { - dependents.add(dependent); - } - } - levels.push(Array.from(dependents)); - } - return levels.map(tasks => tasks.reduce((arr, task) => { - for (const handler of handlers.get(task)) { - arr.push(handler); - } - return arr; - }, [])); +/* STUBBED - DO NOT EDIT */ +function exit(code) { + process.exit(code); } - -/* STUBBED */ function exit (code) { -/* STUBBED */ process.exit(code); -/* STUBBED */ } \ No newline at end of file diff --git a/package.json b/package.json index 525d9bc..5ccbc8e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "node-graceful-shutdown", - "version": "1.0.5", + "version": "1.1.0", "description": "Gracefully shutdown your modular NodeJS application", "main": "index.js", "engines": { diff --git a/readme.md b/readme.md index 229e561..deca735 100644 --- a/readme.md +++ b/readme.md @@ -39,6 +39,29 @@ onShutdown("database", ["http-server", "message-bus"], async function () { // node-graceful-shutdown will run the current handler without waiting for the undefined dependency. ``` +Or, if you have all your modules as exports and they all shutdown one after another, +this will work at its best in your application's `main.js`: + +```javascript +import { onShutdown } from "node-graceful-shutdown"; +import { startModule1, startModule2, stopModule1, stopModule2 /*, ...*/ } from "./src"; + +export const startMyApp = async () => { + await startModule1(); + await startModule2(); +}; + +export const stopMyApp = async () => { + // Stop modules one after another. + await stopModule1(); + await stopModule2(); + // ... +}; + +// Handle application's shutdown. +onShutdown(stopMyApp); +``` + Features and Guidelines ----------------------- @@ -46,7 +69,7 @@ This library, along existing ones, allow your application to be **modular**. You in the same module, where initialization happens. In addition, it allows specifying the order Recommendations: -1. Please, **do not use this module in libraries** (modules, packages). It is intended for end applications only (see why in `5.`). +1. Please, **do not use this module in libraries** (packages). It is intended for end applications only (see why in `5.`). 2. Once imported, `onShutdown` is application-wide (in terms of a single process), so the callbacks and their dependencies will see each other when imported from multiple files. 3. Circular shutdown handler dependencies will throw an error immediately once declared. 4. There's also an `onShutdownError` export which takes an error as an argument when any of assigned shutdown handlers throw an error (added for very-very prudent programmers only). @@ -56,4 +79,4 @@ Recommendations: Licence ------- -[MIT](LICENSE) © [Nikita Savchenko](https://nikita.tk) +[MIT](LICENSE) © [Nikita Savchenko](https://nikita.tk/developer) diff --git a/test.js b/test.js index 86fb8bd..9d7bacd 100644 --- a/test.js +++ b/test.js @@ -12,8 +12,8 @@ const tests = [ process.emit("SIGINT"); await delay(); - assert.equal(callTable[0], true); - assert.equal(pkg.exitCode, 0); + assert.strictEqual(callTable[0], true); + assert.strictEqual(pkg.exitCode, 0); }], ["Handles dependent shutdown actions", async () => { @@ -29,14 +29,14 @@ const tests = [ callTable["b"] = true; }); - assert.equal(pkg.exitCode, -1); + assert.strictEqual(pkg.exitCode, -1); process.emit("SIGINT"); await delay(); - assert.equal(callTable["a"], true); - assert.equal(callTable["b"], true); - assert.equal(pkg.exitCode, 0); + assert.strictEqual(callTable["a"], true); + assert.strictEqual(callTable["b"], true); + assert.strictEqual(pkg.exitCode, 0); }], ["Handles triangle dependencies", async () => { @@ -62,10 +62,42 @@ const tests = [ process.emit("SIGINT"); await delay(500); - assert.equal(callTable["a"], true); - assert.equal(callTable["b"], true); - assert.equal(callTable["c"], true); - assert.equal(pkg.exitCode, 0); + assert.strictEqual(callTable["a"], true); + assert.strictEqual(callTable["b"], true); + assert.strictEqual(callTable["c"], true); + assert.strictEqual(pkg.exitCode, 0); + + }], + ["Handles example case", async () => { + + const pkg = await testModule(); + const callTable = {}; + + pkg.onShutdown("database", ["http-server", "message-bus"], async function () { + if (!callTable["message-bus"]) assert.fail("Must call database after message-bus"); + if (!callTable["http-server"]) assert.fail("Must call database after http-server"); + await delay(100); + callTable["database"] = true; + }); + pkg.onShutdown("message-bus", ["http-server"], async function () { + if (!callTable["http-server"]) assert.fail("Must call http-server before message-bus"); + if (callTable["database"]) assert.fail("Must call database after message-bus"); + await delay(200); + callTable["message-bus"] = true; + }); + pkg.onShutdown("http-server", async function () { + if (callTable["message-bus"]) assert.fail("Must call message-bus after http-server"); + if (callTable["database"]) assert.fail("Must call database after http-server"); + callTable["http-server"] = true; + }); + + process.emit("SIGINT"); + await delay(500); + + assert.strictEqual(pkg.exitCode, 0); + assert.strictEqual(callTable["database"], true, "database"); + assert.strictEqual(callTable["http-server"], true, "http-server"); + assert.strictEqual(callTable["message-bus"], true, "message-bus"); }], ["Handles nonexistent dependency", async () => { @@ -80,10 +112,10 @@ const tests = [ process.emit("SIGINT"); await delay(); - assert.equal(callTable["d"], true); - assert.equal(callTable["a"], true); - assert.equal(callTable["c"], true); - assert.equal(pkg.exitCode, 0); + assert.strictEqual(callTable["d"], true); + assert.strictEqual(callTable["a"], true); + assert.strictEqual(callTable["c"], true); + assert.strictEqual(pkg.exitCode, 0); }], ["Handles duplicated callback", async () => { @@ -103,10 +135,10 @@ const tests = [ process.emit("SIGINT"); await delay(); - assert.equal(callTable["a1"], true); - assert.equal(callTable["a2"], true); - assert.equal(callTable["b"], true); - assert.equal(pkg.exitCode, 0); + assert.strictEqual(callTable["a1"], true); + assert.strictEqual(callTable["a2"], true); + assert.strictEqual(callTable["b"], true); + assert.strictEqual(pkg.exitCode, 0); }], ["Handles complex callbacks with missed dependencies", async () => { @@ -134,10 +166,10 @@ const tests = [ process.emit("SIGTERM"); await delay(200); - assert.equal(!!callTable["a"], false); + assert.strictEqual(!!callTable["a"], false); await delay(200); - ["a", "b", "c", "e"].forEach(letter => assert.equal(callTable[letter], true, `Letter ${ letter }`)); + ["a", "b", "c", "e"].forEach(letter => assert.strictEqual(callTable[letter], true, `Letter ${ letter }`)); }], ["Handles errors as exit code 42759", async () => { @@ -156,9 +188,9 @@ const tests = [ process.emit("SIGINT"); await delay(); - assert.equal(callTable["b"], true); - assert.equal(callTable["e"], true); - assert.equal(pkg.exitCode, 42759); + assert.strictEqual(callTable["b"], true); + assert.strictEqual(callTable["e"], true); + assert.strictEqual(pkg.exitCode, 42759); }], ["Detects circular dependencies", async () => { @@ -196,7 +228,7 @@ const tests = [ process.emit("SIGTERM"); await delay(); - assert.equal(callTable["a"], 1); + assert.strictEqual(callTable["a"], 1); }], // Keep this test at the end. @@ -217,7 +249,7 @@ const tests = [ process.emit("SIGINT"); await delay(); - assert.equal(callTable["a"], 1); + assert.strictEqual(callTable["a"], 1); }] ]; @@ -243,7 +275,7 @@ async function testModule () { async function test () { for (const [info, t] of tests) { - process.stdout.write(`- ${ info }`); + process.stdout.write(`- ${ info }\n`); await t(); console.log(" ✔"); }