import {DocumentMetadata, HomeDBManager} from 'app/gen-server/lib/HomeDBManager'; import log from 'app/server/lib/log'; /** * HostedMetadataManager handles pushing document metadata changes to the Home database when * a doc is updated. Currently updates doc updatedAt time and usage. */ export class HostedMetadataManager { // Document metadata mapped by docId. private _metadata: {[docId: string]: DocumentMetadata} = {}; // Set if the class holder is closing and no further pushes should be scheduled. private _closing: boolean = false; // Last push time in ms since epoch. private _lastPushTime: number = 0.0; // Callback for next opportunity to push changes. private _timeout: NodeJS.Timeout|null = null; // Maintains the update Promise to wait on it if the class is closing. private _push: Promise|null; // The default delay in milliseconds between metadata pushes to the database. private readonly _minPushDelayMs: number; /** * Create an instance of HostedMetadataManager. * The minPushDelay is the default delay in seconds between metadata pushes to the database. */ constructor(private _dbManager: HomeDBManager, minPushDelay: number = 60) { this._minPushDelayMs = minPushDelay * 1000; } /** * Close the manager. Send out any pending updates and prevent more from being scheduled. */ public async close(): Promise { // Pushes will no longer be scheduled. this._closing = true; // Wait for outgoing pushes to finish before proceeding. if (this._push) { await this._push; } if (this._timeout) { // Since an update was scheduled, perform one final update now. this._update(); if (this._push) { await this._push; } } } /** * Schedule a call to _update some time from now. When the update is made, it will * store the given metadata in the updated_at and usage columns of the docs table for * the specified document. * * If `minimizeDelay` is true, the push will be scheduled with minimum delay (0ms) and * will cancel/overwrite an already scheduled push (if present). */ public scheduleUpdate(docId: string, metadata: DocumentMetadata, minimizeDelay = false): void { if (this._closing) { return; } // Update metadata even if an update is already scheduled - if the update has not yet occurred, // the more recent metadata will be used. this._setOrUpdateMetadata(docId, metadata); if (this._timeout && !minimizeDelay) { return; } this._schedulePush(minimizeDelay ? 0 : undefined); } public setDocsMetadata(docUpdateMap: {[docId: string]: DocumentMetadata}): Promise { return this._dbManager.setDocsMetadata(docUpdateMap); } /** * Push all metadata updates to the database. */ private _update(): void { if (this._timeout) { clearTimeout(this._timeout); this._timeout = null; } if (this._push) { return; } this._push = this._performUpdate() .catch(err => { log.error("HostedMetadataManager error performing update: ", err); }) .then(() => { this._push = null; if (!this._closing && !this._timeout && Object.keys(this._metadata).length !== 0) { // If we have metadata that hasn't been pushed up yet, but no push scheduled, // go ahead and schedule an immediate push. This can happen if `scheduleUpdate` // is called frequently with minimizeDelay set to true, particularly when // _performUpdate is taking a bit longer than normal to complete. this._schedulePush(0); } }); } /** * This is called by the update function to actually perform the update. This should not * be called unless to force an immediate update. */ private async _performUpdate(): Promise { // Await the database if it is not yet connected. const docUpdates = this._metadata; this._metadata = {}; this._lastPushTime = Date.now(); await this.setDocsMetadata(docUpdates); } /** * Schedule a metadata push. * * If `delayMs` is specified, the push will be scheduled to occur at least that * number of milliseconds in the future. If `delayMs` is unspecified, the push * will be scheduled to occur at least `_minPushDelayMs` after the last push time. * * If called while a push is already scheduled, that push will be cancelled and * replaced with this one. */ private _schedulePush(delayMs?: number): void { if (delayMs === undefined) { delayMs = Math.round(this._minPushDelayMs - (Date.now() - this._lastPushTime)); } if (this._timeout) { clearTimeout(this._timeout); } this._timeout = setTimeout(() => this._update(), delayMs < 0 ? 0 : delayMs); } /** * Adds `docId` and its `metadata` to the list of queued updates, merging any existing values. */ private _setOrUpdateMetadata(docId: string, metadata: DocumentMetadata): void { if (!this._metadata[docId]) { this._metadata[docId] = metadata; } else { const {updatedAt, usage} = metadata; if (updatedAt) { this._metadata[docId].updatedAt = updatedAt; } if (usage !== undefined) { this._metadata[docId].usage = usage; } } } }