Compare commits

...

2 commits

Author SHA1 Message Date
Vutsal
95b77aea97 Refactor scheduler to use CronExpressionParser
Some checks failed
Release Workflow / determine-release-type (push) Has been cancelled
Release Workflow / checks (push) Has been cancelled
Release Workflow / e2e-tests (push) Has been cancelled
Release Workflow / build-images (push) Has been cancelled
Release Workflow / publish-release (push) Has been cancelled
2026-03-08 08:44:43 +01:00
Nicolas Meienberger
2a332fa4fa fix: restic glob pattern in include
Some checks failed
Release Workflow / determine-release-type (push) Has been cancelled
Release Workflow / checks (push) Has been cancelled
Release Workflow / e2e-tests (push) Has been cancelled
Release Workflow / build-images (push) Has been cancelled
Release Workflow / publish-release (push) Has been cancelled
2026-02-28 10:56:28 +01:00
4 changed files with 84 additions and 8 deletions

View file

@ -75,7 +75,7 @@ export const PathsSection = ({
<FormControl>
<Textarea
{...field}
placeholder="/data/**&#10;/config/*.json&#10;*.db"
placeholder="/data/**&#10;/config/*.json&#10;*.db&#10;**/*.log"
className="font-mono text-sm min-h-25"
/>
</FormControl>

View file

@ -1,4 +1,4 @@
import cron, { type ScheduledTask } from "node-cron";
import CronExpressionParser from "cron-parser";
import { logger } from "../utils/logger";
export abstract class Job {
@ -7,6 +7,73 @@ export abstract class Job {
type JobConstructor = new () => Job;
class ScheduledTask {
private timer: ReturnType<typeof setTimeout> | null = null;
private active = true;
private running = false;
constructor(
private readonly jobName: string,
private readonly cronExpression: string,
private readonly run: () => Promise<void>,
) {
this.scheduleNext();
}
private getDelay(fromDate: Date) {
try {
const interval = CronExpressionParser.parse(this.cronExpression, {
currentDate: fromDate,
tz: Intl.DateTimeFormat().resolvedOptions().timeZone,
});
const nextRun = interval.next().toDate();
return Math.max(0, nextRun.getTime() - Date.now());
} catch (error) {
logger.error(`Failed to parse cron expression for ${this.jobName}:`, error);
return 60_000;
}
}
private scheduleNext(fromDate = new Date()) {
if (!this.active) return;
const delay = this.getDelay(fromDate);
this.timer = setTimeout(() => {
void this.tick();
}, delay);
}
private async tick() {
if (!this.active) return;
if (this.running) {
logger.warn(`Skipping overlapping run for job ${this.jobName}`);
this.scheduleNext(new Date());
return;
}
this.running = true;
try {
await this.run();
} finally {
this.running = false;
this.scheduleNext(new Date());
}
}
async stop() {
this.active = false;
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
}
async destroy() {
await this.stop();
}
}
class SchedulerClass {
private tasks: ScheduledTask[] = [];
@ -18,7 +85,7 @@ class SchedulerClass {
const job = new JobClass();
return {
schedule: (cronExpression: string) => {
const task = cron.schedule(cronExpression, async () => {
const task = new ScheduledTask(JobClass.name, cronExpression, async () => {
try {
await job.run();
} catch (error) {

View file

@ -46,7 +46,12 @@ describe("executeBackup - include / exclude patterns", () => {
expect.anything(),
volumePath,
expect.objectContaining({
include: ["*.zip", path.join(volumePath, "Photos"), `!${path.join(volumePath, "Temp")}`, "!*.log"],
include: [
path.join(volumePath, "*.zip"),
path.join(volumePath, "Photos"),
`!${path.join(volumePath, "Temp")}`,
`!${path.join(volumePath, "*.log")}`,
],
exclude: [".DS_Store", path.join(volumePath, "Config"), `!${path.join(volumePath, "Important")}`, "!*.tmp"],
excludeIfPresent: [".nobackup"],
}),
@ -109,7 +114,7 @@ describe("executeBackup - include / exclude patterns", () => {
expect.anything(),
volumePath,
expect.objectContaining({
include: [relativeInclude, path.join(volumePath, "anchored/include")],
include: [path.join(volumePath, relativeInclude), path.join(volumePath, "anchored/include")],
}),
);
});

View file

@ -19,12 +19,14 @@ export const calculateNextRun = (cronExpression: string) => {
}
};
export const processPattern = (pattern: string, volumePath: string) => {
export const processPattern = (pattern: string, volumePath: string, relative = false) => {
const isNegated = pattern.startsWith("!");
const p = isNegated ? pattern.slice(1) : pattern;
if (!p.startsWith("/")) {
return pattern;
if (!relative) return pattern;
const processed = path.join(volumePath, p);
return isNegated ? `!${processed}` : processed;
}
const processed = path.join(volumePath, p.slice(1));
@ -37,5 +39,7 @@ export const createBackupOptions = (schedule: BackupSchedule, volumePath: string
signal,
exclude: schedule.excludePatterns ? schedule.excludePatterns.map((p) => processPattern(p, volumePath)) : undefined,
excludeIfPresent: schedule.excludeIfPresent ?? undefined,
include: schedule.includePatterns ? schedule.includePatterns.map((p) => processPattern(p, volumePath)) : undefined,
include: schedule.includePatterns
? schedule.includePatterns.map((p) => processPattern(p, volumePath, true))
: undefined,
});