gristlabs_grist-core/app/client/components/DocComm.ts
Paul Fitzpatrick b3b7410ede (core) open documents without blocking on data engine
Summary:
With this diff, when a user opens a Grist document in a browser, they will be able to view its contents without waiting for the data engine to start up. Once the data engine starts, it will run a calculation and send any updates made. Changes to the document will be blocked until the engine is started and the initial calculation is complete.

The increase in responsiveness is useful in its own right, and also reduces the impact of an extra startup time in a candidate next-generation sandbox.

A small unrelated fix is included for `core/package.json`, to catch up with a recent change to `package.json`.

A small `./build schema` convenience is added to just rebuild the typescript schema file.

Test Plan: added test; existing tests pass - small fixes needed in some cases because of new timing

Reviewers: dsagal

Reviewed By: dsagal

Differential Revision: https://phab.getgrist.com/D3036
2021-10-01 10:18:56 -04:00

217 lines
8.8 KiB
TypeScript

import {Comm, CommMessage} from 'app/client/components/Comm';
import {reportError, UserError} from 'app/client/models/errors';
import {Notifier} from 'app/client/models/NotifyModel';
import {ActionGroup} from 'app/common/ActionGroup';
import {ActiveDocAPI, ApplyUAOptions, ApplyUAResult} from 'app/common/ActiveDocAPI';
import {DocAction, UserAction} from 'app/common/DocActions';
import {OpenLocalDocResult} from 'app/common/DocListAPI';
import {docUrl} from 'app/common/urlUtils';
import {Events as BackboneEvents} from 'backbone';
import {Disposable, Emitter} from 'grainjs';
// tslint:disable:no-console
export interface DocUserAction extends CommMessage {
docFD: number;
fromSelf?: boolean;
data: {
docActions: DocAction[];
actionGroup: ActionGroup;
error?: string;
};
}
const SLOW_NOTIFICATION_TIMEOUT_MS = 1000; // applies to user actions only
/**
* The type of data.methods object created by openDoc() in app/client/components/Comm.js.
* This is used in much of client-side code, and exposed firstly as GristDoc.docComm.
*/
export class DocComm extends Disposable implements ActiveDocAPI {
// These are all the methods of ActiveDocAPI. Listing them explicitly lets typescript verify
// that we haven't missed any.
// closeDoc has a special implementation below.
public fetchTable = this._wrapMethod("fetchTable");
public fetchTableSchema = this._wrapMethod("fetchTableSchema");
public useQuerySet = this._wrapMethod("useQuerySet");
public disposeQuerySet = this._wrapMethod("disposeQuerySet");
// applyUserActions has a special implementation below.
public applyUserActionsById = this._wrapMethod("applyUserActionsById");
public importFiles = this._wrapMethod("importFiles");
public finishImportFiles = this._wrapMethod("finishImportFiles");
public cancelImportFiles = this._wrapMethod("cancelImportFiles");
public addAttachments = this._wrapMethod("addAttachments");
public findColFromValues = this._wrapMethod("findColFromValues");
public getFormulaError = this._wrapMethod("getFormulaError");
public fetchURL = this._wrapMethod("fetchURL");
public autocomplete = this._wrapMethod("autocomplete");
public removeInstanceFromDoc = this._wrapMethod("removeInstanceFromDoc");
public getActionSummaries = this._wrapMethod("getActionSummaries");
public startBundleUserActions = this._wrapMethod("startBundleUserActions");
public stopBundleUserActions = this._wrapMethod("stopBundleUserActions");
public forwardPluginRpc = this._wrapMethod("forwardPluginRpc");
public reloadPlugins = this._wrapMethod("reloadPlugins");
public reloadDoc = this._wrapMethod("reloadDoc");
public fork = this._wrapMethod("fork");
public checkAclFormula = this._wrapMethod("checkAclFormula");
public getAclResources = this._wrapMethod("getAclResources");
public waitForInitialization = this._wrapMethod("waitForInitialization");
public changeUrlIdEmitter = this.autoDispose(new Emitter());
// We save the clientId that was used when opening the doc. If it changes (e.g. reconnecting to
// another server), it would be incorrect to use the new clientId without re-opening the doc
// (which is handled by App.ts). This way, Comm can protect against mismatched clientIds.
private _clientId: string;
private _docFD: number;
private _forkPromise: Promise<void>|null = null;
private _isClosed: boolean = false;
private listenTo: BackboneEvents['listenTo']; // set by Backbone
constructor(private _comm: Comm, openResponse: OpenLocalDocResult, private _docId: string,
private _notifier: Notifier) {
super();
this._setOpenResponse(openResponse);
// If *this* doc is shutdown forcibly (e.g. via reloadDoc call), mark it as closed, so we
// don't attempt to close it again.
this.listenTo(_comm, 'docShutdown', (m: CommMessage) => {
if (this.isActionFromThisDoc(m)) { this._isClosed = true; }
});
this.onDispose(async () => {
try {
await this._shutdown();
} catch (e) {
if (!String(e).match(/GristWSConnection disposed/)) {
reportError(e);
}
}
});
}
// Returns the URL params that identifying this open document to the DocWorker
// (used e.g. in attachment and download URLs).
public getUrlParams(): {clientId: string, docFD: number} {
return { clientId: this._clientId, docFD: this._docFD };
}
// Completes a path by adding the correct worker host and prefix for this document.
// E.g. "/uploads" becomes "https://host.name/v/ver/o/org/uploads"
public docUrl(path: string) {
return docUrl(this.docWorkerUrl, path);
}
// Returns a base url to the worker serving the current document, e.g.
// "https://host.name/v/ver/"
public get docWorkerUrl() {
return this._comm.getDocWorkerUrl(this._docId);
}
// Returns whether a message received by this Comm object is for the current doc.
public isActionFromThisDoc(message: CommMessage): boolean {
return message.docFD === this._docFD;
}
/**
* Overrides applyUserActions() method to also add the UserActions to a list, for use in tests.
*/
public applyUserActions(actions: UserAction[], options?: ApplyUAOptions): Promise<ApplyUAResult> {
this._comm.addUserActions(actions);
return this._callMethod('applyUserActions', actions, options);
}
/**
* Overrides closeDoc() method to call to Comm directly, without triggering forking logic.
* This is important in particular since it may be called while forking.
*/
public closeDoc(): Promise<void> {
return this._callDocMethod('closeDoc');
}
/**
* Forks the document, making sure the url gets updated, and holding any actions
* until the fork is complete. If a fork has already been started/completed, this
* does nothing.
*/
public async forkAndUpdateUrl(): Promise<void> {
await (this._forkPromise || (this._forkPromise = this._doForkDoc()));
}
// Clean up connection after closing doc.
private async _shutdown() {
console.log(`DocComm: shutdown clientId ${this._clientId} docFD ${this._docFD}`);
try {
// Close the document to unsubscribe from further updates on it.
if (!this._isClosed) {
await this.closeDoc();
}
} catch (err) {
console.warn(`DocComm: closeDoc failed: ${err}`);
} finally {
if (!this._comm.isDisposed()) {
this._comm.releaseDocConnection(this._docId);
}
}
}
/**
* Store important information from the response to openDoc, and
* ensure we have a connection to a docWorker for the document
* identified by the current docId. the caller of _setOpenResponse
* should call _releaseDocConnection for any previous docId.
*/
private _setOpenResponse(openResponse: OpenLocalDocResult) {
this._docFD = openResponse.docFD;
this._clientId = openResponse.clientId;
this._comm.useDocConnection(this._docId);
}
private _wrapMethod<Name extends keyof ActiveDocAPI>(name: Name): ActiveDocAPI[Name] {
return this._callMethod.bind(this, name);
}
private async _callMethod(name: keyof ActiveDocAPI, ...args: any[]): Promise<any> {
return this._notifier.slowNotification(this._doCallMethod(name, ...args), SLOW_NOTIFICATION_TIMEOUT_MS);
}
private async _doCallMethod(name: keyof ActiveDocAPI, ...args: any[]): Promise<any> {
if (this._forkPromise) {
// If a fork is pending or has finished, call the method after waiting for it.
// (If we've gone through a fork, we will not consider forking again.)
await this._forkPromise;
return this._callDocMethod(name, ...args);
}
try {
return await this._callDocMethod(name, ...args);
} catch (err) {
// TODO should be the suggested fork id and fork user.
if (err.shouldFork) {
// If the server suggests to fork, do it now, or wait for the fork already pending.
await this.forkAndUpdateUrl();
return this._callDocMethod(name, ...args);
}
throw err;
}
}
private _callDocMethod(name: keyof ActiveDocAPI, ...args: any[]): Promise<any> {
return this._comm._makeRequest(this._clientId, this._docId, name, this._docFD, ...args);
}
private async _doForkDoc(): Promise<void> {
reportError(new UserError('Preparing your copy...', {key: 'forking'}));
const {urlId, docId} = await this.fork();
// TODO: may want to preserve linkParameters in call to openDoc.
const openResponse = await this._comm.openDoc(docId);
// Close the old doc and release the old connection. Note that the closeDoc call is expected
// to fail, since we close the websocket immediately after it. So let it fail silently.
this.closeDoc().catch(() => null);
this._comm.releaseDocConnection(this._docId);
this._docId = docId;
this._setOpenResponse(openResponse);
this.changeUrlIdEmitter.emit(urlId);
reportError(new UserError('You are now editing your own copy', {key: 'forking'}));
}
}
Object.assign(DocComm.prototype, BackboneEvents);