diff --git a/src/server/bootstrap/index.ts b/src/server/bootstrap/index.ts index bf106565b..81d42aedb 100644 --- a/src/server/bootstrap/index.ts +++ b/src/server/bootstrap/index.ts @@ -1,25 +1,11 @@ import express, { Express, Request, Response, NextFunction } from 'express' import session from 'express-session' -import { Sequelize, SequelizeOptions } from 'sequelize-typescript' +import { Sequelize } from 'sequelize-typescript' import SequelizeStoreFactory from 'connect-session-sequelize' import bodyParser from 'body-parser' import * as Sentry from '@sentry/node' import * as Tracing from '@sentry/tracing' -// Sequelize-related imports -import { - databaseConfigType, - nodeEnvType, -} from '../../types/server/sequelize-config' -import * as sequelizeConfig from '../database/config/config' -import { - User, - Checker, - UserToChecker, - PublishedChecker, - Template, -} from '../database/models' - import minimatch from 'minimatch' import { totp as totpFactory } from 'otplib' @@ -48,26 +34,7 @@ const emailValidator = new minimatch.Minimatch(mailSuffix, { nonegate: true, }) -export async function bootstrap(): Promise<{ - app: Express - sequelize: Sequelize -}> { - // Create Sequelize instance and add models - const nodeEnv = config.get('nodeEnv') as nodeEnvType - const options: SequelizeOptions = (sequelizeConfig as databaseConfigType)[ - nodeEnv - ] - - logger.info('Creating Sequelize instance and adding models') - const sequelize = new Sequelize(options) - sequelize.addModels([ - User, - Checker, - UserToChecker, - PublishedChecker, - Template, - ]) - +export async function bootstrap(sequelize: Sequelize): Promise { const checker = new CheckerController({ service: new CheckerService({ logger, @@ -161,10 +128,7 @@ export async function bootstrap(): Promise<{ app.use(Sentry.Handlers.errorHandler()) - logger.info('Connecting to Sequelize') - await sequelize.authenticate() - return { app, sequelize } + return app } -export { logger } from './logger' export default bootstrap diff --git a/src/server/bootstrap/loadSequelize.ts b/src/server/bootstrap/loadSequelize.ts new file mode 100644 index 000000000..e92e190d0 --- /dev/null +++ b/src/server/bootstrap/loadSequelize.ts @@ -0,0 +1,37 @@ +import { Sequelize, SequelizeOptions } from 'sequelize-typescript' +import config from '../config' +import logger from './logger' +import { + databaseConfigType, + nodeEnvType, +} from '../../types/server/sequelize-config' +import * as sequelizeConfig from '../database/config/config' +import { + User, + Checker, + UserToChecker, + PublishedChecker, + Template, +} from '../database/models' + +const loadSequelize = async () => { + const nodeEnv = config.get('nodeEnv') as nodeEnvType + const options: SequelizeOptions = (sequelizeConfig as databaseConfigType)[ + nodeEnv + ] + + const sequelize = new Sequelize(options) + sequelize.addModels([ + User, + Checker, + UserToChecker, + PublishedChecker, + Template, + ]) + + logger.info('Connecting to Sequelize') + await sequelize.authenticate() + return sequelize +} + +export default loadSequelize diff --git a/src/server/database/config/config.ts b/src/server/database/config/config.ts index eaf27119c..b0b6fac75 100644 --- a/src/server/database/config/config.ts +++ b/src/server/database/config/config.ts @@ -13,6 +13,43 @@ const connectionConfig = { database: parsed.database, } +// Obtained from marcogrcr: https://github.com/sequelize/sequelize/pull/12642 +// These only apply to the staging and production environment and not development because they are meant for lambda +const lambdaBestPracticesConfig = { + pool: { + /* + * Lambda functions process one request at a time but your code may issue multiple queries + * concurrently. Be wary that `sequelize` has methods that issue 2 queries concurrently + * (e.g. `Model.findAndCountAll()`). Using a value higher than 1 allows concurrent queries to + * be executed in parallel rather than serialized. Careful with executing too many queries in + * parallel per Lambda function execution since that can bring down your database with an + * excessive number of connections. + * + * Ideally you want to choose a `max` number where this holds true: + * max * EXPECTED_MAX_CONCURRENT_LAMBDA_INVOCATIONS < MAX_ALLOWED_DATABASE_CONNECTIONS * 0.8 + */ + max: 2, + /* + * Set this value to 0 so connection pool eviction logic eventually cleans up all connections + * in the event of a Lambda function timeout. + */ + min: 0, + /* + * Set this value to 0 so connections are eligible for cleanup immediately after they're + * returned to the pool. + */ + idle: 0, + // Choose a small enough value that fails fast if a connection takes too long to be established. + acquire: 3000, + /* + * Ensures the connection pool attempts to be cleaned up automatically on the next Lambda + * function invocation, if the previous invocation timed out. This value is set to the default lambda timeout, + * which is 30 seconds. + */ + evict: 30000, + }, +} + module.exports = { development: { storage: config.get('sqlitePath'), @@ -25,6 +62,7 @@ module.exports = { dialect: 'postgres', seederStorage: 'sequelize', ...connectionConfig, + ...lambdaBestPracticesConfig, }, production: { timezone: '+08:00', @@ -32,5 +70,6 @@ module.exports = { dialect: 'postgres', seederStorage: 'sequelize', ...connectionConfig, + ...lambdaBestPracticesConfig, }, } diff --git a/src/server/serverless/api.ts b/src/server/serverless/api.ts index 68d081b67..95e8a7e54 100644 --- a/src/server/serverless/api.ts +++ b/src/server/serverless/api.ts @@ -1,15 +1,37 @@ import serverless, { Handler } from 'serverless-http' +import { Sequelize } from 'sequelize-typescript' import bootstrap from '../bootstrap' import logger from '../bootstrap/logger' +import loadSequelize from '../bootstrap/loadSequelize' + +// This non-handler section runs +let sequelize: Sequelize | null = null export const handler: Handler = async (event, context) => { - const { app, sequelize } = await bootstrap() + // re-use the sequelize instance across invocations to improve performance + if (!sequelize) { + sequelize = await loadSequelize() + } else { + // restart connection pool to ensure connections are not re-used across invocations + sequelize.connectionManager.initPools() + + // restore `getConnection()` if it has been overwritten by `close()` + if (sequelize.connectionManager.hasOwnProperty('getConnection')) { + // @ts-ignore + // There is some jankiness in the Sequelize `connectionManager` module where `close()` + // overwrites the `getConnection()` method to throw an error. + // https://github.com/sequelize/sequelize/pull/12642#discussion_r613287823 + delete sequelize.connectionManager.getConnection + } + } + + const app = await bootstrap(sequelize) const h = serverless(app) try { return await h(event, context) } finally { logger.info('Closing Sequelize connection') - await sequelize.close() + await sequelize.connectionManager.close() } }