Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adds transfers between stores to external attachments #1358

Draft
wants to merge 37 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
8342a4c
Adds "delete" operator to attachment stores
Spoffy Dec 17, 2024
a0cc24c
Enables updating of file info
Spoffy Dec 18, 2024
b62bab8
Incremental refactoring towards transfers
Spoffy Dec 30, 2024
76db9e5
Adds functioning transfers of individual files
Spoffy Dec 30, 2024
8b23319
Adds test for attachment file corruption on transfer
Spoffy Dec 30, 2024
71f1314
Adds additional check to file transfer
Spoffy Dec 31, 2024
a9d36d3
Adds attachment transfer queue and job
Spoffy Dec 31, 2024
382847d
Adds test for all files being transferred
Spoffy Dec 31, 2024
011e5d1
Adds test for recent transfers taking priority
Spoffy Dec 31, 2024
c53c016
Adds test for recent transfer requests taking precedence
Spoffy Jan 2, 2025
7e34d8f
Updates an existing all files transfer if possible
Spoffy Jan 3, 2025
2addc2f
Adds location summary function and misc behaviour tweaks
Spoffy Jan 4, 2025
6de387c
Cleans up transfer logic and adds doc endpoint ID.
Spoffy Jan 4, 2025
7c0ee63
Fixes test compilation problem
Spoffy Jan 6, 2025
a44944a
Removes TODO from _performPendingTransfers
Spoffy Jan 6, 2025
d10ba26
Makes transfer code more understandable
Spoffy Jan 7, 2025
cbaf592
Fixes an inconsistent import
Spoffy Jan 7, 2025
1ffeb45
Fixes a linter issue
Spoffy Jan 8, 2025
ca0ee2d
Improves several checks
Spoffy Jan 14, 2025
d721c79
Splits findOrAttach file into 2 functions
Spoffy Jan 14, 2025
7153b44
Fixes issue with transfer job not type safety
Spoffy Jan 14, 2025
e1c9a8d
Renames "findOrAttachFile" to "attachFileIfNew"
Spoffy Jan 14, 2025
586d5a4
Switches "UNIQUE" constraint check to a "SELECT"
Spoffy Jan 14, 2025
220e6f9
Splits getFileInfo into two functions
Spoffy Jan 14, 2025
83d984e
Splits AttachmentFileManager._addFile into two functions
Spoffy Jan 14, 2025
fdc3fa4
Rephrases a comment
Spoffy Jan 14, 2025
2dc01d0
Fixes a failing AttachmentFileManager test
Spoffy Jan 14, 2025
34ac344
Revert "Switches "UNIQUE" constraint check to a "SELECT""
Spoffy Jan 15, 2025
337c128
Adds a comment explaining why UNIQUE constraint is caught.
Spoffy Jan 15, 2025
4bba04e
Fixes bad record insert
Spoffy Jan 18, 2025
5008b27
Serializes attachment prep
Spoffy Jan 18, 2025
c553390
Moves interfaces to bottom of AttachmentFileManager
Spoffy Jan 18, 2025
981edbb
Simplifies AttachmentFileManager transferJob logic
Spoffy Jan 19, 2025
761d351
Simplifies waiting for transfers to finish
Spoffy Jan 19, 2025
4b733b3
Fixes transferStatus being uncallable, adds locationSummary
Spoffy Jan 20, 2025
5fde08f
Minor cleaning in AttachmentFileManager
Spoffy Jan 20, 2025
30d606e
Merge branch 'main' into spoffy/external-attachments-transfers
Spoffy Jan 20, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions app/server/lib/ActiveDoc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -943,6 +943,26 @@ export class ActiveDoc extends EventEmitter {
return data;
}

public async startTransferringAllAttachmentsToDefaultStore() {
Spoffy marked this conversation as resolved.
Show resolved Hide resolved
const attachmentStoreId = (await this._getDocumentSettings()).attachmentStoreId;
// If no attachment store is set on the doc, it should transfer everything to internal storage
await this._attachmentFileManager.startTransferringAllFilesToOtherStore(attachmentStoreId);
}

/**
* Returns a summary of pending attachment transfers between attachment stores.
*/
public attachmentTransferStatus() {
return this._attachmentFileManager.transferStatus();
}

/**
* Returns a summary of where attachments on this doc are stored.
*/
public async attachmentLocationSummary() {
return await this._attachmentFileManager.locationSummary();
}

/**
* Fetches the meta tables to return to the client when first opening a document.
*/
Expand Down
315 changes: 271 additions & 44 deletions app/server/lib/AttachmentFileManager.ts

Large diffs are not rendered by default.

11 changes: 11 additions & 0 deletions app/server/lib/AttachmentStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ export interface IAttachmentStore {
// implementation and gives them control over local buffering.
download(docPoolId: DocPoolId, fileId: FileId, outputStream: stream.Writable): Promise<void>;

// Remove attachment from the store
delete(docPoolId: DocPoolId, fileId: FileId): Promise<void>;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't actually used anywhere. I've left it in for now (since it's already implemented), but we could remove it?


// Remove attachments for all documents in the given document pool.
removePool(docPoolId: DocPoolId): Promise<void>;

Expand Down Expand Up @@ -120,6 +123,10 @@ export class ExternalStorageAttachmentStore implements IAttachmentStore {
await this._storage.downloadStream(this._getKey(docPoolId, fileId), outputStream);
}

public async delete(docPoolId: string, fileId: string): Promise<void> {
await this._storage.remove(this._getKey(docPoolId, fileId));
}

public async removePool(docPoolId: string): Promise<void> {
// Null assertion is safe because this should be checked before this class is instantiated.
await this._storage.removeAllWithPrefix!(this._getPoolPrefix(docPoolId));
Expand Down Expand Up @@ -164,6 +171,10 @@ export class FilesystemAttachmentStore implements IAttachmentStore {
);
}

public async delete(docPoolId: string, fileId: string): Promise<void> {
await fse.remove(this._createPath(docPoolId, fileId));
}

public async removePool(docPoolId: DocPoolId): Promise<void> {
await fse.remove(this._createPath(docPoolId));
}
Expand Down
19 changes: 19 additions & 0 deletions app/server/lib/DocApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -548,6 +548,25 @@ export class DocWorkerApi {
.send(fileData);
}));

// Starts transferring all attachments to the named store, if it exists.
this._app.post('/api/docs/:docId/attachments/transferAll', isOwner, withDoc(async (activeDoc, req, res) => {
await activeDoc.startTransferringAllAttachmentsToDefaultStore();
const locationSummary = await activeDoc.attachmentLocationSummary();

// Respond with the current status to allow for immediate UI updates.
res.json({
status: activeDoc.attachmentTransferStatus(),
locationSummary,
});
}));

// Returns the status of any current / pending attachment transfers
this._app.get('/api/docs/:docId/attachments/transferStatus', isOwner, withDoc(async (activeDoc, req, res) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Take a look at the endpoint registered above (line 521), it will be matched first and this handler won't be executed.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, nicely spotted, I made the mistake of assuming it was done by specificity. Thanks 🙂

res.json({
status: activeDoc.attachmentTransferStatus(),
});
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about sticking in locationSummary for consistency with /transferAll?

}));

// Mostly for testing
this._app.post('/api/docs/:docId/attachments/updateUsed', canEdit, withDoc(async (activeDoc, req, res) => {
await activeDoc.updateUsedAttachmentsIfNeeded();
Expand Down
55 changes: 37 additions & 18 deletions app/server/lib/DocStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ import cloneDeep = require('lodash/cloneDeep');
import groupBy = require('lodash/groupBy');
import { MinDBOptions } from './SqliteCommon';


// Run with environment variable NODE_DEBUG=db (may include additional comma-separated sections)
// for verbose logging.
const debuglog = util.debuglog('db');
Expand Down Expand Up @@ -780,44 +779,64 @@ export class DocStorage implements ISQLiteDB, OnDemandStorage {
* checksum of the file's contents with the original extension.
* @param {Buffer | undefined} fileData - Contents of the file.
* @param {string | undefined} storageId - Identifier of the store that file is stored in.
* @param {boolean} shouldUpdate - Update the file record if found.
* @returns {Promise[Boolean]} True if the file got attached; false if this ident already exists.
*/
public findOrAttachFile(
fileIdent: string,
fileData: Buffer | undefined,
storageId?: string,
shouldUpdate: boolean = false,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am usually not very comfortable with boolean flag params, due to their lack of expressivity.

But am I right to say the shouldUpdate may be the default? I only see the flag is kept to false only in tests.

In any case, I would suggest for adding more expressivity the following, if that makes sense to you too:

  • make this function private;
  • introducing 2 new functions named updateOrAttachFile (where you call the private function with shouldUpdate = true) and attachFileIfNew (with shouldUpdate = false);
  • maybe (or maybe not, that's arguable if this is now private) change shouldUpdate: boolean into { shouldUpdate = false }: {shouldUpdate: boolean } = {} or similar;

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting thoughts here! I thought a lot about how to split this - I don't really like the flag either, but ended up in the "tangled implementation" case of that article you linked! 🙂

I'd be happy to do it the way you suggest - make the flagged method private, and expose it as two different public methods. It absolutely feels easier to read.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I split this up fully, and separated out the implementation in a way that I think it makes it easier to read. 🙂

Let me know if that works for you!

): Promise<boolean> {
return this.execTransaction(db => {
// Try to insert a new record with the given ident. It'll fail UNIQUE constraint if exists.
return db.run('INSERT INTO _gristsys_Files (ident) VALUES (?)', fileIdent)
// Only if this succeeded, do the work of reading the file and inserting its data.
.then(() =>
db.run('UPDATE _gristsys_Files SET data=?, storageId=? WHERE ident=?', fileData, storageId, fileIdent))
.then(() => true)
// If UNIQUE constraint failed, this ident must already exists, so return false.
.catch(err => {
if (/^(SQLITE_CONSTRAINT: )?UNIQUE constraint failed/.test(err.message)) {
return false;
}
return this.execTransaction(async (db) => {
let isNewFile = true;

try {
// Try to insert a new record with the given ident. It'll fail UNIQUE constraint if exists.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment that used to be line 793 gave some reason that is still useful, I think. So I would add some more context:

Suggested change
// Try to insert a new record with the given ident. It'll fail UNIQUE constraint if exists.
// Try to insert a new record with the given ident. It'll fail UNIQUE constraint if exists.
// Even when attempting to attach a new file exclusively (and do nothing when it exists),
// it's a good idea to first check the existence of the fileIdent and if not insert the file and its data

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if this is still relevant after the other changes made to this function? :)

await db.run('INSERT INTO _gristsys_Files (ident) VALUES (?)', fileIdent);
} catch(err) {
// If UNIQUE constraint failed, this ident must already exist.
if (/^(SQLITE_CONSTRAINT: )?UNIQUE constraint failed/.test(err.message)) {
isNewFile = false;
} else {
throw err;
});
}
}
if (isNewFile || shouldUpdate) {
await db.run('UPDATE _gristsys_Files SET data=?, storageId=? WHERE ident=?', fileData, storageId, fileIdent);
}

return isNewFile;
Copy link
Collaborator

@fflorent fflorent Jan 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code is acceptable as it is for me, so please skip if you don't agree much about my idea.

I feel like, even it introduces a supplementary SQL query to maintain, we would increase the readability by separating the query to check the existence of the file and do the INSERT/UPDATE. Am I right to say the below code would be equivalent?

const exists = await db.run('SELECT 1 from _gristsys_Files where ident = ?', fileIdent); // Not very sure what is returned here
if (exists && shouldUpdate) {
  await db.run('UPDATE _gristsys_Files SET data=?, storageId=? WHERE ident=?', fileData, storageId, fileIdent);
} else {
  await db.run('INSERT INTO _gristsys_Files(ident, data, storageId) VALUES (?, ?, ?)', fileIdent, fileData, storageId);
}
return exists;

Copy link
Contributor Author

@Spoffy Spoffy Jan 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These look equivalent to me. I'm not sure why it was originally done by catching the unique constraint, but it looks like the only place in the code where that's done.

I'll make this change, and we'll see if @paulfitz or anyone has any opinion :)

Edit: Change is pushed

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! If there exist doubts, feel free to rollback

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We hit an error in one of our test cases due to this change, full writeup is below.

Long story short: The way we handle transactions means we can hit a race condition in this example, and catching the "UNIQUE" constraint violation is the simplest way of avoiding that.

I'll change this back, and add a comment explaining why we handle it this way.


Full explanation:

The SQLiteDB class' function execTransaction is built to merge all calls into a single transaction, rather than run a transaction per-call (which is how I believed it worked).

When several atttachments are being inserted simultaneously in one API call, and because Node's async behaviour is unpredictable, the individual SQL statements are effectively re-ordered at random due to multiple promises running in parallel. This means that even if the SELECT statement says "No records exist", they might be inserted before the "INSERT" actually happens, resulting in a UNIQUE constraint violation.

If we're adding two files at the same time, the resulting order of statements ended up going something like this:
SELECT 1 as fileExists FROM _gristsys_files WHERE ident = ?
SELECT 1 as fileExists FROM _gristsys_files WHERE ident = ?
INSERT INTO main._gristsys_Files (ident, data, storageId) VALUES (?, ?, ?)
INSERT INTO main._gristsys_Files (ident, data, storageId) VALUES (?, ?, ?) // Unique constraint error thrown here.

If they hypothetically had been separate transactions on separate DB connections, the second INSERT would have failed with a "database is locked" error instead - so that's actually no better here.

Hence, catching the UNIQUE constraint error is the cleanest way to avoid this.

});
}

/**
* Reads and returns the data for the given attachment.
* @param {string} fileIdent - The unique identifier of a file, as used by findOrAttachFile.
* @returns {Promise[Buffer]} The data buffer associated with fileIdent.
* @param {boolean} includeData - Load file contents from the database, in addition to metadata
* @returns {Promise[FileInfo | null]} - File information, or null if no record exists for that file identifier.
*/
public getFileInfo(fileIdent: string): Promise<FileInfo | null> {
return this.get('SELECT ident, storageId, data FROM _gristsys_Files WHERE ident=?', fileIdent)
public getFileInfo(fileIdent: string, includeData: boolean = true): Promise<FileInfo | null> {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What would you think of the following to avoid the boolean flag param:

  public getFileInfo(fileIdent: string): Promise<FileInfo | null> {
    return this._getFileInfo(fileIdent, 'ident, storageId');
  }
  public getFileInfoWithData(fileIdent: string): Promise<FileInfo | null> {
    return this._getFileInfo(fileIdent, 'ident, storageId, data');
  }
  // ...
  private _getFileInfo(fileIdent: string, columns: string): Promise<FileInfo | null> {
    return this.get(`SELECT ${columns} FROM _gristsys_Files WHERE ident=?`, fileIdent)
      .... // just like what you did
   }

Copy link
Contributor Author

@Spoffy Spoffy Jan 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think splitting them up is a good idea - it definitely makes it more readable.

I think I'd rather duplicate the original function, than pass columns as a string.

That's because there's no risk of bad data accidentally being loaded into the code that formats the result into a typed object (e.g, a missing column).

Additionally, because it lowers the risk of someone doing something silly (such as passing a user-provided column name) in the future.

Copy link
Contributor Author

@Spoffy Spoffy Jan 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is done, please let me know if the refactored version is clearer :)

const columns = includeData ? 'ident, storageId, data' : 'ident, storageId';
return this.get(`SELECT ${columns} FROM _gristsys_Files WHERE ident=?`, fileIdent)
.then(row => row ? ({
ident: row.ident as string,
storageId: (row.storageId ?? null) as (string | null),
data: row.data as Buffer,
// Use a zero buffer for now if it doesn't exist. Should be refactored to allow null.
data: row.data ? row.data as Buffer : Buffer.alloc(0),
}) : null);
}

public async listAllFiles(): Promise<FileInfo[]> {
const rows = await this.all(`SELECT ident, storageId FROM _gristsys_Files`);

return rows.map(row => ({
ident: row.ident as string,
storageId: (row.storageId ?? null) as (string | null),
// Use a zero buffer for now to represent no data. Should be refactored to allow null.
data: Buffer.alloc(0),
}));
}

/**
* Fetches the given table from the database. See fetchQuery() for return value.
Expand Down
161 changes: 155 additions & 6 deletions test/server/lib/AttachmentFileManager.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
import { DocStorage, FileInfo } from "app/server/lib/DocStorage";
import {
AttachmentFileManager, AttachmentRetrievalError,
AttachmentFileManager,
AttachmentRetrievalError,
StoreNotAvailableError,
StoresNotConfiguredError
} from "app/server/lib/AttachmentFileManager";
import { getDocPoolIdFromDocInfo } from "app/server/lib/AttachmentStore";
import { AttachmentStoreProvider, IAttachmentStoreProvider } from "app/server/lib/AttachmentStoreProvider";
import { makeTestingFilesystemStoreSpec } from "./FilesystemAttachmentStore";
import { assert } from "chai";
import * as sinon from "sinon";
import * as stream from "node:stream";

// Minimum features of doc storage that are needed to make AttachmentFileManager work.
type IMinimalDocStorage = Pick<DocStorage, 'docName' | 'getFileInfo' | 'findOrAttachFile'>
type IMinimalDocStorage = Pick<DocStorage, 'docName' | 'getFileInfo' | 'findOrAttachFile' | 'listAllFiles'>

// Implements the minimal functionality needed for the AttachmentFileManager to work.
class DocStorageFake implements IMinimalDocStorage {
Expand All @@ -19,15 +22,24 @@ class DocStorageFake implements IMinimalDocStorage {
constructor(public docName: string) {
}

public async listAllFiles(): Promise<FileInfo[]> {
const fileInfoPromises = Object.keys(this._files).map(key => this.getFileInfo(key));
const fileInfo = await Promise.all(fileInfoPromises);

const isFileInfo = (item: FileInfo | null): item is FileInfo => item !== null;

return fileInfo.filter(isFileInfo);
}

public async getFileInfo(fileIdent: string): Promise<FileInfo | null> {
return this._files[fileIdent] ?? null;
}

// Return value is true if the file was newly added.
// Needs to match the semantics of DocStorage's implementation.
public async findOrAttachFile(
fileIdent: string, fileData: Buffer | undefined, storageId?: string | undefined
fileIdent: string, fileData: Buffer | undefined, storageId?: string | undefined, shouldUpdate: boolean = true
): Promise<boolean> {
if (fileIdent in this._files) {
if (fileIdent in this._files && !shouldUpdate) {
return false;
}
this._files[fileIdent] = {
Expand All @@ -48,7 +60,10 @@ function createDocStorageFake(docName: string): DocStorage {

async function createFakeAttachmentStoreProvider(): Promise<IAttachmentStoreProvider> {
return new AttachmentStoreProvider(
[await makeTestingFilesystemStoreSpec("filesystem")],
[
await makeTestingFilesystemStoreSpec("filesystem"),
await makeTestingFilesystemStoreSpec("filesystem-alt"),
],
"TEST-INSTALLATION-UUID"
);
}
Expand Down Expand Up @@ -126,6 +141,28 @@ describe("AttachmentFileManager", function() {
assert.isTrue(await store!.exists(docId, result.fileIdent), "file does not exist in store");
});

it("shouldn't do anything when trying to add an existing attachment to a new store", async function() {
const docId = "12345";
const manager = new AttachmentFileManager(
defaultDocStorageFake,
defaultProvider,
{ id: docId, trunkId: null },
);

const allStoreIds = defaultProvider.listAllStoreIds();
const result1 = await manager.addFile(allStoreIds[0], ".txt", Buffer.from(defaultTestFileContent));
const store1 = await defaultProvider.getStore(allStoreIds[0]);
assert.isTrue(await store1!.exists(docId, result1.fileIdent), "file does not exist in store");

const result2 = await manager.addFile(allStoreIds[1], ".txt", Buffer.from(defaultTestFileContent));
const store2 = await defaultProvider.getStore(allStoreIds[1]);
// File shouldn't exist in the new store
assert.isFalse(await store2!.exists(docId, result2.fileIdent));

const fileInfo = await defaultDocStorageFake.getFileInfo(result2.fileIdent);
assert.equal(fileInfo?.storageId, allStoreIds[0], "file record should not refer to the new store");
});

it("should get a file from local storage", async function() {
const docId = "12345";
const manager = new AttachmentFileManager(
Expand Down Expand Up @@ -210,4 +247,116 @@ describe("AttachmentFileManager", function() {
assert(fileData);
assert.equal(fileData.toString(), defaultTestFileContent);
});

async function testStoreTransfer(sourceStore?: string, destStore?: string) {
const docInfo = { id: "12345", trunkId: null };
const manager = new AttachmentFileManager(
defaultDocStorageFake,
defaultProvider,
docInfo,
);

const fileAddResult = await manager.addFile(sourceStore, ".txt", Buffer.from(defaultTestFileContent));
manager.startTransferringFileToOtherStore(fileAddResult.fileIdent, destStore);

await manager.allTransfersCompleted();

if (!destStore) {
await defaultDocStorageFake.getFileInfo(fileAddResult.fileIdent);
assert.equal(
(await defaultDocStorageFake.getFileInfo(fileAddResult.fileIdent))?.data?.toString(),
defaultTestFileContent,
);
return;
}

const store = (await defaultProvider.getStore(destStore))!;

assert(
await store.exists(getDocPoolIdFromDocInfo(docInfo), fileAddResult.fileIdent),
"file does not exist in new store"
);
}

it("can transfer a file from internal to external storage", async function() {
await testStoreTransfer(undefined, defaultProvider.listAllStoreIds()[0]);
});

it("can transfer a file from external to internal storage", async function() {
await testStoreTransfer(defaultProvider.listAllStoreIds()[0], undefined);
});

it("can transfer a file from external to a different external storage", async function() {
await testStoreTransfer(defaultProvider.listAllStoreIds()[0], defaultProvider.listAllStoreIds()[1]);
});

it("throws an error if the downloaded file is corrupted", async function() {
const docInfo = { id: "12345", trunkId: null };
const manager = new AttachmentFileManager(
defaultDocStorageFake,
defaultProvider,
docInfo,
);

const sourceStoreId = defaultProvider.listAllStoreIds()[0];
const fileAddResult = await manager.addFile(sourceStoreId, ".txt", Buffer.from(defaultTestFileContent));

const sourceStore = await defaultProvider.getStore(defaultProvider.listAllStoreIds()[0]);
const badData = stream.Readable.from(Buffer.from("I am corrupted"));
await sourceStore?.upload(getDocPoolIdFromDocInfo(docInfo), fileAddResult.fileIdent, badData);

const transferPromise =
manager.transferFileToOtherStore(fileAddResult.fileIdent, defaultProvider.listAllStoreIds()[1]);
await assert.isRejected(transferPromise, AttachmentRetrievalError, "checksum verification failed");
});

it("transfers all files in the background", async function() {
const docInfo = { id: "12345", trunkId: null };
const manager = new AttachmentFileManager(
defaultDocStorageFake,
defaultProvider,
docInfo,
);

const allStoreIds = defaultProvider.listAllStoreIds();
const sourceStoreId = allStoreIds[0];
const fileAddResult1 = await manager.addFile(sourceStoreId, ".txt", Buffer.from("A"));
const fileAddResult2 = await manager.addFile(sourceStoreId, ".txt", Buffer.from("B"));
const fileAddResult3 = await manager.addFile(sourceStoreId, ".txt", Buffer.from("C"));

await manager.startTransferringAllFilesToOtherStore(allStoreIds[1]);
assert.isFalse(manager.allTransfersCompleted());
await manager.allTransfersCompleted();
assert.isTrue(manager.allTransfersCompleted());


const destStore = (await defaultProvider.getStore(allStoreIds[1]))!;
const poolId = getDocPoolIdFromDocInfo(docInfo);
assert.isTrue(await destStore.exists(poolId, fileAddResult1.fileIdent));
assert.isTrue(await destStore.exists(poolId, fileAddResult2.fileIdent));
assert.isTrue(await destStore.exists(poolId, fileAddResult3.fileIdent));
});

it("uses the most recent transfer destination", async function() {
const docInfo = { id: "12345", trunkId: null };
const manager = new AttachmentFileManager(
defaultDocStorageFake,
defaultProvider,
docInfo,
);

const allStoreIds = defaultProvider.listAllStoreIds();
const fileAddResult1 = await manager.addFile(allStoreIds[0], ".txt", Buffer.from("A"));

manager.startTransferringFileToOtherStore(fileAddResult1.fileIdent, allStoreIds[1]);
manager.startTransferringFileToOtherStore(fileAddResult1.fileIdent, allStoreIds[0]);
manager.startTransferringFileToOtherStore(fileAddResult1.fileIdent, allStoreIds[1]);
manager.startTransferringFileToOtherStore(fileAddResult1.fileIdent, allStoreIds[0]);
await manager.allTransfersCompleted();

const fileInfo = await defaultDocStorageFake.getFileInfo(fileAddResult1.fileIdent);
assert.equal(fileInfo?.storageId, allStoreIds[0], "the file should be in the original store");
// We can't assert on if the files exists in the store, as it might be transferred from A to B and back to A,
// and so exist in both stores.
});
});
Loading
Loading