mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(core) Broadcast doc usage updates to clients
Summary: Introduces a new message type, docUsage, that's broadcast to all connected clients whenever document usage is updated in ActiveDoc. Test Plan: Browser tests. Reviewers: jarek Reviewed By: jarek Differential Revision: https://phab.getgrist.com/D3451
This commit is contained in:
@@ -50,7 +50,7 @@ import {
|
||||
getUsageRatio,
|
||||
} from 'app/common/DocUsage';
|
||||
import {normalizeEmail} from 'app/common/emails';
|
||||
import {Features} from 'app/common/Features';
|
||||
import {Product} from 'app/common/Features';
|
||||
import {FormulaProperties, getFormulaProperties} from 'app/common/GranularAccessClause';
|
||||
import {parseUrlId} from 'app/common/gristUrls';
|
||||
import {byteString, countIf, retryOnce, safeJsonParse} from 'app/common/gutil';
|
||||
@@ -135,9 +135,19 @@ const REMOVE_UNUSED_ATTACHMENTS_INTERVAL_MS = 60 * 60 * 1000;
|
||||
// Apply the UpdateCurrentTime user action every hour
|
||||
const UPDATE_CURRENT_TIME_INTERVAL_MS = 60 * 60 * 1000;
|
||||
|
||||
// Measure and broadcast data size every 5 minutes
|
||||
const UPDATE_DATA_SIZE_INTERVAL_MS = 5 * 60 * 1000;
|
||||
|
||||
// A hook for dependency injection.
|
||||
export const Deps = {ACTIVEDOC_TIMEOUT};
|
||||
|
||||
interface UpdateUsageOptions {
|
||||
// Whether usage should be synced to the home database. Defaults to true.
|
||||
syncUsageToDatabase?: boolean;
|
||||
// Whether usage should be broadcast to all doc clients. Defaults to true.
|
||||
broadcastUsageToClients?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents an active document with the given name. The document isn't actually open until
|
||||
* either .loadDoc() or .createEmptyDoc() is called.
|
||||
@@ -184,11 +194,9 @@ export class ActiveDoc extends EventEmitter {
|
||||
// initialized. True on success.
|
||||
private _fullyLoaded: boolean = false; // Becomes true once all columns are loaded/computed.
|
||||
private _lastMemoryMeasurement: number = 0; // Timestamp when memory was last measured.
|
||||
private _lastDataSizeMeasurement: number = 0; // Timestamp when dbstat data size was last measured.
|
||||
private _lastDataLimitStatus?: DataLimitStatus;
|
||||
private _fetchCache = new MapWithTTL<string, Promise<TableDataAction>>(DEFAULT_CACHE_TTL);
|
||||
private _docUsage: DocumentUsage|null = null;
|
||||
private _productFeatures?: Features;
|
||||
private _product?: Product;
|
||||
private _gracePeriodStart: Date|null = null;
|
||||
private _isForkOrSnapshot: boolean = false;
|
||||
|
||||
@@ -208,6 +216,11 @@ export class ActiveDoc extends EventEmitter {
|
||||
() => this._applyUserActions(makeExceptionalDocSession('system'), [["UpdateCurrentTime"]]),
|
||||
UPDATE_CURRENT_TIME_INTERVAL_MS,
|
||||
),
|
||||
// Measure and broadcast data size every 5 minutes
|
||||
setInterval(
|
||||
() => this._checkDataSizeLimitRatio(makeExceptionalDocSession('system')),
|
||||
UPDATE_DATA_SIZE_INTERVAL_MS,
|
||||
),
|
||||
];
|
||||
|
||||
constructor(docManager: DocManager, docName: string, private _options?: ICreateActiveDocOptions) {
|
||||
@@ -217,7 +230,7 @@ export class ActiveDoc extends EventEmitter {
|
||||
if (_options?.safeMode) { this._recoveryMode = true; }
|
||||
if (_options?.doc) {
|
||||
const {gracePeriodStart, workspace, usage} = _options.doc;
|
||||
this._productFeatures = workspace.org.billingAccount?.product.features;
|
||||
this._product = workspace.org.billingAccount?.product;
|
||||
this._gracePeriodStart = gracePeriodStart;
|
||||
|
||||
if (!this._isForkOrSnapshot) {
|
||||
@@ -234,7 +247,6 @@ export class ActiveDoc extends EventEmitter {
|
||||
*
|
||||
* TODO: Revisit this later and patch up the loophole. */
|
||||
this._docUsage = usage;
|
||||
this._lastDataLimitStatus = this.dataLimitStatus;
|
||||
}
|
||||
}
|
||||
this._docManager = docManager;
|
||||
@@ -282,25 +294,25 @@ export class ActiveDoc extends EventEmitter {
|
||||
public get rowLimitRatio(): number {
|
||||
return getUsageRatio(
|
||||
this._docUsage?.rowCount,
|
||||
this._productFeatures?.baseMaxRowsPerDocument
|
||||
this._product?.features.baseMaxRowsPerDocument
|
||||
);
|
||||
}
|
||||
|
||||
public get dataSizeLimitRatio(): number {
|
||||
return getUsageRatio(
|
||||
this._docUsage?.dataSizeBytes,
|
||||
this._productFeatures?.baseMaxDataSizePerDocument
|
||||
this._product?.features.baseMaxDataSizePerDocument
|
||||
);
|
||||
}
|
||||
|
||||
public get dataLimitRatio(): number {
|
||||
return getDataLimitRatio(this._docUsage, this._productFeatures);
|
||||
return getDataLimitRatio(this._docUsage, this._product?.features);
|
||||
}
|
||||
|
||||
public get dataLimitStatus(): DataLimitStatus {
|
||||
return getDataLimitStatus({
|
||||
docUsage: this._docUsage,
|
||||
productFeatures: this._productFeatures,
|
||||
productFeatures: this._product?.features,
|
||||
gracePeriodStart: this._gracePeriodStart,
|
||||
});
|
||||
}
|
||||
@@ -446,15 +458,16 @@ export class ActiveDoc extends EventEmitter {
|
||||
for (const interval of this._intervals) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
// We'll defer syncing usage until everything is calculated.
|
||||
const usageOptions = {syncUsageToDatabase: false, broadcastUsageToClients: false};
|
||||
|
||||
// Remove expired attachments, i.e. attachments that were soft deleted a while ago. This
|
||||
// needs to happen periodically, and doing it here means we can guarantee that it happens
|
||||
// even if the doc is only ever opened briefly, without having to slow down startup.
|
||||
const removeAttachmentsPromise = this.removeUnusedAttachments(true, {syncUsageToDatabase: false});
|
||||
const removeAttachmentsPromise = this.removeUnusedAttachments(true, usageOptions);
|
||||
|
||||
// Update data size as well. We'll schedule a sync to the database once both this and the
|
||||
// above promise settle.
|
||||
const updateDataSizePromise = this._updateDataSize({syncUsageToDatabase: false});
|
||||
// Update data size; we'll be syncing both it and attachments size to the database soon.
|
||||
const updateDataSizePromise = this._updateDataSize(usageOptions);
|
||||
|
||||
try {
|
||||
await removeAttachmentsPromise;
|
||||
@@ -1440,11 +1453,14 @@ export class ActiveDoc extends EventEmitter {
|
||||
* @param expiredOnly: if true, only delete attachments that were soft-deleted sufficiently long ago.
|
||||
* @param options.syncUsageToDatabase: if true, schedule an update to the usage column of the docs table, if
|
||||
* any unused attachments were soft-deleted. defaults to true.
|
||||
* @param options.broadcastUsageToClients: if true, broadcast updated doc usage to all clients, if
|
||||
* any unused attachments were soft-deleted. defaults to true.
|
||||
*/
|
||||
public async removeUnusedAttachments(expiredOnly: boolean, options: {syncUsageToDatabase?: boolean} = {}) {
|
||||
const {syncUsageToDatabase = true} = options;
|
||||
public async removeUnusedAttachments(expiredOnly: boolean, options?: UpdateUsageOptions) {
|
||||
const hadChanges = await this.updateUsedAttachmentsIfNeeded();
|
||||
if (hadChanges) { await this._updateAttachmentsSize({syncUsageToDatabase}); }
|
||||
if (hadChanges) {
|
||||
await this._updateAttachmentsSize(options);
|
||||
}
|
||||
const rowIds = await this.docStorage.getSoftDeletedAttachmentIds(expiredOnly);
|
||||
if (rowIds.length) {
|
||||
const action: BulkRemoveRecord = ["BulkRemoveRecord", "_grist_Attachments", rowIds];
|
||||
@@ -1508,35 +1524,20 @@ export class ActiveDoc extends EventEmitter {
|
||||
}
|
||||
|
||||
public async updateRowCount(rowCount: number, docSession: OptDocSession | null) {
|
||||
this._updateDocUsage({rowCount});
|
||||
// Up-to-date row counts are included in every DocUserAction, so we can skip broadcasting here.
|
||||
await this._updateDocUsage({rowCount}, {broadcastUsageToClients: false});
|
||||
log.rawInfo('Sandbox row count', {...this.getLogMeta(docSession), rowCount});
|
||||
await this._checkDataLimitRatio();
|
||||
|
||||
// Calculating data size is potentially expensive, so by default measure it at most once every 5 minutes.
|
||||
// Measure it after every change if the user is currently being warned specifically about
|
||||
// approaching or exceeding the data size limit but not the row count limit,
|
||||
// because we don't need to warn about both limits at the same time.
|
||||
let checkDataSizePeriod = 5 * 60;
|
||||
// Calculating data size is potentially expensive, so skip calculating it unless the
|
||||
// user is currently being warned specifically about approaching or exceeding the data
|
||||
// size limit, but not the row count limit; we don't need to warn about both limits at
|
||||
// the same time.
|
||||
if (
|
||||
this.dataSizeLimitRatio > APPROACHING_LIMIT_RATIO && this.rowLimitRatio <= APPROACHING_LIMIT_RATIO ||
|
||||
this.dataSizeLimitRatio > 1.0 && this.rowLimitRatio <= 1.0
|
||||
) {
|
||||
checkDataSizePeriod = 0;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (now - this._lastDataSizeMeasurement > checkDataSizePeriod * 1000) {
|
||||
this._lastDataSizeMeasurement = now;
|
||||
|
||||
// When the data size isn't critically high so we're only measuring it infrequently,
|
||||
// do it in the background so we don't delay responding to the client.
|
||||
// When it's being measured after every change, wait for it to finish to avoid race conditions
|
||||
// from multiple measurements and updates happening concurrently.
|
||||
if (checkDataSizePeriod === 0) {
|
||||
await this._checkDataSizeLimitRatio(docSession);
|
||||
} else {
|
||||
this._checkDataSizeLimitRatio(docSession).catch(e => console.error(e));
|
||||
}
|
||||
await this._checkDataSizeLimitRatio(docSession);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1692,43 +1693,43 @@ export class ActiveDoc extends EventEmitter {
|
||||
|
||||
/**
|
||||
* Applies all metrics from `usage` to the current document usage state.
|
||||
* Syncs updated usage to the home database by default, unless
|
||||
* `options.syncUsageToDatabase` is set to false.
|
||||
*
|
||||
* Allows specifying `options` for toggling whether usage is synced to
|
||||
* the home database and/or broadcast to clients.
|
||||
*/
|
||||
private _updateDocUsage(
|
||||
usage: Partial<DocumentUsage>,
|
||||
options: {
|
||||
syncUsageToDatabase?: boolean
|
||||
} = {}
|
||||
) {
|
||||
const {syncUsageToDatabase = true} = options;
|
||||
private async _updateDocUsage(usage: Partial<DocumentUsage>, options: UpdateUsageOptions = {}) {
|
||||
const {syncUsageToDatabase = true, broadcastUsageToClients = true} = options;
|
||||
const oldStatus = this.dataLimitStatus;
|
||||
this._docUsage = {...(this._docUsage || {}), ...usage};
|
||||
if (this._lastDataLimitStatus === this.dataLimitStatus) {
|
||||
// If status is unchanged, there's no need to sync usage to the database, as it currently
|
||||
// won't result in any noticeable difference to site usage banners. On shutdown, we'll
|
||||
// still schedule a sync so that the latest usage is persisted.
|
||||
return;
|
||||
if (syncUsageToDatabase) {
|
||||
/* If status decreased, we'll update usage in the database with minimal delay, so site usage
|
||||
* banners show up-to-date statistics. If status increased or stayed the same, we'll schedule
|
||||
* a delayed update, since it's less critical for banners to update immediately. */
|
||||
const didStatusDecrease = getSeverity(this.dataLimitStatus) < getSeverity(oldStatus);
|
||||
this._syncDocUsageToDatabase(didStatusDecrease);
|
||||
}
|
||||
if (broadcastUsageToClients) {
|
||||
await this._broadcastDocUsageToClients();
|
||||
}
|
||||
|
||||
const lastStatus = this._lastDataLimitStatus;
|
||||
this._lastDataLimitStatus = this.dataLimitStatus;
|
||||
if (!syncUsageToDatabase) { return; }
|
||||
|
||||
// If status decreased, we'll want to update usage in the DB with minimal delay, so that site
|
||||
// usage banners show up-to-date statistics. If status increased or stayed the same, we'll
|
||||
// schedule a delayed update, since it's less critical for such banners to update quickly
|
||||
// when usage grows.
|
||||
const didStatusDecrease = (
|
||||
lastStatus !== undefined &&
|
||||
getSeverity(this.dataLimitStatus) < getSeverity(lastStatus)
|
||||
);
|
||||
this._syncDocUsageToDatabase(didStatusDecrease);
|
||||
}
|
||||
|
||||
private _syncDocUsageToDatabase(minimizeDelay = false) {
|
||||
this._docManager.storageManager.scheduleUsageUpdate(this._docName, this._docUsage, minimizeDelay);
|
||||
}
|
||||
|
||||
private async _broadcastDocUsageToClients() {
|
||||
if (this.muted || this.docClients.clientCount() === 0) { return; }
|
||||
|
||||
await this.docClients.broadcastDocMessage(
|
||||
null,
|
||||
'docUsage',
|
||||
{docUsage: this.getDocUsageSummary(), product: this._product},
|
||||
async (session, data) => {
|
||||
return {...data, docUsage: await this.getFilteredDocUsageSummary(session)};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private async _updateGracePeriodStart(gracePeriodStart: Date | null) {
|
||||
this._gracePeriodStart = gracePeriodStart;
|
||||
if (!this._isForkOrSnapshot) {
|
||||
@@ -1758,15 +1759,14 @@ export class ActiveDoc extends EventEmitter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the total data size in bytes and sets it in _docUsage. Schedules
|
||||
* a sync to the database, unless `options.syncUsageToDatabase` is set to false.
|
||||
* Calculates the total data size in bytes, sets it in _docUsage, and returns it.
|
||||
*
|
||||
* Returns the calculated data size.
|
||||
* Allows specifying `options` for toggling whether usage is synced to
|
||||
* the home database and/or broadcast to clients.
|
||||
*/
|
||||
private async _updateDataSize(options: {syncUsageToDatabase?: boolean} = {}): Promise<number> {
|
||||
const {syncUsageToDatabase = true} = options;
|
||||
private async _updateDataSize(options?: UpdateUsageOptions): Promise<number> {
|
||||
const dataSizeBytes = await this.docStorage.getDataSize();
|
||||
this._updateDocUsage({dataSizeBytes}, {syncUsageToDatabase});
|
||||
await this._updateDocUsage({dataSizeBytes}, options);
|
||||
return dataSizeBytes;
|
||||
}
|
||||
|
||||
@@ -1930,7 +1930,7 @@ export class ActiveDoc extends EventEmitter {
|
||||
const closeTimeout = Math.max(loadMs, 1000) * Deps.ACTIVEDOC_TIMEOUT;
|
||||
this._inactivityTimer.setDelay(closeTimeout);
|
||||
this._log.debug(docSession, `loaded in ${loadMs} ms, InactivityTimer set to ${closeTimeout} ms`);
|
||||
this._initializeDocUsageIfNeeded(docSession);
|
||||
void this._initializeDocUsage(docSession);
|
||||
} catch (err) {
|
||||
this._fullyLoaded = true;
|
||||
if (!this._shuttingDown) {
|
||||
@@ -1971,18 +1971,24 @@ export class ActiveDoc extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
private _initializeDocUsageIfNeeded(docSession: OptDocSession) {
|
||||
// TODO: Broadcast a message to clients after usage is fully calculated.
|
||||
private async _initializeDocUsage(docSession: OptDocSession) {
|
||||
const promises: Promise<unknown>[] = [];
|
||||
// We'll defer syncing/broadcasting usage until everything is calculated.
|
||||
const options = {syncUsageToDatabase: false, broadcastUsageToClients: false};
|
||||
if (this._docUsage?.dataSizeBytes === undefined) {
|
||||
this._updateDataSize().catch(e => {
|
||||
this._log.warn(docSession, 'failed to update data size', e);
|
||||
});
|
||||
promises.push(this._updateDataSize(options));
|
||||
}
|
||||
|
||||
if (this._docUsage?.attachmentsSizeBytes === undefined) {
|
||||
this._updateAttachmentsSize().catch(e => {
|
||||
this._log.warn(docSession, 'failed to update attachments size', e);
|
||||
});
|
||||
promises.push(this._updateAttachmentsSize(options));
|
||||
}
|
||||
if (promises.length === 0) { return; }
|
||||
|
||||
try {
|
||||
await Promise.all(promises);
|
||||
this._syncDocUsageToDatabase();
|
||||
await this._broadcastDocUsageToClients();
|
||||
} catch (e) {
|
||||
this._log.warn(docSession, 'failed to initialize doc usage', e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2089,7 +2095,7 @@ export class ActiveDoc extends EventEmitter {
|
||||
* limits to be exceeded.
|
||||
*/
|
||||
private async _isUploadSizeBelowLimit(uploadSizeBytes: number): Promise<boolean> {
|
||||
const maxSize = this._productFeatures?.baseMaxAttachmentsBytesPerDocument;
|
||||
const maxSize = this._product?.features.baseMaxAttachmentsBytesPerDocument;
|
||||
if (!maxSize) { return true; }
|
||||
|
||||
let currentSize = this._docUsage?.attachmentsSizeBytes;
|
||||
@@ -2098,15 +2104,14 @@ export class ActiveDoc extends EventEmitter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the total attachments size in bytes and sets it in _docUsage. Schedules
|
||||
* a sync to the database, unless `options.syncUsageToDatabase` is set to false.
|
||||
* Calculates the total attachments size in bytes, sets it in _docUsage, and returns it.
|
||||
*
|
||||
* Returns the calculated attachments size.
|
||||
* Allows specifying `options` for toggling whether usage is synced to
|
||||
* the home database and/or broadcast to clients.
|
||||
*/
|
||||
private async _updateAttachmentsSize(options: {syncUsageToDatabase?: boolean} = {}): Promise<number> {
|
||||
const {syncUsageToDatabase = true} = options;
|
||||
private async _updateAttachmentsSize(options?: UpdateUsageOptions): Promise<number> {
|
||||
const attachmentsSizeBytes = await this.docStorage.getTotalAttachmentFileSizes();
|
||||
this._updateDocUsage({attachmentsSizeBytes}, {syncUsageToDatabase});
|
||||
await this._updateDocUsage({attachmentsSizeBytes}, options);
|
||||
return attachmentsSizeBytes;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user