From 4389029ba5f60724443c2f51fb50258800495910 Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Sun, 9 Nov 2025 11:33:14 +0100 Subject: [PATCH] feat: throttle logs during backup --- apps/server/package.json | 1 + apps/server/src/utils/restic.ts | 98 ++++++++++++--------------------- apps/server/src/utils/spawn.ts | 56 +++++++++++++++++++ bun.lock | 5 +- 4 files changed, 97 insertions(+), 63 deletions(-) create mode 100644 apps/server/src/utils/spawn.ts diff --git a/apps/server/package.json b/apps/server/package.json index fba9f71..bd347e9 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -15,6 +15,7 @@ "dockerode": "^4.0.8", "dotenv": "^17.2.1", "drizzle-orm": "^0.44.6", + "es-toolkit": "^1.41.0", "hono": "^4.9.2", "hono-openapi": "^1.1.0", "http-errors-enhanced": "^3.0.2", diff --git a/apps/server/src/utils/restic.ts b/apps/server/src/utils/restic.ts index 49e577e..c289d00 100644 --- a/apps/server/src/utils/restic.ts +++ b/apps/server/src/utils/restic.ts @@ -1,7 +1,7 @@ import crypto from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; -import { spawn } from "node:child_process"; +import { throttle } from "es-toolkit"; import type { RepositoryConfig } from "@ironmount/schemas/restic"; import { type } from "arktype"; import { $ } from "bun"; @@ -9,6 +9,7 @@ import { REPOSITORY_BASE, RESTIC_PASS_FILE } from "../core/constants"; import { logger } from "./logger"; import { cryptoUtils } from "./crypto"; import type { RetentionPolicy } from "../modules/backups/backups.dto"; +import { safeSpawn } from "./spawn"; const backupOutputSchema = type({ message_type: "'summary'", @@ -149,67 +150,41 @@ const backup = async ( args.push("--json"); - return new Promise((resolve, reject) => { - const child = spawn("restic", args, { - env: { ...process.env, ...env }, - signal: options?.signal, - }); + const logData = throttle((data: string) => { + logger.info(data.trim()); + }, 5000); - let stdout = ""; + let stdout = ""; - child.stdout.on("data", (data) => { - stdout = data.toString(); - logger.info(data.toString()); - }); - - child.stderr.on("data", (data) => { - logger.error(data.toString()); - }); - - child.on("error", async (error) => { - if (includeFile) { - await fs.unlink(includeFile).catch(() => {}); - } - - if (error.name === "AbortError") { - logger.info("Restic backup process was aborted"); - reject(error); - } else { - logger.error(`Restic backup process error: ${error.message}`); - reject(new Error(`Restic backup process error: ${error.message}`)); - } - }); - - child.on("close", async (code) => { - if (includeFile) { - await fs.unlink(includeFile).catch(() => {}); - } - - if (code !== 0) { - logger.error(`Restic backup failed with exit code ${code}`); - reject(new Error(`Restic backup failed`)); - return; - } - - try { - const lastLine = stdout.trim(); - const resSummary = JSON.parse(lastLine ?? "{}"); - - const result = backupOutputSchema(resSummary); - - if (result instanceof type.errors) { - logger.error(`Restic backup output validation failed: ${result}`); - reject(new Error(`Restic backup output validation failed: ${result}`)); - return; - } - - resolve(result); - } catch (error) { - logger.error(`Failed to parse restic backup output: ${error}`); - reject(new Error(`Failed to parse restic backup output: ${error}`)); - } - }); + await safeSpawn({ + command: "restic", + args, + env, + signal: options?.signal, + onStdout: (data) => { + stdout = data; + logData(data); + }, + onStderr: (error) => { + logger.error(error.trim()); + }, + finally: async () => { + includeFile && (await fs.unlink(includeFile).catch(() => {})); + }, }); + + const lastLine = stdout.trim(); + const resSummary = JSON.parse(lastLine ?? "{}"); + + const result = backupOutputSchema(resSummary); + + if (result instanceof type.errors) { + logger.error(`Restic backup output validation failed: ${result}`); + + throw new Error(`Restic backup output validation failed: ${result}`); + } + + return result; }; const restoreOutputSchema = type({ @@ -370,7 +345,6 @@ const forget = async (config: RepositoryConfig, options: RetentionPolicy, extra: args.push("--prune"); args.push("--json"); - // await $`restic unlock --repo ${repoUrl}`.env(env).nothrow(); const res = await $`restic ${args}`.env(env).nothrow(); if (res.exitCode !== 0) { @@ -465,7 +439,7 @@ const unlock = async (config: RepositoryConfig) => { const repoUrl = buildRepoUrl(config); const env = await buildEnv(config); - const res = await $`restic unlock --repo ${repoUrl} --json`.env(env).nothrow(); + const res = await $`restic unlock --repo ${repoUrl} --remove-all --json`.env(env).nothrow(); if (res.exitCode !== 0) { logger.error(`Restic unlock failed: ${res.stderr}`); @@ -501,7 +475,7 @@ const check = async (config: RepositoryConfig, options?: { readData?: boolean }) }; } - const hasErrors = stdout.includes("error") || stdout.includes("Fatal"); + const hasErrors = stdout.includes("Fatal"); logger.info(`Restic check completed for repository: ${repoUrl}`); return { diff --git a/apps/server/src/utils/spawn.ts b/apps/server/src/utils/spawn.ts new file mode 100644 index 0000000..023ec95 --- /dev/null +++ b/apps/server/src/utils/spawn.ts @@ -0,0 +1,56 @@ +import { spawn } from "node:child_process"; + +interface Params { + command: string; + args: string[]; + env?: NodeJS.ProcessEnv; + signal?: AbortSignal; + onStdout?: (data: string) => void; + onStderr?: (error: string) => void; + onError?: (error: Error) => Promise | void; + onClose?: (code: number | null) => Promise | void; + finally?: () => Promise | void; +} + +export const safeSpawn = (params: Params) => { + const { command, args, env = {}, signal, ...callbacks } = params; + + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + env: { ...process.env, ...env }, + signal: signal, + }); + + child.stdout.on("data", (data) => { + if (callbacks.onStdout) { + callbacks.onStdout(data.toString()); + } + }); + + child.stderr.on("data", (data) => { + if (callbacks.onStderr) { + callbacks.onStderr(data.toString()); + } + }); + + child.on("error", async (error) => { + if (callbacks.onError) { + await callbacks.onError(error); + } + if (callbacks.finally) { + await callbacks.finally(); + } + reject(error); + }); + + child.on("close", async (code) => { + if (callbacks.onClose) { + await callbacks.onClose(code); + } + if (callbacks.finally) { + await callbacks.finally(); + } + resolve(code); + }); + }); +}; diff --git a/bun.lock b/bun.lock index b529419..9eb481b 100644 --- a/bun.lock +++ b/bun.lock @@ -76,6 +76,7 @@ "dockerode": "^4.0.8", "dotenv": "^17.2.1", "drizzle-orm": "^0.44.6", + "es-toolkit": "^1.41.0", "hono": "^4.9.2", "hono-openapi": "^1.1.0", "http-errors-enhanced": "^3.0.2", @@ -812,7 +813,7 @@ "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], - "es-toolkit": ["es-toolkit@1.39.10", "", {}, "sha512-E0iGnTtbDhkeczB0T+mxmoVlT4YNweEKBLq7oaU4p11mecdsZpNWOglI4895Vh4usbQ+LsJiuLuI2L0Vdmfm2w=="], + "es-toolkit": ["es-toolkit@1.41.0", "", {}, "sha512-bDd3oRmbVgqZCJS6WmeQieOrzpl3URcWBUVDXxOELlUW2FuW+0glPOz1n0KnRie+PdyvUZcXz2sOn00c6pPRIA=="], "esbuild": ["esbuild@0.25.10", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.10", "@esbuild/android-arm": "0.25.10", "@esbuild/android-arm64": "0.25.10", "@esbuild/android-x64": "0.25.10", "@esbuild/darwin-arm64": "0.25.10", "@esbuild/darwin-x64": "0.25.10", "@esbuild/freebsd-arm64": "0.25.10", "@esbuild/freebsd-x64": "0.25.10", "@esbuild/linux-arm": "0.25.10", "@esbuild/linux-arm64": "0.25.10", "@esbuild/linux-ia32": "0.25.10", "@esbuild/linux-loong64": "0.25.10", "@esbuild/linux-mips64el": "0.25.10", "@esbuild/linux-ppc64": "0.25.10", "@esbuild/linux-riscv64": "0.25.10", "@esbuild/linux-s390x": "0.25.10", "@esbuild/linux-x64": "0.25.10", "@esbuild/netbsd-arm64": "0.25.10", "@esbuild/netbsd-x64": "0.25.10", "@esbuild/openbsd-arm64": "0.25.10", "@esbuild/openbsd-x64": "0.25.10", "@esbuild/openharmony-arm64": "0.25.10", "@esbuild/sunos-x64": "0.25.10", "@esbuild/win32-arm64": "0.25.10", "@esbuild/win32-ia32": "0.25.10", "@esbuild/win32-x64": "0.25.10" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ=="], @@ -1594,6 +1595,8 @@ "protobufjs/@types/node": ["@types/node@20.19.19", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-pb1Uqj5WJP7wrcbLU7Ru4QtA0+3kAXrkutGiD26wUKzSMgNNaPARTUDQmElUXp64kh3cWdou3Q0C7qwwxqSFmg=="], + "recharts/es-toolkit": ["es-toolkit@1.39.10", "", {}, "sha512-E0iGnTtbDhkeczB0T+mxmoVlT4YNweEKBLq7oaU4p11mecdsZpNWOglI4895Vh4usbQ+LsJiuLuI2L0Vdmfm2w=="], + "rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], "send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],