mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
feat(backups): gzip compress backups and add retention config to Instance Settings
Compress database backups with gzip (.sql.gz), reducing file size ~83%. Add backup retention configuration to Instance Settings UI with preset options (7 days, 2 weeks, 1 month). The backup scheduler now reads retention from the database on each tick so changes take effect without restart. Default retention changed from 30 to 7 days.
This commit is contained in:
parent
316790ea0a
commit
cc44d309c0
11 changed files with 107 additions and 17 deletions
|
|
@ -129,8 +129,8 @@ describeEmbeddedPostgres("runDatabaseBackup", () => {
|
|||
filenamePrefix: "paperclip-test",
|
||||
});
|
||||
|
||||
expect(result.backupFile).toMatch(/paperclip-test-.*\.sql$/);
|
||||
expect(result.sizeBytes).toBeGreaterThan(1024 * 1024);
|
||||
expect(result.backupFile).toMatch(/paperclip-test-.*\.sql\.gz$/);
|
||||
expect(result.sizeBytes).toBeGreaterThan(0);
|
||||
expect(fs.existsSync(result.backupFile)).toBe(true);
|
||||
|
||||
await runDatabaseRestore({
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { createReadStream, createWriteStream, existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from "node:fs";
|
||||
import { basename, resolve } from "node:path";
|
||||
import { createInterface } from "node:readline";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import { createGunzip, createGzip } from "node:zlib";
|
||||
import postgres from "postgres";
|
||||
|
||||
export type RunDatabaseBackupOptions = {
|
||||
|
|
@ -82,7 +84,8 @@ function pruneOldBackups(backupDir: string, retentionDays: number, filenamePrefi
|
|||
let pruned = 0;
|
||||
|
||||
for (const name of readdirSync(backupDir)) {
|
||||
if (!name.startsWith(`${filenamePrefix}-`) || !name.endsWith(".sql")) continue;
|
||||
if (!name.startsWith(`${filenamePrefix}-`)) continue;
|
||||
if (!name.endsWith(".sql") && !name.endsWith(".sql.gz")) continue;
|
||||
const fullPath = resolve(backupDir, name);
|
||||
const stat = statSync(fullPath);
|
||||
if (stat.mtimeMs < cutoff) {
|
||||
|
|
@ -148,7 +151,9 @@ function tableKey(schemaName: string, tableName: string): string {
|
|||
}
|
||||
|
||||
async function* readRestoreStatements(backupFile: string): AsyncGenerator<string> {
|
||||
const stream = createReadStream(backupFile, { encoding: "utf8" });
|
||||
const raw = createReadStream(backupFile);
|
||||
const stream = backupFile.endsWith(".gz") ? raw.pipe(createGunzip()) : raw;
|
||||
stream.setEncoding("utf8");
|
||||
const reader = createInterface({
|
||||
input: stream,
|
||||
crlfDelay: Infinity,
|
||||
|
|
@ -180,6 +185,7 @@ async function* readRestoreStatements(backupFile: string): AsyncGenerator<string
|
|||
} finally {
|
||||
reader.close();
|
||||
stream.destroy();
|
||||
raw.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -288,8 +294,9 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
|
|||
const nullifiedColumnsByTable = normalizeNullifyColumnMap(opts.nullifyColumns);
|
||||
const sql = postgres(opts.connectionString, { max: 1, connect_timeout: connectTimeout });
|
||||
mkdirSync(opts.backupDir, { recursive: true });
|
||||
const backupFile = resolve(opts.backupDir, `${filenamePrefix}-${timestamp()}.sql`);
|
||||
const writer = createBufferedTextFileWriter(backupFile);
|
||||
const sqlFile = resolve(opts.backupDir, `${filenamePrefix}-${timestamp()}.sql`);
|
||||
const backupFile = `${sqlFile}.gz`;
|
||||
const writer = createBufferedTextFileWriter(sqlFile);
|
||||
|
||||
try {
|
||||
await sql`SELECT 1`;
|
||||
|
|
@ -664,6 +671,12 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
|
|||
|
||||
await writer.close();
|
||||
|
||||
// Compress the SQL file with gzip
|
||||
const sqlReadStream = createReadStream(sqlFile);
|
||||
const gzWriteStream = createWriteStream(backupFile);
|
||||
await pipeline(sqlReadStream, createGzip(), gzWriteStream);
|
||||
unlinkSync(sqlFile);
|
||||
|
||||
const sizeBytes = statSync(backupFile).size;
|
||||
const prunedCount = pruneOldBackups(opts.backupDir, retentionDays, filenamePrefix);
|
||||
|
||||
|
|
@ -674,6 +687,9 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
|
|||
};
|
||||
} catch (error) {
|
||||
await writer.abort();
|
||||
if (existsSync(backupFile)) {
|
||||
try { unlinkSync(backupFile); } catch { /* ignore */ }
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
await sql.end();
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ export const llmConfigSchema = z.object({
|
|||
export const databaseBackupConfigSchema = z.object({
|
||||
enabled: z.boolean().default(true),
|
||||
intervalMinutes: z.number().int().min(1).max(7 * 24 * 60).default(60),
|
||||
retentionDays: z.number().int().min(1).max(3650).default(30),
|
||||
retentionDays: z.number().int().min(1).max(3650).default(7),
|
||||
dir: z.string().default("~/.paperclip/instances/default/data/backups"),
|
||||
});
|
||||
|
||||
|
|
@ -33,7 +33,7 @@ export const databaseConfigSchema = z.object({
|
|||
backup: databaseBackupConfigSchema.default({
|
||||
enabled: true,
|
||||
intervalMinutes: 60,
|
||||
retentionDays: 30,
|
||||
retentionDays: 7,
|
||||
dir: "~/.paperclip/instances/default/data/backups",
|
||||
}),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -189,6 +189,7 @@ export type {
|
|||
InstanceExperimentalSettings,
|
||||
InstanceGeneralSettings,
|
||||
InstanceSettings,
|
||||
BackupRetentionDays,
|
||||
Agent,
|
||||
AgentAccessState,
|
||||
AgentChainOfCommandEntry,
|
||||
|
|
@ -369,6 +370,11 @@ export {
|
|||
DEFAULT_FEEDBACK_DATA_SHARING_TERMS_VERSION,
|
||||
} from "./types/feedback.js";
|
||||
|
||||
export {
|
||||
BACKUP_RETENTION_PRESETS,
|
||||
DEFAULT_BACKUP_RETENTION_DAYS,
|
||||
} from "./types/instance.js";
|
||||
|
||||
export {
|
||||
getClosedIsolatedExecutionWorkspaceMessage,
|
||||
isClosedIsolatedExecutionWorkspace,
|
||||
|
|
|
|||
|
|
@ -11,7 +11,8 @@ export type {
|
|||
FeedbackTraceBundleFile,
|
||||
FeedbackTraceBundle,
|
||||
} from "./feedback.js";
|
||||
export type { InstanceExperimentalSettings, InstanceGeneralSettings, InstanceSettings } from "./instance.js";
|
||||
export type { InstanceExperimentalSettings, InstanceGeneralSettings, InstanceSettings, BackupRetentionDays } from "./instance.js";
|
||||
export { BACKUP_RETENTION_PRESETS, DEFAULT_BACKUP_RETENTION_DAYS } from "./instance.js";
|
||||
export type {
|
||||
CompanySkillSourceType,
|
||||
CompanySkillTrustLevel,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,14 @@
|
|||
import type { FeedbackDataSharingPreference } from "./feedback.js";
|
||||
|
||||
export const BACKUP_RETENTION_PRESETS = [7, 14, 30] as const;
|
||||
export type BackupRetentionDays = (typeof BACKUP_RETENTION_PRESETS)[number];
|
||||
export const DEFAULT_BACKUP_RETENTION_DAYS: BackupRetentionDays = 7;
|
||||
|
||||
export interface InstanceGeneralSettings {
|
||||
censorUsernameInLogs: boolean;
|
||||
keyboardShortcuts: boolean;
|
||||
feedbackDataSharingPreference: FeedbackDataSharingPreference;
|
||||
backupRetentionDays: BackupRetentionDays;
|
||||
}
|
||||
|
||||
export interface InstanceExperimentalSettings {
|
||||
|
|
|
|||
|
|
@ -1,13 +1,21 @@
|
|||
import { z } from "zod";
|
||||
import { DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE } from "../types/feedback.js";
|
||||
import { BACKUP_RETENTION_PRESETS, DEFAULT_BACKUP_RETENTION_DAYS } from "../types/instance.js";
|
||||
import { feedbackDataSharingPreferenceSchema } from "./feedback.js";
|
||||
|
||||
export const backupRetentionDaysSchema = z.number().refine(
|
||||
(v): v is (typeof BACKUP_RETENTION_PRESETS)[number] =>
|
||||
(BACKUP_RETENTION_PRESETS as readonly number[]).includes(v),
|
||||
{ message: `Must be one of: ${BACKUP_RETENTION_PRESETS.join(", ")}` },
|
||||
);
|
||||
|
||||
export const instanceGeneralSettingsSchema = z.object({
|
||||
censorUsernameInLogs: z.boolean().default(false),
|
||||
keyboardShortcuts: z.boolean().default(false),
|
||||
feedbackDataSharingPreference: feedbackDataSharingPreferenceSchema.default(
|
||||
DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE,
|
||||
),
|
||||
backupRetentionDays: backupRetentionDaysSchema.default(DEFAULT_BACKUP_RETENTION_DAYS),
|
||||
}).strict();
|
||||
|
||||
export const patchInstanceGeneralSettingsSchema = instanceGeneralSettingsSchema.partial();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue