mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(core) make document reloading cleaner
Summary: Currently when reloading a document, we may have two sqlite connections to the document for a small period of time. This diff removes that overlap. Test Plan: added test Reviewers: georgegevoian Reviewed By: georgegevoian Differential Revision: https://phab.getgrist.com/D3140
This commit is contained in:
parent
561e32fb44
commit
c7331e2453
@ -307,57 +307,68 @@ export class ActiveDoc extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shut down the ActiveDoc, and (by default) remove it from the docManager.
|
* Shut down the ActiveDoc, and remove it from the DocManager. An optional
|
||||||
* @returns {Promise} Promise for when database and data engine are done shutting down.
|
* afterShutdown operation can be provided, which will be run once the ActiveDoc
|
||||||
|
* is completely shut down but before it is removed from the DocManager, ensuring
|
||||||
|
* that the operation will not overlap with a new ActiveDoc starting up for the
|
||||||
|
* same document.
|
||||||
*/
|
*/
|
||||||
public async shutdown(removeThisActiveDoc: boolean = true): Promise<void> {
|
public async shutdown(options: {
|
||||||
|
afterShutdown?: () => Promise<void>
|
||||||
|
} = {}): Promise<void> {
|
||||||
const docSession = makeExceptionalDocSession('system');
|
const docSession = makeExceptionalDocSession('system');
|
||||||
this._log.debug(docSession, "shutdown starting");
|
this._log.debug(docSession, "shutdown starting");
|
||||||
this._inactivityTimer.disable();
|
|
||||||
if (this.docClients.clientCount() > 0) {
|
|
||||||
this._log.warn(docSession, `Doc being closed with ${this.docClients.clientCount()} clients left`);
|
|
||||||
await this.docClients.broadcastDocMessage(null, 'docShutdown', null);
|
|
||||||
this.docClients.removeAllClients();
|
|
||||||
}
|
|
||||||
|
|
||||||
this._triggers.shutdown();
|
|
||||||
|
|
||||||
// Clear the MapWithTTL to remove all timers from the event loop.
|
|
||||||
this._fetchCache.clear();
|
|
||||||
|
|
||||||
if (removeThisActiveDoc) { await this._docManager.removeActiveDoc(this); }
|
|
||||||
try {
|
try {
|
||||||
await this._docManager.storageManager.closeDocument(this.docName);
|
this.setMuted();
|
||||||
} catch (err) {
|
this._inactivityTimer.disable();
|
||||||
log.error('Problem shutting down document: %s %s', this.docName, err.message);
|
if (this.docClients.clientCount() > 0) {
|
||||||
}
|
this._log.warn(docSession, `Doc being closed with ${this.docClients.clientCount()} clients left`);
|
||||||
|
await this.docClients.broadcastDocMessage(null, 'docShutdown', null);
|
||||||
try {
|
this.docClients.interruptAllClients();
|
||||||
const dataEngine = this._dataEngine ? await this._getEngine() : null;
|
this.docClients.removeAllClients();
|
||||||
this._shuttingDown = true; // Block creation of engine if not yet in existence.
|
|
||||||
if (dataEngine) {
|
|
||||||
// Give a small grace period for finishing initialization if we are being shut
|
|
||||||
// down while initialization is still in progress, and we don't have an easy
|
|
||||||
// way yet to cancel it cleanly. This is mainly for the benefit of automated
|
|
||||||
// tests.
|
|
||||||
await timeoutReached(3000, this.waitForInitialization());
|
|
||||||
}
|
}
|
||||||
await Promise.all([
|
|
||||||
this.docStorage.shutdown(),
|
this._triggers.shutdown();
|
||||||
this.docPluginManager.shutdown(),
|
|
||||||
dataEngine?.shutdown()
|
// Clear the MapWithTTL to remove all timers from the event loop.
|
||||||
]);
|
this._fetchCache.clear();
|
||||||
// The this.waitForInitialization promise may not yet have resolved, but
|
|
||||||
// should do so quickly now we've killed everything it depends on.
|
|
||||||
try {
|
try {
|
||||||
await this.waitForInitialization();
|
await this._docManager.storageManager.closeDocument(this.docName);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Initialization errors do not matter at this point.
|
log.error('Problem shutting down document: %s %s', this.docName, err.message);
|
||||||
}
|
}
|
||||||
this._log.debug(docSession, "shutdown complete");
|
|
||||||
} catch (err) {
|
try {
|
||||||
this._log.error(docSession, "failed to shutdown some resources", err);
|
const dataEngine = this._dataEngine ? await this._getEngine() : null;
|
||||||
|
this._shuttingDown = true; // Block creation of engine if not yet in existence.
|
||||||
|
if (dataEngine) {
|
||||||
|
// Give a small grace period for finishing initialization if we are being shut
|
||||||
|
// down while initialization is still in progress, and we don't have an easy
|
||||||
|
// way yet to cancel it cleanly. This is mainly for the benefit of automated
|
||||||
|
// tests.
|
||||||
|
await timeoutReached(3000, this.waitForInitialization());
|
||||||
|
}
|
||||||
|
await Promise.all([
|
||||||
|
this.docStorage.shutdown(),
|
||||||
|
this.docPluginManager.shutdown(),
|
||||||
|
dataEngine?.shutdown()
|
||||||
|
]);
|
||||||
|
// The this.waitForInitialization promise may not yet have resolved, but
|
||||||
|
// should do so quickly now we've killed everything it depends on.
|
||||||
|
try {
|
||||||
|
await this.waitForInitialization();
|
||||||
|
} catch (err) {
|
||||||
|
// Initialization errors do not matter at this point.
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this._log.error(docSession, "failed to shutdown some resources", err);
|
||||||
|
}
|
||||||
|
await options.afterShutdown?.();
|
||||||
|
} finally {
|
||||||
|
this._docManager.removeActiveDoc(this);
|
||||||
}
|
}
|
||||||
|
this._log.debug(docSession, "shutdown complete");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -450,32 +461,12 @@ export class ActiveDoc extends EventEmitter {
|
|||||||
* DocManager.
|
* DocManager.
|
||||||
*/
|
*/
|
||||||
public async replace(source: DocReplacementOptions) {
|
public async replace(source: DocReplacementOptions) {
|
||||||
// During replacement, it is important for all hands to be off the document. So:
|
// During replacement, it is important for all hands to be off the document. So we
|
||||||
// - We set the "mute" flag. Setting this means that any operations in progress
|
// ask the shutdown method to do the replacement when the ActiveDoc is shutdown but
|
||||||
// using this ActiveDoc should be ineffective (apart from the replacement).
|
// before a new one could be opened.
|
||||||
// In other words, the operations shouldn't ultimately result in any changes in S3,
|
return this.shutdown({
|
||||||
// and any related requests should result in a failure or be retried. TODO:
|
afterShutdown: () => this._docManager.storageManager.replace(this.docName, source)
|
||||||
// review how well we do on meeting this goal.
|
});
|
||||||
// - We close the ActiveDoc, retaining its listing in DocManager but shutting down
|
|
||||||
// all its component parts. We retain it in DocManager to delay another
|
|
||||||
// ActiveDoc being opened for the same document if someone is trying to operate
|
|
||||||
// on it.
|
|
||||||
// - We replace the document.
|
|
||||||
// - We remove the ActiveDoc from DocManager, opening the way for the document to be
|
|
||||||
// freshly opened.
|
|
||||||
// The "mute" flag is borrowed from worker shutdown. Note this scenario is a little
|
|
||||||
// different, since the worker is not withdrawing from service, so fresh work may get
|
|
||||||
// assigned to it at any time.
|
|
||||||
this.setMuted();
|
|
||||||
this.docClients.interruptAllClients();
|
|
||||||
try {
|
|
||||||
await this.shutdown(false);
|
|
||||||
await this._docManager.storageManager.replace(this.docName, source);
|
|
||||||
} finally {
|
|
||||||
// Whatever happened, success or failure, there is nothing further we can do
|
|
||||||
// with this ActiveDoc. Unlist it.
|
|
||||||
await this._docManager.removeActiveDoc(this);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -280,56 +280,57 @@ export class DocManager extends EventEmitter {
|
|||||||
// Fetch the document, and continue when we have the ActiveDoc (which may be immediately).
|
// Fetch the document, and continue when we have the ActiveDoc (which may be immediately).
|
||||||
const docSessionPrecursor = makeOptDocSession(client);
|
const docSessionPrecursor = makeOptDocSession(client);
|
||||||
docSessionPrecursor.authorizer = auth;
|
docSessionPrecursor.authorizer = auth;
|
||||||
const activeDoc: ActiveDoc = await this.fetchDoc(docSessionPrecursor, docId);
|
|
||||||
|
|
||||||
if (activeDoc.muted) {
|
return this._withUnmutedDoc(docSessionPrecursor, docId, async () => {
|
||||||
log.debug('DocManager.openDoc interrupting, called for a muted doc', docId);
|
const activeDoc: ActiveDoc = await this.fetchDoc(docSessionPrecursor, docId);
|
||||||
client.interruptConnection();
|
|
||||||
throw new Error(`document ${docId} cannot be opened right now`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get a fresh DocSession object.
|
// Get a fresh DocSession object.
|
||||||
const docSession = activeDoc.addClient(client, auth);
|
const docSession = activeDoc.addClient(client, auth);
|
||||||
|
|
||||||
// If opening in (pre-)fork mode, check if it is appropriate to treat the user as
|
// If opening in (pre-)fork mode, check if it is appropriate to treat the user as
|
||||||
// an owner for granular access purposes.
|
// an owner for granular access purposes.
|
||||||
if (mode === 'fork') {
|
if (mode === 'fork') {
|
||||||
if (await activeDoc.canForkAsOwner(docSession)) {
|
if (await activeDoc.canForkAsOwner(docSession)) {
|
||||||
// Mark the session specially and flush any cached access
|
// Mark the session specially and flush any cached access
|
||||||
// information. It is easier to make this a property of the
|
// information. It is easier to make this a property of the
|
||||||
// session than to try computing it later in the heat of
|
// session than to try computing it later in the heat of
|
||||||
// battle, since it introduces a loop where a user property
|
// battle, since it introduces a loop where a user property
|
||||||
// (user.Access) depends on evaluating rules, but rules need
|
// (user.Access) depends on evaluating rules, but rules need
|
||||||
// the user properties in order to be evaluated. It is also
|
// the user properties in order to be evaluated. It is also
|
||||||
// somewhat justifiable even if permissions change later on
|
// somewhat justifiable even if permissions change later on
|
||||||
// the theory that the fork is theoretically happening at this
|
// the theory that the fork is theoretically happening at this
|
||||||
// instance).
|
// instance).
|
||||||
docSession.forkingAsOwner = true;
|
docSession.forkingAsOwner = true;
|
||||||
activeDoc.flushAccess(docSession);
|
activeDoc.flushAccess(docSession);
|
||||||
} else {
|
} else {
|
||||||
// TODO: it would be kind to pass on a message to the client
|
// TODO: it would be kind to pass on a message to the client
|
||||||
// to let them know they won't be able to fork. They'll get
|
// to let them know they won't be able to fork. They'll get
|
||||||
// an error when they make their first change. But currently
|
// an error when they make their first change. But currently
|
||||||
// we only have the blunt instrument of throwing an error,
|
// we only have the blunt instrument of throwing an error,
|
||||||
// which would prevent access to the document entirely.
|
// which would prevent access to the document entirely.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const [metaTables, recentActions] = await Promise.all([
|
const [metaTables, recentActions] = await Promise.all([
|
||||||
activeDoc.fetchMetaTables(docSession),
|
activeDoc.fetchMetaTables(docSession),
|
||||||
activeDoc.getRecentMinimalActions(docSession)
|
activeDoc.getRecentMinimalActions(docSession)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
this.emit('open-doc', this.storageManager.getPath(activeDoc.docName));
|
const result = {
|
||||||
|
docFD: docSession.fd,
|
||||||
|
clientId: docSession.client.clientId,
|
||||||
|
doc: metaTables,
|
||||||
|
log: recentActions,
|
||||||
|
recoveryMode: activeDoc.recoveryMode,
|
||||||
|
userOverride: await activeDoc.getUserOverride(docSession),
|
||||||
|
} as OpenLocalDocResult;
|
||||||
|
|
||||||
return {
|
if (!activeDoc.muted) {
|
||||||
docFD: docSession.fd,
|
this.emit('open-doc', this.storageManager.getPath(activeDoc.docName));
|
||||||
clientId: docSession.client.clientId,
|
}
|
||||||
doc: metaTables,
|
|
||||||
log: recentActions,
|
return {activeDoc, result};
|
||||||
recoveryMode: activeDoc.recoveryMode,
|
});
|
||||||
userOverride: await activeDoc.getUserOverride(docSession),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -353,7 +354,7 @@ export class DocManager extends EventEmitter {
|
|||||||
return this._activeDocs.get(docName);
|
return this._activeDocs.get(docName);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async removeActiveDoc(activeDoc: ActiveDoc): Promise<void> {
|
public removeActiveDoc(activeDoc: ActiveDoc): void {
|
||||||
this._activeDocs.delete(activeDoc.docName);
|
this._activeDocs.delete(activeDoc.docName);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -395,37 +396,16 @@ export class DocManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches an ActiveDoc object. Used by openDoc.
|
* Fetches an ActiveDoc object. Used by openDoc. If ActiveDoc is muted (for safe closing),
|
||||||
|
* wait for another.
|
||||||
*/
|
*/
|
||||||
public async fetchDoc(docSession: OptDocSession, docName: string,
|
public async fetchDoc(docSession: OptDocSession, docName: string,
|
||||||
wantRecoveryMode?: boolean): Promise<ActiveDoc> {
|
wantRecoveryMode?: boolean): Promise<ActiveDoc> {
|
||||||
log.debug('DocManager.fetchDoc', docName);
|
log.debug('DocManager.fetchDoc', docName);
|
||||||
// Repeat until we acquire an ActiveDoc that is not muted (shutting down).
|
return this._withUnmutedDoc(docSession, docName, async () => {
|
||||||
for (;;) {
|
const activeDoc = await this._fetchPossiblyMutedDoc(docSession, docName, wantRecoveryMode);
|
||||||
if (this._activeDocs.has(docName) && wantRecoveryMode !== undefined) {
|
return {activeDoc, result: activeDoc};
|
||||||
const activeDoc = await this._activeDocs.get(docName);
|
});
|
||||||
if (activeDoc && activeDoc.recoveryMode !== wantRecoveryMode && await activeDoc.isOwner(docSession)) {
|
|
||||||
// shutting doc down to have a chance to re-open in the correct mode.
|
|
||||||
// TODO: there could be a battle with other users opening it in a different mode.
|
|
||||||
await activeDoc.shutdown();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!this._activeDocs.has(docName)) {
|
|
||||||
return mapSetOrClear(this._activeDocs, docName,
|
|
||||||
this._createActiveDoc(docSession, docName, wantRecoveryMode)
|
|
||||||
.then(newDoc => {
|
|
||||||
// Propagate backupMade events from newly opened activeDocs (consolidate all to DocMan)
|
|
||||||
newDoc.on('backupMade', (bakPath: string) => {
|
|
||||||
this.emit('backupMade', bakPath);
|
|
||||||
});
|
|
||||||
return newDoc.loadDoc(docSession);
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
const activeDoc = await this._activeDocs.get(docName)!;
|
|
||||||
if (!activeDoc.muted) { return activeDoc; }
|
|
||||||
log.debug('DocManager.fetchDoc waiting because doc is muted', docName);
|
|
||||||
await bluebird.delay(1000);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public makeAccessId(userId: number|null): string|null {
|
public makeAccessId(userId: number|null): string|null {
|
||||||
@ -437,6 +417,50 @@ export class DocManager extends EventEmitter {
|
|||||||
return userId === this._homeDbManager.getAnonymousUserId();
|
return userId === this._homeDbManager.getAnonymousUserId();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform the supplied operation and return its result - unless the activeDoc it returns
|
||||||
|
* is found to be muted, in which case we retry.
|
||||||
|
*/
|
||||||
|
private async _withUnmutedDoc<T>(docSession: OptDocSession, docName: string,
|
||||||
|
op: () => Promise<{ result: T, activeDoc: ActiveDoc }>): Promise<T> {
|
||||||
|
// Repeat until we acquire an ActiveDoc that is not muted (shutting down).
|
||||||
|
for (;;) {
|
||||||
|
const { result, activeDoc } = await op();
|
||||||
|
if (!activeDoc.muted) { return result; }
|
||||||
|
log.debug('DocManager._withUnmutedDoc waiting because doc is muted', docName);
|
||||||
|
await bluebird.delay(1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Like fetchDoc(), but doesn't check if ActiveDoc returned is unmuted.
|
||||||
|
private async _fetchPossiblyMutedDoc(docSession: OptDocSession, docName: string,
|
||||||
|
wantRecoveryMode?: boolean): Promise<ActiveDoc> {
|
||||||
|
if (this._activeDocs.has(docName) && wantRecoveryMode !== undefined) {
|
||||||
|
const activeDoc = await this._activeDocs.get(docName);
|
||||||
|
if (activeDoc && activeDoc.recoveryMode !== wantRecoveryMode && await activeDoc.isOwner(docSession)) {
|
||||||
|
// shutting doc down to have a chance to re-open in the correct mode.
|
||||||
|
// TODO: there could be a battle with other users opening it in a different mode.
|
||||||
|
await activeDoc.shutdown();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let activeDoc: ActiveDoc;
|
||||||
|
if (!this._activeDocs.has(docName)) {
|
||||||
|
activeDoc = await mapSetOrClear(
|
||||||
|
this._activeDocs, docName,
|
||||||
|
this._createActiveDoc(docSession, docName, wantRecoveryMode)
|
||||||
|
.then(newDoc => {
|
||||||
|
// Propagate backupMade events from newly opened activeDocs (consolidate all to DocMan)
|
||||||
|
newDoc.on('backupMade', (bakPath: string) => {
|
||||||
|
this.emit('backupMade', bakPath);
|
||||||
|
});
|
||||||
|
return newDoc.loadDoc(docSession);
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
activeDoc = await this._activeDocs.get(docName)!;
|
||||||
|
}
|
||||||
|
return activeDoc;
|
||||||
|
}
|
||||||
|
|
||||||
private async _createActiveDoc(docSession: OptDocSession, docName: string, safeMode?: boolean) {
|
private async _createActiveDoc(docSession: OptDocSession, docName: string, safeMode?: boolean) {
|
||||||
// Get URL for document for use with SELF_HYPERLINK().
|
// Get URL for document for use with SELF_HYPERLINK().
|
||||||
const cachedDoc = getDocSessionCachedDoc(docSession);
|
const cachedDoc = getDocSessionCachedDoc(docSession);
|
||||||
|
Loading…
Reference in New Issue
Block a user