Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,51 @@ day of week 0-7 (0 or 7 is Sunday, or use names)

- `utcOffset`: [OPTIONAL] - Analogous to `utcOffset` from `CronJob` parameters.

### Scheduler Class

The `Scheduler` provides centralized management of multiple named `CronJob` instances. Registration is opt-in via `schedule()` or `register()` — passing `name` to `CronJob` alone does not register the job.

A default singleton is exported as `scheduler`. Use `new Scheduler()` when you need an isolated registry (e.g. tests or workers).

#### schedule

`schedule(params)`: Creates a `CronJob`, registers it under `params.name`, then applies `runOnInit` and `start`. Accepts `ScheduleParams`, which extends `CronJobParams` with a required `name`.

```javascript
import { scheduler } from 'cron';

scheduler.schedule({
name: 'backup',
cronTime: '0 2 * * *',
onTick: () => console.log('backup running'),
start: true,
timeZone: 'America/Sao_Paulo'
});
```

#### register

`register(name, job)`: Registers an existing `CronJob`. Sets `job.name` when unset. Throws if the job name mismatches or the name is already registered.

#### Lifecycle Methods

- `start(name)` / `stop(name)`: Start or stop a registered job.
- `startAll()` / `stopAll()`: Start or stop all registered jobs.
- `remove(name)`: Stop the job and remove it from the registry.
- `unregister(name)`: Remove the job from the registry without stopping it.
- `clear()`: Stop all jobs and empty the registry.

#### Query Methods

- `getJobNames()`: Returns all registered job names.
- `getJob(name)`: Returns the `CronJob` instance, if registered.
- `getJobInfo(name)`: Returns `{ name, executions, lastExecution, isActive, nextExecution, createdAt }`.
- `getActiveJobs()` / `getInactiveJobs()`: Returns names of running or stopped jobs.
- `getPending()`: Returns names of jobs that have never executed.
- `nextJobs(count?)`: Returns a `Map` of upcoming execution dates per job.

See the [examples/scheduler](examples/scheduler/) directory for runnable validation scripts.

## 💢 Gotchas

- Both JS `Date` and Luxon `DateTime` objects don't guarantee millisecond precision due to computation delays. This module excludes millisecond precision for standard cron syntax but allows execution date specification through JS `Date` or Luxon `DateTime` objects. However, specifying a precise future execution time, such as adding a millisecond to the current time, may not always work due to these computation delays. It's observed that delays less than 4-5 ms might lead to inconsistencies. While we could limit all date granularity to seconds, we've chosen to allow greater precision but advise users of potential issues.
Expand Down
35 changes: 35 additions & 0 deletions examples/scheduler.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* Exemplo simples do Scheduler.
*
* Para testes isolados com validação automática, veja:
* examples/scheduler/
*
* Rodar todos: node examples/scheduler/run-all.mjs
*/

import { scheduler } from '../dist/index.js';

const MAX = 5;
let n = 0;

scheduler.schedule({
name: 'example',
cronTime: '*/2 * * * * *',
onTick() {
n++;
console.log(`[${n}/${MAX}]`, scheduler.getJobInfo('example'));
if (n >= MAX) {
scheduler.remove('exemplo');
process.exit(0);
}
},
start: true,
timeZone: 'America/Sao_Paulo'
});

process.on('SIGINT', () => {
scheduler.clear();
process.exit(0);
});

console.log('Jobs:', scheduler.getJobNames());
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ export {
TimeUnit
} from './types/cron.types';

export { Scheduler, scheduler } from './scheduler';
export type { ScheduleParams } from './scheduler';
export type { RegistryEntry } from './registry';

export const sendAt = (cronTime: string | Date | DateTime): DateTime =>
new CronTime(cronTime).sendAt();

Expand Down
58 changes: 58 additions & 0 deletions src/registry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { CronJob } from './job';
import { CronOnCompleteCommand } from './types/cron.types';

interface RegistryEntry<
OC extends CronOnCompleteCommand | null = null,
C = null
> {
job: CronJob<OC, C>;
createdAt: Date;
executions: number;
}

class JobRegistry {
private _jobs = new Map<string, RegistryEntry<any, any>>();

register<OC extends CronOnCompleteCommand | null = null, C = null>(
name: string,
job: CronJob<OC, C>
): void {
if (this._jobs.has(name)) {
throw new Error(`Job "${name}" is already registered`);
}

this._jobs.set(name, {
job,
createdAt: new Date(),
executions: 0
});
}

incrementExecutions(name: string): void {
const entry = this._jobs.get(name);
if (entry) entry.executions++;
}

getAll(): Map<string, RegistryEntry<any, any>> {
return new Map(this._jobs);
}

get(name: string): RegistryEntry<any, any> | undefined {
return this._jobs.get(name);
}

has(name: string): boolean {
return this._jobs.has(name);
}

remove(name: string): boolean {
return this._jobs.delete(name);
}

clear(): void {
this._jobs.clear();
}
}

export { JobRegistry };
export type { RegistryEntry };
177 changes: 177 additions & 0 deletions src/scheduler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { DateTime } from 'luxon';
import { CronJob } from './job';
import { JobRegistry } from './registry';
import { CronJobParams, CronOnCompleteCommand } from './types/cron.types';

export type ScheduleParams<
OC extends CronOnCompleteCommand | null = null,
C = null
> = CronJobParams<OC, C> & { name: string };

class Scheduler {
private registry = new JobRegistry();

/**
* creates a CronJob and registers it under the given name.
*/
schedule<OC extends CronOnCompleteCommand | null = null, C = null>(
params: ScheduleParams<OC, C>
): CronJob<OC, C> {
const shouldStart = params.start ?? false;
const shouldRunOnInit = params.runOnInit ?? false;
const job = CronJob.from({ ...params, start: false, runOnInit: false });
this.register(params.name, job);

if (shouldRunOnInit) {
job.lastExecution = new Date();
void job.fireOnTick();
}

if (shouldStart) job.start();

return job;
}

/**
* registers an existing CronJob. Registration is opt-in and never automatic.
*/
register<OC extends CronOnCompleteCommand | null = null, C = null>(
name: string,
job: CronJob<OC, C>
): this {
if (job.name != null && job.name !== name) {
throw new Error(
`Job name mismatch: expected "${name}", got "${job.name}"`
);
}

job.name = name;
this.registry.register(name, job);
job.addCallback(() => this.registry.incrementExecutions(name));
return this;
}

/**
* removes a job from the registry without stopping it.
*/
unregister(name: string): this {
this.registry.remove(name);
return this;
}

/**
* returns all registered job names.
*/
getJobNames(): string[] {
return [...this.registry.getAll().keys()];
}

/**
* returns a specific job by name.
*/
getJob(name: string): CronJob | undefined {
return this.registry.get(name)?.job;
}

/**
* returns detailed information about a specific job by name.
*/
getJobInfo(name: string) {
const entry = this.registry.get(name);
if (!entry) throw new Error(`Job "${name}" not found`);
return {
name,
executions: entry.executions,
lastExecution: entry.job.lastDate(),
isActive: entry.job.isActive,
nextExecution: entry.job.nextDate(),
createdAt: entry.createdAt
};
}

/**
* returns names of jobs that are currently active (running).
*/
getActiveJobs(): string[] {
return [...this.registry.getAll().entries()]
.filter(([, entry]) => entry.job.isActive)
.map(([name]) => name);
}

/**
* returns names of jobs that are currently inactive (stopped).
*/
getInactiveJobs(): string[] {
return [...this.registry.getAll().entries()]
.filter(([, entry]) => !entry.job.isActive)
.map(([name]) => name);
}

/**
* returns names of jobs that have never been executed.
*/
getPending(): string[] {
return [...this.registry.getAll().entries()]
.filter(([, entry]) => entry.job.lastDate() === null)
.map(([name]) => name);
}

/**
* returns the next execution dates for all registered jobs.
*/
nextJobs(count = 1): Map<string, DateTime | DateTime[]> {
const result = new Map<string, DateTime | DateTime[]>();

for (const [name, entry] of this.registry.getAll()) {
result.set(name, entry.job.nextDates(count));
}

return result;
}

start(name: string): this {
const entry = this.registry.get(name);
if (!entry) throw new Error(`Job "${name}" not found`);
entry.job.start();
return this;
}

stop(name: string): this {
const entry = this.registry.get(name);
if (!entry) throw new Error(`Job "${name}" not found`);
entry.job.stop();
return this;
}

startAll(): this {
for (const { job } of this.registry.getAll().values()) job.start();
return this;
}

stopAll(): this {
for (const { job } of this.registry.getAll().values()) job.stop();
return this;
}

/**
* stops a job and removes it from the registry.
*/
remove(name: string): this {
const entry = this.registry.get(name);
if (entry) {
entry.job.stop();
this.registry.remove(name);
}
return this;
}

clear(): this {
for (const { job } of this.registry.getAll().values()) job.stop();
this.registry.clear();
return this;
}
}

const scheduler = new Scheduler();

export { Scheduler, scheduler };
Loading
Loading