dotnet-quartz-background-jobs について
.NET 開発者向けの Quartz.NET バックグラウンドジョブスキルです。ジョブの定義、登録、スケジュール設定のテンプレートに加え、Cron と実行時の注意点をまとめています。
主な機能
- 01間隔・Cron スケジュールのテンプレート
- 02ジョブ登録と依存性注入
- 03JobDataMap によるデータ受け渡し
- 04再試行と動的スケジュールの例
- 05Cron 式リファレンス
活用シーン
- 01アプリケーションへの定期バックグラウンドタスク追加
- 02Cron によるレポート生成
- 03ジョブデータの受け渡しや再試行設定
- 04実行時の動的なジョブ登録
インストール
コマンドをコピーしてターミナルで実行します。
$
npx skills add https:/ / github.com/ ronnythedev/ dotnet-clean-architecture-skills --skill dotnet-quartz-background-jobsインストール後、Claude Code で/dotnet-quartz-background-jobsを入力して読み込まれたか確認します。
詳細
バックグラウンドジョブジェネレーター(Quartz)
概要
Quartz.NET は、豊富な機能を備えたジョブスケジューリングライブラリです。
- ジョブスケジューリング - タスクを指定した時刻や間隔で実行します
- Cron 式 - 複雑なスケジューリングパターンに対応します
- 永続化 - アプリケーションの再起動後もジョブを保持します
- 依存性注入 - DI を全面的にサポートします
- クラスタリング - 分散ジョブ実行に対応します
クイックリファレンス
| コンポーネント | 目的 |
|---|---|
IJob | 実装するジョブインターフェイス |
IConfigureOptions<QuartzOptions> | ジョブの登録 |
JobKey | 一意のジョブ識別子 |
TriggerBuilder | ジョブの実行タイミングを定義します |
CronScheduleBuilder | Cron ベースのスケジューリング |
SimpleScheduleBuilder | 間隔ベースのスケジューリング |
ジョブの構造
/Infrastructure/
├── BackgroundJobs/
│ ├── {JobName}Job.cs
│ ├── {JobName}JobSetup.cs
│ └── ...
└── DependencyInjection.cs
テンプレート:単純な間隔ジョブ
// src/{name}.infrastructure/BackgroundJobs/ProcessPendingOrdersJob.cs
using Microsoft.Extensions.Logging;
using Quartz;
namespace {name}.infrastructure.backgroundjobs;
/// <summary>
/// Processes pending orders every 5 minutes
/// </summary>
[DisallowConcurrentExecution] // Prevent overlapping executions
public sealed class ProcessPendingOrdersJob : IJob
{
private readonly IOrderRepository _orderRepository;
private readonly IOrderProcessor _orderProcessor;
private readonly ILogger<ProcessPendingOrdersJob> _logger;
public ProcessPendingOrdersJob(
IOrderRepository orderRepository,
IOrderProcessor orderProcessor,
ILogger<ProcessPendingOrdersJob> logger)
{
_orderRepository = orderRepository;
_orderProcessor = orderProcessor;
_logger = logger;
}
public async Task Execute(IJobExecutionContext context)
{
_logger.LogInformation("Starting pending orders processing...");
try
{
var pendingOrders = await _orderRepository
.GetPendingOrdersAsync(context.CancellationToken);
_logger.LogInformation(
"Found {Count} pending orders to process",
pendingOrders.Count);
foreach (var order in pendingOrders)
{
try
{
await _orderProcessor.ProcessAsync(order, context.CancellationToken);
_logger.LogInformation(
"Processed order {OrderId}",
order.Id);
}
catch (Exception ex)
{
_logger.LogError(
ex,
"Failed to process order {OrderId}",
order.Id);
}
}
_logger.LogInformation("Completed pending orders processing");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in pending orders processing job");
throw; // Quartz will handle retry based on configuration
}
}
}
テンプレート:ジョブのセットアップ(IConfigureOptions)
// src/{name}.infrastructure/BackgroundJobs/ProcessPendingOrdersJobSetup.cs
using Microsoft.Extensions.Options;
using Quartz;
namespace {name}.infrastructure.backgroundjobs;
internal sealed class ProcessPendingOrdersJobSetup
: IConfigureOptions<QuartzOptions>
{
public void Configure(QuartzOptions options)
{
var jobKey = JobKey.Create(nameof(ProcessPendingOrdersJob));
options
.AddJob<ProcessPendingOrdersJob>(jobBuilder =>
jobBuilder
.WithIdentity(jobKey)
.WithDescription("Processes pending orders"))
.AddTrigger(triggerBuilder =>
triggerBuilder
.ForJob(jobKey)
.WithIdentity($"{nameof(ProcessPendingOrdersJob)}-trigger")
.WithSimpleSchedule(schedule =>
schedule
.WithIntervalInMinutes(5)
.RepeatForever())
.StartNow());
}
}
テンプレート:Cron スケジュールジョブ
// src/{name}.infrastructure/BackgroundJobs/DailyReportJob.cs
using Microsoft.Extensions.Logging;
using Quartz;
namespace {name}.infrastructure.backgroundjobs;
/// <summary>
/// Generates daily reports at 6:00 AM every day
/// </summary>
[DisallowConcurrentExecution]
public sealed class DailyReportJob : IJob
{
private readonly IReportService _reportService;
private readonly IEmailService _emailService;
private readonly ILogger<DailyReportJob> _logger;
public DailyReportJob(
IReportService reportService,
IEmailService emailService,
ILogger<DailyReportJob> logger)
{
_reportService = reportService;
_emailService = emailService;
_logger = logger;
}
public async Task Execute(IJobExecutionContext context)
{
_logger.LogInformation("Starting daily report generation...");
var reportDate = DateTime.UtcNow.Date.AddDays(-1);
var report = await _reportService.GenerateDailyReportAsync(
reportDate,
context.CancellationToken);
await _emailService.SendReportAsync(
report,
context.CancellationToken);
_logger.LogInformation(
"Daily report for {Date} sent successfully",
reportDate.ToShortDateString());
}
}
// src/{name}.infrastructure/BackgroundJobs/DailyReportJobSetup.cs
using Microsoft.Extensions.Options;
using Quartz;
namespace {name}.infrastructure.backgroundjobs;
internal sealed class DailyReportJobSetup : IConfigureOptions<QuartzOptions>
{
public void Configure(QuartzOptions options)
{
var jobKey = JobKey.Create(nameof(DailyReportJob));
options
.AddJob<DailyReportJob>(jobBuilder =>
jobBuilder
.WithIdentity(jobKey)
.WithDescription("Daily report generation"))
.AddTrigger(triggerBuilder =>
triggerBuilder
.ForJob(jobKey)
.WithIdentity($"{nameof(DailyReportJob)}-trigger")
.WithCronSchedule(
"0 0 6 * * ?", // 6:00 AM every day
builder => builder.InTimeZone(TimeZoneInfo.Utc))
.WithDescription("Fires at 6:00 AM UTC daily"));
}
}
Cron 式のリファレンス
| 式 | 説明 |
|---|---|
0 0 * * * ? | 毎時 0 分に実行 |
0 0/15 * * * ? | 15 分ごとに実行 |
0 0 6 * * ? | 毎日午前 6 時に実行 |
0 0 6 ? * MON-FRI | 平日の午前 6 時に実行 |
0 0 0 1 * ? | 毎月 1 日の午前 0 時に実行 |
0 0 0 L * ? | 毎月最終日の午前 0 時に実行 |
0 0 12 ? * SUN | 毎週日曜日の正午に実行 |
形式:seconds minutes hours day-of-month month day-of-week [year]
| フィールド | 値 |
|---|---|
| 秒 | 0-59 |
| 分 | 0-59 |
| 時 | 0-23 |
| 日 | 1-31、L(最終日)、W(平日) |
| 月 | 1-12 または JAN-DEC |
| 曜日 | 1-7 または SUN-SAT、L(最終) |
| 年 | 任意、1970-2099 |
特殊文字:
*- すべての値?- 特定の値なし(日/曜日)-- 範囲(例:MON-FRI),- リスト(例:MON,WED,FRI)/- 増分(例:0/15= 15 ごと)L- 最終(例:月の最終日)W- 最も近い平日#- 第 N 曜日(例:2#3= 第 3 月曜日)
テンプレート:データマップ付きジョブ
// src/{name}.infrastructure/BackgroundJobs/SendScheduledEmailJob.cs
using Microsoft.Extensions.Logging;
using Quartz;
namespace {name}.infrastructure.backgroundjobs;
/// <summary>
/// Sends a scheduled email using data from JobDataMap
/// </summary>
public sealed class SendScheduledEmailJob : IJob
{
public const string EmailIdKey = "EmailId";
public const string RecipientKey = "Recipient";
private readonly IEmailService _emailService;
private readonly ILogger<SendScheduledEmailJob> _logger;
public SendScheduledEmailJob(
IEmailService emailService,
ILogger<SendScheduledEmailJob> logger)
{
_emailService = emailService;
_logger = logger;
}
public async Task Execute(IJobExecutionContext context)
{
// Get data from job data map
var dataMap = context.MergedJobDataMap;
var emailId = dataMap.GetGuid(EmailIdKey);
var recipient = dataMap.GetString(RecipientKey);
_logger.LogInformation(
"Sending scheduled email {EmailId} to {Recipient}",
emailId,
recipient);
await _emailService.SendScheduledEmailAsync(
emailId,
context.CancellationToken);
}
}
// Scheduling the job with data
public class EmailScheduler
{
private readonly ISchedulerFactory _schedulerFactory;
public async Task ScheduleEmailAsync(
Guid emailId,
string recipient,
DateTime sendAt)
{
var scheduler = await _schedulerFactory.GetScheduler();
var jobKey = new JobKey($"email-{emailId}", "scheduled-emails");
var job = JobBuilder.Create<SendScheduledEmailJob>()
.WithIdentity(jobKey)
.UsingJobData(SendScheduledEmailJob.EmailIdKey, emailId.ToString())
.UsingJobData(SendScheduledEmailJob.RecipientKey, recipient)
.Build();
var trigger = TriggerBuilder.Create()
.WithIdentity($"email-{emailId}-trigger", "scheduled-emails")
.StartAt(sendAt)
.Build();
await scheduler.ScheduleJob(job, trigger);
}
}
テンプレート:リトライロジック付きジョブ
// src/{name}.infrastructure/BackgroundJobs/SyncExternalDataJob.cs
using Microsoft.Extensions.Logging;
using Quartz;
namespace {name}.infrastructure.backgroundjobs;
/// <summary>
/// Syncs data from external API with retry support
/// </summary>
[DisallowConcurrentExecution]
[PersistJobDataAfterExecution] // Persist data map changes
public sealed class SyncExternalDataJob : IJob
{
private const int MaxRetries = 3;
private const string RetryCountKey = "RetryCount";
private readonly IExternalApiClient _apiClient;
private readonly IDataSyncService _syncService;
private readonly ILogger<SyncExternalDataJob> _logger;
public SyncExternalDataJob(
IExternalApiClient apiClient,
IDataSyncService syncService,
ILogger<SyncExternalDataJob> logger)
{
_apiClient = apiClient;
_syncService = syncService;
_logger = logger;
}
public async Task Execute(IJobExecutionContext context)
{
var retryCount = context.MergedJobDataMap.GetInt(RetryCountKey);
try
{
_logger.LogInformation(
"Starting external data sync (attempt {Attempt})",
retryCount + 1);
var data = await _apiClient.FetchDataAsync(context.CancellationToken);
await _syncService.SyncAsync(data, context.CancellationToken);
// Reset retry count on success
context.JobDetail.JobDataMap.Put(RetryCountKey, 0);
_logger.LogInformation("External data sync completed successfully");
}
catch (Exception ex)
{
_logger.LogError(
ex,
"External data sync failed (attempt {Attempt} of {MaxRetries})",
retryCount + 1,
MaxRetries);
if (retryCount < MaxRetries - 1)
{
// Increment retry count
context.JobDetail.JobDataMap.Put(RetryCountKey, retryCount + 1);
// Throw to trigger Quartz retry
throw new JobExecutionException(ex, refireImmediately: false);
}
else
{
// Max retries reached, log and don't retry
_logger.LogCritical(
"External data sync failed after {MaxRetries} attempts. Manual intervention required.",
MaxRetries);
context.JobDetail.JobDataMap.Put(RetryCountKey, 0);
}
}
}
}
テンプレート:Quartz の登録
// src/{name}.infrastructure/DependencyInjection.cs
using Quartz;
using Microsoft.Extensions.DependencyInjection;
public static class DependencyInjection
{
public static IServiceCollection AddInfrastructure(
this IServiceCollection services,
IConfiguration configuration)
{
// ... other registrations
AddBackgroundJobs(services, configuration);
return services;
}
private static void AddBackgroundJobs(
IServiceCollection services,
IConfiguration configuration)
{
services.AddQuartz(configure =>
{
// ═══════════════════════════════════════════════════════════════
// IN-MEMORY STORE (Development)
// ═══════════════════════════════════════════════════════════════
configure.UseInMemoryStore();
// ═══════════════════════════════════════════════════════════════
// PERSISTENT STORE (Production - uncomment for production)
// ═══════════════════════════════════════════════════════════════
// configure.UsePersistentStore(store =>
// {
// store.UsePostgres(configuration.GetConnectionString("Database")!);
// store.UseJsonSerializer();
// store.PerformSchemaValidation = true;
// });
// ═══════════════════════════════════════════════════════════════
// CLUSTERING (Multi-instance - uncomment for distributed)
// ═══════════════════════════════════════════════════════════════
// configure.UsePersistentStore(store =>
// {
// store.UsePostgres(configuration.GetConnectionString("Database")!);
// store.UseJsonSerializer();
// store.UseClustering(cluster =>
// {
// cluster.CheckinMisfireThreshold = TimeSpan.FromSeconds(20);
// cluster.CheckinInterval = TimeSpan.FromSeconds(10);
// });
// });
});
// Register hosted service
services.AddQuartzHostedService(options =>
{
options.WaitForJobsToComplete = true;
options.AwaitApplicationStarted = true;
});
// Register job setups
services.ConfigureOptions<ProcessPendingOrdersJobSetup>();
services.ConfigureOptions<DailyReportJobSetup>();
services.ConfigureOptions<SyncExternalDataJobSetup>();
}
}
テンプレート:ジョブスケジューラーサービス
// src/{name}.infrastructure/BackgroundJobs/JobSchedulerService.cs
using Quartz;
namespace {name}.infrastructure.backgroundjobs;
/// <summary>
/// Service for dynamically scheduling jobs at runtime
/// </summary>
public interface IJobSchedulerService
{
Task ScheduleJobAsync<TJob>(DateTime runAt, JobDataMap? data = null)
where TJob : IJob;
Task ScheduleJobAsync<TJob>(TimeSpan delay, JobDataMap? data = null)
where TJob : IJob;
Task CancelJobAsync(string jobName, string groupName);
Task<bool> IsJobScheduledAsync(string jobName, string groupName);
}
internal sealed class JobSchedulerService : IJobSchedulerService
{
private readonly ISchedulerFactory _schedulerFactory;
public JobSchedulerService(ISchedulerFactory schedulerFactory)
{
_schedulerFactory = schedulerFactory;
}
public async Task ScheduleJobAsync<TJob>(DateTime runAt, JobDataMap? data = null)
where TJob : IJob
{
var scheduler = await _schedulerFactory.GetScheduler();
var jobName = $"{typeof(TJob).Name}-{Guid.NewGuid()}";
var jobBuilder = JobBuilder.Create<TJob>()
.WithIdentity(jobName, "dynamic-jobs");
if (data is not null)
{
jobBuilder.UsingJobData(data);
}
var job = jobBuilder.Build();
var trigger = TriggerBuilder.Create()
.WithIdentity($"{jobName}-trigger", "dynamic-jobs")
.StartAt(runAt)
.Build();
await scheduler.ScheduleJob(job, trigger);
}
public async Task ScheduleJobAsync<TJob>(TimeSpan delay, JobDataMap? data = null)
where TJob : IJob
{
await ScheduleJobAsync<TJob>(DateTime.UtcNow.Add(delay), data);
}
public async Task CancelJobAsync(string jobName, string groupName)
{
var scheduler = await _schedulerFactory.GetScheduler();
await scheduler.DeleteJob(new JobKey(jobName, groupName));
}
public async Task<bool> IsJobScheduledAsync(string jobName, string groupName)
{
var scheduler = await _schedulerFactory.GetScheduler();
return await scheduler.CheckExists(new JobKey(jobName, groupName));
}
}
重要なルール
- [DisallowConcurrentExecution] を使用する - 実行の重複を防ぎます
- 例外を適切に処理する - ログを記録し、リトライ方針を決めます
- CancellationToken を使用する -
context.CancellationTokenから取得します - ジョブの責務を絞る - 1 つのジョブにつき 1 つの責務とします
- 本番環境では永続ストアを使用する - 再起動後もジョブを保持します
- タイムゾーンは重要です - Cron トリガーにはタイムゾーンを指定します
- ジョブの実行を監視する - 開始/終了時刻と所要時間を記録します
- スレッドをブロックしない - async/await を使用します
- スコープ付きサービスを注入する - 実行ごとに新しいスコープを取得します
- ジョブロジックを個別にテストする - テスト可能なサービスにロジックを切り出します
避けるべきアンチパターン
// ❌ WRONG: Long-running synchronous code
public Task Execute(IJobExecutionContext context)
{
Thread.Sleep(60000); // Don't block!
return Task.CompletedTask;
}
// ✅ CORRECT: Async operations
public async Task Execute(IJobExecutionContext context)
{
await Task.Delay(60000, context.CancellationToken);
}
// ❌ WRONG: Swallowing exceptions silently
public async Task Execute(IJobExecutionContext context)
{
try { await DoWork(); }
catch { } // Silent failure, no logging!
}
// ✅ CORRECT: Log and handle exceptions
public async Task Execute(IJobExecutionContext context)
{
try { await DoWork(); }
catch (Exception ex)
{
_logger.LogError(ex, "Job failed");
throw; // Let Quartz handle retry
}
}
// ❌ WRONG: Ignoring cancellation
public async Task Execute(IJobExecutionContext context)
{
foreach (var item in items)
{
await ProcessItem(item); // Ignores shutdown signal
}
}
// ✅ CORRECT: Respect cancellation
public async Task Execute(IJobExecutionContext context)
{
foreach (var item in items)
{
context.CancellationToken.ThrowIfCancellationRequested();
await ProcessItem(item, context.CancellationToken);
}
}
関連スキル
dotnet-outbox-pattern- Outbox プロセッサージョブemail-service- スケジュールされたメールジョブdotnet-clean-architecture- インフラストラクチャレイヤーのセットアップ
コメント
Markdown 対応