feat(server): compress database backups with pigz when it is installed (#32806)
(cherry picked from commit 97af09ca3cbd7f08ea033f176e777c9a9de8fdb5)
This commit is contained in:
@@ -27,6 +27,16 @@ describe(ProcessRepository.name, () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('isCommandAvailable', () => {
|
||||||
|
it('should find an executable on the PATH', async () => {
|
||||||
|
await expect(sut.isCommandAvailable('bash')).resolves.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not find a missing executable', async () => {
|
||||||
|
await expect(sut.isCommandAvailable('immich-missing-command')).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('createSpawnDuplexStream', () => {
|
describe('createSpawnDuplexStream', () => {
|
||||||
it('should work (drain to stdout)', async () => {
|
it('should work (drain to stdout)', async () => {
|
||||||
const process = sut.spawnDuplexStream('bash', ['-c', 'exit 0']);
|
const process = sut.spawnDuplexStream('bash', ['-c', 'exit 0']);
|
||||||
|
|||||||
@@ -1,10 +1,27 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { SpawnOptionsWithoutStdio, fork, spawn } from 'node:child_process';
|
import { SpawnOptionsWithoutStdio, fork, spawn } from 'node:child_process';
|
||||||
|
import { constants } from 'node:fs';
|
||||||
|
import { access } from 'node:fs/promises';
|
||||||
|
import { availableParallelism } from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
import { Duplex } from 'node:stream';
|
import { Duplex } from 'node:stream';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ProcessRepository {
|
export class ProcessRepository {
|
||||||
spawn = spawn;
|
spawn = spawn;
|
||||||
|
availableParallelism = availableParallelism;
|
||||||
|
|
||||||
|
async isCommandAvailable(command: string): Promise<boolean> {
|
||||||
|
const directories = (process.env.PATH ?? '').split(path.delimiter).filter(Boolean);
|
||||||
|
const found = await Promise.all(
|
||||||
|
directories.map((directory) =>
|
||||||
|
access(path.join(directory, command), constants.X_OK)
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return found.includes(true);
|
||||||
|
}
|
||||||
|
|
||||||
spawnDuplexStream(command: string, args?: readonly string[], options?: SpawnOptionsWithoutStdio): Duplex {
|
spawnDuplexStream(command: string, args?: readonly string[], options?: SpawnOptionsWithoutStdio): Duplex {
|
||||||
let isStdinClosed = false;
|
let isStdinClosed = false;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ describe(DatabaseBackupService.name, () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mocks = getMocks();
|
mocks = getMocks();
|
||||||
|
mocks.process.isCommandAvailable.mockResolvedValue(false);
|
||||||
maintenanceHealthRepositoryMock = automock(MaintenanceHealthRepository, {
|
maintenanceHealthRepositoryMock = automock(MaintenanceHealthRepository, {
|
||||||
args: [mocks.logger],
|
args: [mocks.logger],
|
||||||
strict: false,
|
strict: false,
|
||||||
@@ -228,6 +229,19 @@ describe(DatabaseBackupService.name, () => {
|
|||||||
expect(mocks.storage.rename).toHaveBeenCalled();
|
expect(mocks.storage.rename).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should compress with gzip when pigz is not installed', async () => {
|
||||||
|
await sut.handleBackupDatabase();
|
||||||
|
expect(mocks.process.isCommandAvailable).toHaveBeenCalledWith('pigz');
|
||||||
|
expect(mocks.process.spawnDuplexStream).toHaveBeenLastCalledWith('gzip', ['--rsyncable']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should compress with pigz on every available core when it is installed', async () => {
|
||||||
|
mocks.process.isCommandAvailable.mockResolvedValue(true);
|
||||||
|
mocks.process.availableParallelism.mockReturnValue(8);
|
||||||
|
await sut.handleBackupDatabase();
|
||||||
|
expect(mocks.process.spawnDuplexStream).toHaveBeenLastCalledWith('pigz', ['--rsyncable', '--processes', '8']);
|
||||||
|
});
|
||||||
|
|
||||||
it('should fail if pg_dump fails', async () => {
|
it('should fail if pg_dump fails', async () => {
|
||||||
mocks.process.spawnDuplexStream.mockReturnValueOnce(mockDuplex()('pg_dump', 1, '', 'error'));
|
mocks.process.spawnDuplexStream.mockReturnValueOnce(mockDuplex()('pg_dump', 1, '', 'error'));
|
||||||
await expect(sut.handleBackupDatabase()).rejects.toThrow('pg_dump non-zero exit code (1)');
|
await expect(sut.handleBackupDatabase()).rejects.toThrow('pg_dump non-zero exit code (1)');
|
||||||
|
|||||||
@@ -236,6 +236,8 @@ export class DatabaseBackupService {
|
|||||||
const backupFilePath = path.join(StorageCore.getBaseFolder(StorageFolder.Backups), filename);
|
const backupFilePath = path.join(StorageCore.getBaseFolder(StorageFolder.Backups), filename);
|
||||||
const temporaryFilePath = `${backupFilePath}.tmp`;
|
const temporaryFilePath = `${backupFilePath}.tmp`;
|
||||||
|
|
||||||
|
const compression = await this.getCompressionCommand();
|
||||||
|
|
||||||
let pgdump: Duplex | undefined;
|
let pgdump: Duplex | undefined;
|
||||||
let gzip: Duplex | undefined;
|
let gzip: Duplex | undefined;
|
||||||
|
|
||||||
@@ -247,7 +249,7 @@ export class DatabaseBackupService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
gzip = this.processRepository.spawnDuplexStream('gzip', ['--rsyncable']);
|
gzip = this.processRepository.spawnDuplexStream(compression.command, compression.args);
|
||||||
const fileStream = this.storageRepository.createWriteStream(temporaryFilePath);
|
const fileStream = this.storageRepository.createWriteStream(temporaryFilePath);
|
||||||
|
|
||||||
await pipeline(pgdump, gzip, fileStream);
|
await pipeline(pgdump, gzip, fileStream);
|
||||||
@@ -267,6 +269,15 @@ export class DatabaseBackupService {
|
|||||||
return backupFilePath;
|
return backupFilePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getCompressionCommand() {
|
||||||
|
if (await this.processRepository.isCommandAvailable('pigz')) {
|
||||||
|
const processes = this.processRepository.availableParallelism();
|
||||||
|
return { command: 'pigz', args: ['--rsyncable', '--processes', processes.toString()] };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { command: 'gzip', args: ['--rsyncable'] };
|
||||||
|
}
|
||||||
|
|
||||||
async uploadBackup(file: Express.Multer.File): Promise<void> {
|
async uploadBackup(file: Express.Multer.File): Promise<void> {
|
||||||
const backupsFolder = StorageCore.getBaseFolder(StorageFolder.Backups);
|
const backupsFolder = StorageCore.getBaseFolder(StorageFolder.Backups);
|
||||||
const fn = basename(file.originalname);
|
const fn = basename(file.originalname);
|
||||||
|
|||||||
Reference in New Issue
Block a user