diff --git a/CODEOWNERS b/CODEOWNERS index 227f2620800d..4caff064830a 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -132,6 +132,7 @@ # - Standby owner(s): @emonddr /packages/core/src/component.ts @emonddr @jannyHou @raymondfeng /extensions/cron @raymondfeng +/extensions/pooling @raymondfeng /examples/greeting-app @emonddr @jannyHou @raymondfeng /examples/log-extension @emonddr @jannyHou @raymondfeng /examples/greeter-extension @emonddr @jannyHou @raymondfeng diff --git a/docs/site/MONOREPO.md b/docs/site/MONOREPO.md index d4c272c68667..7d7a463607dd 100644 --- a/docs/site/MONOREPO.md +++ b/docs/site/MONOREPO.md @@ -59,6 +59,7 @@ The [loopback-next](https://github.com/strongloop/loopback-next) repository uses | [model-api-builder](https://github.com/strongloop/loopback-next/tree/master/packages/model-api-builder) | @loopback/model-api-builder | Types and helpers for packages contributing Model API builders. | | [openapi-spec-builder](https://github.com/strongloop/loopback-next/tree/master/packages/openapi-spec-builder) | @loopback/openapi-spec-builder | Builders to create OpenAPI (Swagger) specification documents in tests | | [openapi-v3](https://github.com/strongloop/loopback-next/tree/master/packages/openapi-v3) | @loopback/openapi-v3 | Decorators that annotate LoopBack artifacts with OpenAPI v3 metadata and utilities that transform LoopBack metadata to OpenAPI v3 specifications | +| [pooling](https://github.com/strongloop/loopback-next/tree/master/extensions/pooling) | @loopback/pooling | Resource pooling service for LoopBack 4 | | [repository-json-schema](https://github.com/strongloop/loopback-next/tree/master/packages/repository-json-schema) | @loopback/repository-json-schema | Convert a TypeScript class/model to a JSON Schema | | [repository](https://github.com/strongloop/loopback-next/tree/master/packages/repository) | @loopback/repository | Define and implement a common set of interfaces for interacting with databases | | [repository-tests](https://github.com/strongloop/loopback-next/tree/master/packages/repository-tests) | @loopback/repository-tests | A shared test suite to verify `@loopback/repository` functionality with a given compatible connector | diff --git a/extensions/pooling/.npmrc b/extensions/pooling/.npmrc new file mode 100644 index 000000000000..34fbbbb3f3e4 --- /dev/null +++ b/extensions/pooling/.npmrc @@ -0,0 +1,2 @@ +package-lock=true +scripts-prepend-node-path=true diff --git a/extensions/pooling/LICENSE b/extensions/pooling/LICENSE new file mode 100644 index 000000000000..41ddc6ecf731 --- /dev/null +++ b/extensions/pooling/LICENSE @@ -0,0 +1,25 @@ +Copyright (c) IBM Corp. 2020. +Node module: @loopback/pooling +This project is licensed under the MIT License, full text below. + +-------- + +MIT license + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/extensions/pooling/README.md b/extensions/pooling/README.md new file mode 100644 index 000000000000..f7a2cb4b8850 --- /dev/null +++ b/extensions/pooling/README.md @@ -0,0 +1,281 @@ +# @loopback/pooling + +This module contains a resource pooling service for LoopBack 4. + +## Overview + +Some resources can be expensive to create/start. For example, a datasource has +overhead to connect to the database. There will be performance penalty to use +`TRANSIENT` binding scope and creates a new instance per request. But it is not +feasible to be a singleton for some use cases, for example, each request may +have different security contexts. + +The `PoolingService` is a singleton service to maintain a pool of resources. +This pool service can be bound to different keys to represent multiple pools. +Each binding is a singleton so that the state stays the same for injections into +multiple instances for other artifacts. + +The pooling service observes life cycle events to start and stop. + +The extension is built with +[generic-pool](https://github.com/coopernurse/node-pool). + +![pooling.png](pooling.png) + +## Stability: ⚠️Experimental⚠️ + +> Experimental packages provide early access to advanced or experimental +> functionality to get community feedback. Such modules are published to npm +> using `0.x.y` versions. Their APIs and functionality may be subject to +> breaking changes in future releases. + +## Installation + +```sh +npm install --save @loopback/pooling +``` + +## Basic use + +Let's use the following class as an expensive resource that requires pooling for +performance. + +```ts +class ExpensiveResource { + static id = 1; + id: number; + status: string; + + constructor() { + this.status = 'created'; + this.id = ExpensiveResource.id++; + } +} +``` + +### Register a pooling service + +```ts +import {Application, ContextTags} from '@loopback/core'; +import {PoolingService, PoolServiceOptions} from '@loopback/pooling'; + +const app = new Application(); +const poolingServiceBinding = app.service(PoolingService, { + [ContextTags.KEY]: 'services.MyPoolingService', +}); +``` + +### Configure the pooling service + +A pooling service has to be configured first. We must provide a factory that +handles `create/destroy` of resource instances to be pooled. There are also +options to control the pooling behavior. + +```ts +app + .configure>(poolingServiceBinding.key) + .to({ + factory: { + async create() { + const res = new ExpensiveResource(); + return res; + }, + + async destroy(resource: ExpensiveResource) { + resource.status = 'destroyed'; + }, + }, + {max: 16}, // Pooling options + }); +``` + +See more details at +https://github.com/coopernurse/node-pool/blob/master/README.md#creating-a-pool. + +### Locate the pooling service + +```ts +const myPoolingService = await app.get( + 'services.MyPoolingService', +); +``` + +### Acquire a resource instance from the pool + +```ts +const res1 = await myPoolingService.acquire(); +// Do some work with res1 +``` + +### Release the resource instance back to the pool + +After the resource is used, it **MUST** be released back to the pool. + +```ts +myPoolingService.release(res1); +``` + +## Advanced use + +### Pooling life cycle methods + +We can optionally implement life cycle methods for the factory and the resource +to provide additional logic for pooling life cycle events: + +- create +- destroy +- acquire +- release + +#### Factory level methods + +```ts +const options: PoolingServiceOptions = { + factory: { + async create() { + const res = new ctor(); + res.status = status; + if (status === 'invalid') { + // Reset status so that the next try will be good + status = 'created'; + } + return res; + }, + + async destroy(resource: ExpensiveResource) { + resource.status = 'destroyed'; + }, + + async validate(resource) { + const result = resource.status === 'created'; + resource.status = 'validated'; + return result; + }, + + acquire(resource) { + resource.status = 'in-use-set-by-factory'; + }, + + release(resource) { + resource.status = 'idle-set-by-factory'; + }; + }, + poolOptions, +}; +``` + +#### Resource level methods + +The resource can also implement similar methods: + +```ts +class ExpensiveResourceWithHooks extends ExpensiveResource implements Poolable { + /** + * Life cycle method to be called by `create` + */ + start() { + // In real world, this may take a few seconds to start + this.status = 'started'; + } + + /** + * Life cycle method to be called by `destroy` + */ + stop() { + this.status = 'stopped'; + } + + acquire() { + this.status = 'in-use'; + } + + release() { + this.status = 'idle'; + } +} +``` + +If the resource implements life cycle methods, they will be invoked for the +pooled resource. + +- `start`: It will be called right after the resource is newly created by the + pool. This method should be used to initialize/start the resource. + +- `stop`: It will be called when the pool is stopping/draining. This method + should be used to stop the resource. + +- `acquire`: It will be called right after the resource is acquired from the + pool. If it fails, the resource will be destroyed from the pool. The method + should be used to set up the acquired resource. + +- `release`: It will be called right before the resource is released back to the + pool. If it fails, the resource will be destroyed from the pool. The method + should be used to clean up the resource to be released. + +### Pooled resource provider + +The pooled resource can be wrapped into a provider class to provide pooled +instances. + +```ts +import {PooledValue, PoolingService} from '@loopback/pooling'; + +class ExpensiveResourceProvider + implements Provider> { + constructor( + @inject(POOL_SERVICE) + private poolingService: PoolingService, + ) {} + + async value() { + return getPooledValue(this.poolingService); + } +} +``` + +Now we can bind the pooled resource provider: + +```ts +ctx.bind('resources.ExpensiveResource').toProvider(ExpensiveResourceProvider); +const res: PooledValue = await ctx.get( + 'resources.ExpensiveResource', +); +// Do some work with the acquired resource +// The resource must be released back to the pool +await res.release(); +``` + +### Use a binding as the pooled resource + +We can leverage a binding as the factory to create resources for a pool. + +```ts +const MY_RESOURCE = BindingKey.create('my-resource'); +ctx.bind(MY_RESOURCE).toClass(ExpensiveResource); +const factory = createPooledBindingFactory(MY_RESOURCE); +const poolBinding = createBindingFromClass(PoolingService, { + [ContextTags.KEY]: POOL_SERVICE, +}); +ctx.add(poolBinding); +ctx.configure>(poolBinding.key).to({ + factory, +}); +``` + +## Contributions + +- [Guidelines](https://github.com/strongloop/loopback-next/blob/master/docs/CONTRIBUTING.md) +- [Join the team](https://github.com/strongloop/loopback-next/issues/110) + +## Tests + +Run `npm test` from the root folder. + +## Contributors + +See +[all contributors](https://github.com/strongloop/loopback-next/graphs/contributors). + +## License + +MIT diff --git a/extensions/pooling/package-lock.json b/extensions/pooling/package-lock.json new file mode 100644 index 000000000000..8a73a049eb58 --- /dev/null +++ b/extensions/pooling/package-lock.json @@ -0,0 +1,37 @@ +{ + "name": "@loopback/pooling", + "version": "0.0.1", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "@types/generic-pool": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/@types/generic-pool/-/generic-pool-3.1.9.tgz", + "integrity": "sha512-IkXMs8fhV6+E4J8EWv8iL7mLvApcLLQUH4m1Rex3KCPRqT+Xya0DDHIeGAokk/6VXe9zg8oTWyr+FGyeuimEYQ==", + "requires": { + "@types/node": "*" + } + }, + "@types/node": { + "version": "10.17.24", + "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.24.tgz", + "integrity": "sha512-5SCfvCxV74kzR3uWgTYiGxrd69TbT1I6+cMx1A5kEly/IVveJBimtAMlXiEyVFn5DvUFewQWxOOiJhlxeQwxgA==" + }, + "generic-pool": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.7.1.tgz", + "integrity": "sha512-ug6DAZoNgWm6q5KhPFA+hzXfBLFQu5sTXxPpv44DmE0A2g+CiHoq9LTVdkXpZMkYVMoGw83F6W+WT0h0MFMK/w==" + }, + "tslib": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.0.0.tgz", + "integrity": "sha512-lTqkx847PI7xEDYJntxZH89L2/aXInsyF2luSafe/+0fHOMjlBNXdH6th7f70qxLDhul7KZK0zC8V5ZIyHl0/g==" + }, + "typescript": { + "version": "3.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.9.5.tgz", + "integrity": "sha512-hSAifV3k+i6lEoCJ2k6R2Z/rp/H3+8sdmcn5NrS3/3kE7+RyZXm9aqvxWqjEXHAd8b0pShatpcdMTvEdvAJltQ==", + "dev": true + } + } +} diff --git a/extensions/pooling/package.json b/extensions/pooling/package.json new file mode 100644 index 000000000000..f83a0630de5c --- /dev/null +++ b/extensions/pooling/package.json @@ -0,0 +1,50 @@ +{ + "name": "@loopback/pooling", + "version": "0.0.1", + "description": "Resource pooling service for LoopBack 4", + "keywords": [ + "loopback-extension", + "loopback" + ], + "main": "dist/index.js", + "types": "dist/index.d.ts", + "engines": { + "node": ">=10" + }, + "scripts": { + "build": "lb-tsc", + "build:watch": "lb-tsc --watch", + "pretest": "npm run clean && npm run build", + "test": "lb-mocha \"dist/__tests__/**/*.js\"", + "clean": "lb-clean dist *.tsbuildinfo .eslintcache" + }, + "repository": { + "type": "git", + "url": "https://github.com/strongloop/loopback-next.git", + "directory": "extensions/pooling" + }, + "author": "IBM Corp.", + "license": "MIT", + "files": [ + "README.md", + "dist", + "src", + "!*/__tests__" + ], + "dependencies": { + "@loopback/core": "^2.7.1", + "@types/generic-pool": "^3.1.9", + "generic-pool": "^3.7.1", + "tslib": "^2.0.0" + }, + "devDependencies": { + "@loopback/build": "^5.4.2", + "@loopback/testlab": "^3.1.6", + "@types/node": "^10.17.24", + "typescript": "~3.9.3" + }, + "copyright.owner": "IBM Corp.", + "publishConfig": { + "access": "public" + } +} diff --git a/extensions/pooling/pooling.png b/extensions/pooling/pooling.png new file mode 100644 index 000000000000..961bf6882072 Binary files /dev/null and b/extensions/pooling/pooling.png differ diff --git a/extensions/pooling/src/__tests__/acceptance/README.md b/extensions/pooling/src/__tests__/acceptance/README.md new file mode 100644 index 000000000000..5bb177829768 --- /dev/null +++ b/extensions/pooling/src/__tests__/acceptance/README.md @@ -0,0 +1 @@ +# Acceptance tests diff --git a/extensions/pooling/src/__tests__/acceptance/pooling.acceptance.ts b/extensions/pooling/src/__tests__/acceptance/pooling.acceptance.ts new file mode 100644 index 000000000000..5565cedc6b71 --- /dev/null +++ b/extensions/pooling/src/__tests__/acceptance/pooling.acceptance.ts @@ -0,0 +1,370 @@ +// Copyright IBM Corp. 2020. All Rights Reserved. +// Node module: @loopback/pooling +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import { + BindingKey, + Constructor, + Context, + ContextTags, + createBindingFromClass, + inject, + Provider, +} from '@loopback/core'; +import {expect} from '@loopback/testlab'; +import {once} from 'events'; +import {Options} from 'generic-pool'; +import { + createPooledBindingFactory, + PoolingService, + PoolingServiceOptions, +} from '../../'; +import { + getPooledValue, + Poolable, + PooledValue, + PoolFactory, +} from '../../pooling'; + +describe('Resource pool', () => { + const POOLING_SERVICE = BindingKey.create>( + 'services.pooling', + ); + let ctx: Context; + + beforeEach(givenContext); + + it('creates a resource pool', async () => { + const poolService = await givenPoolService(); + expect(poolService.pool.size).to.eql(0); + }); + + it('creates a resource pool as singleton', async () => { + const poolService = await givenPoolService(); + const result = await ctx.get(POOLING_SERVICE); + expect(result).to.be.exactly(poolService); + }); + + it('acquires/releases a resource from the pool', async () => { + const poolService = await givenPoolService({max: 5}); + poolService.start(); + const res = await poolService.acquire(); + expect(res.status).to.eql('created'); + expect(poolService.pool.borrowed).to.eql(1); + await poolService.release(res); + expect(poolService.pool.borrowed).to.eql(0); + }); + + it('runs a task', async () => { + const poolService = await givenPoolService({max: 5}); + await poolService.run(resource => { + resource.status = 'running'; + }); + expect(poolService.pool.borrowed).to.eql(0); + }); + + it('runs a task that throws an error', async () => { + const poolService = await givenPoolService({max: 5}); + let acquired: ExpensiveResource; + await expect( + poolService.run(resource => { + acquired = resource; + throw new Error('fail'); + }), + ).to.be.rejectedWith(/fail/); + expect(acquired!).to.be.instanceOf(ExpensiveResource); + expect(poolService.pool.borrowed).to.eql(0); + expect(acquired!.status).to.eql('destroyed'); + expect(poolService.pool.isBorrowedResource(acquired!)).to.be.false(); + }); + + it('honors poolOptions.min', async () => { + const poolService = await givenPoolService({min: 2, max: 5}); + expect(poolService.pool.size).to.eql(0); + poolService.start(); + const res = await poolService.acquire(); + expect(res.status).to.eql('created'); + expect(poolService.pool.available).to.eql(1); + expect(poolService.pool.borrowed).to.eql(1); + expect(poolService.pool.size).to.eql(2); + await poolService.release(res); + expect(poolService.pool.available).to.eql(2); + expect(poolService.pool.borrowed).to.eql(0); + expect(poolService.pool.size).to.eql(2); + }); + + it('honors poolOptions.max', async () => { + const poolService = await givenPoolService({ + min: 1, + max: 2, + acquireTimeoutMillis: 100, + }); + // 1st + const res1 = await poolService.acquire(); + expect(poolService.pool.borrowed).to.eql(1); + // 2nd + await poolService.acquire(); + expect(poolService.pool.available).to.eql(0); + expect(poolService.pool.borrowed).to.eql(2); + expect(poolService.pool.size).to.eql(2); + // 3rd has to wait + await expect(poolService.acquire()).to.be.rejectedWith( + /ResourceRequest timed out/, + ); + await poolService.release(res1); + expect(poolService.pool.available).to.eql(1); + await poolService.acquire(); + expect(poolService.pool.available).to.eql(0); + expect(poolService.pool.borrowed).to.eql(2); + expect(poolService.pool.size).to.eql(2); + }); + + it('destroys a resource from the pool', async () => { + const poolService = await givenPoolService(); + const res = await poolService.acquire(); + expect(res.status).to.eql('created'); + await poolService.release(res); + expect(res.status).to.eql('created'); + await poolService.stop(); + expect(res.status).to.eql('destroyed'); + }); + + it('validates a resource during acquire', async () => { + const poolService = await givenPoolService({ + testOnBorrow: true, + }); + const res = await poolService.acquire(); + expect(res.status).to.eql('validated'); + }); + + it('fails a resource during acquire', async () => { + const poolService = await givenPoolService( + { + testOnBorrow: true, + }, + 'invalid', + ); + const res = await poolService.acquire(); + expect(res.status).to.eql('validated'); + // The first creation fails and generic-pool calls `create` again + expect(res.id).to.eql(2); + }); + + it.skip('validates a resource during release', async () => { + const poolService = await givenPoolService({ + testOnReturn: true, + }); + const res = await poolService.acquire(); + expect(res.status).to.eql('created'); + res.status = 'invalid'; + await poolService.release(res); + expect(poolService.pool.isBorrowedResource(res)).to.be.false(); + }); + + it('invokes resource-level acquire/release methods', async () => { + const poolService = await givenPoolService( + {max: 5}, + undefined, + ExpensiveResourceWithHooks, + ); + poolService.start(); + const res = await poolService.acquire(); + expect(res.status).to.eql('in-use'); + await poolService.release(res); + expect(res.status).to.eql('idle'); + }); + + it('invokes factory-level acquire/release methods', async () => { + setupPoolingService({max: 5}, undefined, ExpensiveResourceWithHooks); + const factory = (await ctx.getConfig< + PoolingServiceOptions + >(POOLING_SERVICE))!.factory as PoolFactory; + factory.acquire = (resource: ExpensiveResource) => { + resource.status = 'in-use-set-by-factory'; + }; + factory.release = (resource: ExpensiveResource) => { + resource.status = 'idle-set-by-factory'; + }; + const poolService = await ctx.get(POOLING_SERVICE); + poolService.start(); + const res = await poolService.acquire(); + expect(res.status).to.eql('in-use-set-by-factory'); + await poolService.release(res); + expect(res.status).to.eql('idle-set-by-factory'); + }); + + it('supports pooled binding factory', async () => { + const poolService = await givenPoolServiceForBinding(); + const res = await poolService.acquire(); + expect(res.toJSON()).to.eql({status: 'started', id: 1}); + await poolService.release(res); + expect(res.toJSON()).to.eql({status: 'started', id: 1}); + await poolService.stop(); + expect(res.toJSON()).to.eql({status: 'stopped', id: 1}); + }); + + it('releases pooled binding on context.close', async () => { + const poolService = await givenPoolServiceForBinding(); + const reqCtx = new Context(ctx, 'req'); + const res = await poolService.acquire(); + reqCtx.once('close', () => { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + poolService.release(res); + }); + const requestClosed = once(reqCtx, 'close'); + reqCtx.close(); + reqCtx.emit('close'); + await requestClosed; + expect(poolService.pool.isBorrowedResource(res)).to.be.false(); + }); + + it('allows injection of pooling service', async () => { + await givenPoolService({max: 16}); + ctx + .bind('resources.ExpensiveResource') + .toProvider(ExpensiveResourceProvider); + const res1: PooledValue = await ctx.get( + 'resources.ExpensiveResource', + ); + expect(res1.value.toJSON()).to.eql({status: 'created', id: 1}); + const res2: PooledValue = await ctx.get( + 'resources.ExpensiveResource', + ); + expect(res2.value.toJSON()).to.eql({status: 'created', id: 2}); + await res1.release(); + const res3: PooledValue = await ctx.get( + 'resources.ExpensiveResource', + ); + expect(res3.value.toJSON()).to.eql({status: 'created', id: 1}); + }); + + function givenContext() { + ctx = new Context('test'); + } + + function givenPoolService( + poolOptions?: Options, + status = 'created', + ctor: Constructor = ExpensiveResource, + ) { + setupPoolingService(poolOptions, status, ctor); + return ctx.get(POOLING_SERVICE); + } + + function givenPoolServiceForBinding() { + ExpensiveResource.id = 1; + const MY_RESOURCE = BindingKey.create('my-resource'); + ctx.bind(MY_RESOURCE).toClass(ExpensiveResource); + const factory = createPooledBindingFactory(MY_RESOURCE); + const poolBinding = createBindingFromClass(PoolingService, { + [ContextTags.KEY]: POOLING_SERVICE, + }); + ctx.add(poolBinding); + ctx + .configure>(poolBinding.key) + .to({ + factory, + }); + return ctx.get(POOLING_SERVICE); + } + + /** + * This simulates a resource that is expensive to create/start. For example, + * a datasource has overhead to connect to the database. There will be performance + * penalty to use `TRANSIENT` scope and creates a new instance per request. + * But it is not feasible to be a singleton for some use cases, for example, + * each request may have different security contexts. + */ + class ExpensiveResource implements Poolable { + static id = 1; + id: number; + status: string; + + constructor() { + this.status = 'created'; + this.id = ExpensiveResource.id++; + } + + toJSON() { + return {id: this.id, status: this.status}; + } + + /** + * Life cycle method to be called by `create` + */ + start() { + // In real world, this may take a few seconds to start + this.status = 'started'; + } + + /** + * Life cycle method to be called by `destroy` + */ + stop() { + this.status = 'stopped'; + } + } + + class ExpensiveResourceWithHooks extends ExpensiveResource + implements Poolable { + acquire() { + this.status = 'in-use'; + } + + release() { + this.status = 'idle'; + } + } + + class ExpensiveResourceProvider + implements Provider> { + constructor( + @inject(POOLING_SERVICE) + private poolingService: PoolingService, + ) {} + + async value() { + return getPooledValue(this.poolingService); + } + } + + function setupPoolingService( + poolOptions?: Options, + status = 'created', + ctor: Constructor = ExpensiveResource, + ) { + ExpensiveResource.id = 1; + const poolBinding = createBindingFromClass(PoolingService, { + [ContextTags.KEY]: POOLING_SERVICE, + }); + ctx.add(poolBinding); + const options: PoolingServiceOptions = { + factory: { + async create() { + const res = new ctor(); + res.status = status; + if (status === 'invalid') { + // Reset status so that the next try will be good + status = 'created'; + } + return res; + }, + + async destroy(resource: ExpensiveResource) { + resource.status = 'destroyed'; + }, + + async validate(resource) { + const result = resource.status === 'created'; + resource.status = 'validated'; + return result; + }, + }, + poolOptions, + }; + ctx + .configure>(poolBinding.key) + .to(options); + } +}); diff --git a/extensions/pooling/src/__tests__/integration/README.md b/extensions/pooling/src/__tests__/integration/README.md new file mode 100644 index 000000000000..0ca287e97688 --- /dev/null +++ b/extensions/pooling/src/__tests__/integration/README.md @@ -0,0 +1 @@ +# Integration tests diff --git a/extensions/pooling/src/__tests__/unit/README.md b/extensions/pooling/src/__tests__/unit/README.md new file mode 100644 index 000000000000..a0291f0699f9 --- /dev/null +++ b/extensions/pooling/src/__tests__/unit/README.md @@ -0,0 +1 @@ +# Unit tests diff --git a/extensions/pooling/src/index.ts b/extensions/pooling/src/index.ts new file mode 100644 index 000000000000..92af3c4593ee --- /dev/null +++ b/extensions/pooling/src/index.ts @@ -0,0 +1,6 @@ +// Copyright IBM Corp. 2020. All Rights Reserved. +// Node module: @loopback/pooling +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +export * from './pooling'; diff --git a/extensions/pooling/src/pooling.ts b/extensions/pooling/src/pooling.ts new file mode 100644 index 000000000000..6f884b2afccb --- /dev/null +++ b/extensions/pooling/src/pooling.ts @@ -0,0 +1,314 @@ +// Copyright IBM Corp. 2020. All Rights Reserved. +// Node module: @loopback/pooling +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import { + bind, + BindingAddress, + BindingScope, + config, + Context, + inject, + LifeCycleObserver, + ValueOrPromise, +} from '@loopback/core'; +import debugFactory from 'debug'; +import {createPool, Factory, Options, Pool} from 'generic-pool'; + +const debug = debugFactory('loopback:pooling'); + +/** + * Factory for the pooling service + */ +export interface PoolFactory extends Factory { + /** + * To be called right after the resource is acquired from the pool. If it + * fails, the resource will be destroyed from the pool. The method should be + * used to set up the acquired resource. + */ + acquire?(resource: T): ValueOrPromise; + /** + * To be called right before the resource is released to the pool. If it + * fails, the resource will be destroyed from the pool. This method should be + * used to clean up the resource to be returned. + */ + release?(resource: T): ValueOrPromise; +} + +/** + * Options to configure a resource pool + */ +export interface PoolingServiceOptions { + /** + * A factory to create/destroy/validate resources for the pool or a function + * to create a factory for the given context + */ + factory: PoolFactory | ((ctx: Context) => PoolFactory); + /** + * Options for the generic pool + */ + poolOptions?: Options; +} + +/** + * Life cycle methods that a poolable resource can optionally implement so that + * they can be triggered by the pooling service + */ +export interface Poolable extends LifeCycleObserver { + /** + * To be called right after the resource is acquired from the pool. If it + * fails, the resource will be destroyed from the pool. The method should be + * used to set up the acquired resource. + */ + acquire?(): ValueOrPromise; + /** + * To be called right before the resource is released to the pool. If it + * fails, the resource will be destroyed from the pool. This method should be + * used to clean up the resource to be returned. + */ + release?(): ValueOrPromise; +} + +/** + * Pooled resource instance + */ +export interface PooledValue { + /** + * The resource pool + */ + pool: Pool; + /** + * Acquired value from the pool + */ + value: T; + /** + * The function to release the acquired value back to the pool + */ + release(): Promise; +} + +/** + * Acquire a resource from the pooling service or pool + * @param poolingService - Pooling service or pool + */ +export async function getPooledValue( + poolingService: PoolingService | Pool, +): Promise> { + const value = await poolingService.acquire(); + const pool = + poolingService instanceof PoolingService + ? poolingService.pool + : poolingService; + return { + pool, + value, + async release() { + return poolingService.release(value); + }, + }; +} + +/** + * A singleton service to maintain a pool of resources. This pool service can + * be bound to different keys to represent multiple pools. Each binding is a + * singleton so that the state stays the same for injections into multiple + * instances for other artifacts. + * + * @remarks + * + * Some resources can be expensive to create/start. For example, a datasource + * has overhead to connect to the database. There will be performance penalty + * to use `TRANSIENT` scope and creates a new instance per request. But it is + * not feasible to be a singleton for some use cases, for example, each request + * may have different security contexts. + * + * The pool service observes life cycle events to start and stop. + */ +@bind({scope: BindingScope.SINGLETON}) +export class PoolingService implements LifeCycleObserver { + private readonly factory: PoolFactory; + /** + * The resource pool + */ + readonly pool: Pool; + + constructor( + @inject.context() readonly context: Context, + @config() private options: PoolingServiceOptions, + ) { + let factory = this.options.factory; + if (typeof factory === 'function') { + factory = factory(this.context); + } + this.factory = factory; + this.options = {...options, factory}; + this.pool = createPool(factory, { + max: 8, // Default to 8 instances + ...this.options.poolOptions, + autostart: false, + }); + } + + /** + * Start the pool + */ + start() { + if (this.options.poolOptions?.autostart !== false) { + debug('Starting pool for context %s', this.context.name); + this.pool.start(); + } + } + + /** + * Stop the pool + */ + async stop() { + debug('Stopping pool for context %s', this.context.name); + if (this.pool == null) return; + await this.pool.drain(); + await this.pool.clear(); + } + + /** + * Acquire a new instance + */ + async acquire() { + debug( + 'Acquiring a resource from the pool in context %s', + this.context.name, + ); + const resource = await this.pool.acquire(); + + try { + // Try factory-level acquire hook first + if (this.factory.acquire) { + await this.factory.acquire(resource); + } else { + // Fall back to resource-level acquire hook + await invokePoolableMethod(resource, 'acquire'); + } + } catch (err) { + await this.pool.destroy(resource); + throw err; + } + debug( + 'Resource acquired from the pool in context %s', + this.context.name, + resource, + ); + return resource; + } + + /** + * Release the resource back to the pool. + * @param resource - Resource instance to be returned back to the pool + */ + async release(resource: T) { + debug( + 'Releasing a resource from the pool in context %s', + this.context.name, + resource, + ); + try { + // Try factory-level acquire hook first + if (this.factory.release) { + await this.factory.release(resource); + } else { + await invokePoolableMethod(resource, 'release'); + } + await this.pool.release(resource); + } catch (err) { + await this.pool.destroy(resource); + throw err; + } + debug( + 'Resource released to the pool in context %s', + this.context.name, + resource, + ); + } + + /** + * Destroy a resource from the pool + * @param resource - Resource instance to be destroyed + */ + async destroy(resource: T) { + debug( + 'Destroying a resource from the pool in context %s', + this.context.name, + resource, + ); + await this.pool.destroy(resource); + debug('Resource destroyed in context %s', this.context.name, resource); + } + + /** + * Run the task with an acquired resource from the pool. If task is completed + * successfully, the resource is returned to the pool. Otherwise, the + * resource is destroyed. + * + * @param task - A function that accepts a resource and returns a Promise. + */ + async run(task: (resource: T) => ValueOrPromise) { + const resource = await this.acquire(); + try { + await task(resource); + } catch (err) { + await this.destroy(resource); + throw err; + } + await this.release(resource); + } +} + +/** + * Create a function to return a pooled binding factory + * @param bindingAddress - Binding address + */ +export function createPooledBindingFactory( + bindingAddress: BindingAddress, +): (context: Context) => PoolFactory { + const bindingPoolFactory = (context: Context): PoolFactory => + new PooledBindingFactory(context, bindingAddress); + return bindingPoolFactory; +} + +/** + * Factory for pooled binding values. This is specialized factory to create + * new resources from the binding. If the bound value observes life cycle events, + * the `start` method is called by `create` and the `stop` method is called + * by `destroy`. + */ +class PooledBindingFactory implements PoolFactory { + constructor( + private readonly context: Context, + private readonly bindingAddress: BindingAddress, + ) {} + + async create() { + debug( + 'Creating a resource for %s#%s', + this.context.name, + this.bindingAddress, + ); + const value = await this.context.get(this.bindingAddress); + await invokePoolableMethod(value, 'start'); + return value; + } + async destroy(value: T) { + debug( + 'Destroying a resource for %s#%s', + this.context.name, + this.bindingAddress, + value, + ); + await invokePoolableMethod(value, 'stop'); + } +} + +function invokePoolableMethod(resource: Poolable, method: keyof Poolable) { + if (typeof resource[method] === 'function') { + return resource[method]!(); + } +} diff --git a/extensions/pooling/tsconfig.json b/extensions/pooling/tsconfig.json new file mode 100644 index 000000000000..9414a59a5470 --- /dev/null +++ b/extensions/pooling/tsconfig.json @@ -0,0 +1,20 @@ +{ + "$schema": "http://json.schemastore.org/tsconfig", + "extends": "@loopback/build/config/tsconfig.common.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "composite": true + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../packages/core/tsconfig.json" + }, + { + "path": "../../packages/testlab/tsconfig.json" + } + ] +} diff --git a/tsconfig.json b/tsconfig.json index afa91467db5f..e9e7cf67870c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -103,6 +103,9 @@ { "path": "extensions/metrics/tsconfig.json" }, + { + "path": "extensions/pooling/tsconfig.json" + }, { "path": "fixtures/mock-oauth2-provider/tsconfig.json" },