Skip to content

Commit 81423b2

Browse files
authored
Adds transfers between stores to external attachments (#1358)
This PR follows on from #1320, and adds support for: - Moving attachments between stores - Basic configuration of external attachments using environment variables This allows a user to change the document's store, then transfer all of the attachments from their current store(s) to the new default. This includes transfers from internal (SQLite) storage to external storage, external to internal, and external to external (e.g MinIO to filesystem). This PR also introduces the concept of "labels", which are an admin-friendly way to refer to a store, and map 1-to-1 with store IDs. Labels don't need to be unique between instances, only within an instance. ### User-facing changes: - Adds API endpoints to: - Migrate all attachments from their current store to the store set for that document - Check on the status of transfers - Get and set the store for a document - Adds an environment variable for setting external attachments behaviour `GRIST_EXTERNAL_ATTACHMENTS_MODE` - `test` mode sets Grist to use a temporary folder in the filesystem. - `snapshots` mode sets Grist to use the external storage currently used for snapshots, to also be used for attachments. ### Internal changes: - Adds methods to AttachmentFileManager to facilitate transfers from one storage to another. - Exposes new methods on ActiveDoc for triggering transfers, retrieving transfer status and setting the store. - Refactors how DocStorage provides access to attachment details - Adds a way to retrieve attachment config from env vars, and use them to decide which stores will be available. - Adds a `snapshot` external storage provider, that uses an attachment-compatible external storage for attachments. All of the logic behind these changes should be documented in the source code with comments.
1 parent 2f5ec0d commit 81423b2

26 files changed

+1378
-208
lines changed

app/common/UserAPI.ts

+6
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
import {addCurrentOrgToPath, getGristConfig} from 'app/common/urlUtils';
2626
import { AxiosProgressEvent } from 'axios';
2727
import omitBy from 'lodash/omitBy';
28+
import {StringUnion} from 'app/common/StringUnion';
2829

2930

3031
export type {FullUser, UserProfile};
@@ -481,6 +482,11 @@ interface SqlResult extends TableRecordValuesWithoutIds {
481482
statement: string;
482483
}
483484

485+
export const DocAttachmentsLocation = StringUnion(
486+
"none", "internal", "mixed", "external"
487+
);
488+
export type DocAttachmentsLocation = typeof DocAttachmentsLocation.type;
489+
484490
/**
485491
* Collect endpoints related to the content of a single document that we've been thinking
486492
* of as the (restful) "Doc API". A few endpoints that could be here are not, for historical

app/plugin/DocApiTypes-ti.ts

+5
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,10 @@ export const SqlPost = t.iface([], {
9090
"timeout": t.opt("number"),
9191
});
9292

93+
export const SetAttachmentStorePost = t.iface([], {
94+
"type": t.union(t.lit("internal"), t.lit("external")),
95+
});
96+
9397
const exportedTypeSuite: t.ITypeSuite = {
9498
NewRecord,
9599
NewRecordWithStringId,
@@ -108,5 +112,6 @@ const exportedTypeSuite: t.ITypeSuite = {
108112
TablesPost,
109113
TablesPatch,
110114
SqlPost,
115+
SetAttachmentStorePost,
111116
};
112117
export default exportedTypeSuite;

app/plugin/DocApiTypes.ts

+4
Original file line numberDiff line numberDiff line change
@@ -120,3 +120,7 @@ export interface SqlPost {
120120
// other queued queries on same document, because of
121121
// limitations of API node-sqlite3 exposes.
122122
}
123+
124+
export interface SetAttachmentStorePost {
125+
type: "internal" | "external"
126+
}

app/server/generateInitialDocSql.ts

+7-5
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,13 @@ export async function main(baseName: string) {
3434
if (await fse.pathExists(fname)) {
3535
await fse.remove(fname);
3636
}
37-
const docManager = new DocManager(storageManager, pluginManager, null as any, new AttachmentStoreProvider([], ""), {
38-
create,
39-
getAuditLogger() { return createNullAuditLogger(); },
40-
getTelemetry() { return createDummyTelemetry(); },
41-
} as any);
37+
const docManager = new DocManager(storageManager, pluginManager, null as any,
38+
new AttachmentStoreProvider([], ""), {
39+
create,
40+
getAuditLogger() { return createNullAuditLogger(); },
41+
getTelemetry() { return createDummyTelemetry(); },
42+
} as any
43+
);
4244
const activeDoc = new ActiveDoc(docManager, baseName);
4345
const session = makeExceptionalDocSession('nascent');
4446
await activeDoc.createEmptyDocWithDataEngine(session);

app/server/lib/ActiveDoc.ts

+72-8
Original file line numberDiff line numberDiff line change
@@ -286,7 +286,7 @@ export class ActiveDoc extends EventEmitter {
286286
constructor(
287287
private readonly _docManager: DocManager,
288288
private _docName: string,
289-
externalAttachmentStoreProvider?: IAttachmentStoreProvider,
289+
private _attachmentStoreProvider?: IAttachmentStoreProvider,
290290
private _options?: ICreateActiveDocOptions
291291
) {
292292
super();
@@ -392,11 +392,11 @@ export class ActiveDoc extends EventEmitter {
392392
loadTable: this._rawPyCall.bind(this, 'load_table'),
393393
});
394394

395-
// This will throw errors if _options?.doc or externalAttachmentStoreProvider aren't provided,
395+
// This will throw errors if _options?.doc or _attachmentStoreProvider aren't provided,
396396
// and ActiveDoc tries to use an external attachment store.
397397
this._attachmentFileManager = new AttachmentFileManager(
398398
this.docStorage,
399-
externalAttachmentStoreProvider,
399+
_attachmentStoreProvider,
400400
_options?.doc,
401401
);
402402

@@ -871,8 +871,12 @@ export class ActiveDoc extends EventEmitter {
871871
}
872872
}
873873
);
874-
const userActions: UserAction[] = await Promise.all(
875-
upload.files.map(file => this._prepAttachment(docSession, file)));
874+
const userActions: UserAction[] = [];
875+
// Process uploads sequentially to reduce risk of race conditions.
876+
// Minimal performance impact due to the main async operation being serialized SQL queries.
877+
for (const file of upload.files) {
878+
userActions.push(await this._prepAttachment(docSession, file));
879+
}
876880
const result = await this._applyUserActionsWithExtendedOptions(docSession, userActions, {
877881
attachment: true,
878882
});
@@ -945,6 +949,57 @@ export class ActiveDoc extends EventEmitter {
945949
return data;
946950
}
947951

952+
@ActiveDoc.keepDocOpen
953+
public async startTransferringAllAttachmentsToDefaultStore() {
954+
const attachmentStoreId = (await this._getDocumentSettings()).attachmentStoreId;
955+
// If no attachment store is set on the doc, it should transfer everything to internal storage
956+
await this._attachmentFileManager.startTransferringAllFilesToOtherStore(attachmentStoreId);
957+
}
958+
959+
/**
960+
* Returns a summary of pending attachment transfers between attachment stores.
961+
*/
962+
public attachmentTransferStatus() {
963+
return this._attachmentFileManager.transferStatus();
964+
}
965+
966+
/**
967+
* Returns a summary of where attachments on this doc are stored.
968+
*/
969+
public async attachmentLocationSummary() {
970+
return await this._attachmentFileManager.locationSummary();
971+
}
972+
973+
/*
974+
* Wait for all attachment transfers to be finished, keeping the doc open
975+
* for as long as possible.
976+
*/
977+
@ActiveDoc.keepDocOpen
978+
public async allAttachmentTransfersCompleted() {
979+
await this._attachmentFileManager.allTransfersCompleted();
980+
}
981+
982+
983+
public async setAttachmentStore(docSession: OptDocSession, id: string | undefined): Promise<void> {
984+
const docSettings = await this._getDocumentSettings();
985+
docSettings.attachmentStoreId = id;
986+
await this._updateDocumentSettings(docSession, docSettings);
987+
}
988+
989+
/**
990+
* Sets the document attachment store using the store's label.
991+
* This avoids needing to know the exact store ID, which can be challenging to calculate in all
992+
* the places we might want to set the store.
993+
*/
994+
public async setAttachmentStoreFromLabel(docSession: OptDocSession, label: string | undefined): Promise<void> {
995+
const id = label === undefined ? undefined : this._attachmentStoreProvider?.getStoreIdFromLabel(label);
996+
return this.setAttachmentStore(docSession, id);
997+
}
998+
999+
public async getAttachmentStore(): Promise<string | undefined> {
1000+
return (await this._getDocumentSettings()).attachmentStoreId;
1001+
}
1002+
9481003
/**
9491004
* Fetches the meta tables to return to the client when first opening a document.
9501005
*/
@@ -2857,15 +2912,24 @@ export class ActiveDoc extends EventEmitter {
28572912
}
28582913

28592914
private async _getDocumentSettings(): Promise<DocumentSettings> {
2860-
const docInfo = await this.docStorage.get('SELECT documentSettings FROM _grist_DocInfo');
2861-
const docSettingsString = docInfo?.documentSettings;
2862-
const docSettings = docSettingsString ? safeJsonParse(docSettingsString, undefined) : undefined;
2915+
const docSettings = this.docData?.docSettings();
28632916
if (!docSettings) {
28642917
throw new Error("No document settings found");
28652918
}
28662919
return docSettings;
28672920
}
28682921

2922+
private async _updateDocumentSettings(docSessions: OptDocSession, settings: DocumentSettings): Promise<void> {
2923+
const docInfo = this.docData?.docInfo();
2924+
if (!docInfo) {
2925+
throw new Error("No document info found");
2926+
}
2927+
await this.applyUserActions(docSessions, [
2928+
// Use docInfo.id to avoid hard-coding a reference to a specific row id, in case it changes.
2929+
["UpdateRecord", "_grist_DocInfo", docInfo.id, { documentSettings: JSON.stringify(settings) }]
2930+
]);
2931+
}
2932+
28692933
private async _makeEngine(): Promise<ISandbox> {
28702934
// Figure out what kind of engine we need for this document.
28712935
let preferredPythonVersion: '2' | '3' = process.env.PYTHON_VERSION === '2' ? '2' : '3';

0 commit comments

Comments
 (0)