mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(core) add GVISOR_LIMIT_MEMORY to cap memory available in sandbox
Summary: This allows limiting the memory available to documents in the sandbox when gvisor is used. If memory limit is exceeded, we offer to open doc in recovery mode. Recovery mode is tweaked to open docs with tables in "ondemand" mode, which will generally take less memory and allow for deleting rows. The limit is on the size of the virtual address space available to the sandbox (`RLIMIT_AS`), which in practice appears to function as one would want, and is the only practical option. There is a documented `RLIMIT_RSS` limit to `specifies the limit (in bytes) of the process's resident set (the number of virtual pages resident in RAM)` but this is no longer enforced by the kernel (neither the host nor gvisor). When the sandbox runs out of memory, there are many ways it can fail. This diff catches all the ones I saw, but there could be more. Test Plan: added tests Reviewers: alexmojaki Reviewed By: alexmojaki Subscribers: alexmojaki Differential Revision: https://phab.getgrist.com/D3398
This commit is contained in:
@@ -584,7 +584,13 @@ export class ActiveDoc extends EventEmitter {
|
||||
this._startLoadingTables(docSession, desiredTableNames);
|
||||
const pendingTableNames = tableNames.filter(name => !name.startsWith('_grist_'));
|
||||
await this._initDoc(docSession);
|
||||
this._initializationPromise = this._finishInitialization(docSession, pendingTableNames, onDemandNames, startTime);
|
||||
this._initializationPromise = this._finishInitialization(docSession, pendingTableNames,
|
||||
onDemandNames, startTime).catch(async (err) => {
|
||||
await this.docClients.broadcastDocMessage(null, 'docError', {
|
||||
when: 'initialization',
|
||||
message: String(err),
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
await this.shutdown();
|
||||
throw err;
|
||||
@@ -613,7 +619,8 @@ export class ActiveDoc extends EventEmitter {
|
||||
public async _initDoc(docSession: OptDocSession): Promise<void> {
|
||||
const metaTableData = await this._tableMetadataLoader.fetchTablesAsActions();
|
||||
this.docData = new DocData(tableId => this.fetchTable(makeExceptionalDocSession('system'), tableId), metaTableData);
|
||||
this._onDemandActions = new OnDemandActions(this.docStorage, this.docData);
|
||||
this._onDemandActions = new OnDemandActions(this.docStorage, this.docData,
|
||||
this._recoveryMode);
|
||||
|
||||
await this._actionHistory.initialize();
|
||||
this._granularAccess = new GranularAccess(this.docData, this.docClients, (query) => {
|
||||
@@ -1579,7 +1586,8 @@ export class ActiveDoc extends EventEmitter {
|
||||
|
||||
// Figure out which tables are on-demand.
|
||||
const onDemandMap = zipObject(tablesParsed.tableId as string[], tablesParsed.onDemand);
|
||||
const onDemandNames = remove(tableNames, (t) => onDemandMap[t]);
|
||||
const onDemandNames = remove(tableNames, (t) => (onDemandMap[t] ||
|
||||
(this._recoveryMode && !t.startsWith('_grist_'))));
|
||||
|
||||
this._log.debug(docSession, "Loading %s normal tables, skipping %s on-demand tables",
|
||||
tableNames.length, onDemandNames.length);
|
||||
|
||||
@@ -495,6 +495,7 @@ export class DocWorkerApi {
|
||||
const recoveryModeRaw = req.body.recoveryMode;
|
||||
const recoveryMode = (typeof recoveryModeRaw === 'boolean') ? recoveryModeRaw : undefined;
|
||||
if (!await this._isOwner(req)) { throw new Error('Only owners can control recovery mode'); }
|
||||
this._docManager.setRecovery(getDocId(req), recoveryMode ?? true);
|
||||
const activeDoc = await this._docManager.fetchDoc(docSessionFromRequest(req), getDocId(req), recoveryMode);
|
||||
res.json({
|
||||
recoveryMode: activeDoc.recoveryMode
|
||||
|
||||
@@ -6,7 +6,7 @@ import {EventEmitter} from 'events';
|
||||
import * as path from 'path';
|
||||
|
||||
import {ApiError} from 'app/common/ApiError';
|
||||
import {mapSetOrClear} from 'app/common/AsyncCreate';
|
||||
import {mapSetOrClear, MapWithTTL} from 'app/common/AsyncCreate';
|
||||
import {BrowserSettings} from 'app/common/BrowserSettings';
|
||||
import {DocCreationInfo, DocEntry, DocListAPI, OpenDocMode, OpenLocalDocResult} from 'app/common/DocListAPI';
|
||||
import {FilteredDocUsageSummary} from 'app/common/DocUsage';
|
||||
@@ -37,6 +37,10 @@ import noop = require('lodash/noop');
|
||||
// but is a bit of a burden under heavy traffic.
|
||||
export const DEFAULT_CACHE_TTL = 10000;
|
||||
|
||||
// How long to remember that a document has been explicitly set in a
|
||||
// recovery mode.
|
||||
export const RECOVERY_CACHE_TTL = 30000;
|
||||
|
||||
/**
|
||||
* DocManager keeps track of "active" Grist documents, i.e. those loaded
|
||||
* in-memory, with clients connected to them.
|
||||
@@ -45,6 +49,8 @@ export class DocManager extends EventEmitter {
|
||||
// Maps docName to promise for ActiveDoc object. Most of the time the promise
|
||||
// will be long since resolved, with the resulting document cached.
|
||||
private _activeDocs: Map<string, Promise<ActiveDoc>> = new Map();
|
||||
// Remember recovery mode of documents.
|
||||
private _inRecovery = new MapWithTTL<string, boolean>(RECOVERY_CACHE_TTL);
|
||||
|
||||
constructor(
|
||||
public readonly storageManager: IDocStorageManager,
|
||||
@@ -55,6 +61,10 @@ export class DocManager extends EventEmitter {
|
||||
super();
|
||||
}
|
||||
|
||||
public setRecovery(docId: string, recovery: boolean) {
|
||||
this._inRecovery.set(docId, recovery);
|
||||
}
|
||||
|
||||
// attach a home database to the DocManager. During some tests, it
|
||||
// is awkward to have this set up at the point of construction.
|
||||
public testSetHomeDbManager(dbManager: HomeDBManager) {
|
||||
@@ -459,7 +469,7 @@ export class DocManager extends EventEmitter {
|
||||
if (!this._activeDocs.has(docName)) {
|
||||
activeDoc = await mapSetOrClear(
|
||||
this._activeDocs, docName,
|
||||
this._createActiveDoc(docSession, docName, wantRecoveryMode)
|
||||
this._createActiveDoc(docSession, docName, wantRecoveryMode ?? this._inRecovery.get(docName))
|
||||
.then(newDoc => {
|
||||
// Propagate backupMade events from newly opened activeDocs (consolidate all to DocMan)
|
||||
newDoc.on('backupMade', (bakPath: string) => {
|
||||
|
||||
@@ -1381,7 +1381,7 @@ export class GranularAccess implements GranularAccessForBundle {
|
||||
|
||||
// If aclAsUserId/aclAsUser is set, then override user for acl purposes.
|
||||
if (linkParameters.aclAsUserId || linkParameters.aclAsUser) {
|
||||
if (access !== 'owners') { throw new Error('only an owner can override user'); }
|
||||
if (access !== 'owners') { throw new ErrorWithCode('ACL_DENY', 'only an owner can override user'); }
|
||||
if (attrs.override) {
|
||||
// Used cached properties.
|
||||
access = attrs.override.access;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* JS controller for the pypy sandbox.
|
||||
*/
|
||||
import {arrayToString} from 'app/common/arrayToString';
|
||||
import * as marshal from 'app/common/marshal';
|
||||
import {ISandbox, ISandboxCreationOptions, ISandboxCreator} from 'app/server/lib/ISandbox';
|
||||
import * as log from 'app/server/lib/log';
|
||||
@@ -94,6 +95,7 @@ export class NSandbox implements ISandbox {
|
||||
private _logMeta: log.ILogMeta;
|
||||
private _streamToSandbox: Writable;
|
||||
private _streamFromSandbox: Stream;
|
||||
private _lastStderr: Uint8Array; // Record last error line seen.
|
||||
|
||||
// Create a unique subdirectory for each sandbox process so they can be replayed separately
|
||||
private _recordBuffersDir = recordBuffersRoot ? path.resolve(recordBuffersRoot, new Date().toISOString()) : null;
|
||||
@@ -131,7 +133,11 @@ export class NSandbox implements ISandbox {
|
||||
this._streamFromSandbox = (this.childProc.stdio as Stream[])[4];
|
||||
this.childProc.stdout.on('data', sandboxUtil.makeLinePrefixer('Sandbox stdout: ', this._logMeta));
|
||||
}
|
||||
this.childProc.stderr.on('data', sandboxUtil.makeLinePrefixer('Sandbox stderr: ', this._logMeta));
|
||||
const sandboxStderrLogger = sandboxUtil.makeLinePrefixer('Sandbox stderr: ', this._logMeta);
|
||||
this.childProc.stderr.on('data', data => {
|
||||
this._lastStderr = data;
|
||||
sandboxStderrLogger(data);
|
||||
});
|
||||
|
||||
this.childProc.on('close', this._onExit.bind(this));
|
||||
this.childProc.on('error', this._onError.bind(this));
|
||||
@@ -254,7 +260,7 @@ export class NSandbox implements ISandbox {
|
||||
*/
|
||||
private _sendData(msgCode: MsgCode, data: any) {
|
||||
if (this._isReadClosed) {
|
||||
throw new sandboxUtil.SandboxError("PipeToSandbox is closed");
|
||||
throw this._sandboxClosedError('PipeToSandbox');
|
||||
}
|
||||
this._marshaller.marshal(msgCode);
|
||||
this._marshaller.marshal(data);
|
||||
@@ -287,12 +293,24 @@ export class NSandbox implements ISandbox {
|
||||
this._control.prepareToClose();
|
||||
this._isReadClosed = true;
|
||||
// Clear out all reads pending on PipeFromSandbox, rejecting them with the given error.
|
||||
const err = new sandboxUtil.SandboxError("PipeFromSandbox is closed");
|
||||
const err = this._sandboxClosedError('PipeFromSandbox');
|
||||
|
||||
this._pendingReads.forEach(resolvePair => resolvePair[1](err));
|
||||
this._pendingReads = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an error message for a pipe to the sandbox. Include the
|
||||
* last stderr line seen from the sandbox - more reliable than
|
||||
* error results send via the standard protocol.
|
||||
*/
|
||||
private _sandboxClosedError(label: string) {
|
||||
const parts = [`${label} is closed`];
|
||||
if (this._lastStderr) {
|
||||
parts.push(arrayToString(this._lastStderr));
|
||||
}
|
||||
return new sandboxUtil.SandboxError(parts.join(': '));
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a parsed message from the sandboxed process.
|
||||
|
||||
@@ -25,11 +25,13 @@ export class OnDemandActions {
|
||||
private _tablesMeta: TableData = this._docData.getMetaTable('_grist_Tables');
|
||||
private _columnsMeta: TableData = this._docData.getMetaTable('_grist_Tables_column');
|
||||
|
||||
constructor(private _storage: OnDemandStorage, private _docData: DocData) {}
|
||||
constructor(private _storage: OnDemandStorage, private _docData: DocData,
|
||||
private _forceOnDemand: boolean = false) {}
|
||||
|
||||
// TODO: Ideally a faster data structure like an index by tableId would be used to decide whether
|
||||
// the table is onDemand.
|
||||
public isOnDemand(tableId: string): boolean {
|
||||
if (this._forceOnDemand) { return true; }
|
||||
const tableRef = this._tablesMeta.findRow('tableId', tableId);
|
||||
// OnDemand tables must have a record in the _grist_Tables metadata table.
|
||||
return tableRef ? Boolean(this._tablesMeta.getValue(tableRef, 'onDemand')) : false;
|
||||
|
||||
Reference in New Issue
Block a user