UNPKG

@microsoft/windows-admin-center-sdk

Version:

Microsoft - Windows Admin Center Shell

548 lines (546 loc) 21.2 kB
import { EMPTY, merge, Observable, of, ReplaySubject, Subject } from 'rxjs'; import { delay, expand, multicast, refCount, switchMap } from 'rxjs/operators'; import { Logging } from '../diagnostics/logging'; /** * Query Cache class. * - Create a cache entry by "create" call with creator object. * - Subscribe with options to get query result. * - Refresh data on-demand and interval. * - Dispose the resource when it's done. * - Recover and re-subscribe with the same handlers if necessary after an error. * * TData the data type of observable responds. * TParams the options parameters to pass the creator to create new observable. */ export class QueryCache { create; serializeParams; destroy; /** * Internal instance id counter. * (set id number to be 0 to bring back older behavior) */ static id = 10000; /** * Delay clean up time (10 seconds) */ static delayTime = 10 * 1000; /** * Repeater is parked state. */ static repeaterParked = -1; /** * Cached data collection with observable ReplaySubject. */ cachedData; /** * Current options, passed from the fetch call. */ options; /** * Current observer of create observable. */ observer; /** * Key string serialized by TParams. */ key; /** * Delay clean timer object. */ delayTimer; /** * Auto fetch options. */ autoFetchOptions; /** * Repeater context to cycle repeating observable. */ repeaterContext; /** * Instance id of query cache for internal debugging. */ id = '{0}-{1}'.format(MsftSme.self().Init.moduleName, ++QueryCache.id); /** * The tracing name. */ name; /** * The start time of tracing. */ traceTime; /** * Initializes a new instance of the QueryCache. * * @param create the function to create a new observable with specified parameters. * @param serializeParams the function to generate serialized string from specified parameters. (optional) * @param destroy the function to clean up any residue after all reference was gone. (optional) */ constructor(create, serializeParams, destroy) { this.create = create; this.serializeParams = serializeParams; this.destroy = destroy; } /** * Use the QueryCache inside of Worker or background tab view. * setInterval time lost accuracy within background. Use setTimeout recursive query by expand observable operator. * Call the static function once before creating new instance of QueryCache, probably set at app.component.ts file. */ static useExpandInterval() { QueryCache.id = 0; } /** * Create or return the cached observable. * * @param autoFetchOptions the options parameter auto-fetch when subscribe is called. * @return Observable<TData> the observable object. */ createObservable(autoFetchOptions) { // for recovery scenario, this.cachedData.subscribers is retained. // clear them here if no publish ever made. if (this.cachedData && this.cachedData.publish) { if (autoFetchOptions) { // schedule the fetch immediately after current call stack. setTimeout(() => { // cancel auto fetch if already unsubscribed. if (this.cachedData && this.cachedData.publish) { this.fetch(autoFetchOptions); } }); } return this.cachedData.publish; } const fetch = new Subject(); const refresh = new Subject(); const apply = new Subject(); const subscribers = []; const publish = this.createPublishObservable(fetch, refresh, apply, subscribers); this.cachedData = { fetch, refresh, publish, apply, subscribers }; this.autoFetchOptions = autoFetchOptions; return publish; } /** * Unsubscribe any remained subscribers and dispose all remained resources. * - clearCache() is not necessary to be called if all subscriptions are unsubscribe'ed properly. */ clearCache() { if (!this.cachedData) { return; } if (this.cachedData.subscribers) { const tempSubscribers = this.cachedData.subscribers.slice(0); for (const context of tempSubscribers) { context.subscription.unsubscribe(); } } this.cleanup(false); } /** * Fetch with new query options. * - The fetch starts new query when cache is empty, current cache doesn't match the key generated by * serializedParams function which was configured at the constructor, interval was changed (ex. * changing zero interval to active interval or other way around), or the first subscriber like when * delayClean comes back active subscription. * * @param options the options with interval, delay clean, recovery and parameters. */ fetch(options) { if (!this.cachedData) { const message = MsftSme.getStrings().MsftSmeShell.Core.Error.QueryCacheFetchOrder.message; Logging.logError('QueryCache', message); throw new Error(message); } if (this.cachedData.fetch) { // send new request when cache doesn't match the key, interval was changed, or single subscriber. const key = this.serializeParams ? this.serializeParams(options.params) : ''; if (!this.options || this.key !== key || this.options.interval !== options.interval || this.key == null) { this.key = key; this.cachedData.fetch.next(options); } } else { Logging.logWarning('QueryCache', MsftSme.getStrings().MsftSmeShell.Core.Error.QueryCacheFetchErrorOnce.message); } } /** * Refresh the query cache with last options and parameters provided on the fetch call. */ refresh() { if (!this.cachedData) { const message = MsftSme.getStrings().MsftSmeShell.Core.Error.QueryCacheRefreshOrder.message; Logging.logError('QueryCache', message); throw new Error(message); } if (this.cachedData.refresh) { this.cachedData.refresh.next(undefined); } else { Logging.logWarning('QueryCache', MsftSme.getStrings().MsftSmeShell.Core.Error.QueryCacheRefreshErrorOnce.message); } } /** * Recover the observable and subscription. * - Recover can be used to resubscribe when the observable got any error situation. The observable would * be unsubscribed state when it got an error response, this function allows to resubscribe without * recreate observable and subscription. * * @param autoFetch if true auto fetch after re-subscribed. */ recover(autoFetch) { if (!this.cachedData) { const message = MsftSme.getStrings().MsftSmeShell.Core.Error.QueryCacheRecoverNoCachedResource.message; Logging.logError('QueryCache', message); throw new Error(message); } if (!this.options || !this.options.enableRecovery) { const message = MsftSme.getStrings().MsftSmeShell.Core.Error.QueryCacheRecoverMissingRecoveryOption.message; Logging.logError('QueryCache', message); throw new Error(message); } const tempCachedData = this.cachedData; if (tempCachedData.refresh) { tempCachedData.refresh.complete(); } if (tempCachedData.fetch) { tempCachedData.fetch.complete(); } if (tempCachedData.apply) { tempCachedData.apply.complete(); } for (const context of this.cachedData.subscribers) { // call original unsubscribe function. context.subscription.unsubscribe(); } // recreate new publish observable and subscribe to original set of handlers. const tempSubscribers = this.cachedData.subscribers.slice(0); const fetch = new Subject(); const refresh = new Subject(); const apply = new Subject(); const subscribers = []; const publish = this.createPublishObservable(fetch, refresh, apply, subscribers); this.cachedData = { fetch, refresh, publish, apply, subscribers }; for (const context of tempSubscribers) { this.cachedData.publish.subscribe(context.next, context.error, context.complete, context.subscription); } if (autoFetch) { fetch.next(this.options); } else { this.options = {}; } } /** * Apply instant data to the query cache. The data will be delivered to the subscriber immediately. * * @param data the data to apply to the replay. */ apply(data) { if (this.cachedData.apply) { this.cachedData.apply.next(data); } } /** * Enable tracing of this query cache instance. * * @param name the name of query cache. */ trace(name) { this.name = name; this.traceTime = Date.now(); this.log(`tracing ${this.name}: ${this.id}`); } log(message) { const time = ((Date.now() - this.traceTime)) / 1000; Logging.debug(`QC: ${this.id} ${this.name}: ${time}sec: ${message}`); } createPublishObservable(fetch, refresh, apply, subscribers) { const publish = // start data query when new fetch is requested. fetch .pipe( // switch map unsubscribe previous fetch observable tree, and re-create new one. switchMap(options => { // remember last options. this.options = options || {}; // merge output from expand-delay object and refresh object. return merge( // submit initial query. this.repeat(), // refresh to trigger new observable. refresh.pipe(switchMap(() => this.create(this.options.params))), // apply data. apply); }), // multicast the result so multiple subscribers can share. // and Replay last result if later subscriber looks for the result. multicast(new ReplaySubject(1)), // keep the reference count so publish observable active. refCount()); // override subscribe call to keep track subscription. publish.subscribe = ((next, error, complete, recoverSubscription) => { if (this.delayTimer) { clearTimeout(this.delayTimer); this.delayTimer = null; if (this.options && this.options.interval) { this.options.interval = null; } } // override default handler with No-OP call if not set. // this allows all subscribe call to be get called when an error reported. next = next || MsftSme.noop; error = error || MsftSme.noop; const context = { next, error, complete, errorCount: 0, unsubscribeCount: 0 }; const hookError = (errorData) => { context.errorCount++; error(errorData); }; context.subscription = Object.getPrototypeOf(publish).subscribe.call(publish, next, hookError, complete); // hook up old subscription so old subscriber can unsubscribe properly. if (recoverSubscription) { recoverSubscription.unsubscribe = () => context.subscription.unsubscribe(); } // add subscribers to the inventory before adding to the un-subscription list. add() could call unsubscribe immediately. subscribers.push(context); // add internal unsubscribe to the original subscription. context.subscription.add(this.internalUnsubscribe.bind(this, context)); // if createObservable() is called with autoFetchOptions, it start fetching data immediately when subscribe() is called. if (this.autoFetchOptions) { this.fetch(this.autoFetchOptions); this.autoFetchOptions = null; } if (this.name) { this.log(`subscribe count=${subscribers.length}`); } return context.subscription; }); return publish; } internalUnsubscribe(context) { context.unsubscribeCount++; if (context.unsubscribeCount > 1) { // ignore if it's called twice and more. return; } if (!this.cachedData) { return; } const options = this.options || {}; if (options.enableRecovery) { // indicating normal unsubscribe call, so delete it. if (context.errorCount === 0) { const contextIndex = this.cachedData.subscribers.indexOf(context); if (contextIndex >= 0) { this.cachedData.subscribers.splice(contextIndex, 1); } } // on the recovery mode, retains all subscriber context, and don't clean up. // if there no active subscription, make cleanup call. const findAny = this.cachedData.subscribers.find(item => !item.subscription.closed); if (!findAny) { if (options.delayClean) { this.delayTimer = setTimeout(() => { this.cleanup(true); this.delayTimer = null; }, QueryCache.delayTime); } else { this.cleanup(true); } } return; } // non recovery mode. const index = this.cachedData.subscribers.indexOf(context); if (index >= 0) { this.cachedData.subscribers.splice(index, 1); } if (this.cachedData.subscribers.length === 0) { if (options.delayClean) { this.delayTimer = setTimeout(() => { this.cleanup(false); this.delayTimer = null; }, QueryCache.delayTime); } else { this.cleanup(false); } } } cleanup(recovery) { if (this.name) { this.log('unsubscribed'); } this.repeaterStop(); if (!this.cachedData) { return; } if (this.cachedData.fetch) { this.cachedData.fetch.complete(); this.cachedData.fetch = null; } if (this.cachedData.refresh) { this.cachedData.refresh.complete(); this.cachedData.refresh = null; } if (this.cachedData.apply) { this.cachedData.apply.complete(); this.cachedData.apply = null; } this.cachedData.publish = null; this.key = null; // on recovery cleanup, retain subscribers and options. if (!recovery) { this.cachedData.subscribers = null; this.cachedData = null; this.options = {}; } if (this.destroy) { this.destroy(); } } /** * Repeat observable. * Using free running interval to avoid setTimeout recursive or Observable.expand recursive stacks. * It reduces the usage of browser memory but use more frequent timer event as pulse in the code. */ repeat() { if (QueryCache.id < 10000) { // Legacy code pattern which use expand/recursive/setTimeout // submit initial query. return this.create(this.options.params) // expand to re-query next interval .pipe(expand((result) => { if (!this.options.interval || this.options.interval <= 0) { // stop the interval delay. return EMPTY; } // return new observable after the delay. return of(result) .pipe( // delay for the interval. delay(this.options.interval), // complete previous observable and switch to new observable. switchMap(() => this.create(this.options.params))); })); } this.repeaterStop(); if (!this.options.interval) { // single observable. (no repeat) return this.create(this.options.params); } // create new observable to repeat calling repeaterCreate() function. return new Observable((observer) => { this.observer = observer; this.repeaterInitialize(); this.repeaterStart(); return () => { // either closed by unsubscribe or observer is completed. this.observer?.complete(); this.observer = null; this.repeaterStop(); }; }); } repeaterInitialize() { if (this.options.interval < 1000) { // min 1 sec interval. this.options.interval = 1000; } // 100 or 200 or 1000 pulse. const pulse = this.options.interval <= 5000 ? 100 : (this.options.interval <= 10000 ? 200 : 1000); const cycle = Math.floor(this.options.interval / pulse); this.repeaterContext = { counter: 0, request: QueryCache.repeaterParked, timer: 0, pulse, cycle, verify: 0 }; } repeaterStart() { // run first query. this.repeaterCreate(); // start interval counter is incremented but cycled by cycle number. // if it hits request === counter, call this.repeaterCreate(); this.repeaterContext.timer = setInterval(this.repeaterCheck.bind(this), this.repeaterContext.pulse); } repeaterStop() { if (!this.repeaterContext) { return; } if (this.repeaterContext.timer) { clearInterval(this.repeaterContext.timer); this.repeaterContext.timer = null; } this.repeaterContext.request = QueryCache.repeaterParked; this.repeaterContext.verify = 0; this.repeaterContext.counter = 0; } repeaterCreate() { if (!this.observer) { this.repeaterStop(); return; } if (!this.options.interval) { if (this.observer) { this.observer.complete(); this.observer = null; } this.repeaterStop(); return; } // subscribe to the create observable with params passed. this.create(this.options.params) .subscribe({ next: data => { this.observer?.next(data); }, error: error => { this.observer?.error(error); this.repeaterStop(); }, complete: () => { if (!this.options.interval) { // complete if the interval was reset. if (this.observer) { this.observer.complete(); this.observer = null; } this.repeaterStop(); return; } if (this.name) { this.log(`repeat: request=${this.repeaterContext.request}, counter=${this.repeaterContext.counter}`); } // set next request counter number if it's parked. if (this.repeaterContext.request === QueryCache.repeaterParked) { this.repeaterContext.request = this.repeaterContext.counter; } } }); } repeaterCheck() { if (this.repeaterContext.request === QueryCache.repeaterParked) { return; } // update cycle counter this.repeaterContext.counter = ++this.repeaterContext.counter % this.repeaterContext.cycle; // update verify counter which starts from 0 to cycle. this.repeaterContext.verify++; // check if it hits a cycle. if (this.repeaterContext.counter === this.repeaterContext.request) { // wait for repeatCreate observable to complete. reset verify counter first before activate the observable. this.repeaterContext.request = QueryCache.repeaterParked; this.repeaterContext.verify = 0; this.repeaterCreate(); } else if (this.repeaterContext.verify > this.repeaterContext.cycle + 1) { throw new Error('QueryCache: Cycle counter running over. cycle={0}, counter={1}, request={2}, verify={3}'.format(this.repeaterContext.cycle, this.repeaterContext.counter, this.repeaterContext.request, this.repeaterContext.verify)); } } } //# sourceMappingURL=query-cache.js.map