@LeaderOnly Decorator
Run a method only on the elected leader instance; everywhere else it resolves to undefined without executing.
Usage
import { Injectable } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { LeaderOnly } from '@nestjs-redisx/leader';
import { ReportBuilder } from './types';
@Injectable()
export class ReportJobs {
constructor(private readonly reports: ReportBuilder) {}
// The cron fires on every instance of the fleet;
// the body runs only where the lease is held.
@Cron(CronExpression.EVERY_HOUR)
@LeaderOnly()
async hourlyReport() {
await this.reports.build('hourly');
}
// Independent election group: a different instance may lead it.
@Cron(CronExpression.EVERY_5_MINUTES)
@LeaderOnly({ group: 'cleanup' })
async cleanupExpired() {
await this.reports.cleanup();
}
}Options Reference
interface ILeaderOnlyOptions {
group?: string; // election group, default 'default'
}How It Works
@LeaderOnly is a proxy-based method decorator (like @Cached and @WithLock): it wraps the method at class-load time and resolves the leader service lazily on each call. That means:
- It works on any regular
@Injectablemethod — services, jobs, repositories — not just controllers. Generator and async-generator methods are the one exception: gating cannot preserve iterator semantics (a skipped follower would hand the callerundefinedinstead of an iterator), so they are rejected at class load withInvalidLeaderConfigError— gate the generator's caller withisLeader()/runIfLeader()instead. (Detection needs native generators, i.e. TypeScript target ES2018+; older targets downlevel them into plain functions the guard cannot see — do not decorate generators there either.) - It composes with
@nestjs/schedule:@Cronfires on every instance; the wrapped body checksisLeader(group)and either runs or skips. - The group named in the decorator registers itself at decoration time, so its election starts at application bootstrap — leadership is already resolved by the first cron tick (the first election round is awaited during
onModuleInit).
Skip Semantics (fail-safe)
When the method is skipped, the call resolves to undefined — no error, no retry:
| Situation | Behavior |
|---|---|
| This instance is a follower | Skipped |
| Leadership lost mid-flight (lease expired) | The current invocation FINISHES; the next one skips |
LeaderPlugin not registered / not initialized yet | Skipped, with a one-time warning |
The last row is deliberate: for singleton work, "nobody runs" is recoverable (the next tick after initialization runs normally), while "everybody runs" — the failure mode of falling through without a leader check — is not.
Return type
A gated method returns Promise<T | undefined> semantically — callers that use the return value should handle the skipped case. Scheduled jobs typically ignore it.
Decorator Order with @Cron
Both orders work — @Cron registers the method with the scheduler, @LeaderOnly wraps its body. The wrapper carries over metadata stamped by decorators written below it, so @LeaderOnly above @Cron cannot silently unregister the job:
@Cron(CronExpression.EVERY_HOUR)
@LeaderOnly()
async job() {}Programmatic Alternative
Inside code paths where a decorator does not fit, use the service directly:
await this.leaderService.runIfLeader(() => this.rebuildIndex());Next Steps
- Service API —
isLeader,runIfLeader,stepDown - Recipes — startup migrations, maintenance drains