fix(server): sync files to disk

Ensure that all files are flushed after they've been written.

At current, files are not explicitly flushed to disk, which can cause
data corruption. In extreme circumstances, it's possible that uploaded
files may not ever be persisted at all.
This commit is contained in:
Thomas Way
2026-03-15 11:53:38 +00:00
parent 6c531e0a5a
commit 0ab057f453
3 changed files with 31 additions and 2 deletions

View File

@@ -10,6 +10,7 @@ import { UploadFieldName } from 'src/dtos/asset-media.dto';
import { RouteKey } from 'src/enum';
import { AuthRequest } from 'src/middleware/auth.guard';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { StorageRepository } from 'src/repositories/storage.repository';
import { AssetMediaService } from 'src/services/asset-media.service';
import { ImmichFile, UploadFile, UploadFiles } from 'src/types';
import { asUploadRequest, mapToUploadFile } from 'src/utils/asset.util';
@@ -54,6 +55,7 @@ export class FileUploadInterceptor implements NestInterceptor {
constructor(
private reflect: Reflector,
private assetService: AssetMediaService,
private storageRepository: StorageRepository,
private logger: LoggingRepository,
) {
this.logger.setContext(FileUploadInterceptor.name);
@@ -125,7 +127,18 @@ export class FileUploadInterceptor implements NestInterceptor {
});
if (!this.isAssetUploadFile(file)) {
this.defaultStorage._handleFile(request, file, callback);
this.defaultStorage._handleFile(request, file, (error, info) => {
if (error) {
return callback(error);
}
// Multer does not sync files to disk after writing.
//
// TODO: use `flush: true` in multer when available: https://github.com/expressjs/multer/issues/1381
this.storageRepository
.datasync(info!.path!)
.then(() => callback(null, info!))
.catch((error) => callback(error));
});
return;
}
@@ -136,7 +149,13 @@ export class FileUploadInterceptor implements NestInterceptor {
hash.destroy();
callback(error);
} else {
callback(null, { ...info, checksum: hash.digest() });
this.storageRepository
.datasync(info!.path!)
.then(() => callback(null, { ...info, checksum: hash.digest() }))
.catch((error) => {
hash.destroy();
callback(error);
});
}
});
}

View File

@@ -54,6 +54,15 @@ export class StorageRepository {
return fs.copyFile(source, target);
}
async datasync(filepath: string) {
const handle = await fs.open(filepath, 'r');
try {
await handle.datasync();
} finally {
await handle.close();
}
}
stat(filepath: string) {
return fs.stat(filepath);
}

View File

@@ -72,6 +72,7 @@ export const newStorageRepositoryMock = (): Mocked<RepositoryInterface<StorageRe
walk: vitest.fn().mockImplementation(async function* () {}),
rename: vitest.fn(),
copyFile: vitest.fn(),
datasync: vitest.fn(),
utimes: vitest.fn(),
watch: vitest.fn().mockImplementation(makeMockWatcher({})),
};