diff --git a/examples/context/src/context-observation.ts b/examples/context/src/context-observation.ts index 4b0f38677a3f..816c2c9c5781 100644 --- a/examples/context/src/context-observation.ts +++ b/examples/context/src/context-observation.ts @@ -35,7 +35,7 @@ class RequestContext extends Context { * Wait until the context event queue is empty or an error is thrown */ waitUntilObserversNotified(): Promise { - return this.waitUntilPendingNotificationsDone(100); + return this.subscriptionManager.waitUntilPendingNotificationsDone(100); } } diff --git a/packages/context/src/__tests__/acceptance/interceptor.acceptance.ts b/packages/context/src/__tests__/acceptance/interceptor.acceptance.ts index d39b8b56b018..7c328643c5b8 100644 --- a/packages/context/src/__tests__/acceptance/interceptor.acceptance.ts +++ b/packages/context/src/__tests__/acceptance/interceptor.acceptance.ts @@ -186,8 +186,8 @@ describe('Interceptor', () => { } } - // No listeners yet - expect(ctx.listenerCount('bind')).to.eql(0); + // No invocation context related listeners yet + const listenerCount = ctx.listenerCount('bind'); const controller = new MyController(); // Run the invocation 5 times @@ -199,7 +199,7 @@ describe('Interceptor', () => { 'greet', ['John'], ); - // New listeners are added to `ctx` + // New listeners are added to `ctx` by the invocation context expect(ctx.listenerCount('bind')).to.be.greaterThan(count); // Wait until the invocation finishes @@ -208,7 +208,7 @@ describe('Interceptor', () => { // Listeners added by invocation context are gone now // There is one left for ctx.observers - expect(ctx.listenerCount('bind')).to.eql(1); + expect(ctx.listenerCount('bind')).to.eql(listenerCount + 1); }); it('invokes static interceptors', async () => { diff --git a/packages/context/src/__tests__/unit/binding-filter.unit.ts b/packages/context/src/__tests__/unit/binding-filter.unit.ts index be30aaeda602..6404af242d86 100644 --- a/packages/context/src/__tests__/unit/binding-filter.unit.ts +++ b/packages/context/src/__tests__/unit/binding-filter.unit.ts @@ -4,7 +4,7 @@ // License text available at https://opensource.org/licenses/MIT import {expect} from '@loopback/testlab'; -import {Binding, filterByKey, filterByTag} from '../..'; +import {Binding, filterByKey, filterByTag, isBindingTagFilter} from '../..'; const key = 'foo'; @@ -72,6 +72,53 @@ describe('BindingFilter', () => { }); }); + describe('BindingTagFilter', () => { + it('allows tag name as string', () => { + const filter = filterByTag('controller'); + expect(filter.bindingTagPattern).to.eql('controller'); + }); + + it('allows tag name wildcard as string', () => { + const filter = filterByTag('controllers.*'); + expect(filter.bindingTagPattern).to.eql(/^controllers\.[^.:]*$/); + }); + + it('allows tag name as regexp', () => { + const filter = filterByTag(/controller/); + expect(filter.bindingTagPattern).to.eql(/controller/); + }); + + it('allows tag name as map', () => { + const filter = filterByTag({controller: 'controller', rest: true}); + expect(filter.bindingTagPattern).to.eql({ + controller: 'controller', + rest: true, + }); + }); + }); + + describe('isBindingTagFilter', () => { + it('returns true for binding tag filter functions', () => { + const filter = filterByTag('controller'); + expect(isBindingTagFilter(filter)).to.be.true(); + }); + + it('returns false for binding filter functions without tag', () => { + const filter = () => true; + expect(isBindingTagFilter(filter)).to.be.false(); + }); + + it('returns false for undefined', () => { + expect(isBindingTagFilter(undefined)).to.be.false(); + }); + + it('returns false if the bindingTagPattern with wrong type', () => { + const filter = () => true; + filter.bindingTagPattern = true; // wrong type + expect(isBindingTagFilter(filter)).to.be.false(); + }); + }); + describe('filterByKey', () => { it('accepts bindings MATCHING the provided key', () => { const filter = filterByKey(key); diff --git a/packages/context/src/__tests__/unit/context-observer.unit.ts b/packages/context/src/__tests__/unit/context-observer.unit.ts index 60d7cab1e2f3..2891920ba287 100644 --- a/packages/context/src/__tests__/unit/context-observer.unit.ts +++ b/packages/context/src/__tests__/unit/context-observer.unit.ts @@ -12,7 +12,6 @@ import { ContextEventType, ContextObserver, filterByTag, - ContextEventListener, } from '../..'; const setImmediateAsync = promisify(setImmediate); @@ -22,14 +21,15 @@ const setImmediateAsync = promisify(setImmediate); * for assertions */ class TestContext extends Context { - // Make parentEventListener public for testing purpose - parentEventListener: ContextEventListener; + get parentEventListener() { + return this.subscriptionManager.parentContextEventListener; + } /** * Wait until the context event queue is empty or an error is thrown */ waitUntilObserversNotified(): Promise { - return this.waitUntilPendingNotificationsDone(100); + return this.subscriptionManager.waitUntilPendingNotificationsDone(100); } } diff --git a/packages/context/src/__tests__/unit/context-tag-indexer.unit.ts b/packages/context/src/__tests__/unit/context-tag-indexer.unit.ts new file mode 100644 index 000000000000..cf61252be51c --- /dev/null +++ b/packages/context/src/__tests__/unit/context-tag-indexer.unit.ts @@ -0,0 +1,144 @@ +// Copyright IBM Corp. 2020. All Rights Reserved. +// Node module: @loopback/context +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {expect} from '@loopback/testlab'; +import {Binding, BindingTag, Context, filterByTag} from '../..'; + +/** + * Create a subclass of context so that we can access parents and registry + * for assertions + */ +class TestContext extends Context { + constructor() { + super('app'); + } + + get parent() { + return this._parent; + } + + get bindingMap() { + const map = new Map(this.registry); + return map; + } + + get tagIndex() { + return this.tagIndexer.bindingsIndexedByTag; + } + + findByTagInvoked = false; + + _findByTagIndex(tag: BindingTag | RegExp) { + this.findByTagInvoked = true; + return super._findByTagIndex(tag); + } +} + +describe('Context with tag indexer', () => { + let ctx: TestContext; + beforeEach('given a context', createContext); + + describe('bind', () => { + it('indexes a binding by tag', () => { + const binding = ctx.bind('foo').tag('a', {b: 1}); + assertBindingIndexedByTag(binding, 'a', 'b'); + }); + + it('indexes a binding by tag after being bound', () => { + const binding = ctx.bind('foo'); + assertBindingNotIndexedByTag(binding, 'a', 'b'); + binding.tag('a', {b: 1}); + assertBindingIndexedByTag(binding, 'a', 'b'); + }); + }); + + describe('add', () => { + it('indexes a binding by tag', () => { + const binding = new Binding('foo').to('bar').tag('a', {b: 1}); + ctx.add(binding); + assertBindingIndexedByTag(binding, 'a', 'b'); + }); + + it('indexes a binding by tag after being bound', () => { + const binding = new Binding('foo').to('bar'); + ctx.add(binding); + assertBindingNotIndexedByTag(binding, 'a', 'b'); + binding.tag('a', {b: 1}); + assertBindingIndexedByTag(binding, 'a', 'b'); + }); + }); + + describe('unbind', () => { + it('removes indexes for a binding by tag', () => { + const binding = ctx + .bind('foo') + .to('bar') + .tag('a', {b: 1}); + assertBindingIndexedByTag(binding, 'a', 'b'); + ctx.unbind(binding.key); + assertBindingNotIndexedByTag(binding, 'a', 'b'); + }); + }); + + describe('find', () => { + it('leverages binding index by tag', () => { + ctx.bind('foo'); + const b2 = ctx.bind('bar').tag('b'); + const b3 = ctx.bind('baz').tag('b'); + const result = ctx.find(filterByTag('b')); + expect(result).to.eql([b2, b3]); + expect(ctx.findByTagInvoked).to.be.true(); + }); + + it('leverages binding index by tag wildcard', () => { + ctx.bind('foo'); + const b2 = ctx.bind('bar').tag('b2'); + const b3 = ctx.bind('baz').tag('b3'); + const result = ctx.find(filterByTag('b?')); + expect(result).to.eql([b2, b3]); + expect(ctx.findByTagInvoked).to.be.true(); + }); + + it('leverages binding index by tag regexp', () => { + ctx.bind('foo'); + const b2 = ctx.bind('bar').tag('b2'); + const b3 = ctx.bind('baz').tag('b3'); + const result = ctx.find(filterByTag(/b\d/)); + expect(result).to.eql([b2, b3]); + expect(ctx.findByTagInvoked).to.be.true(); + }); + + it('leverages binding index by tag name/value pairs', () => { + ctx.bind('foo'); + const b2 = ctx.bind('bar').tag({a: 1}); + ctx.bind('baz').tag({a: 2, b: 1}); + const result = ctx.find(filterByTag({a: 1})); + expect(result).to.eql([b2]); + expect(ctx.findByTagInvoked).to.be.true(); + }); + }); + + function createContext() { + ctx = new TestContext(); + } + + function assertBindingIndexedByTag( + binding: Binding, + ...tags: string[] + ) { + for (const t of tags) { + expect(ctx.tagIndex.get(t)?.has(binding)).to.be.true(); + } + } + + function assertBindingNotIndexedByTag( + binding: Binding, + ...tags: string[] + ) { + for (const t of tags) { + expect(!!ctx.tagIndex.get(t)?.has(binding)).to.be.false(); + } + } +}); diff --git a/packages/context/src/__tests__/unit/context-view.unit.ts b/packages/context/src/__tests__/unit/context-view.unit.ts index ac52868a0566..20ce066e9e3a 100644 --- a/packages/context/src/__tests__/unit/context-view.unit.ts +++ b/packages/context/src/__tests__/unit/context-view.unit.ts @@ -7,6 +7,7 @@ import {expect} from '@loopback/testlab'; import { Binding, BindingScope, + BindingTag, compareBindingsByTag, Context, ContextView, @@ -16,7 +17,7 @@ import { describe('ContextView', () => { let app: Context; - let server: Context; + let server: ServerContext; let bindings: Binding[]; let taggedAsFoo: ContextView; @@ -27,6 +28,11 @@ describe('ContextView', () => { expect(taggedAsFoo.bindings).to.eql(bindings); }); + it('leverages findByTag for binding tag filter', () => { + expect(taggedAsFoo.bindings).to.eql(bindings); + expect(server.findByTagInvoked).to.be.true(); + }); + it('sorts matched bindings', () => { const view = new ContextView( server, @@ -199,9 +205,21 @@ describe('ContextView', () => { taggedAsFoo = server.createView(filterByTag('foo')); } + class ServerContext extends Context { + findByTagInvoked = false; + constructor(parent: Context, name: string) { + super(parent, name); + } + + _findByTagIndex(tag: BindingTag) { + this.findByTagInvoked = true; + return super._findByTagIndex(tag); + } + } + function givenContext() { app = new Context('app'); - server = new Context(app, 'server'); + server = new ServerContext(app, 'server'); bindings.push( server .bind('bar') diff --git a/packages/context/src/__tests__/unit/context.unit.ts b/packages/context/src/__tests__/unit/context.unit.ts index 44d2999d58ce..eb3fa1abd029 100644 --- a/packages/context/src/__tests__/unit/context.unit.ts +++ b/packages/context/src/__tests__/unit/context.unit.ts @@ -11,8 +11,6 @@ import { BindingScope, BindingType, Context, - ContextEventListener, - ContextEventObserver, isPromiseLike, Provider, } from '../..'; @@ -22,14 +20,19 @@ import { * for assertions */ class TestContext extends Context { - observers: Set | undefined; + get observers() { + return this.subscriptionManager.observers; + } // Make parentEventListener public for testing purpose - parentEventListener: ContextEventListener; + get parentEventListener() { + return this.subscriptionManager.parentContextEventListener; + } get parent() { return this._parent; } + get bindingMap() { const map = new Map(this.registry); return map; @@ -318,6 +321,34 @@ describe('Context', () => { expect(result).to.be.eql([b1]); }); + it('returns matching binding for multiple tags', () => { + const b1 = ctx + .bind('controllers.ProductController') + .tag({name: 'my-controller'}) + .tag('controller'); + ctx.bind('controllers.OrderController').tag('controller'); + ctx.bind('dataSources.mysql').tag({dbType: 'mysql'}); + const result = ctx.findByTag({ + name: 'my-controller', + controller: 'controller', + }); + expect(result).to.be.eql([b1]); + }); + + it('returns empty array if one of the tags does not match', () => { + ctx + .bind('controllers.ProductController') + .tag({name: 'my-controller'}) + .tag('controller'); + ctx.bind('controllers.OrderController').tag('controller'); + ctx.bind('dataSources.mysql').tag({dbType: 'mysql'}); + const result = ctx.findByTag({ + controller: 'controller', + name: 'your-controller', + }); + expect(result).to.be.eql([]); + }); + it('returns empty array if no matching tag value is found', () => { ctx.bind('controllers.ProductController').tag({name: 'my-controller'}); ctx.bind('controllers.OrderController').tag('controller'); diff --git a/packages/context/src/binding-filter.ts b/packages/context/src/binding-filter.ts index 7d28ea85670c..8fd7d0868061 100644 --- a/packages/context/src/binding-filter.ts +++ b/packages/context/src/binding-filter.ts @@ -5,6 +5,7 @@ import {Binding, BindingTag} from './binding'; import {BindingAddress} from './binding-key'; +import {MapObject} from './value-promise'; /** * A function that filters bindings. It returns `true` to select a given @@ -59,27 +60,78 @@ export function isBindingAddress( return typeof bindingSelector !== 'function'; } +/** + * Binding filter function that holds a binding tag pattern. `Context.find()` + * uses the `bindingTagPattern` to optimize the matching of bindings by tag to + * avoid expensive check for all bindings. + */ +export interface BindingTagFilter extends BindingFilter { + /** + * A special property on the filter function to provide access to the binding + * tag pattern which can be utilized to optimize the matching of bindings by + * tag in a context. + */ + bindingTagPattern: BindingTag | RegExp; +} + +/** + * Type guard for BindingTagFilter + * @param filter - A BindingFilter function + */ +export function isBindingTagFilter( + filter?: BindingFilter, +): filter is BindingTagFilter { + if (filter == null || !('bindingTagPattern' in filter)) return false; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const tagPattern = (filter as any).bindingTagPattern; + return ( + tagPattern instanceof RegExp || + typeof tagPattern === 'string' || + typeof tagPattern === 'object' + ); +} + /** * Create a binding filter for the tag pattern * @param tagPattern - Binding tag name, regexp, or object */ -export function filterByTag(tagPattern: BindingTag | RegExp): BindingFilter { - if (typeof tagPattern === 'string' || tagPattern instanceof RegExp) { - const regexp = - typeof tagPattern === 'string' - ? wildcardToRegExp(tagPattern) - : tagPattern; - return b => Array.from(b.tagNames).some(t => regexp!.test(t)); +export function filterByTag(tagPattern: BindingTag | RegExp): BindingTagFilter { + let filter: BindingFilter; + let regex: RegExp | undefined = undefined; + if (tagPattern instanceof RegExp) { + // RegExp for tag names + regex = tagPattern; + } + if ( + typeof tagPattern === 'string' && + (tagPattern.includes('*') || tagPattern.includes('?')) + ) { + // Wildcard tag name + regex = wildcardToRegExp(tagPattern); + } + + if (regex != null) { + // RegExp or wildcard match + filter = b => b.tagNames.some(t => regex!.test(t)); + } else if (typeof tagPattern === 'string') { + // Plain tag string match + filter = b => b.tagNames.includes(tagPattern); } else { - return b => { + // Match tag name/value pairs + const tagMap = tagPattern as MapObject; + filter = b => { for (const t in tagPattern) { // One tag name/value does not match - if (b.tagMap[t] !== tagPattern[t]) return false; + if (b.tagMap[t] !== tagMap[t]) return false; } // All tag name/value pairs match return true; }; } + // Set up binding tag for the filter + const tagFilter = filter as BindingTagFilter; + tagFilter.bindingTagPattern = regex ?? tagPattern; + return tagFilter; } /** diff --git a/packages/context/src/context-event.ts b/packages/context/src/context-event.ts new file mode 100644 index 000000000000..c47a11329f55 --- /dev/null +++ b/packages/context/src/context-event.ts @@ -0,0 +1,30 @@ +// Copyright IBM Corp. 2020. All Rights Reserved. +// Node module: @loopback/context +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {Binding} from './binding'; +import {Context} from './context'; + +/** + * Events emitted by a context + */ +export type ContextEvent = { + /** + * Source context that emits the event + */ + context: Context; + /** + * Binding that is being added/removed/updated + */ + binding: Readonly>; + /** + * Event type + */ + type: string; // 'bind' or 'unbind' +}; + +/** + * Synchronous listener for context events + */ +export type ContextEventListener = (event: ContextEvent) => void; diff --git a/packages/context/src/context-observer.ts b/packages/context/src/context-observer.ts index c58156bcd8d8..e9923d4b7da4 100644 --- a/packages/context/src/context-observer.ts +++ b/packages/context/src/context-observer.ts @@ -5,7 +5,7 @@ import {Binding} from './binding'; import {BindingFilter} from './binding-filter'; -import {Context, ContextEvent} from './context'; +import {Context} from './context'; import {ValueOrPromise} from './value-promise'; /** @@ -48,28 +48,3 @@ export interface ContextObserver { * Context event observer type - An instance of `ContextObserver` or a function */ export type ContextEventObserver = ContextObserver | ContextObserverFn; - -/** - * Subscription of context events. It's modeled after - * https://github.com/tc39/proposal-observable. - */ -export interface Subscription { - /** - * unsubscribe - */ - unsubscribe(): void; - /** - * Is the subscription closed? - */ - closed: boolean; -} - -/** - * Event data for observer notifications - */ -export interface Notification extends ContextEvent { - /** - * A snapshot of observers when the original event is emitted - */ - observers: Set; -} diff --git a/packages/context/src/context-subscription.ts b/packages/context/src/context-subscription.ts new file mode 100644 index 000000000000..2afbfbd8fe9d --- /dev/null +++ b/packages/context/src/context-subscription.ts @@ -0,0 +1,403 @@ +// Copyright IBM Corp. 2020. All Rights Reserved. +// Node module: @loopback/context +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import debugFactory from 'debug'; +import {EventEmitter} from 'events'; +import {Context} from './context'; +import {ContextEvent, ContextEventListener} from './context-event'; +import { + ContextEventObserver, + ContextEventType, + ContextObserver, +} from './context-observer'; +const debug = debugFactory('loopback:context:subscription'); + +/** + * Polyfill Symbol.asyncIterator as required by TypeScript for Node 8.x. + * See https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-3.html + */ +if (!Symbol.asyncIterator) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (Symbol as any).asyncIterator = Symbol.for('Symbol.asyncIterator'); +} +/** + * WARNING: This following import must happen after the polyfill. The + * `auto-import` by an IDE such as VSCode may move the import before the + * polyfill. It must be then fixed manually. + */ +import {iterator, multiple} from 'p-event'; + +/** + * Subscription of context events. It's modeled after + * https://github.com/tc39/proposal-observable. + */ +export interface Subscription { + /** + * unsubscribe + */ + unsubscribe(): void; + /** + * Is the subscription closed? + */ + closed: boolean; +} + +/** + * Event data for observer notifications + */ +export interface Notification extends ContextEvent { + /** + * A snapshot of observers when the original event is emitted + */ + observers: Set; +} + +/** + * An implementation of `Subscription` interface for context events + */ +class ContextSubscription implements Subscription { + constructor( + protected context: Context, + protected observer: ContextEventObserver, + ) {} + + private _closed = false; + + unsubscribe() { + this.context.unsubscribe(this.observer); + this._closed = true; + } + + get closed() { + return this._closed; + } +} + +/** + * Manager for context observer subscriptions + */ +export class ContextSubscriptionManager extends EventEmitter { + /** + * A listener to watch parent context events + */ + protected _parentContextEventListener?: ContextEventListener; + + /** + * A list of registered context observers. The Set will be created when the + * first observer is added. + */ + protected _observers: Set | undefined; + + /** + * Internal counter for pending notification events which are yet to be + * processed by observers. + */ + private pendingNotifications = 0; + + /** + * Queue for background notifications for observers + */ + private notificationQueue: AsyncIterableIterator | undefined; + + constructor(protected readonly context: Context) { + super(); + this.setMaxListeners(Infinity); + } + + /** + * @internal + */ + get parentContextEventListener() { + return this._parentContextEventListener; + } + + /** + * @internal + */ + get observers() { + return this._observers; + } + + /** + * Wrap the debug statement so that it always print out the context name + * as the prefix + * @param args - Arguments for the debug + */ + private _debug(...args: unknown[]) { + /* istanbul ignore if */ + if (!debug.enabled) return; + const formatter = args.shift(); + if (typeof formatter === 'string') { + debug(`[%s] ${formatter}`, this.context.name, ...args); + } else { + debug('[%s] ', this.context.name, formatter, ...args); + } + } + + /** + * Set up an internal listener to notify registered observers asynchronously + * upon `bind` and `unbind` events. This method will be called lazily when + * the first observer is added. + */ + private setupEventHandlersIfNeeded() { + if (this.notificationQueue != null) return; + + if (this.context.parent != null) { + /** + * Add an event listener to its parent context so that this context will + * be notified of parent events, such as `bind` or `unbind`. + */ + this._parentContextEventListener = event => { + this.handleParentEvent(event); + }; + + // Listen on the parent context events + this.context.parent.on('bind', this._parentContextEventListener!); + this.context.parent.on('unbind', this._parentContextEventListener!); + } + + // The following are two async functions. Returned promises are ignored as + // they are long-running background tasks. + this.startNotificationTask().catch(err => { + this.handleNotificationError(err); + }); + + let ctx = this.context.parent; + while (ctx) { + ctx.subscriptionManager.setupEventHandlersIfNeeded(); + ctx = ctx.parent; + } + } + + private handleParentEvent(event: ContextEvent) { + const {binding, context, type} = event; + // Propagate the event to this context only if the binding key does not + // exist in this context. The parent binding is shadowed if there is a + // binding with the same key in this one. + if (this.context.contains(binding.key)) { + this._debug( + 'Event %s %s is not re-emitted from %s to %s', + type, + binding.key, + context.name, + this.context.name, + ); + return; + } + this._debug( + 'Re-emitting %s %s from %s to %s', + type, + binding.key, + context.name, + this.context.name, + ); + this.context.emitEvent(type, event); + } + + /** + * A strongly-typed method to emit context events + * @param type Event type + * @param event Context event + */ + private emitEvent(type: string, event: T) { + this.emit(type, event); + } + + /** + * Emit an `error` event + * @param err Error + */ + private emitError(err: unknown) { + this.emit('error', err); + } + + /** + * Start a background task to listen on context events and notify observers + */ + private startNotificationTask() { + // Set up listeners on `bind` and `unbind` for notifications + this.setupNotification('bind', 'unbind'); + + // Create an async iterator for the `notification` event as a queue + this.notificationQueue = iterator(this, 'notification'); + + return this.processNotifications(); + } + + /** + * Publish an event to the registered observers. Please note the + * notification is queued and performed asynchronously so that we allow fluent + * APIs such as `ctx.bind('key').to(...).tag(...);` and give observers the + * fully populated binding. + * + * @param event - Context event + * @param observers - Current set of context observers + */ + protected async notifyObservers( + event: ContextEvent, + observers = this._observers, + ) { + if (!observers || observers.size === 0) return; + + const {type, binding, context} = event; + for (const observer of observers) { + if (typeof observer === 'function') { + await observer(type, binding, context); + } else if (!observer.filter || observer.filter(binding)) { + await observer.observe(type, binding, context); + } + } + } + + /** + * Process notification events as they arrive on the queue + */ + private async processNotifications() { + const events = this.notificationQueue; + if (events == null) return; + for await (const {type, binding, context, observers} of events) { + // The loop will happen asynchronously upon events + try { + // The execution of observers happen in the Promise micro-task queue + await this.notifyObservers({type, binding, context}, observers); + this.pendingNotifications--; + this._debug( + 'Observers notified for %s of binding %s', + type, + binding.key, + ); + this.emitEvent('observersNotified', {type, binding, context}); + } catch (err) { + this.pendingNotifications--; + this._debug('Error caught from observers', err); + // Errors caught from observers. Emit it to the current context. + // If no error listeners are registered, crash the process. + this.emitError(err); + } + } + } + + /** + * Listen on given event types and emit `notification` event. This method + * merge multiple event types into one for notification. + * @param eventTypes - Context event types + */ + private setupNotification(...eventTypes: ContextEventType[]) { + for (const type of eventTypes) { + this.context.on(type, ({binding, context}) => { + // No need to schedule notifications if no observers are present + if (!this._observers || this._observers.size === 0) return; + // Track pending events + this.pendingNotifications++; + // Take a snapshot of current observers to ensure notifications of this + // event will only be sent to current ones. Emit a new event to notify + // current context observers. + this.emitEvent('notification', { + type, + binding, + context, + observers: new Set(this._observers), + }); + }); + } + } + + /** + * Wait until observers are notified for all of currently pending notification + * events. + * + * This method is for test only to perform assertions after observers are + * notified for relevant events. + */ + async waitUntilPendingNotificationsDone(timeout?: number) { + const count = this.pendingNotifications; + if (count === 0) return; + await multiple(this, 'observersNotified', {count, timeout}); + } + + /** + * Add a context event observer to the context + * @param observer - Context observer instance or function + */ + subscribe(observer: ContextEventObserver): Subscription { + this._observers = this._observers ?? new Set(); + this.setupEventHandlersIfNeeded(); + this._observers.add(observer); + return new ContextSubscription(this.context, observer); + } + + /** + * Remove the context event observer from the context + * @param observer - Context event observer + */ + unsubscribe(observer: ContextEventObserver): boolean { + if (!this._observers) return false; + return this._observers.delete(observer); + } + + /** + * Check if an observer is subscribed to this context + * @param observer - Context observer + */ + isSubscribed(observer: ContextObserver) { + if (!this._observers) return false; + return this._observers.has(observer); + } + + /** + * Handle errors caught during the notification of observers + * @param err - Error + */ + private handleNotificationError(err: unknown) { + // Bubbling up the error event over the context chain + // until we find an error listener + let ctx: Context | undefined = this.context; + while (ctx) { + if (ctx.listenerCount('error') === 0) { + // No error listener found, try its parent + ctx = ctx.parent; + continue; + } + this._debug('Emitting error to context %s', ctx.name, err); + ctx.emitError(err); + return; + } + // No context with error listeners found + this._debug('No error handler is configured for the context chain', err); + // Let it crash now by emitting an error event + this.context.emitError(err); + } + + /** + * Close the context: clear observers, stop notifications, and remove event + * listeners from its parent context. + * + * @remarks + * This method MUST be called to avoid memory leaks once a context object is + * no longer needed and should be recycled. An example is the `RequestContext`, + * which is created per request. + */ + close() { + this._observers = undefined; + if (this.notificationQueue != null) { + // Cancel the notification iterator + this.notificationQueue.return!(undefined).catch(err => { + this.handleNotificationError(err); + }); + this.notificationQueue = undefined; + } + if (this.context.parent && this._parentContextEventListener) { + this.context.parent.removeListener( + 'bind', + this._parentContextEventListener, + ); + this.context.parent.removeListener( + 'unbind', + this._parentContextEventListener, + ); + this._parentContextEventListener = undefined; + } + } +} diff --git a/packages/context/src/context-tag-indexer.ts b/packages/context/src/context-tag-indexer.ts new file mode 100644 index 000000000000..b1812f944c44 --- /dev/null +++ b/packages/context/src/context-tag-indexer.ts @@ -0,0 +1,149 @@ +// Copyright IBM Corp. 2020. All Rights Reserved. +// Node module: @loopback/context +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {Binding, BindingEventListener, BindingTag} from './binding'; +import {BindingFilter, filterByTag} from './binding-filter'; +import {Context} from './context'; +import {ContextEventListener} from './context-event'; +import {BoundValue} from './value-promise'; + +/** + * Indexer for context bindings by tag + */ +export class ContextTagIndexer { + /** + * Index for bindings by tag names + */ + readonly bindingsIndexedByTag: Map< + string, + Set>> + > = new Map(); + + /** + * A listener for binding events + */ + private bindingEventListener: BindingEventListener; + + /** + * A listener to maintain tag index for bindings + */ + private tagIndexListener: ContextEventListener; + + constructor(protected readonly context: Context) { + this.setupTagIndexForBindings(); + } + + /** + * Set up context/binding listeners and refresh index for bindings by tag + */ + private setupTagIndexForBindings() { + this.bindingEventListener = ({binding, operation}) => { + if (operation === 'tag') { + this.updateTagIndexForBinding(binding); + } + }; + this.tagIndexListener = event => { + const {binding, type} = event; + if (event.context !== this.context) return; + if (type === 'bind') { + this.updateTagIndexForBinding(binding); + binding.on('changed', this.bindingEventListener); + } else if (type === 'unbind') { + this.removeTagIndexForBinding(binding); + binding.removeListener('changed', this.bindingEventListener); + } + }; + this.context.on('bind', this.tagIndexListener); + this.context.on('unbind', this.tagIndexListener); + } + + /** + * Remove tag index for the given binding + * @param binding - Binding object + */ + private removeTagIndexForBinding(binding: Readonly>) { + for (const [, bindings] of this.bindingsIndexedByTag) { + bindings.delete(binding); + } + } + + /** + * Update tag index for the given binding + * @param binding - Binding object + */ + private updateTagIndexForBinding(binding: Readonly>) { + this.removeTagIndexForBinding(binding); + for (const tag of binding.tagNames) { + let bindings = this.bindingsIndexedByTag.get(tag); + if (bindings == null) { + bindings = new Set(); + this.bindingsIndexedByTag.set(tag, bindings); + } + bindings.add(binding); + } + } + + /** + * Find bindings by tag leveraging indexes + * @param tag - Tag name pattern or name/value pairs + */ + findByTagIndex( + tag: BindingTag | RegExp, + ): Readonly>[] { + let tagNames: string[]; + // A flag to control if a union of matched bindings should be created + let union = false; + if (tag instanceof RegExp) { + // For wildcard/regexp, a union of matched bindings is desired + union = true; + // Find all matching tag names + tagNames = []; + for (const t of this.bindingsIndexedByTag.keys()) { + if (tag.test(t)) { + tagNames.push(t); + } + } + } else if (typeof tag === 'string') { + tagNames = [tag]; + } else { + tagNames = Object.keys(tag); + } + let filter: BindingFilter | undefined; + let bindings: Set>> | undefined; + for (const t of tagNames) { + const bindingsByTag = this.bindingsIndexedByTag.get(t); + if (bindingsByTag == null) break; // One of the tags is not found + filter = filter ?? filterByTag(tag); + const matched = new Set(Array.from(bindingsByTag).filter(filter)) as Set< + Readonly> + >; + if (!union && matched.size === 0) break; // One of the tag name/value is not found + if (bindings == null) { + // First set of bindings matching the tag + bindings = matched; + } else { + if (union) { + matched.forEach(b => bindings?.add(b)); + } else { + // Now need to find intersected bindings against visited tags + const intersection = new Set>>(); + bindings.forEach(b => { + if (matched.has(b)) { + intersection.add(b); + } + }); + bindings = intersection; + } + if (!union && bindings.size === 0) break; + } + } + return bindings == null ? [] : Array.from(bindings); + } + + close() { + this.context.removeListener('bind', this.tagIndexListener); + this.context.removeListener('unbind', this.tagIndexListener); + } +} diff --git a/packages/context/src/context-view.ts b/packages/context/src/context-view.ts index 8d90bbf9a97a..a29d67b9a2a3 100644 --- a/packages/context/src/context-view.ts +++ b/packages/context/src/context-view.ts @@ -10,11 +10,8 @@ import {Binding} from './binding'; import {BindingFilter} from './binding-filter'; import {BindingComparator} from './binding-sorter'; import {Context} from './context'; -import { - ContextEventType, - ContextObserver, - Subscription, -} from './context-observer'; +import {ContextEventType, ContextObserver} from './context-observer'; +import {Subscription} from './context-subscription'; import {Getter} from './inject'; import {ResolutionSession} from './resolution-session'; import {isPromiseLike, resolveList, ValueOrPromise} from './value-promise'; diff --git a/packages/context/src/context.ts b/packages/context/src/context.ts index 423bf56fd31b..b2b3e40da0b3 100644 --- a/packages/context/src/context.ts +++ b/packages/context/src/context.ts @@ -11,16 +11,18 @@ import { ConfigurationResolver, DefaultConfigurationResolver, } from './binding-config'; -import {BindingFilter, filterByKey, filterByTag} from './binding-filter'; +import { + BindingFilter, + filterByKey, + filterByTag, + isBindingTagFilter, +} from './binding-filter'; import {BindingAddress, BindingKey} from './binding-key'; import {BindingComparator} from './binding-sorter'; -import { - ContextEventObserver, - ContextEventType, - ContextObserver, - Notification, - Subscription, -} from './context-observer'; +import {ContextEvent} from './context-event'; +import {ContextEventObserver, ContextObserver} from './context-observer'; +import {ContextSubscriptionManager, Subscription} from './context-subscription'; +import {ContextTagIndexer} from './context-tag-indexer'; import {ContextView} from './context-view'; import {ContextBindings} from './keys'; import { @@ -36,46 +38,8 @@ import { ValueOrPromise, } from './value-promise'; -/** - * Polyfill Symbol.asyncIterator as required by TypeScript for Node 8.x. - * See https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-3.html - */ -if (!Symbol.asyncIterator) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (Symbol as any).asyncIterator = Symbol.for('Symbol.asyncIterator'); -} -/** - * WARNING: This following import must happen after the polyfill. The - * `auto-import` by an IDE such as VSCode may move the import before the - * polyfill. It must be then fixed manually. - */ -import {iterator, multiple} from 'p-event'; - const debug = debugFactory('loopback:context'); -/** - * Events emitted by a context - */ -export type ContextEvent = { - /** - * Source context that emits the event - */ - context: Context; - /** - * Binding that is being added/removed/updated - */ - binding: Readonly>; - /** - * Event type - */ - type: string; // 'bind' or 'unbind' -}; - -/** - * Synchronous listener for context events - */ -export type ContextEventListener = (event: ContextEvent) => void; - /** * Context provides an implementation of Inversion of Control (IoC) container */ @@ -91,33 +55,24 @@ export class Context extends EventEmitter { protected readonly registry: Map = new Map(); /** - * Parent context - */ - protected _parent?: Context; - - protected configResolver: ConfigurationResolver; - - /** - * A listener to watch parent context events + * Indexer for bindings by tag */ - protected parentEventListener?: ContextEventListener; + protected readonly tagIndexer: ContextTagIndexer; /** - * A list of registered context observers. The Set will be created when the - * first observer is added. + * Manager for observer subscriptions */ - protected observers: Set | undefined; + readonly subscriptionManager: ContextSubscriptionManager; /** - * Internal counter for pending notification events which are yet to be - * processed by observers. + * Parent context */ - private pendingNotifications = 0; + protected _parent?: Context; /** - * Queue for background notifications for observers + * Configuration resolver */ - private notificationQueue: AsyncIterableIterator | undefined; + protected configResolver: ConfigurationResolver; /** * Create a new context. @@ -154,6 +109,16 @@ export class Context extends EventEmitter { } this._parent = _parent; this.name = name ?? uuidv1(); + this.tagIndexer = new ContextTagIndexer(this); + this.subscriptionManager = new ContextSubscriptionManager(this); + } + + /** + * @internal + * Getter for ContextSubscriptionManager + */ + get parent() { + return this._parent; } /** @@ -161,8 +126,7 @@ export class Context extends EventEmitter { * as the prefix * @param args - Arguments for the debug */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private _debug(...args: any[]) { + private _debug(...args: unknown[]) { /* istanbul ignore if */ if (!debug.enabled) return; const formatter = args.shift(); @@ -173,47 +137,12 @@ export class Context extends EventEmitter { } } - /** - * Set up an internal listener to notify registered observers asynchronously - * upon `bind` and `unbind` events. This method will be called lazily when - * the first observer is added. - */ - private setupEventHandlersIfNeeded() { - if (this.notificationQueue != null) return; - - if (this._parent != null) { - /** - * Add an event listener to its parent context so that this context will - * be notified of parent events, such as `bind` or `unbind`. - */ - this.parentEventListener = event => { - this.handleParentEvent(event); - }; - - // Listen on the parent context events - this._parent.on('bind', this.parentEventListener!); - this._parent.on('unbind', this.parentEventListener!); - } - - // The following are two async functions. Returned promises are ignored as - // they are long-running background tasks. - this.startNotificationTask().catch(err => { - this.handleNotificationError(err); - }); - - let ctx = this._parent; - while (ctx) { - ctx.setupEventHandlersIfNeeded(); - ctx = ctx._parent; - } - } - /** * A strongly-typed method to emit context events * @param type Event type * @param event Context event */ - private emitEvent(type: string, event: T) { + emitEvent(type: string, event: T) { this.emit(type, event); } @@ -221,139 +150,10 @@ export class Context extends EventEmitter { * Emit an `error` event * @param err Error */ - private emitError(err: unknown) { + emitError(err: unknown) { this.emit('error', err); } - private handleParentEvent(event: ContextEvent) { - const {binding, context, type} = event; - // Propagate the event to this context only if the binding key does not - // exist in this context. The parent binding is shadowed if there is a - // binding with the same key in this one. - if (this.contains(binding.key)) { - this._debug( - 'Event %s %s is not re-emitted from %s to %s', - type, - binding.key, - context.name, - this.name, - ); - return; - } - this._debug( - 'Re-emitting %s %s from %s to %s', - type, - binding.key, - context.name, - this.name, - ); - this.emitEvent(type, event); - } - - /** - * Handle errors caught during the notification of observers - * @param err - Error - */ - private handleNotificationError(err: unknown) { - // Bubbling up the error event over the context chain - // until we find an error listener - // eslint-disable-next-line @typescript-eslint/no-this-alias - let ctx: Context | undefined = this; - while (ctx) { - if (ctx.listenerCount('error') === 0) { - // No error listener found, try its parent - ctx = ctx._parent; - continue; - } - this._debug('Emitting error to context %s', ctx.name, err); - ctx.emitError(err); - return; - } - // No context with error listeners found - this._debug('No error handler is configured for the context chain', err); - // Let it crash now by emitting an error event - this.emitError(err); - } - - /** - * Start a background task to listen on context events and notify observers - */ - private startNotificationTask() { - // Set up listeners on `bind` and `unbind` for notifications - this.setupNotification('bind', 'unbind'); - - // Create an async iterator for the `notification` event as a queue - this.notificationQueue = iterator(this, 'notification'); - - return this.processNotifications(); - } - - /** - * Process notification events as they arrive on the queue - */ - private async processNotifications() { - const events = this.notificationQueue; - if (events == null) return; - for await (const {type, binding, context, observers} of events) { - // The loop will happen asynchronously upon events - try { - // The execution of observers happen in the Promise micro-task queue - await this.notifyObservers({type, binding, context}, observers); - this.pendingNotifications--; - this._debug( - 'Observers notified for %s of binding %s', - type, - binding.key, - ); - this.emitEvent('observersNotified', {type, binding, context}); - } catch (err) { - this.pendingNotifications--; - this._debug('Error caught from observers', err); - // Errors caught from observers. Emit it to the current context. - // If no error listeners are registered, crash the process. - this.emitError(err); - } - } - } - - /** - * Listen on given event types and emit `notification` event. This method - * merge multiple event types into one for notification. - * @param eventTypes - Context event types - */ - private setupNotification(...eventTypes: ContextEventType[]) { - for (const type of eventTypes) { - this.on(type, ({binding, context}) => { - // No need to schedule notifications if no observers are present - if (!this.observers || this.observers.size === 0) return; - // Track pending events - this.pendingNotifications++; - // Take a snapshot of current observers to ensure notifications of this - // event will only be sent to current ones. Emit a new event to notify - // current context observers. - this.emitEvent('notification', { - type, - binding, - context, - observers: new Set(this.observers), - }); - }); - } - } - - /** - * Wait until observers are notified for all of currently pending notification - * events. - * - * This method is for test only to perform assertions after observers are - * notified for relevant events. - */ - protected async waitUntilPendingNotificationsDone(timeout?: number) { - const count = this.pendingNotifications; - if (count === 0) return; - await multiple(this, 'observersNotified', {count, timeout}); - } - /** * Create a binding with the given key in the context. If a locked binding * already exists with the same key, an error will be thrown. @@ -548,10 +348,7 @@ export class Context extends EventEmitter { * @param observer - Context observer instance or function */ subscribe(observer: ContextEventObserver): Subscription { - this.observers = this.observers ?? new Set(); - this.setupEventHandlersIfNeeded(); - this.observers.add(observer); - return new ContextSubscription(this, observer); + return this.subscriptionManager.subscribe(observer); } /** @@ -559,8 +356,7 @@ export class Context extends EventEmitter { * @param observer - Context event observer */ unsubscribe(observer: ContextEventObserver): boolean { - if (!this.observers) return false; - return this.observers.delete(observer); + return this.subscriptionManager.unsubscribe(observer); } /** @@ -574,19 +370,8 @@ export class Context extends EventEmitter { */ close() { this._debug('Closing context...'); - this.observers = undefined; - if (this.notificationQueue != null) { - // Cancel the notification iterator - this.notificationQueue.return!(undefined).catch(err => { - this.handleNotificationError(err); - }); - this.notificationQueue = undefined; - } - if (this._parent && this.parentEventListener) { - this._parent.removeListener('bind', this.parentEventListener); - this._parent.removeListener('unbind', this.parentEventListener); - this.parentEventListener = undefined; - } + this.subscriptionManager.close(); + this.tagIndexer.close(); } /** @@ -594,8 +379,7 @@ export class Context extends EventEmitter { * @param observer - Context observer */ isSubscribed(observer: ContextObserver) { - if (!this.observers) return false; - return this.observers.has(observer); + return this.subscriptionManager.isSubscribed(observer); } /** @@ -612,31 +396,6 @@ export class Context extends EventEmitter { return view; } - /** - * Publish an event to the registered observers. Please note the - * notification is queued and performed asynchronously so that we allow fluent - * APIs such as `ctx.bind('key').to(...).tag(...);` and give observers the - * fully populated binding. - * - * @param event - Context event - * @param observers - Current set of context observers - */ - protected async notifyObservers( - event: ContextEvent, - observers = this.observers, - ) { - if (!observers || observers.size === 0) return; - - const {type, binding, context} = event; - for (const observer of observers) { - if (typeof observer === 'function') { - await observer(type, binding, context); - } else if (!observer.filter || observer.filter(binding)) { - await observer.observe(type, binding, context); - } - } - } - /** * Check if a binding exists with the given key in the local context without * delegating to the parent context @@ -688,6 +447,11 @@ export class Context extends EventEmitter { find( pattern?: string | RegExp | BindingFilter, ): Readonly>[] { + // Optimize if the binding filter is for tags + if (typeof pattern === 'function' && isBindingTagFilter(pattern)) { + return this._findByTagIndex(pattern.bindingTagPattern); + } + const bindings: Readonly>[] = []; const filter = filterByKey(pattern); @@ -719,6 +483,18 @@ export class Context extends EventEmitter { return this.find(filterByTag(tagFilter)); } + /** + * Find bindings by tag leveraging indexes + * @param tag - Tag name pattern or name/value pairs + */ + protected _findByTagIndex( + tag: BindingTag | RegExp, + ): Readonly>[] { + const currentBindings = this.tagIndexer.findByTagIndex(tag); + const parentBindings = this._parent && this._parent?._findByTagIndex(tag); + return this._mergeWithParent(currentBindings, parentBindings); + } + protected _mergeWithParent( childList: Readonly>[], parentList?: Readonly>[], @@ -1023,27 +799,6 @@ export class Context extends EventEmitter { } } -/** - * An implementation of `Subscription` interface for context events - */ -class ContextSubscription implements Subscription { - constructor( - protected context: Context, - protected observer: ContextEventObserver, - ) {} - - private _closed = false; - - unsubscribe() { - this.context.unsubscribe(this.observer); - this._closed = true; - } - - get closed() { - return this._closed; - } -} - /** * Policy to control if a binding should be created for the context */ diff --git a/packages/context/src/index.ts b/packages/context/src/index.ts index 553feb79ab40..49bb0925732b 100644 --- a/packages/context/src/index.ts +++ b/packages/context/src/index.ts @@ -12,7 +12,9 @@ export * from './binding-inspector'; export * from './binding-key'; export * from './binding-sorter'; export * from './context'; +export * from './context-event'; export * from './context-observer'; +export * from './context-subscription'; export * from './context-view'; export * from './inject'; export * from './inject-config'; diff --git a/packages/context/src/interceptor.ts b/packages/context/src/interceptor.ts index be0ef45b4210..8ea227367750 100644 --- a/packages/context/src/interceptor.ts +++ b/packages/context/src/interceptor.ts @@ -15,7 +15,6 @@ import assert from 'assert'; import debugFactory from 'debug'; import {Binding, BindingTemplate} from './binding'; import {bind} from './binding-decorator'; -import {filterByTag} from './binding-filter'; import {BindingSpec} from './binding-inspector'; import {sortBindingsByPhase} from './binding-sorter'; import {Context} from './context'; @@ -47,12 +46,14 @@ export class InterceptedInvocationContext extends InvocationContext { * ContextTags.GLOBAL_INTERCEPTOR) */ getGlobalInterceptorBindingKeys(): string[] { - const bindings: Readonly>[] = this.find( - binding => - filterByTag(ContextTags.GLOBAL_INTERCEPTOR)(binding) && - // Only include interceptors that match the source type of the invocation - this.applicableTo(binding), + let bindings: Readonly>[] = this.findByTag( + ContextTags.GLOBAL_INTERCEPTOR, ); + bindings = bindings.filter(binding => + // Only include interceptors that match the source type of the invocation + this.applicableTo(binding), + ); + this.sortGlobalInterceptorBindings(bindings); const keys = bindings.map(b => b.key); debug('Global interceptor binding keys:', keys); diff --git a/packages/context/src/invocation.ts b/packages/context/src/invocation.ts index bc63d61a0b29..b380d2bf40e1 100644 --- a/packages/context/src/invocation.ts +++ b/packages/context/src/invocation.ts @@ -55,9 +55,7 @@ export class InvocationContext extends Context { * @param args - An array of arguments */ constructor( - // Make `parent` public so that the interceptor can add bindings to - // the request context, for example, tracing id - public readonly parent: Context, + parent: Context, public readonly target: object, public readonly methodName: string, public readonly args: InvocationArgs, diff --git a/packages/context/src/value-promise.ts b/packages/context/src/value-promise.ts index 99f036500437..73dcbb54cd97 100644 --- a/packages/context/src/value-promise.ts +++ b/packages/context/src/value-promise.ts @@ -29,7 +29,7 @@ export type BoundValue = any; */ export type ValueOrPromise = T | PromiseLike; -export type MapObject = {[name: string]: T}; +export type MapObject = Record; /** * Check whether a value is a Promise-like instance. diff --git a/packages/rest/src/__tests__/integration/request-context.integration.ts b/packages/rest/src/__tests__/integration/request-context.integration.ts index 49231df5a818..066dd9f3b58d 100644 --- a/packages/rest/src/__tests__/integration/request-context.integration.ts +++ b/packages/rest/src/__tests__/integration/request-context.integration.ts @@ -142,7 +142,7 @@ describe('close', () => { .expect(200); } expect(observedCtx.contains('req.originalUrl')); - expect(server.listenerCount('bind')).to.eql(1); + expect(server.listenerCount('bind')).to.eql(2); }); });