Skip to content

Commit 3f6d3b4

Browse files
committed
feat: add file and Redis persistence providers for cron job state management
1 parent 89f6937 commit 3f6d3b4

10 files changed

Lines changed: 238 additions & 40 deletions

File tree

lib/baker.ts

Lines changed: 33 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
11
import Cron from './cron';
2-
import {
3-
CronOptions,
4-
IBaker,
5-
IBakerOptions,
6-
ICron,
7-
Status,
8-
ExecutionHistory,
2+
import {
3+
CronOptions,
4+
IBaker,
5+
IBakerOptions,
6+
ICron,
7+
Status,
8+
ExecutionHistory,
99
JobMetrics,
1010
SchedulerConfig,
11-
PersistenceOptions
11+
PersistenceOptions,
1212
} from './types';
13-
import * as fs from 'fs';
14-
import * as path from 'path';
13+
import { FilePersistenceProvider } from './persistence/file';
14+
import type { PersistenceProvider } from './persistence/types';
1515

1616
/**
1717
* A class that implements the `IBaker` interface and provides methods to manage cron jobs.
@@ -20,6 +20,7 @@ class Baker implements IBaker {
2020
private crons: Map<string, ICron> = new Map();
2121
private config: SchedulerConfig;
2222
private persistence: PersistenceOptions;
23+
private persistenceProvider?: PersistenceProvider;
2324
private enableMetrics: boolean;
2425
private onError?: (error: Error, jobName: string) => void;
2526

@@ -34,7 +35,20 @@ class Baker implements IBaker {
3435
enabled: options.persistence?.enabled ?? false,
3536
filePath: options.persistence?.filePath ?? './cronbake-state.json',
3637
autoRestore: options.persistence?.autoRestore ?? true,
38+
strategy: options.persistence?.strategy ?? 'file',
39+
provider: options.persistence?.provider,
40+
redis: options.persistence?.redis,
3741
};
42+
43+
if (this.persistence.enabled) {
44+
if (this.persistence.provider) {
45+
this.persistenceProvider = this.persistence.provider;
46+
} else if (this.persistence.strategy === 'file') {
47+
this.persistenceProvider = new FilePersistenceProvider(this.persistence.filePath!);
48+
} else if (this.persistence.strategy === 'redis') {
49+
throw new Error('Redis persistence selected but no provider supplied. Pass persistence.provider or use FilePersistenceProvider.');
50+
}
51+
}
3852

3953
this.enableMetrics = options.enableMetrics ?? true;
4054
this.onError = options.onError;
@@ -225,58 +239,40 @@ class Baker implements IBaker {
225239
}
226240

227241
async saveState(): Promise<void> {
228-
if (!this.persistence.enabled) return;
229-
242+
if (!this.persistence.enabled || !this.persistenceProvider) return;
230243
try {
231244
const state = {
245+
version: 1,
232246
timestamp: new Date().toISOString(),
233247
jobs: Array.from(this.crons.entries()).map(([name, cron]) => ({
234248
name,
235-
cron: cron.cron,
249+
cron: String(cron.cron),
236250
status: cron.getStatus(),
237251
priority: cron.priority,
238252
metrics: this.enableMetrics ? cron.getMetrics() : undefined,
239253
history: this.enableMetrics ? cron.getHistory() : undefined,
240254
})),
241255
config: this.config,
242-
};
243-
244-
const filePath = path.resolve(this.persistence.filePath!);
245-
const dir = path.dirname(filePath);
246-
247-
if (!fs.existsSync(dir)) {
248-
fs.mkdirSync(dir, { recursive: true });
249-
}
250-
251-
await fs.promises.writeFile(filePath, JSON.stringify(state, null, 2), 'utf8');
256+
} as const;
257+
await this.persistenceProvider.save(state);
252258
} catch (error) {
253259
throw new Error(`Failed to save state: ${error}`);
254260
}
255261
}
256262

257263
async restoreState(): Promise<void> {
258-
if (!this.persistence.enabled) return;
259-
264+
if (!this.persistence.enabled || !this.persistenceProvider) return;
260265
try {
261-
const filePath = path.resolve(this.persistence.filePath!);
262-
263-
if (!fs.existsSync(filePath)) {
264-
return;
265-
}
266-
267-
const data = await fs.promises.readFile(filePath, 'utf8');
268-
const state = JSON.parse(data);
269-
266+
const state = await this.persistenceProvider.load();
267+
if (!state) return;
270268
if (!state.jobs || !Array.isArray(state.jobs)) {
271-
throw new Error('Invalid state file format');
269+
throw new Error('Invalid state format');
272270
}
273-
274271
for (const jobData of state.jobs) {
275272
if (!jobData.name || !jobData.cron) {
276273
console.warn('Skipping invalid job data:', jobData);
277274
continue;
278275
}
279-
280276
try {
281277
const options: CronOptions = {
282278
name: jobData.name,
@@ -287,13 +283,11 @@ class Baker implements IBaker {
287283
priority: jobData.priority,
288284
start: jobData.status === 'running',
289285
};
290-
291286
this.add(options);
292287
} catch (error) {
293288
console.warn(`Failed to restore job '${jobData.name}':`, error);
294289
}
295290
}
296-
297291
console.log(`Restored ${state.jobs.length} cron jobs from persistence`);
298292
} catch (error) {
299293
throw new Error(`Failed to restore state: ${error}`);

lib/index.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,7 @@ describe("Baker", () => {
364364
persistenceBaker.destroyAll();
365365
});
366366

367+
367368
it("should prevent overrun when enabled (interval mode)", async () => {
368369
const configuredBaker = new Baker({
369370
schedulerConfig: {

lib/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import Cron from "./cron";
22
import Baker from "./baker";
33
import CronParser from "./parser";
4+
import { FilePersistenceProvider } from "./persistence/file";
5+
import { RedisPersistenceProvider } from "./persistence/redis";
46

57
export {
68
type CronOptions,
@@ -21,5 +23,5 @@ export {
2123
type unit,
2224
} from "./types";
2325

24-
export { Cron, Baker, CronParser };
26+
export { Cron, Baker, CronParser, FilePersistenceProvider, RedisPersistenceProvider };
2527
export default Baker;

lib/persistence.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { describe, it, expect } from 'bun:test';
2+
import Baker from '@/lib/baker';
3+
import { createFileProvider, createRedisProvider, cleanupFile, sleep } from '@/lib/test/utils';
4+
5+
describe('Persistence Providers', () => {
6+
it('File provider: saves and restores jobs', async () => {
7+
const { provider, filePath } = createFileProvider();
8+
9+
const baker1 = new Baker({
10+
persistence: { enabled: true, autoRestore: false, strategy: 'file', provider },
11+
});
12+
13+
baker1.add({ name: 'file-job', cron: '* * * * * *', callback: () => {} });
14+
await baker1.saveState();
15+
16+
const baker2 = new Baker({
17+
persistence: { enabled: true, autoRestore: true, strategy: 'file', provider },
18+
});
19+
await sleep(10);
20+
expect(baker2.getJobNames()).toContain('file-job');
21+
baker2.destroyAll();
22+
baker1.destroyAll();
23+
cleanupFile(filePath);
24+
});
25+
26+
it('Redis provider: saves and restores jobs with fake client', async () => {
27+
const { provider } = createRedisProvider('test:cronbake');
28+
29+
const baker1 = new Baker({
30+
persistence: { enabled: true, autoRestore: false, strategy: 'redis', provider },
31+
});
32+
baker1.add({ name: 'redis-job', cron: '* * * * * *', callback: () => {} });
33+
await baker1.saveState();
34+
35+
const baker2 = new Baker({
36+
persistence: { enabled: true, autoRestore: true, strategy: 'redis', provider },
37+
});
38+
await sleep(10);
39+
expect(baker2.getJobNames()).toContain('redis-job');
40+
baker2.destroyAll();
41+
baker1.destroyAll();
42+
});
43+
44+
it('Throws if redis strategy without provider', () => {
45+
expect(() => new Baker({ persistence: { enabled: true, strategy: 'redis' } })).toThrow();
46+
});
47+
});

lib/persistence/file.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import * as fs from "fs";
2+
import * as path from "path";
3+
import { PersistedState, PersistenceProvider } from "./types";
4+
5+
class FilePersistenceProvider implements PersistenceProvider {
6+
constructor(private filePath: string) {}
7+
8+
async save(state: PersistedState): Promise<void> {
9+
const filePath = path.resolve(this.filePath);
10+
const dir = path.dirname(filePath);
11+
if (!fs.existsSync(dir)) {
12+
fs.mkdirSync(dir, { recursive: true });
13+
}
14+
await fs.promises.writeFile(filePath, JSON.stringify(state, null, 2), "utf8");
15+
}
16+
17+
async load(): Promise<PersistedState | null> {
18+
const filePath = path.resolve(this.filePath);
19+
if (!fs.existsSync(filePath)) return null;
20+
const data = await fs.promises.readFile(filePath, "utf8");
21+
const state = JSON.parse(data);
22+
if (!state || typeof state !== "object") return null;
23+
return state as PersistedState;
24+
}
25+
}
26+
27+
export { FilePersistenceProvider };
28+

lib/persistence/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export { type PersistedState, type PersistedJob, type PersistenceProvider, type RedisLikeClient } from "./types";
2+
export { FilePersistenceProvider } from "./file";
3+
export { RedisPersistenceProvider, type RedisProviderOptions } from "./redis";
4+

lib/persistence/redis.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { PersistedState, PersistenceProvider, RedisLikeClient } from "./types";
2+
3+
type RedisProviderOptions = {
4+
client: RedisLikeClient;
5+
key?: string;
6+
};
7+
8+
class RedisPersistenceProvider implements PersistenceProvider {
9+
private key: string;
10+
constructor(private options: RedisProviderOptions) {
11+
if (!options?.client) {
12+
throw new Error("RedisPersistenceProvider requires a redis-like client with get/set");
13+
}
14+
this.key = options.key ?? "cronbake:state";
15+
}
16+
17+
async save(state: PersistedState): Promise<void> {
18+
await this.options.client.set(this.key, JSON.stringify(state));
19+
}
20+
21+
async load(): Promise<PersistedState | null> {
22+
const raw = await this.options.client.get(this.key);
23+
if (!raw) return null;
24+
try {
25+
const parsed = JSON.parse(raw) as PersistedState;
26+
if (!parsed || typeof parsed !== "object") return null;
27+
return parsed;
28+
} catch {
29+
return null;
30+
}
31+
}
32+
}
33+
34+
export { RedisPersistenceProvider };
35+
export type { RedisProviderOptions };
36+

lib/persistence/types.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { ExecutionHistory, JobMetrics, SchedulerConfig, Status } from "../types";
2+
3+
type PersistedJob = {
4+
name: string;
5+
cron: string;
6+
status: Status;
7+
priority: number;
8+
metrics?: JobMetrics;
9+
history?: ExecutionHistory[];
10+
};
11+
12+
type PersistedState = {
13+
version: number;
14+
timestamp: string;
15+
config: SchedulerConfig;
16+
jobs: PersistedJob[];
17+
};
18+
19+
interface PersistenceProvider {
20+
save(state: PersistedState): Promise<void>;
21+
load(): Promise<PersistedState | null>;
22+
}
23+
24+
/** Minimal redis-like client */
25+
interface RedisLikeClient {
26+
get(key: string): Promise<string | null>;
27+
set(key: string, value: string): Promise<unknown>;
28+
}
29+
30+
export type { PersistedState, PersistedJob, PersistenceProvider, RedisLikeClient };
31+

lib/test/utils.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import * as fs from 'fs';
2+
import * as path from 'path';
3+
import * as os from 'os';
4+
import { FilePersistenceProvider } from '@/lib/persistence/file';
5+
import { RedisPersistenceProvider } from '@/lib/persistence/redis';
6+
import type { RedisLikeClient } from '@/lib/persistence/types';
7+
8+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
9+
10+
const tmpFile = (prefix = 'cronbake-test-'): string => {
11+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
12+
return path.join(dir, 'state.json');
13+
};
14+
15+
const cleanupFile = (filePath: string) => {
16+
try { fs.unlinkSync(filePath); } catch {}
17+
try { fs.rmdirSync(path.dirname(filePath)); } catch {}
18+
};
19+
20+
class FakeRedis implements RedisLikeClient {
21+
private store = new Map<string, string>();
22+
async get(key: string): Promise<string | null> {
23+
return this.store.get(key) ?? null;
24+
}
25+
async set(key: string, value: string): Promise<void> {
26+
this.store.set(key, value);
27+
}
28+
}
29+
30+
const createFileProvider = (filePath?: string) => {
31+
const fp = filePath ?? tmpFile();
32+
return { provider: new FilePersistenceProvider(fp), filePath: fp };
33+
};
34+
35+
const createRedisProvider = (key = 'test:cronbake') => {
36+
const client = new FakeRedis();
37+
const provider = new RedisPersistenceProvider({ client, key });
38+
return { provider, client };
39+
};
40+
41+
export { sleep, tmpFile, cleanupFile, FakeRedis, createFileProvider, createRedisProvider };
42+

lib/types.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,10 +300,22 @@ type JobMetrics = {
300300
/**
301301
* Persistence options for cron jobs
302302
*/
303+
import type { PersistenceProvider } from "./persistence/types";
304+
305+
type PersistenceStrategy = 'file' | 'redis' | 'custom';
306+
307+
type RedisPersistenceOptions = {
308+
key?: string;
309+
// user must provide a client via provider for now; kept for future extension
310+
};
311+
303312
type PersistenceOptions = {
304313
enabled: boolean;
305314
filePath?: string;
306315
autoRestore?: boolean;
316+
strategy?: PersistenceStrategy;
317+
provider?: PersistenceProvider;
318+
redis?: RedisPersistenceOptions;
307319
};
308320

309321
/**
@@ -485,4 +497,5 @@ export {
485497
type JobMetrics,
486498
type PersistenceOptions,
487499
type SchedulerConfig,
500+
type PersistenceStrategy,
488501
};

0 commit comments

Comments
 (0)