feat(server): compress database backups with pigz when it is installed (#32806)

(cherry picked from commit 97af09ca3cbd7f08ea033f176e777c9a9de8fdb5)
This commit is contained in:
Zack Pollard
2026-10-29 20:43:01 +03:00
committed by Codex
parent 79b175465e
commit 79ea67f6be
4 changed files with 53 additions and 1 deletions
@@ -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', () => {
it('should work (drain to stdout)', async () => {
const process = sut.spawnDuplexStream('bash', ['-c', 'exit 0']);
@@ -1,10 +1,27 @@
import { Injectable } from '@nestjs/common';
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';
@Injectable()
export class ProcessRepository {
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 {
let isStdinClosed = false;
@@ -16,6 +16,7 @@ describe(DatabaseBackupService.name, () => {
beforeEach(() => {
mocks = getMocks();
mocks.process.isCommandAvailable.mockResolvedValue(false);
maintenanceHealthRepositoryMock = automock(MaintenanceHealthRepository, {
args: [mocks.logger],
strict: false,
@@ -228,6 +229,19 @@ describe(DatabaseBackupService.name, () => {
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 () => {
mocks.process.spawnDuplexStream.mockReturnValueOnce(mockDuplex()('pg_dump', 1, '', 'error'));
await expect(sut.handleBackupDatabase()).rejects.toThrow('pg_dump non-zero exit code (1)');
+12 -1
View File
@@ -236,6 +236,8 @@ export class DatabaseBackupService {
const backupFilePath = path.join(StorageCore.getBaseFolder(StorageFolder.Backups), filename);
const temporaryFilePath = `${backupFilePath}.tmp`;
const compression = await this.getCompressionCommand();
let pgdump: 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);
await pipeline(pgdump, gzip, fileStream);
@@ -267,6 +269,15 @@ export class DatabaseBackupService {
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> {
const backupsFolder = StorageCore.getBaseFolder(StorageFolder.Backups);
const fn = basename(file.originalname);