Skip to content

@LeaderOnly Decorator ​

Run a method only on the elected leader instance; everywhere else it resolves to undefined without executing.

Usage ​

typescript
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 ​

typescript
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 @Injectable method — 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 caller undefined instead of an iterator), so they are rejected at class load with InvalidLeaderConfigError — gate the generator's caller with isLeader()/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: @Cron fires on every instance; the wrapped body checks isLeader(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:

SituationBehavior
This instance is a followerSkipped
Leadership lost mid-flight (lease expired)The current invocation FINISHES; the next one skips
LeaderPlugin not registered / not initialized yetSkipped, 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:

typescript
@Cron(CronExpression.EVERY_HOUR)
@LeaderOnly()
async job() {}

Programmatic Alternative ​

Inside code paths where a decorator does not fit, use the service directly:

typescript
await this.leaderService.runIfLeader(() => this.rebuildIndex());

Next Steps ​

  • Service API — isLeader, runIfLeader, stepDown
  • Recipes — startup migrations, maintenance drains

Released under the MIT License.