diff --git a/.gitignore b/.gitignore index 696107b..6b8fe3c 100644 --- a/.gitignore +++ b/.gitignore @@ -170,4 +170,9 @@ dist node_modules dist -.DS_Store \ No newline at end of file +.DS_Store + +# Example persistence state files +examples/.cronbake-state.json +examples/cronbake-state.json +*.cronbake-state.json \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 31b465a..1ee1bbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # cronbake +## 0.3.2 + +### Fixes + +- Persistence: Fix job redefinition warning by allowing user-defined jobs to replace restored jobs without conflicts ([#14](https://github.com/chaqchase/cronbake/issues/14)). Previously, restarting an app with updated job configurations would show a warning "Failed to restore job 'my-job': warn: Cron job with name 'my-job' already exists". Now, user-defined configurations take precedence over restored state. +- Runtime: Failed job executions now bubble up properly so metrics/history and `lastError` reflect the failure, and `onError` handlers still fire exactly once. +- Runtime: `baker.lastExecution()` uses the timestamp of the most recent actual run and `Cron.time()` once again reports the remaining delay rather than the current epoch time, matching the documented API. + +### Minor Changes + +- Persistence: Honor the `persist` flag per job so only opted-in jobs are saved/restored, and hydrate metrics/history snapshots during restore. +- API: Added `baker.ready()` to await automatic restoration before interacting with jobs; `autoStart` now waits for persistence to finish. +- DX: Prevent redundant persistence writes during restore for faster startups and fewer warnings. +- Metadata: Point npm metadata to `chaqchase/cronbake`. +- Docs: Added `examples/` folder plus README guidance so common use cases have copy-paste starting points without bloating published bundles. + ## 0.3.1 ### Patch Changes diff --git a/README.md b/README.md index 1ca759a..22f6b2e 100644 --- a/README.md +++ b/README.md @@ -78,13 +78,11 @@ Track detailed execution history and performance metrics including: - Execution history with timestamps and durations - Error tracking and reporting -#### Job Persistence +#### Job Persistence (opt-in) -Cronbake supports job persistence across application restarts: - -- Save job state to file system or Redis (pluggable providers) -- Automatic restoration on startup -- Configurable persistence options +- Opt in by setting `persistence.enabled: true` +- Use `await baker.ready()` before interacting when `autoRestore` restores jobs for you +- Select file- or Redis-backed providers depending on your environment #### Callbacks and Error Handling @@ -120,59 +118,106 @@ yarn add cronbake pnpm add cronbake ``` -### Usage +### Quick Start (stateless defaults) To get started with Cronbake, create a new instance of the `Baker` class and add cron jobs using the `add` method: ```typescript -import Baker from 'cronbake'; +import Baker from "cronbake"; // Create a new Baker instance const baker = Baker.create(); // Add a cron job that runs daily at midnight const dailyJob = baker.add({ - name: 'daily-job', - cron: '0 0 0 * * *', // Runs daily at midnight + name: "daily-job", + cron: "0 0 0 * * *", // Runs daily at midnight callback: () => { - console.log('Daily job executed!'); + console.log("Daily job executed!"); }, }); // Runs every first 3 minutes for the first 3 hours of every day const everyFirst3MinutesJob = baker.add({ - name: 'every-first-3-minutes-job', - cron: '0 0-3 1,2,3 * * *', // Runs every first 3 minutes for the first 3 hours of every day + name: "every-first-3-minutes-job", + cron: "0 0-3 1,2,3 * * *", // Runs every first 3 minutes for the first 3 hours of every day callback: () => { - console.log('Every first 3 minutes job executed!'); + console.log("Every first 3 minutes job executed!"); }, }); // using custom presets const everyMinuteJob = baker.add({ - name: 'every-minute-job', - cron: '@every_minute', + name: "every-minute-job", + cron: "@every_minute", callback: () => { - console.log('Every minute job executed!'); + console.log("Every minute job executed!"); }, }); // Start all cron jobs baker.bakeAll(); + +// Process exits? No problem—cron definitions live only in memory by default. +// Enable persistence to keep jobs across restarts (see next section). +``` + +### Persistence & Advanced Controls + +When you need your job definitions to survive application restarts, opt into persistence explicitly: + +```typescript +import Baker from "cronbake"; +import { FilePersistenceProvider } from "cronbake"; + +const baker = Baker.create({ + persistence: { + enabled: true, + strategy: "file", + provider: new FilePersistenceProvider("./cronbake-state.json"), + autoRestore: true, + }, +}); + +await baker.ready(); // wait for autoRestore before interacting with jobs + +baker.add({ + name: "daily-job", + cron: "@daily", + persist: true, // default when persistence is enabled, but explicit for clarity + callback: () => console.log("runs daily"), +}); + +// Jobs with persist: false stay ephemeral even when persistence is on +baker.add({ + name: "one-off", + cron: "@hourly", + persist: false, + callback: () => console.log("won't be restored"), +}); ``` +### Examples + +This repo includes runnable TypeScript scripts under `examples/`. They are excluded from the published package (only `dist/` ships), so you can use them freely as references: + +- `examples/basic.ts` – minimal stateless jobs with graceful shutdown +- `examples/persistence.ts` – file-backed persistence plus `await baker.ready()` and mixed `persist` flags + +Run them with your favorite TS runner, e.g. `pnpm tsx examples/basic.ts`. + #### Immediate/Delayed First Run and Overrun Protection ```typescript -import Baker from 'cronbake'; +import Baker from "cronbake"; const baker = Baker.create(); baker.add({ - name: 'fast-job', - cron: '@every_10_seconds', - immediate: true, // run the first time right away - delay: '2s', // but wait 2 seconds before that first run + name: "fast-job", + cron: "@every_10_seconds", + immediate: true, // run the first time right away + delay: "2s", // but wait 2 seconds before that first run overrunProtection: true, // skip overlaps if a previous run still executes callback: async () => { // Do work @@ -187,7 +232,7 @@ baker.bakeAll(); You can configure the Baker with advanced options: ```typescript -import Baker from 'cronbake'; +import Baker from "cronbake"; const baker = Baker.create({ autoStart: false, @@ -199,7 +244,7 @@ const baker = Baker.create({ }, persistence: { enabled: true, - filePath: './cronbake-state.json', + filePath: "./cronbake-state.json", autoRestore: true, }, onError: (error, jobName) => { @@ -214,25 +259,25 @@ Jobs can be configured with various options: ```typescript const job = baker.add({ - name: 'advanced-job', - cron: '*/30 * * * * *', + name: "advanced-job", + cron: "*/30 * * * * *", callback: async () => { // Your async job logic here await processData(); }, onTick: () => { - console.log('Job started'); + console.log("Job started"); }, onComplete: () => { - console.log('Job completed'); + console.log("Job completed"); }, onError: (error) => { - console.error('Job failed:', error.message); + console.error("Job failed:", error.message); }, priority: 5, maxHistory: 50, start: true, - persist: true, + persist: true, // default true when persistence enabled; set false for ephemeral }); ``` @@ -242,10 +287,10 @@ You can manage cron jobs using the various methods provided by the `Baker` class ```typescript // Start, stop, pause, and resume jobs -baker.bake('job-name'); -baker.stop('job-name'); -baker.pause('job-name'); -baker.resume('job-name'); +baker.bake("job-name"); +baker.stop("job-name"); +baker.pause("job-name"); +baker.resume("job-name"); // Bulk operations baker.bakeAll(); @@ -254,14 +299,14 @@ baker.pauseAll(); baker.resumeAll(); // Get job information -const status = baker.getStatus('job-name'); -const isRunning = baker.isRunning('job-name'); -const nextRun = baker.nextExecution('job-name'); -const remaining = baker.remaining('job-name'); +const status = baker.getStatus("job-name"); +const isRunning = baker.isRunning("job-name"); +const nextRun = baker.nextExecution("job-name"); +const remaining = baker.remaining("job-name"); // Get metrics and history -const metrics = baker.getMetrics('job-name'); -const history = baker.getHistory('job-name'); +const metrics = baker.getMetrics("job-name"); +const history = baker.getHistory("job-name"); // Job management const jobNames = baker.getJobNames(); @@ -273,6 +318,8 @@ const allJobs = baker.getAllJobs(); Save and restore job state: ```typescript +await baker.ready(); // Wait for autoRestore to finish before inspecting jobs + // Save current state await baker.saveState(); @@ -285,24 +332,31 @@ await baker.restoreState(); Cronbake uses pluggable providers for persistence. A file provider is included by default. A Redis provider is available; you inject your Redis client. ```typescript -import { Baker, FilePersistenceProvider, RedisPersistenceProvider } from 'cronbake'; +import { + Baker, + FilePersistenceProvider, + RedisPersistenceProvider, +} from "cronbake"; // File-based const bakerFile = Baker.create({ persistence: { enabled: true, - strategy: 'file', - provider: new FilePersistenceProvider('./cronbake-state.json'), + strategy: "file", + provider: new FilePersistenceProvider("./cronbake-state.json"), autoRestore: true, }, }); // Redis-based (provide your own client implementing get/set) -const redisProvider = new RedisPersistenceProvider({ client: redisClient, key: 'cronbake:state' }); +const redisProvider = new RedisPersistenceProvider({ + client: redisClient, + key: "cronbake:state", +}); const bakerRedis = Baker.create({ persistence: { enabled: true, - strategy: 'redis', + strategy: "redis", provider: redisProvider, autoRestore: true, }, @@ -311,57 +365,57 @@ const bakerRedis = Baker.create({ ### Baker Methods -| Method | Description | -| --- | --- | -| `add(options: CronOptions)` | Adds a new cron job to the baker. | -| `remove(name: string)` | Removes a cron job from the baker. | -| `bake(name: string)` | Starts a cron job. | -| `stop(name: string)` | Stops a cron job. | -| `pause(name: string)` | Pauses a cron job. | -| `resume(name: string)` | Resumes a paused cron job. | -| `destroy(name: string)` | Destroys a cron job. | -| `getStatus(name: string)` | Returns the status of a cron job. | -| `isRunning(name: string)` | Checks if a cron job is running. | -| `lastExecution(name: string)` | Returns the last execution time of a cron job. | -| `nextExecution(name: string)` | Returns the next execution time of a cron job. | -| `remaining(name: string)` | Returns the remaining time until the next execution of a cron job. | -| `time(name: string)` | Returns the current time of a cron job. | -| `getHistory(name: string)` | Returns the execution history of a cron job. | -| `getMetrics(name: string)` | Returns the metrics of a cron job. | -| `getJobNames()` | Returns all cron job names. | -| `getAllJobs()` | Returns all cron jobs with their status. | -| `bakeAll()` | Starts all cron jobs. | -| `stopAll()` | Stops all cron jobs. | -| `pauseAll()` | Pauses all cron jobs. | -| `resumeAll()` | Resumes all cron jobs. | -| `destroyAll()` | Destroys all cron jobs. | -| `saveState()` | Saves the current state of all jobs to persistence storage. | -| `restoreState()` | Restores jobs from persistence storage. | -| `resetAllMetrics()` | Resets metrics for all jobs. | -| `static create(options: IBakerOptions)` | Creates a new instance of `Baker`. | +| Method | Description | +| --------------------------------------- | ------------------------------------------------------------------ | +| `add(options: CronOptions)` | Adds a new cron job to the baker. | +| `remove(name: string)` | Removes a cron job from the baker. | +| `bake(name: string)` | Starts a cron job. | +| `stop(name: string)` | Stops a cron job. | +| `pause(name: string)` | Pauses a cron job. | +| `resume(name: string)` | Resumes a paused cron job. | +| `destroy(name: string)` | Destroys a cron job. | +| `getStatus(name: string)` | Returns the status of a cron job. | +| `isRunning(name: string)` | Checks if a cron job is running. | +| `lastExecution(name: string)` | Returns the last execution time of a cron job. | +| `nextExecution(name: string)` | Returns the next execution time of a cron job. | +| `remaining(name: string)` | Returns the remaining time until the next execution of a cron job. | +| `time(name: string)` | Returns the remaining time until the next execution. | +| `getHistory(name: string)` | Returns the execution history of a cron job. | +| `getMetrics(name: string)` | Returns the metrics of a cron job. | +| `getJobNames()` | Returns all cron job names. | +| `getAllJobs()` | Returns all cron jobs with their status. | +| `bakeAll()` | Starts all cron jobs. | +| `stopAll()` | Stops all cron jobs. | +| `pauseAll()` | Pauses all cron jobs. | +| `resumeAll()` | Resumes all cron jobs. | +| `destroyAll()` | Destroys all cron jobs. | +| `saveState()` | Saves the current state of all jobs to persistence storage. | +| `restoreState()` | Restores jobs from persistence storage. | +| `resetAllMetrics()` | Resets metrics for all jobs. | +| `static create(options: IBakerOptions)` | Creates a new instance of `Baker`. | ### Advanced Usage Cronbake also provides a `Cron` class that you can use directly to create and manage individual cron jobs. This can be useful if you need more granular control over cron job instances. ```typescript -import { Cron } from 'cronbake'; +import { Cron } from "cronbake"; // Create a new Cron instance const job = Cron.create({ - name: 'custom-job', - cron: '1-10/2 1,2,3 * * * *', // Runs every 2 seconds in the first 3 minutes of every hour + name: "custom-job", + cron: "1-10/2 1,2,3 * * * *", // Runs every 2 seconds in the first 3 minutes of every hour callback: () => { - console.log('Custom job executed!'); + console.log("Custom job executed!"); }, onTick: () => { - console.log('Job ticked!'); + console.log("Job ticked!"); }, onComplete: () => { - console.log('Job completed!'); + console.log("Job completed!"); }, onError: (error) => { - console.error('Job failed:', error.message); + console.error("Job failed:", error.message); }, priority: 10, immediate: false, @@ -386,15 +440,13 @@ const history = job.getHistory(); // Metrics include skippedExecutions when overrun protection skips overlaps ``` - - Cronbake also provides utility functions for parsing cron expressions, getting the next or previous execution times, and validating cron expressions. ```typescript -import { Cron } from 'cronbake'; +import { Cron } from "cronbake"; // Parse a cron expression -const cronExpression = '0 0 0 * * *'; +const cronExpression = "0 0 0 * * *"; const cronTime = Cron.parse(cronExpression); // Get the next execution time for a cron expression const nextExecution = Cron.getNext(cronExpression); @@ -406,23 +458,23 @@ const isValid = Cron.isValid(cronExpression); // true ### Cron Methods -| Method | Description | -| --- | --- | -| `start()` | Starts the cron job. | -| `stop()` | Stops the cron job. | -| `pause()` | Pauses the cron job. | -| `resume()` | Resumes the cron job. | -| `getStatus()` | Returns the current status of the cron job. | -| `isRunning()` | Checks if the cron job is running. | -| `nextExecution()` | Returns the date of the next execution of the cron job. | -| `getHistory()` | Returns the execution history of the cron job. | -| `getMetrics()` | Returns the metrics of the cron job. | -| `resetMetrics()` | Resets the metrics and history of the cron job. | -| `static create(options: CronOptions)` | Creates a new cron job with the specified options. | -| `static parse(cron: CronExpressionType)` | Parses the specified cron expression and returns a `CronTime` object. | -| `static getNext(cron: CronExpressionType)` | Gets the next execution time for the specified cron expression. | -| `static getPrevious(cron: CronExpressionType)` | Gets the previous execution time for the specified cron expression. | -| `static isValid(cron: CronExpressionType)` | Checks if the specified string is a valid cron expression. | +| Method | Description | +| ------------------------------------------------- | --------------------------------------------------------------------- | +| `start()` | Starts the cron job. | +| `stop()` | Stops the cron job. | +| `pause()` | Pauses the cron job. | +| `resume()` | Resumes the cron job. | +| `getStatus()` | Returns the current status of the cron job. | +| `isRunning()` | Checks if the cron job is running. | +| `nextExecution()` | Returns the date of the next execution of the cron job. | +| `getHistory()` | Returns the execution history of the cron job. | +| `getMetrics()` | Returns the metrics of the cron job. | +| `resetMetrics()` | Resets the metrics and history of the cron job. | +| `static create(options: CronOptions)` | Creates a new cron job with the specified options. | +| `static parse(cron: CronExpressionType)` | Parses the specified cron expression and returns a `CronTime` object. | +| `static getNext(cron: CronExpressionType)` | Gets the next execution time for the specified cron expression. | +| `static getPrevious(cron: CronExpressionType)` | Gets the previous execution time for the specified cron expression. | +| `static isValid(cron: CronExpressionType)` | Checks if the specified string is a valid cron expression. | ## Contributing diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..adfed45 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,29 @@ +# Cronbake Examples + +These TypeScript scripts demonstrate common Cronbake setups. They live outside the published bundle (`package.json` only ships the `dist/` folder), so they are safe to keep in the repo. + +## Prerequisites + +From the project root: + +```bash +bun install +``` + +## Run the stateless example + +```bash +bun run examples/basic.ts +``` + +This spawns two in-memory jobs and listens for Ctrl+C to stop them. + +## Run the persistence example + +```bash +bun run examples/persistence.ts +``` + +The script writes state to `./.cronbake-state.json`, demonstrating `persist: true` vs. `persist: false` jobs and `await baker.ready()`. + +Feel free to copy these files or adapt them into your own app bootstrap logic. diff --git a/examples/basic.ts b/examples/basic.ts new file mode 100644 index 0000000..9ee0d3e --- /dev/null +++ b/examples/basic.ts @@ -0,0 +1,37 @@ +import Baker from "../lib/index"; + +async function main() { + const baker = Baker.create({ + autoStart: true, + }); + + baker.add({ + name: "log-every-minute", + cron: "@every_minute", + callback: () => { + console.log(`[${new Date().toISOString()}] Hello from Cronbake!`); + }, + }); + + baker.add({ + name: "at-noon", + cron: "0 0 12 * * *", + callback: () => { + console.log("It's noon somewhere! Time to stretch."); + }, + }); + + const shutdown = () => { + console.log("Stopping all jobs..."); + baker.stopAll(); + process.exit(0); + }; + + process.once("SIGINT", shutdown); + process.once("SIGTERM", shutdown); +} + +main().catch((error) => { + console.error("Example failed:", error); + process.exit(1); +}); diff --git a/examples/persistence.ts b/examples/persistence.ts new file mode 100644 index 0000000..fc70b46 --- /dev/null +++ b/examples/persistence.ts @@ -0,0 +1,49 @@ +import Baker from "../lib/index"; +import { FilePersistenceProvider } from "../lib/persistence/index"; + +async function main() { + const baker = Baker.create({ + autoStart: true, + persistence: { + enabled: true, + strategy: "file", + provider: new FilePersistenceProvider("./.cronbake-state.json"), + autoRestore: true, + }, + }); + + await baker.ready(); + + baker.add({ + name: "daily-report", + cron: "@daily", + persist: true, + callback: () => { + console.log(`[${new Date().toISOString()}] Generating daily summary...`); + }, + }); + + baker.add({ + name: "ephemeral-debug", + cron: "@every_10_seconds", + persist: false, + callback: () => { + console.log("Debug job ran – this will not be restored after restart."); + }, + }); + + const shutdown = async () => { + console.log("Stopping all jobs and saving state..."); + baker.stopAll(); + await baker.saveState(); + process.exit(0); + }; + + process.once("SIGINT", shutdown); + process.once("SIGTERM", shutdown); +} + +main().catch((error) => { + console.error("Persistence example failed:", error); + process.exit(1); +}); diff --git a/index.ts b/index.ts index 8475e1b..a201952 100644 --- a/index.ts +++ b/index.ts @@ -27,6 +27,7 @@ export { RedisPersistenceProvider, PersistedJob, PersistedState, + PersistedExecutionHistory, PersistenceProvider, RedisLikeClient, RedisProviderOptions, diff --git a/lib/baker.ts b/lib/baker.ts index b3af0e9..18a9ead 100644 --- a/lib/baker.ts +++ b/lib/baker.ts @@ -1,4 +1,4 @@ -import Cron from './cron'; +import Cron from "./cron"; import { CronOptions, IBaker, @@ -9,15 +9,19 @@ import { JobMetrics, SchedulerConfig, PersistenceOptions, -} from './types'; -import { FilePersistenceProvider } from './persistence/file'; -import type { PersistenceProvider } from './persistence/types'; +} from "./types"; +import { FilePersistenceProvider } from "./persistence/file"; +import type { PersistenceProvider } from "./persistence/types"; /** * A class that implements the `IBaker` interface and provides methods to manage cron jobs. */ class Baker implements IBaker { private crons: Map = new Map(); + private restoredJobs: Set = new Set(); + private jobPersistence: Map = new Map(); + private isRestoring = false; + private initialRestorePromise: Promise = Promise.resolve(); private config: SchedulerConfig; private persistence: PersistenceOptions; private persistenceProvider?: PersistenceProvider; @@ -27,15 +31,16 @@ class Baker implements IBaker { constructor(options: IBakerOptions = {}) { this.config = { pollingInterval: options.schedulerConfig?.pollingInterval ?? 1000, - useCalculatedTimeouts: options.schedulerConfig?.useCalculatedTimeouts ?? true, + useCalculatedTimeouts: + options.schedulerConfig?.useCalculatedTimeouts ?? true, maxHistoryEntries: options.schedulerConfig?.maxHistoryEntries ?? 100, }; - + this.persistence = { enabled: options.persistence?.enabled ?? false, - filePath: options.persistence?.filePath ?? './cronbake-state.json', + filePath: options.persistence?.filePath ?? "./cronbake-state.json", autoRestore: options.persistence?.autoRestore ?? true, - strategy: options.persistence?.strategy ?? 'file', + strategy: options.persistence?.strategy ?? "file", provider: options.persistence?.provider, redis: options.persistence?.redis, }; @@ -43,31 +48,46 @@ class Baker implements IBaker { if (this.persistence.enabled) { if (this.persistence.provider) { this.persistenceProvider = this.persistence.provider; - } else if (this.persistence.strategy === 'file') { - this.persistenceProvider = new FilePersistenceProvider(this.persistence.filePath!); - } else if (this.persistence.strategy === 'redis') { - throw new Error('Redis persistence selected but no provider supplied. Pass persistence.provider or use FilePersistenceProvider.'); + } else if (this.persistence.strategy === "file") { + this.persistenceProvider = new FilePersistenceProvider( + this.persistence.filePath! + ); + } else if (this.persistence.strategy === "redis") { + throw new Error( + "Redis persistence selected but no provider supplied. Pass persistence.provider or use FilePersistenceProvider." + ); } } - + this.enableMetrics = options.enableMetrics ?? true; this.onError = options.onError; if (this.persistence.enabled && this.persistence.autoRestore) { - this.restoreState().catch(err => { - console.warn('Failed to restore state:', err); + const restorePromise = this.restoreState(); + restorePromise.catch((err) => { + console.warn("Failed to restore state:", err); }); + this.initialRestorePromise = restorePromise; } if (options.autoStart) { - this.bakeAll(); + this.initialRestorePromise.finally(() => this.bakeAll()); } } add(options: CronOptions): ICron { - if (this.crons.has(options.name)) { - throw new Error(`Cron job with name '${options.name}' already exists`); + const existingCron = this.crons.get(options.name); + if (existingCron) { + if (this.restoredJobs.has(options.name)) { + existingCron.destroy(); + this.crons.delete(options.name); + this.restoredJobs.delete(options.name); + this.jobPersistence.delete(options.name); + } else { + throw new Error(`Cron job with name '${options.name}' already exists`); + } } + this.jobPersistence.delete(options.name); const cronConfig = { useCalculatedTimeouts: this.config.useCalculatedTimeouts, @@ -77,19 +97,23 @@ class Baker implements IBaker { const enhancedOptions: CronOptions = { ...options, maxHistory: options.maxHistory ?? this.config.maxHistoryEntries, - onError: options.onError ?? ((error: Error) => { - if (this.onError) { - this.onError(error, options.name); - } - }), + onError: + options.onError ?? + ((error: Error) => { + if (this.onError) { + this.onError(error, options.name); + } + }), }; const cron = Cron.create(enhancedOptions, cronConfig); + const shouldPersist = options.persist ?? true; + this.jobPersistence.set(cron.name, shouldPersist); this.crons.set(cron.name, cron); - if (this.persistence.enabled) { - this.saveState().catch(err => { - console.warn('Failed to save state:', err); + if (this.persistence.enabled && !this.isRestoring) { + this.saveState().catch((err) => { + console.warn("Failed to save state:", err); }); } @@ -101,10 +125,12 @@ class Baker implements IBaker { if (cron) { cron.destroy(); this.crons.delete(name); + this.restoredJobs.delete(name); + this.jobPersistence.delete(name); if (this.persistence.enabled) { - this.saveState().catch(err => { - console.warn('Failed to save state:', err); + this.saveState().catch((err) => { + console.warn("Failed to save state:", err); }); } } @@ -143,10 +169,12 @@ class Baker implements IBaker { if (cron) { cron.destroy(); this.crons.delete(name); + this.restoredJobs.delete(name); + this.jobPersistence.delete(name); if (this.persistence.enabled) { - this.saveState().catch(err => { - console.warn('Failed to save state:', err); + this.saveState().catch((err) => { + console.warn("Failed to save state:", err); }); } } @@ -154,7 +182,7 @@ class Baker implements IBaker { getStatus(name: string): Status { const cron = this.crons.get(name); - return cron ? cron.getStatus() : 'stopped'; + return cron ? cron.getStatus() : "stopped"; } isRunning(name: string): boolean { @@ -189,12 +217,14 @@ class Baker implements IBaker { getMetrics(name: string): JobMetrics { const cron = this.crons.get(name); - return cron && this.enableMetrics ? cron.getMetrics() : { - totalExecutions: 0, - successfulExecutions: 0, - failedExecutions: 0, - averageExecutionTime: 0, - }; + return cron && this.enableMetrics + ? cron.getMetrics() + : { + totalExecutions: 0, + successfulExecutions: 0, + failedExecutions: 0, + averageExecutionTime: 0, + }; } getJobNames(): string[] { @@ -224,10 +254,12 @@ class Baker implements IBaker { destroyAll(): void { this.crons.forEach((cron) => cron.destroy()); this.crons.clear(); + this.restoredJobs.clear(); + this.jobPersistence.clear(); if (this.persistence.enabled) { - this.saveState().catch(err => { - console.warn('Failed to save state:', err); + this.saveState().catch((err) => { + console.warn("Failed to save state:", err); }); } } @@ -241,17 +273,26 @@ class Baker implements IBaker { async saveState(): Promise { if (!this.persistence.enabled || !this.persistenceProvider) return; try { - const state = { - version: 1, - timestamp: new Date().toISOString(), - jobs: Array.from(this.crons.entries()).map(([name, cron]) => ({ + const jobs = Array.from(this.crons.entries()) + .filter(([name]) => this.jobPersistence.get(name) !== false) + .map(([name, cron]) => ({ name, cron: String(cron.cron), status: cron.getStatus(), priority: cron.priority, + persist: this.jobPersistence.get(name) !== false, metrics: this.enableMetrics ? cron.getMetrics() : undefined, - history: this.enableMetrics ? cron.getHistory() : undefined, - })), + history: this.enableMetrics + ? cron.getHistory().map((entry) => ({ + ...entry, + timestamp: entry.timestamp.toISOString(), + })) + : undefined, + })); + const state = { + version: 1, + timestamp: new Date().toISOString(), + jobs, config: this.config, } as const; await this.persistenceProvider.save(state); @@ -262,15 +303,25 @@ class Baker implements IBaker { async restoreState(): Promise { if (!this.persistence.enabled || !this.persistenceProvider) return; + if (this.isRestoring) return; + this.isRestoring = true; try { const state = await this.persistenceProvider.load(); if (!state) return; if (!state.jobs || !Array.isArray(state.jobs)) { - throw new Error('Invalid state format'); + throw new Error("Invalid state format"); } + let restoredCount = 0; for (const jobData of state.jobs) { if (!jobData.name || !jobData.cron) { - console.warn('Skipping invalid job data:', jobData); + console.warn("Skipping invalid job data:", jobData); + continue; + } + if (jobData.persist === false) { + continue; + } + if (this.crons.has(jobData.name)) { + // User code already defined this job; treat their configuration as canonical. continue; } try { @@ -278,22 +329,50 @@ class Baker implements IBaker { name: jobData.name, cron: jobData.cron, callback: () => { - console.warn(`Restored job '${jobData.name}' executed but no callback was provided`); + console.warn( + `Restored job '${jobData.name}' executed but no callback was provided` + ); }, priority: jobData.priority, - start: jobData.status === 'running', + start: jobData.status === "running", + persist: true, }; this.add(options); + const cron = this.crons.get(jobData.name); + if ( + cron && + typeof cron.hydratePersistence === "function" && + this.enableMetrics + ) { + const history = jobData.history?.map((entry) => ({ + ...entry, + timestamp: new Date(entry.timestamp), + })); + cron.hydratePersistence({ + history, + metrics: jobData.metrics, + }); + } + this.restoredJobs.add(jobData.name); + restoredCount++; } catch (error) { console.warn(`Failed to restore job '${jobData.name}':`, error); } } - console.log(`Restored ${state.jobs.length} cron jobs from persistence`); + if (restoredCount > 0) { + console.log(`Restored ${restoredCount} cron jobs from persistence`); + } } catch (error) { throw new Error(`Failed to restore state: ${error}`); + } finally { + this.isRestoring = false; } } + async ready(): Promise { + await this.initialRestorePromise; + } + /** * Creates a new instance of `Baker`. */ diff --git a/lib/cron.ts b/lib/cron.ts index 9617615..4970d35 100644 --- a/lib/cron.ts +++ b/lib/cron.ts @@ -9,7 +9,7 @@ import { type JobMetrics, } from "./types"; import CronParser from "./parser"; -import { CBResolver } from "./utils"; +import { CBResolver, resolveIfPromise } from "./utils"; /** * A class that implements the `ICron` interface and provides methods manage a cron job. @@ -41,6 +41,7 @@ class Cron implements ICron { private isExecuting: boolean = false; private immediate: boolean; private initialDelayMs?: number; + private lastRunTime: Date | null = null; /** * Creates a new instance of the `Cron` class. @@ -230,11 +231,16 @@ class Cron implements ICron { this.interval = setInterval(async () => { if (this.overrunProtection && this.isExecuting) { // Overrun protection: skip overlapping start - this.metrics.skippedExecutions = (this.metrics.skippedExecutions ?? 0) + 1; + this.metrics.skippedExecutions = + (this.metrics.skippedExecutions ?? 0) + 1; return; } - if (this.next && this.next.getTime() <= Date.now() && this.status === "running") { + if ( + this.next && + this.next.getTime() <= Date.now() && + this.status === "running" + ) { await this.executeJob(); this.next = this.parser.getNext(); } @@ -251,14 +257,13 @@ class Cron implements ICron { this.isExecuting = true; try { - await CBResolver(this.callback, this.onError); - this.onTick(); - this.metrics.successfulExecutions++; + if (this.callback) { + await resolveIfPromise(this.callback()); + } + await this.onTick(); } catch (err) { success = false; error = err instanceof Error ? err.message : String(err); - this.metrics.failedExecutions++; - this.metrics.lastError = error; if (this.onError) { try { @@ -266,14 +271,21 @@ class Cron implements ICron { } catch (handlerError) { console.warn("Error handler failed:", handlerError); } + } else { + console.warn(`Cron job '${this.name}' failed:`, err); } - } - finally { + this.metrics.lastError = error; + } finally { this.isExecuting = false; } const duration = Date.now() - startTime; this.metrics.totalExecutions++; + if (success) { + this.metrics.successfulExecutions++; + } else { + this.metrics.failedExecutions++; + } this.metrics.lastExecutionTime = duration; this.metrics.averageExecutionTime = (this.metrics.averageExecutionTime * (this.metrics.totalExecutions - 1) + @@ -291,6 +303,7 @@ class Cron implements ICron { if (this.history.length > this.maxHistory) { this.history = this.history.slice(0, this.maxHistory); } + this.lastRunTime = historyEntry.timestamp; } stop(): void { @@ -352,7 +365,9 @@ class Cron implements ICron { } lastExecution(): Date { - return this.parser.getPrevious(); + return this.lastRunTime + ? new Date(this.lastRunTime) + : this.parser.getPrevious(); } nextExecution(): Date { @@ -364,7 +379,7 @@ class Cron implements ICron { } time(): number { - return Date.now(); + return this.remaining(); } getHistory(): ExecutionHistory[] { @@ -385,6 +400,18 @@ class Cron implements ICron { }; } + hydratePersistence(state: { + history?: ExecutionHistory[]; + metrics?: JobMetrics; + }): void { + if (state.history) { + this.history = state.history.slice(0, this.maxHistory); + } + if (state.metrics) { + this.metrics = { ...state.metrics }; + } + } + /** Parse a human-friendly delay string or number into milliseconds */ private parseDelay(delay?: number | string): number | undefined { if (delay === undefined || delay === null) return undefined; @@ -394,7 +421,16 @@ class Cron implements ICron { if (!match) return undefined; const value = parseInt(match[1], 10); const unit = match[2] ?? "ms"; - const factor = unit === "ms" ? 1 : unit === "s" ? 1000 : unit === "m" ? 60000 : unit === "h" ? 3600000 : 86400000; + const factor = + unit === "ms" + ? 1 + : unit === "s" + ? 1000 + : unit === "m" + ? 60000 + : unit === "h" + ? 3600000 + : 86400000; return value * factor; } diff --git a/lib/index.test.ts b/lib/index.test.ts index 2f9ec1f..19e5f37 100644 --- a/lib/index.test.ts +++ b/lib/index.test.ts @@ -304,6 +304,33 @@ describe("Baker", () => { expect(cron.onError).toBeDefined(); }); + it("should record failed executions when callbacks throw", async () => { + const onError = jest.fn(); + baker.add({ + name: "failing-job", + cron: "* * * * * *", + callback: () => { + throw new Error("boom"); + }, + start: true, + immediate: true, + onError, + }); + + await new Promise((resolve) => setTimeout(resolve, 30)); + baker.stop("failing-job"); + + const metrics = baker.getMetrics("failing-job"); + expect(metrics.totalExecutions).toBe(1); + expect(metrics.failedExecutions).toBe(1); + expect(metrics.successfulExecutions).toBe(0); + expect(metrics.lastError).toBe("boom"); + + const history = baker.getHistory("failing-job"); + expect(history[0]?.success).toBe(false); + expect(onError).toHaveBeenCalled(); + }); + it("should reset metrics", () => { const cron = baker.add({ name: "test", @@ -360,11 +387,10 @@ describe("Baker", () => { }); expect(cron).toBeDefined(); - + persistenceBaker.destroyAll(); }); - it("should prevent overrun when enabled (interval mode)", async () => { const configuredBaker = new Baker({ schedulerConfig: { @@ -458,6 +484,20 @@ describe("Baker", () => { await new Promise((r) => setTimeout(r, 60)); expect(cb).toHaveBeenCalledTimes(1); }); + + it("should update lastExecution after a job runs", async () => { + baker.add({ + name: "last-run", + cron: "0 0 0 1 1 *", + callback: jest.fn(), + start: true, + immediate: true, + }); + + await new Promise((resolve) => setTimeout(resolve, 30)); + const last = baker.lastExecution("last-run"); + expect(Math.abs(last.getTime() - Date.now())).toBeLessThan(1000); + }); }); describe("CronParser", () => { @@ -657,8 +697,11 @@ describe("Cron", () => { }); it("should get the time until the next execution of the cron job", () => { + cron.start(); + const remaining = cron.remaining(); const time = cron.time(); - expect(typeof time).toBe("number"); + expect(Math.abs(time - remaining)).toBeLessThanOrEqual(10); + cron.stop(); }); it("should get execution history", () => { diff --git a/lib/persistence.test.ts b/lib/persistence.test.ts index 771cc56..921269e 100644 --- a/lib/persistence.test.ts +++ b/lib/persistence.test.ts @@ -16,7 +16,7 @@ describe('Persistence Providers', () => { const baker2 = new Baker({ persistence: { enabled: true, autoRestore: true, strategy: 'file', provider }, }); - await sleep(10); + await baker2.ready(); expect(baker2.getJobNames()).toContain('file-job'); // Stop timers and persist final state before cleanup baker2.stopAll(); @@ -37,7 +37,7 @@ describe('Persistence Providers', () => { const baker2 = new Baker({ persistence: { enabled: true, autoRestore: true, strategy: 'redis', provider }, }); - await sleep(10); + await baker2.ready(); expect(baker2.getJobNames()).toContain('redis-job'); baker2.destroyAll(); baker1.destroyAll(); @@ -46,4 +46,114 @@ describe('Persistence Providers', () => { it('Throws if redis strategy without provider', () => { expect(() => new Baker({ persistence: { enabled: true, strategy: 'redis' } })).toThrow(); }); + + it('allows overriding restored jobs with new definitions', async () => { + const { provider, filePath } = createFileProvider(); + + const baker1 = new Baker({ + persistence: { enabled: true, autoRestore: false, strategy: 'file', provider }, + }); + baker1.add({ name: 'restored-job', cron: '@daily', callback: () => {} }); + await baker1.saveState(); + baker1.destroyAll(); + + const baker2 = new Baker({ + persistence: { enabled: true, autoRestore: true, strategy: 'file', provider }, + }); + await baker2.ready(); + const restored = baker2.getAllJobs().get('restored-job'); + expect(restored?.cron).toBe('@daily'); + + baker2.add({ name: 'restored-job', cron: '@hourly', callback: () => {} }); + const updated = baker2.getAllJobs().get('restored-job'); + expect(updated?.cron).toBe('@hourly'); + + baker2.destroyAll(); + await baker2.saveState(); + cleanupFile(filePath); + }); + + it('skips restoring jobs already defined before restore runs', async () => { + const { provider, filePath } = createFileProvider(); + + const baker1 = new Baker({ + persistence: { enabled: true, autoRestore: false, strategy: 'file', provider }, + }); + baker1.add({ name: 'predefined-job', cron: '@daily', callback: () => {} }); + await baker1.saveState(); + baker1.destroyAll(); + + const baker2 = new Baker({ + persistence: { enabled: true, autoRestore: false, strategy: 'file', provider }, + }); + baker2.add({ name: 'predefined-job', cron: '@hourly', callback: () => {} }); + await baker2.restoreState(); + const job = baker2.getAllJobs().get('predefined-job'); + expect(job?.cron).toBe('@hourly'); + + baker2.destroyAll(); + await baker2.saveState(); + cleanupFile(filePath); + }); + + it('respects per-job persistence flags', async () => { + const { provider, filePath } = createFileProvider(); + + const baker1 = new Baker({ + persistence: { enabled: true, autoRestore: false, strategy: 'file', provider }, + }); + + baker1.add({ name: 'persisted-job', cron: '@daily', callback: () => {}, persist: true }); + baker1.add({ name: 'ephemeral-job', cron: '@hourly', callback: () => {}, persist: false }); + await baker1.saveState(); + baker1.destroyAll(); + + const baker2 = new Baker({ + persistence: { enabled: true, autoRestore: true, strategy: 'file', provider }, + }); + await baker2.ready(); + const jobNames = baker2.getJobNames(); + expect(jobNames).toContain('persisted-job'); + expect(jobNames).not.toContain('ephemeral-job'); + + baker2.destroyAll(); + await baker2.saveState(); + cleanupFile(filePath); + }); + + it('restores metrics and history snapshots', async () => { + const { provider, filePath } = createFileProvider(); + + const baker1 = new Baker({ + enableMetrics: true, + persistence: { enabled: true, autoRestore: false, strategy: 'file', provider }, + }); + + baker1.add({ + name: 'metrics-job', + cron: '@every_second', + callback: () => {}, + start: true, + immediate: true, + persist: true, + }); + + await sleep(120); + baker1.stopAll(); + await baker1.saveState(); + baker1.destroyAll(); + + const baker2 = new Baker({ + enableMetrics: true, + persistence: { enabled: true, autoRestore: true, strategy: 'file', provider }, + }); + await baker2.ready(); + const metricsJob = baker2.getAllJobs().get('metrics-job'); + expect(metricsJob?.getMetrics().totalExecutions ?? 0).toBeGreaterThan(0); + expect(metricsJob?.getHistory().length ?? 0).toBeGreaterThan(0); + + baker2.destroyAll(); + await baker2.saveState(); + cleanupFile(filePath); + }); }); diff --git a/lib/persistence/index.ts b/lib/persistence/index.ts index 491f221..ba08dd1 100644 --- a/lib/persistence/index.ts +++ b/lib/persistence/index.ts @@ -1,4 +1,10 @@ -export { type PersistedState, type PersistedJob, type PersistenceProvider, type RedisLikeClient } from "./types"; +export { + type PersistedState, + type PersistedJob, + type PersistedExecutionHistory, + type PersistenceProvider, + type RedisLikeClient, +} from "./types"; export { FilePersistenceProvider } from "./file"; export { RedisPersistenceProvider, type RedisProviderOptions } from "./redis"; diff --git a/lib/persistence/types.ts b/lib/persistence/types.ts index d43f959..fbc1d97 100644 --- a/lib/persistence/types.ts +++ b/lib/persistence/types.ts @@ -1,4 +1,11 @@ -import { ExecutionHistory, JobMetrics, SchedulerConfig, Status } from "../types"; +import { JobMetrics, SchedulerConfig, Status } from "../types"; + +type PersistedExecutionHistory = { + timestamp: string; + duration: number; + success: boolean; + error?: string; +}; type PersistedJob = { name: string; @@ -6,7 +13,8 @@ type PersistedJob = { status: Status; priority: number; metrics?: JobMetrics; - history?: ExecutionHistory[]; + history?: PersistedExecutionHistory[]; + persist?: boolean; }; type PersistedState = { @@ -27,5 +35,11 @@ interface RedisLikeClient { set(key: string, value: string): Promise; } -export type { PersistedState, PersistedJob, PersistenceProvider, RedisLikeClient }; +export type { + PersistedState, + PersistedJob, + PersistedExecutionHistory, + PersistenceProvider, + RedisLikeClient, +}; diff --git a/lib/types.ts b/lib/types.ts index cd0af47..fadcbcb 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -175,6 +175,13 @@ interface ICron { * Resets the metrics and history of the cron job. */ resetMetrics: () => void; + /** + * Hydrates metrics/history from persisted state. + */ + hydratePersistence?: (state: { + history?: ExecutionHistory[]; + metrics?: JobMetrics; + }) => void; } /** @@ -466,6 +473,10 @@ interface IBaker { * Resets metrics for all jobs. */ resetAllMetrics: () => void; + /** + * Resolves when the initial persistence restoration completes. + */ + ready: () => Promise; } interface IBakerOptions { diff --git a/package.json b/package.json index 89ff513..f62fa64 100644 --- a/package.json +++ b/package.json @@ -2,18 +2,18 @@ "name": "cronbake", "description": "A powerful and flexible cron job manager built with TypeScript", "module": "dist/index.js", - "version": "0.3.1", + "version": "0.3.2", "publishConfig": { "access": "public" }, "repository": { "type": "git", - "url": "https://github.com/triyanox/cronbake.git" + "url": "https://github.com/chaqchase/cronbake.git" }, "bugs": { - "url": "https://github.com/triyanox/cronbake/issues" + "url": "https://github.com/chaqchase/cronbake/issues" }, - "homepage": "https://github.com/triyanox/cronbake", + "homepage": "https://github.com/chaqchase/cronbake", "main": "dist/index.js", "author": { "name": "Mohamed Achaq",