gristlabs_grist-core/app/client/models/DocData.ts
Dmitry S 2a592d8b4d (core) Automatically finalize action bundles when unrelated actions/bundles come in.
Summary:
Type conversions and formula tranforms wait for the user and bundle multiple
actions. When an unrelated action is done (e.g. adding a page widget or a
column), we want to finalize the transform before applying it.

The approach turns out fairly complicated. There is an implicit queue of
bundles (which we don't let grow beyond 2, as that's too abnormal). Bundles may
be finalized by a user clicking something, or by an unrelated action/bundle, or
(as before) by transform DOM getting disposed.

- Updated RecordLayout to use bundleActions() helper
- Added support for nesting bundleActions inside another bundle (needed for
  setting visibleCol during type change)
- In an unrelated tweak, when in debug-log in ActiveDoc, use a short representation of result.

Test Plan: Added a unittest for action bundling during type transform

Reviewers: paulfitz

Reviewed By: paulfitz

Differential Revision: https://phab.getgrist.com/D2655
2020-11-10 10:32:07 -05:00

274 lines
11 KiB
TypeScript

/**
* DocData maintains all underlying data for a Grist document, knows how to load it,
* subscribes to actions which change it, and forwards those actions to individual tables.
* It also provides the interface to apply actions to data.
*/
import {DocComm} from 'app/client/components/DocComm';
import {TableData} from 'app/client/models/TableData';
import {ApplyUAOptions, ApplyUAResult} from 'app/common/ActiveDocAPI';
import {CellValue, TableDataAction, UserAction} from 'app/common/DocActions';
import {DocData as BaseDocData} from 'app/common/DocData';
import {ColTypeMap} from 'app/common/TableData';
import * as bluebird from 'bluebird';
import {Emitter} from 'grainjs';
import defaults = require('lodash/defaults');
const gristNotify = (window as any).gristNotify;
export class DocData extends BaseDocData {
public readonly sendActionsEmitter = new Emitter();
public readonly sendActionsDoneEmitter = new Emitter();
private _bundlesPending: number = 0; // How many bundles are currently pending.
private _lastBundlePromise?: Promise<void>; // Promise for completion of the last pending bundle.
private _triggerBundleFinalize?: () => void; // When a bundle is pending, trigger its finalize() callback.
// When a bundle is pending and actions should be checked, the callback to check them.
private _shouldIncludeInBundle?: (actions: UserAction[]) => boolean;
private _nextDesc: string|null = null; // The description for the next incoming action.
private _lastActionNum: number|null = null; // ActionNum of the last action in the current bundle, or null.
private _bundleSender: BundleSender;
/**
* Constructor for DocData.
* @param {Object} docComm: A map of server methods availble on this document.
* @param {Object} metaTableData: A map from tableId to table data, presented as an action,
* equivalent to BulkAddRecord, i.e. ["TableData", tableId, rowIds, columnValues].
*/
constructor(public readonly docComm: DocComm, metaTableData: {[tableId: string]: TableDataAction}) {
super((tableId) => docComm.fetchTable(tableId), metaTableData);
this._bundleSender = new BundleSender(this.docComm);
}
public createTableData(tableId: string, tableData: TableDataAction|null, colTypes: ColTypeMap): TableData {
return new TableData(this, tableId, tableData, colTypes);
}
// Version of inherited getTable() which returns the enhance TableData type.
public getTable(tableId: string): TableData|undefined {
return super.getTable(tableId) as TableData;
}
/**
* Finds up to n most likely target columns for the given values in the document.
*/
public async findColFromValues(values: any[], n: number, optTableId?: string): Promise<number[]> {
try {
return await this.docComm.findColFromValues(values, n, optTableId);
} catch (e) {
gristNotify(`Error finding matching columns: ${e.message}`);
return [];
}
}
/**
* Returns error message (traceback) for one invalid formula cell.
*/
public getFormulaError(tableId: string, colId: string, rowId: number): Promise<CellValue> {
return this.docComm.getFormulaError(tableId, colId, rowId);
}
// Sets a bundle to collect all incoming actions. Throws an error if any actions which
// do not match the verification callback are sent.
public startBundlingActions<T>(options: BundlingOptions<T>): BundlingInfo<T> {
if (this._bundlesPending >= 2) {
// We don't expect a full-blown queue of bundles or actions at any point. If a bundle is
// pending, a new bundle should immediately finalize it. Here we refuse to queue up more
// actions than that. (This could crop up in theory while disconnected, but is hard to
// trigger to test.)
throw new Error('Too many actions already pending');
}
this._bundlesPending++;
// Promise to allow waiting for the result of prepare() callback before it's even called.
let prepareResolve!: (value: T) => void;
const preparePromise = new Promise<T>(resolve => { prepareResolve = resolve; });
// Manually-triggered promise for when finalize() should be called. It's triggered by user,
// and when an unrelated action or a new bundle is started.
let triggerFinalize!: () => void;
const triggerFinalizePromise = new Promise<void>(resolve => { triggerFinalize = resolve; });
const doBundleActions = async () => {
if (this._lastBundlePromise) {
this._triggerBundleFinalize?.();
await this._lastBundlePromise;
}
this._nextDesc = options.description;
this._lastActionNum = null;
this._triggerBundleFinalize = triggerFinalize;
const value = await options.prepare();
prepareResolve(value);
this._shouldIncludeInBundle = options.shouldIncludeInBundle;
try {
await triggerFinalizePromise;
// Unset _shouldIncludeInBundle so that actions sent by finalize() are included in the
// bundle. If they were checked and incorrectly failed the check, we'd have a deadlock.
// TODO The downside is that when sending multiple unrelated actions quickly, the first
// can trigger finalize, and subsequent ones can get bundled in while finalize() is
// running. This changes the order of actions and may create problems (e.g. with undo).
this._shouldIncludeInBundle = undefined;
await options.finalize();
} finally {
// In all cases, reset the bundle-specific values we set above
this._shouldIncludeInBundle = undefined;
this._triggerBundleFinalize = undefined;
this._bundlesPending--;
if (this._bundlesPending === 0) {
this._lastBundlePromise = undefined;
}
}
};
this._lastBundlePromise = doBundleActions();
return {preparePromise, triggerFinalize};
}
// Execute a callback that may send multiple actions, and bundle those actions together. The
// callback may return a promise, in which case bundleActions() will wait for it to resolve.
// If nestInActiveBundle is true, and there is an active bundle, then simply calls callback()
// without starting a new bundle.
public async bundleActions<T>(desc: string|null, callback: () => T|Promise<T>,
options: {nestInActiveBundle?: boolean} = {}): Promise<T> {
if (options.nestInActiveBundle && this._bundlesPending) {
return await callback();
}
const bundlingInfo = this.startBundlingActions<T>({
description: desc,
shouldIncludeInBundle: () => true,
prepare: callback,
finalize: async () => undefined,
});
try {
return await bundlingInfo.preparePromise;
} finally {
bundlingInfo.triggerFinalize();
}
}
/**
* Sends actions to the server to be applied.
* @param {String} optDesc: Optional description of the actions to be shown in the log.
*
* sendActions also emits two events:
* 'sendActions': emitted before the action is sent, with { actions } object as data.
* 'sendActionsDone': emitted on success, with the same data object.
* Note that it allows a handler for 'sendActions' to pass along information to the handler
* for the corresponding 'sendActionsDone', by tacking it onto the event data object.
*/
public sendActions(actions: UserAction[], optDesc?: string): Promise<any[]> {
// Some old code relies on this promise being a bluebird Promise.
// TODO Remove bluebird and this cast.
return bluebird.Promise.resolve(this._sendActionsImpl(actions, optDesc)) as any;
}
/**
* Sends a single action to the server to be applied. Calls this.sendActions to manage the
* optional bundle.
* @param {String} optDesc: Optional description of the actions to be shown in the log.
*/
public sendAction(action: UserAction, optDesc?: string): Promise<any> {
return this.sendActions([action], optDesc).then((retValues) => retValues[0]);
}
// See documentation of sendActions().
private async _sendActionsImpl(actions: UserAction[], optDesc?: string): Promise<any[]> {
const eventData = {actions};
this.sendActionsEmitter.emit(eventData);
const options = { desc: optDesc };
if (this._shouldIncludeInBundle && !this._shouldIncludeInBundle(actions)) {
this._triggerBundleFinalize?.();
await this._lastBundlePromise;
}
if (this._bundlesPending) {
defaults(options, {
desc: this._nextDesc,
linkId: this._lastActionNum,
});
this._nextDesc = null;
}
const result: ApplyUAResult = await this._bundleSender.applyUserActions(actions, options);
this._lastActionNum = result.actionNum;
this.sendActionsDoneEmitter.emit(eventData);
return result.retValues;
}
}
/**
* BundleSender helper class collects multiple applyUserActions() calls that happen on the same
* tick, and sends them to the server all at once.
*/
class BundleSender {
private _options = {};
private _actions: UserAction[] = [];
private _sendPromise?: Promise<ApplyUAResult>;
constructor(private _docComm: DocComm) {}
public applyUserActions(actions: UserAction[], options: ApplyUAOptions): Promise<ApplyUAResult> {
defaults(this._options, options);
const start = this._actions.length;
this._actions.push(...actions);
const end = this._actions.length;
return this._getSendPromise()
.then(result => ({
actionNum: result.actionNum,
retValues: result.retValues.slice(start, end),
isModification: result.isModification
}));
}
public _getSendPromise(): Promise<ApplyUAResult> {
if (!this._sendPromise) {
// Note that the first Promise.resolve() ensures that the next step (actual send) happens on
// the next tick. By that time, more actions may have been added to this._actions array.
this._sendPromise = Promise.resolve()
.then(() => {
this._sendPromise = undefined;
const ret = this._docComm.applyUserActions(this._actions, this._options);
this._options = {};
this._actions = [];
return ret;
});
}
return this._sendPromise;
}
}
/**
* Options to startBundlingAction().
*/
export interface BundlingOptions<T = unknown> {
// Description of the action bundle.
description: string|null;
// Checker for whether an action belongs in the current bundle. If not, finalize() will be
// called immediately. Note that this checker is NOT applied for actions sent from prepare()
// or finalize() callbacks, only those in between.
shouldIncludeInBundle: (actions: UserAction[]) => boolean;
// Callback to start this action bundle.
prepare: () => T|Promise<T>;
// Callback to finalize this action bundle.
finalize: () => Promise<void>;
}
/**
* Result of startBundlingActions(), to allow waiting for prepare() to complete, and to trigger
* finalize() manually.
*/
export interface BundlingInfo<T = unknown> {
// Promise for when the prepare() has completed. Note that sometimes it's delayed until the
// previous bundle has been finalized.
preparePromise: Promise<T>;
// Ask DocData to call the finalize callback immediately.
triggerFinalize: () => void;
}