Compare commits
3 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b0309448c | ||
|
|
95b77aea97 | ||
|
|
2a332fa4fa |
8 changed files with 174 additions and 15 deletions
|
|
@ -75,7 +75,7 @@ export const PathsSection = ({
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea
|
<Textarea
|
||||||
{...field}
|
{...field}
|
||||||
placeholder="/data/** /config/*.json *.db"
|
placeholder="/data/** /config/*.json *.db **/*.log"
|
||||||
className="font-mono text-sm min-h-25"
|
className="font-mono text-sm min-h-25"
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
|
||||||
65
app/server/core/__tests__/scheduler.test.ts
Normal file
65
app/server/core/__tests__/scheduler.test.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
import { afterEach, describe, expect, mock, spyOn, test, vi } from "bun:test";
|
||||||
|
import { logger } from "~/server/utils/logger";
|
||||||
|
import { Job, Scheduler } from "../scheduler";
|
||||||
|
|
||||||
|
const flushMicrotasks = async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("Scheduler", () => {
|
||||||
|
afterEach(async () => {
|
||||||
|
await Scheduler.clear();
|
||||||
|
mock.restore();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps future cron ticks armed while a job is still running", async () => {
|
||||||
|
vi.useFakeTimers({ now: new Date("2026-03-08T00:00:00.000Z") });
|
||||||
|
|
||||||
|
let runCount = 0;
|
||||||
|
let releaseRun: (() => void) | undefined;
|
||||||
|
const warn = spyOn(logger, "warn");
|
||||||
|
|
||||||
|
class TestJob extends Job {
|
||||||
|
async run() {
|
||||||
|
runCount += 1;
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
releaseRun = resolve;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Scheduler.build(TestJob).schedule("* * * * *");
|
||||||
|
|
||||||
|
expect(vi.getTimerCount()).toBe(1);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(60_000);
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
expect(runCount).toBe(1);
|
||||||
|
expect(vi.getTimerCount()).toBe(1);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(60_000);
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
expect(runCount).toBe(1);
|
||||||
|
expect(warn).toHaveBeenCalledWith("Skipping overlapping run for job TestJob");
|
||||||
|
expect(vi.getTimerCount()).toBe(1);
|
||||||
|
|
||||||
|
releaseRun?.();
|
||||||
|
await flushMicrotasks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws for invalid cron expressions instead of retrying with a fallback timer", () => {
|
||||||
|
vi.useFakeTimers({ now: new Date("2026-03-08T00:00:00.000Z") });
|
||||||
|
|
||||||
|
class InvalidCronJob extends Job {
|
||||||
|
async run() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(() => Scheduler.build(InvalidCronJob).schedule("not a cron")).toThrow();
|
||||||
|
expect(vi.getTimerCount()).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import cron, { type ScheduledTask } from "node-cron";
|
import CronExpressionParser from "cron-parser";
|
||||||
import { logger } from "../utils/logger";
|
import { logger } from "../utils/logger";
|
||||||
|
|
||||||
export abstract class Job {
|
export abstract class Job {
|
||||||
|
|
@ -7,6 +7,86 @@ export abstract class Job {
|
||||||
|
|
||||||
type JobConstructor = new () => Job;
|
type JobConstructor = new () => Job;
|
||||||
|
|
||||||
|
class ScheduledTask {
|
||||||
|
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private active = true;
|
||||||
|
private running = false;
|
||||||
|
private readonly timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || undefined;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly jobName: string,
|
||||||
|
private readonly cronExpression: string,
|
||||||
|
private readonly run: () => Promise<void>,
|
||||||
|
) {
|
||||||
|
CronExpressionParser.parse(this.cronExpression);
|
||||||
|
this.scheduleNext();
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDelay(fromDate: Date) {
|
||||||
|
const interval = CronExpressionParser.parse(this.cronExpression, {
|
||||||
|
currentDate: fromDate,
|
||||||
|
tz: this.timeZone,
|
||||||
|
});
|
||||||
|
const nextRun = interval.next().toDate();
|
||||||
|
return Math.max(0, nextRun.getTime() - Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearTimer() {
|
||||||
|
if (this.timer) {
|
||||||
|
clearTimeout(this.timer);
|
||||||
|
this.timer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopScheduling(error: unknown) {
|
||||||
|
this.active = false;
|
||||||
|
this.clearTimer();
|
||||||
|
logger.error(`Stopping scheduled job ${this.jobName} after cron parsing failed:`, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
this.timer = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.scheduleNext(new Date());
|
||||||
|
} catch (error) {
|
||||||
|
this.stopScheduling(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.running) {
|
||||||
|
logger.warn(`Skipping overlapping run for job ${this.jobName}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.running = true;
|
||||||
|
try {
|
||||||
|
await this.run();
|
||||||
|
} finally {
|
||||||
|
this.running = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop() {
|
||||||
|
this.active = false;
|
||||||
|
this.clearTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
async destroy() {
|
||||||
|
await this.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class SchedulerClass {
|
class SchedulerClass {
|
||||||
private tasks: ScheduledTask[] = [];
|
private tasks: ScheduledTask[] = [];
|
||||||
|
|
||||||
|
|
@ -18,7 +98,7 @@ class SchedulerClass {
|
||||||
const job = new JobClass();
|
const job = new JobClass();
|
||||||
return {
|
return {
|
||||||
schedule: (cronExpression: string) => {
|
schedule: (cronExpression: string) => {
|
||||||
const task = cron.schedule(cronExpression, async () => {
|
const task = new ScheduledTask(JobClass.name, cronExpression, async () => {
|
||||||
try {
|
try {
|
||||||
await job.run();
|
await job.run();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,12 @@ describe("executeBackup - include / exclude patterns", () => {
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
volumePath,
|
volumePath,
|
||||||
expect.objectContaining({
|
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"],
|
exclude: [".DS_Store", path.join(volumePath, "Config"), `!${path.join(volumePath, "Important")}`, "!*.tmp"],
|
||||||
excludeIfPresent: [".nobackup"],
|
excludeIfPresent: [".nobackup"],
|
||||||
}),
|
}),
|
||||||
|
|
@ -109,7 +114,7 @@ describe("executeBackup - include / exclude patterns", () => {
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
volumePath,
|
volumePath,
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
include: [relativeInclude, path.join(volumePath, "anchored/include")],
|
include: [path.join(volumePath, relativeInclude), path.join(volumePath, "anchored/include")],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 isNegated = pattern.startsWith("!");
|
||||||
const p = isNegated ? pattern.slice(1) : pattern;
|
const p = isNegated ? pattern.slice(1) : pattern;
|
||||||
|
|
||||||
if (!p.startsWith("/")) {
|
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));
|
const processed = path.join(volumePath, p.slice(1));
|
||||||
|
|
@ -37,5 +39,7 @@ export const createBackupOptions = (schedule: BackupSchedule, volumePath: string
|
||||||
signal,
|
signal,
|
||||||
exclude: schedule.excludePatterns ? schedule.excludePatterns.map((p) => processPattern(p, volumePath)) : undefined,
|
exclude: schedule.excludePatterns ? schedule.excludePatterns.map((p) => processPattern(p, volumePath)) : undefined,
|
||||||
excludeIfPresent: schedule.excludeIfPresent ?? 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,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,15 @@
|
||||||
import { and, eq, inArray } from "drizzle-orm";
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
import cron from "node-cron";
|
import CronExpressionParser from "cron-parser";
|
||||||
import { NotFoundError, BadRequestError, ConflictError } from "http-errors-enhanced";
|
import { NotFoundError, BadRequestError, ConflictError } from "http-errors-enhanced";
|
||||||
|
|
||||||
|
const isValidCron = (expression: string): boolean => {
|
||||||
|
try {
|
||||||
|
CronExpressionParser.parse(expression);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
import { db } from "../../db/db";
|
import { db } from "../../db/db";
|
||||||
import { backupScheduleMirrorsTable, backupScheduleNotificationsTable, backupSchedulesTable } from "../../db/schema";
|
import { backupScheduleMirrorsTable, backupScheduleNotificationsTable, backupSchedulesTable } from "../../db/schema";
|
||||||
import type { CreateBackupScheduleBody, UpdateBackupScheduleBody, UpdateScheduleMirrorsBody } from "./backups.dto";
|
import type { CreateBackupScheduleBody, UpdateBackupScheduleBody, UpdateScheduleMirrorsBody } from "./backups.dto";
|
||||||
|
|
@ -82,7 +91,7 @@ const getScheduleByIdOrShortId = async (idOrShortId: string | number) => {
|
||||||
|
|
||||||
const createSchedule = async (data: CreateBackupScheduleBody) => {
|
const createSchedule = async (data: CreateBackupScheduleBody) => {
|
||||||
const organizationId = getOrganizationId();
|
const organizationId = getOrganizationId();
|
||||||
if (!cron.validate(data.cronExpression)) {
|
if (!isValidCron(data.cronExpression)) {
|
||||||
throw new BadRequestError("Invalid cron expression");
|
throw new BadRequestError("Invalid cron expression");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -162,7 +171,7 @@ const updateSchedule = async (scheduleIdOrShortId: number | string, data: Update
|
||||||
throw new NotFoundError("Backup schedule not found");
|
throw new NotFoundError("Backup schedule not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.cronExpression && !cron.validate(data.cronExpression)) {
|
if (data.cronExpression && !isValidCron(data.cronExpression)) {
|
||||||
throw new BadRequestError("Invalid cron expression");
|
throw new BadRequestError("Invalid cron expression");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
3
bun.lock
3
bun.lock
|
|
@ -52,7 +52,6 @@
|
||||||
"isbot": "^5.1.35",
|
"isbot": "^5.1.35",
|
||||||
"lucide-react": "^0.575.0",
|
"lucide-react": "^0.575.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"node-cron": "^4.2.1",
|
|
||||||
"qrcode.react": "^4.2.0",
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
|
|
@ -1421,8 +1420,6 @@
|
||||||
|
|
||||||
"nitro": ["nitro@3.0.1-alpha.2", "", { "dependencies": { "consola": "^3.4.2", "crossws": "^0.4.3", "db0": "^0.3.4", "h3": "^2.0.1-rc.11", "jiti": "^2.6.1", "nf3": "^0.3.5", "ofetch": "^2.0.0-alpha.3", "ohash": "^2.0.11", "oxc-minify": "^0.110.0", "oxc-transform": "^0.110.0", "srvx": "^0.10.1", "undici": "^7.18.2", "unenv": "^2.0.0-rc.24", "unstorage": "^2.0.0-alpha.5" }, "peerDependencies": { "rolldown": ">=1.0.0-beta.0", "rollup": "^4", "vite": "^7 || ^8 || >=8.0.0-0", "xml2js": "^0.6.2" }, "optionalPeers": ["rolldown", "rollup", "vite", "xml2js"], "bin": { "nitro": "dist/cli/index.mjs" } }, "sha512-YviDY5J/trS821qQ1fpJtpXWIdPYiOizC/meHavlm1Hfuhx//H+Egd1+4C5SegJRgtWMnRPW9n//6Woaw81cTQ=="],
|
"nitro": ["nitro@3.0.1-alpha.2", "", { "dependencies": { "consola": "^3.4.2", "crossws": "^0.4.3", "db0": "^0.3.4", "h3": "^2.0.1-rc.11", "jiti": "^2.6.1", "nf3": "^0.3.5", "ofetch": "^2.0.0-alpha.3", "ohash": "^2.0.11", "oxc-minify": "^0.110.0", "oxc-transform": "^0.110.0", "srvx": "^0.10.1", "undici": "^7.18.2", "unenv": "^2.0.0-rc.24", "unstorage": "^2.0.0-alpha.5" }, "peerDependencies": { "rolldown": ">=1.0.0-beta.0", "rollup": "^4", "vite": "^7 || ^8 || >=8.0.0-0", "xml2js": "^0.6.2" }, "optionalPeers": ["rolldown", "rollup", "vite", "xml2js"], "bin": { "nitro": "dist/cli/index.mjs" } }, "sha512-YviDY5J/trS821qQ1fpJtpXWIdPYiOizC/meHavlm1Hfuhx//H+Egd1+4C5SegJRgtWMnRPW9n//6Woaw81cTQ=="],
|
||||||
|
|
||||||
"node-cron": ["node-cron@4.2.1", "", {}, "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg=="],
|
|
||||||
|
|
||||||
"node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
|
"node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
|
||||||
|
|
||||||
"node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
|
"node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,6 @@
|
||||||
"isbot": "^5.1.35",
|
"isbot": "^5.1.35",
|
||||||
"lucide-react": "^0.575.0",
|
"lucide-react": "^0.575.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"node-cron": "^4.2.1",
|
|
||||||
"qrcode.react": "^4.2.0",
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue