| name | background-job |
| description | Use when creating recurring jobs, scheduled tasks, or batch processing operations with proper paging strategies. |
Background Job Development Workflow
When to Use This Skill
- Scheduled/recurring tasks (cron-based)
- Batch data processing
- Bulk operations across tenants
- Data synchronization jobs
- Cleanup/maintenance tasks
Pre-Flight Checklist
File Location
{Service}.Application/
└── BackgroundJobs/
└── {Feature}/
└── {JobName}BackgroundJob.cs
Job Type Decision Tree
Does processing affect the query result?
├── NO → Simple Paged (skip/take stays consistent)
│ └── Use: PlatformApplicationPagedBackgroundJobExecutor
│
└── YES → Scrolling needed (processed items excluded from next query)
│
└── Is this multi-tenant (company-based)?
├── YES → Batch Scrolling (batch by company, scroll within)
│ └── Use: PlatformApplicationBatchScrollingBackgroundJobExecutor
│
└── NO → Simple Scrolling
└── Use: ExecuteInjectScopedScrollingPagingAsync
Pattern 1: Simple Paged Job
Use when: Items don't change during processing (or changes don't affect query).
[PlatformRecurringJob("0 3 * * *")]
public sealed class ProcessPendingItemsJob : PlatformApplicationPagedBackgroundJobExecutor
{
private readonly IServiceRepository<Item> repository;
protected override int PageSize => 50;
private IQueryable<Item> QueryBuilder(IQueryable<Item> query)
=> query.Where(x => x.Status == Status.Pending);
protected override async Task<int> MaxItemsCount(
PlatformApplicationPagedBackgroundJobParam<object?> param)
{
return await repository.CountAsync((uow, q) => QueryBuilder(q));
}
protected override async Task ProcessPagedAsync(
int? skip,
int? take,
object? param,
IServiceProvider serviceProvider,
IPlatformUnitOfWorkManager unitOfWorkManager)
{
var items = await repository.GetAllAsync((uow, q) =>
QueryBuilder(q)
.OrderBy(x => x.CreatedDate)
.PageBy(skip, take));
await items.ParallelAsync(async item =>
{
item.Process();
await repository.UpdateAsync(item);
}, maxConcurrent: 5);
}
}
Pattern 2: Batch Scrolling Job (Multi-Tenant)
Use when: Processing per-company, data changes during processing.
[PlatformRecurringJob("0 0 * * *")]
public sealed class SyncCompanyDataJob
: PlatformApplicationBatchScrollingBackgroundJobExecutor<Entity, string>
{
protected override int BatchKeyPageSize => 50;
protected override int BatchPageSize => 25;
protected override IQueryable<Entity> EntitiesQueryBuilder(
IQueryable<Entity> query,
object? param,
string? batchKey = null)
{
return query
.Where(e => e.NeedsSync)
.WhereIf(batchKey != null, e => e.CompanyId == batchKey)
.OrderBy(e => e.Id);
}
protected override IQueryable<string> EntitiesBatchKeyQueryBuilder(
IQueryable<Entity> query,
object? param,
string? batchKey = null)
{
return EntitiesQueryBuilder(query, param, batchKey)
.Select(e => e.CompanyId)
.Distinct();
}
{
Logger.LogInformation(,
entities.Count, batchKey);
entities.ParallelAsync( entity =>
{
entity.MarkSynced();
repository.UpdateAsync(entity);
}, maxConcurrent: );
}
}
Pattern 3: Scrolling Job (Data Changes During Processing)
Use when: Processing creates a log/record that excludes item from next query.
public sealed class ProcessAndLogJob : PlatformApplicationBackgroundJobExecutor
{
public override async Task ProcessAsync(object? param)
{
var queryBuilder = repository.GetQueryBuilder((uow, q) =>
q.Where(x => x.Status == Status.Pending)
.Where(x => !processedLogRepo.Query().Any(log => log.EntityId == x.Id)));
var totalCount = await repository.CountAsync((uow, q) => queryBuilder(uow, q));
await UnitOfWorkManager.ExecuteInjectScopedScrollingPagingAsync<Entity>(
processingDelegate: ExecutePaged,
maxPageCount: totalCount / PageSize,
param: param,
pageSize: PageSize);
}
private static async Task<List<Entity>> ExecutePaged(
object? param,
int? limitPageSize,
IServiceRepository<Entity> repo,
IServiceRepository<ProcessedLog> logRepo)
{
var items = await repo.GetAllAsync((uow, q) =>
q.Where(x => x.Status == Status.Pending)
.Where(x => !logRepo.Query().Any(log => log.EntityId == x.Id))
.OrderBy(x => x.Id)
.PipeIf(limitPageSize != null, q => q.Take(limitPageSize!.Value)));
if (items.IsEmpty()) return items;
await logRepo.CreateManyAsync(items.SelectList(e => new ProcessedLog(e)));
foreach (var item in items)
{
item.Process();
await repo.UpdateAsync(item, dismissSendEvent: true);
}
items;
}
}
Pattern 4: Master Job (Schedules Child Jobs)
Use when: Complex coordination across companies and date ranges.
[PlatformRecurringJob("0 6 * * *")]
public sealed class MasterSchedulerJob : PlatformApplicationBackgroundJobExecutor
{
public override async Task ProcessAsync(object? param)
{
var companies = await companyRepo.GetAllAsync(c => c.IsActive);
var dateRange = DateRangeBuilder.BuildDateRange(
Clock.UtcNow.AddDays(-7),
Clock.UtcNow);
await companies.ParallelAsync(async company =>
{
await dateRange.ParallelAsync(async date =>
{
await BackgroundJobScheduler.Schedule<ChildProcessingJob, ChildJobParam>(
Clock.UtcNow,
new ChildJobParam
{
CompanyId = company.Id,
ProcessDate = date
});
});
}, maxConcurrent: 10);
}
}
Cron Schedule Reference
| Schedule | Cron Expression | Description |
|---|
| Every 5 min | */5 * * * * | Every 5 minutes |
| Hourly | 0 * * * * | Top of every hour |
| Daily midnight | 0 0 * * * | 00:00 daily |
| Daily 3 AM | 0 3 * * * | 03:00 daily |
| Weekly Sunday | 0 0 * * 0 | Midnight Sunday |
| Monthly 1st | 0 0 1 * * | Midnight, 1st day |
Job Attributes
[PlatformRecurringJob("0 3 * * *")]
[PlatformRecurringJob("5 0 * * *", executeOnStartUp: true)]
[PlatformRecurringJob(isDisabled: true)]
Anti-Patterns to AVOID
:x: Processing without paging
var allItems = await repository.GetAllAsync();
foreach (var item in allItems) { }
:x: Wrong pagination for changing data
.Skip(skip).Take(take)
:x: No parallel control
await items.ParallelAsync(ProcessAsync);
await items.ParallelAsync(ProcessAsync, maxConcurrent: 5);
:x: Long-running without unit of work
foreach (var item in items) {
await repository.UpdateAsync(item);
}
using (var uow = UnitOfWorkManager.Begin()) {
await ProcessBatch(items);
await uow.CompleteAsync();
}
Verification Checklist