Configuration Reference
This is a complete reference for all EverTask configuration options.
Table of Contents
- Service Configuration
- Queue Configuration
- Rate Limiting Configuration
- Storage Configuration
- Logging Configuration
- Monitoring Configuration
- Storage Provider Details
- Handler Configuration
- Dispatch Parameters
- Recurring Task Builder
- Runtime Schedule Management
- Complete Examples
- Configuration Validation
- Performance Tuning Guidelines
Service Configuration
Use the fluent API in AddEverTask() to configure EverTask’s core behavior.
SetChannelOptions
Controls how many tasks can be queued and what happens when the queue fills up.
Signatures:
SetChannelOptions(int capacity)
SetChannelOptions(BoundedChannelOptions options)
Parameters:
capacity(int): Maximum number of tasks that can be queuedoptions(BoundedChannelOptions): Fully configured channel options instance
Default: Environment.ProcessorCount * 200 (minimum 1000)
Examples:
// Simple capacity
opt.SetChannelOptions(5000)
// Custom configuration (keep FullMode = Wait; see warning below)
opt.SetChannelOptions(new BoundedChannelOptions(5000)
{
FullMode = BoundedChannelFullMode.Wait
})
FullMode Options:
Wait: Block until space is available (default). The only mode EverTask’s queue-full handling supportsDropWrite/DropOldest/DropNewest: ⚠ Not recommended. EverTask’s queue-full detection and the scheduler’s backoff/QueueFullBehaviorrely onTryWriterejecting when the channel is full. WithDrop*modesTryWritenever rejects, so a write is treated as a successful enqueue even when the channel silently drops the item: theQueueFullsignal (and the scheduler backoff that depends on it) never fires. A dropped task is not silently lost, though: the channel’sitemDroppedcallback releases the delivery registration and reverts the victim’s storage row toWaitingQueue, so startup recovery re-queues it later, but it will not run in the current process and there is no immediate backpressure. UseWait(and tune capacity /MaxDegreeOfParallelism) instead of aDrop*mode.
SetMaxDegreeOfParallelism
Controls how many tasks can run at the same time.
Signature:
SetMaxDegreeOfParallelism(int parallelism)
Parameters:
parallelism(int): Number of concurrent workers
Default: Environment.ProcessorCount * 2 (minimum 4)
Examples:
// Fixed parallelism
opt.SetMaxDegreeOfParallelism(16)
// Scale with CPUs
opt.SetMaxDegreeOfParallelism(Environment.ProcessorCount * 4)
Notes:
- Use higher values for I/O-bound tasks like API calls or database operations
- Use lower values for CPU-intensive tasks
- Setting to 1 will log a warning since it’s generally a bad idea in production
SetDefaultRetryPolicy
Sets how tasks should retry when they fail (applies to all tasks unless overridden).
Signature:
SetDefaultRetryPolicy(IRetryPolicy policy)
Parameters:
policy(IRetryPolicy): Retry policy implementation
Default: LinearRetryPolicy(3, TimeSpan.FromMilliseconds(500))
Examples:
// Linear retry with fixed delay
opt.SetDefaultRetryPolicy(new LinearRetryPolicy(5, TimeSpan.FromSeconds(1)))
// Linear retry with custom delays
opt.SetDefaultRetryPolicy(new LinearRetryPolicy(new[]
{
TimeSpan.FromMilliseconds(100),
TimeSpan.FromMilliseconds(500),
TimeSpan.FromSeconds(2)
}))
// Exponential backoff: 500ms, 1s, 2s, 4s, 8s
opt.SetDefaultRetryPolicy(new ExponentialRetryPolicy(5, TimeSpan.FromMilliseconds(500)))
// Exponential backoff with cap and jitter: 1s, 3s, 9s, 10s, 10s (±20% jitter, still capped)
opt.SetDefaultRetryPolicy(new ExponentialRetryPolicy(5, TimeSpan.FromSeconds(1),
backoffFactor: 3.0, maxDelay: TimeSpan.FromSeconds(10), useJitter: true))
// Custom retry policy (your own IRetryPolicy implementation; see
// resilience/retry-policies.md)
opt.SetDefaultRetryPolicy(new MyCustomRetryPolicy())
Notes:
- Built-in policies:
LinearRetryPolicy(fixed delay or per-attempt delay array) andExponentialRetryPolicy(growing delay:initialDelay × backoffFactor^(n-1), optionalmaxDelaycap, optional ±20% per-attempt jitter).retryCountis the number of retries AFTER the initial attempt (e.g.LinearRetryPolicy(3, ...)= up to 4 executions);retryCountand delays must be greater than zero,backoffFactormust be >= 1.0 (1.0 behaves like linear), andmaxDelay(when set) must be >=initialDelay. Without amaxDelay, exponential growth is clamped at the longest waitTask.Delayaccepts (about 49.7 days); jitter respects the same ceiling.LinearRetryPolicyrejects explicit delays above that ceiling at construction, timeouts and audit cleanup intervals are clamped to it, and the bundled analyzer reports ET0009 on constant over-limit values. - Retries cannot be disabled via the built-in policies: to disable them, implement a trivial
IRetryPolicythat invokes the action once; see Custom Retry Policies.
Exception filtering (fluent, shared by LinearRetryPolicy and ExponentialRetryPolicy via RetryPolicyBase<TPolicy>): by default every exception is retried except OperationCanceledException and TimeoutException, which are always fail-fast (hardcoded, cannot be overridden by a filter). Configure which exceptions retry with one of these modes (whitelist and blacklist cannot be mixed: doing so throws InvalidOperationException):
.Handle<DbException>().Handle<HttpRequestException>() // whitelist: retry ONLY these (+ derived)
.DoNotHandle<ArgumentException>() // blacklist: retry all EXCEPT these
.HandleWhen(ex => ex is HttpRequestException h && (int?)h.StatusCode >= 500) // predicate (highest priority)
.HandleTransientDatabaseErrors() // preset: DbException (+ TimeoutException, but it's blocked by the fail-fast guard → effectively DbException only)
.HandleTransientNetworkErrors() // preset: HttpRequestException, SocketException, WebException, TaskCanceledException (⚠ TaskCanceledException : OperationCanceledException → blocked by the fail-fast guard, never actually retried)
.HandleAllTransientErrors() // both presets combined
Resolution priority: OCE/TimeoutException fail-fast → HandleWhen → whitelist → blacklist → retry-all. Because the OCE/TimeoutException guard runs first, any preset entry that is (or derives from) those types (TaskCanceledException, TimeoutException) is never retried even though it appears in the preset. See Resilience › Exception Filtering.
SetDefaultTimeout
Sets a maximum execution time for tasks (applies globally unless overridden).
Signature:
SetDefaultTimeout(TimeSpan? timeout)
Parameters:
timeout(TimeSpan?): Maximum execution time, ornullfor no timeout
Default: null (no timeout)
Examples:
// 5 minute timeout
opt.SetDefaultTimeout(TimeSpan.FromMinutes(5))
// 30 second timeout
opt.SetDefaultTimeout(TimeSpan.FromSeconds(30))
// No timeout (explicit)
opt.SetDefaultTimeout(null)
Notes:
- When the timeout is reached, the
CancellationTokengets cancelled - Your handler needs to check the token for this to work (cooperative cancellation)
- You can override this per handler or per queue
SetDefaultAuditLevel
Sets the default audit trail level for all tasks (controls database bloat from high-frequency tasks).
Signature:
SetDefaultAuditLevel(AuditLevel auditLevel)
Parameters:
auditLevel(AuditLevel): Audit verbosity levelFull(default): Complete audit trail:StatusAuditfor all status transitions andRunsAuditfor every runMinimal:StatusAuditonly on real errors;RunsAuditis still written for every recurring run (so run-frequency history is preserved) andQueuedTask.LastExecutionUtcis updatedErrorsOnly: aStatusAudit/RunsAuditrow is written only for a run that records a non-empty exception string or ends in statusFailed(successful runs write neither;QueuedTaskstatus is still updated toCompleted)None: noStatusAudit/RunsAuditrows at all
MinimalandErrorsOnlydiffer only inRunsAudit:Minimalrecords every recurring run,ErrorsOnlyonly the runs with a non-empty exception string or statusFailed. The authoritative rules are inAuditPolicy.ShouldCreateStatusAudit/ShouldCreateRunsAudit.
Default: AuditLevel.Full
Examples:
// Full audit (default)
opt.SetDefaultAuditLevel(AuditLevel.Full)
// Minimal audit for high-frequency tasks
opt.SetDefaultAuditLevel(AuditLevel.Minimal)
// Only audit errors
opt.SetDefaultAuditLevel(AuditLevel.ErrorsOnly)
// No audit trail
opt.SetDefaultAuditLevel(AuditLevel.None)
Notes:
- For a recurring task running every 5 minutes:
Full≈ 1,152 audit records/day (StatusAudit + RunsAudit);Minimal≈ 288 RunsAudit/day (one per successful run, no StatusAudit);ErrorsOnly/None≈ 0 when executions succeed - You can override this per task when dispatching
- Use lower levels (Minimal/ErrorsOnly/None) for high-frequency recurring tasks
- See Audit Configuration for detailed usage guide
SetMisfireThreshold
Sets how late a delivery may start before its execution context reports it as a misfire.
Signature:
SetMisfireThreshold(TimeSpan threshold)
Parameters:
threshold(TimeSpan): tolerance between the nominal slot (ITaskExecutionContext.ScheduledAtUtc) and the actual start (StartedAtUtc). Must not be negative;TimeSpan.Zeroreports every delivery that starts after its slot.
Default: 5 seconds
Examples:
// A handler that compensates for lateness wants to know early
opt.SetMisfireThreshold(TimeSpan.FromMilliseconds(500))
// A nightly report does not care about a minute of scheduler drift
opt.SetMisfireThreshold(TimeSpan.FromMinutes(5))
Notes:
- This is a classification threshold. It decides what gets reported about a delivery or persisted as durable-occurrence misfire metadata, never whether anything runs: a late task runs exactly as it did before, and no status, retry or execution decision depends on it.
- Below the threshold
Misfireisnull, so a handler that does not care never has to inspect a kind. Above it,Misfire.KindisLateandMisfire.Latenessis the real gap. - It is the same threshold a durable schedule applies one step earlier, when it materializes an occurrence: a slot that came due longer ago than this produces an occurrence stamped with the backlog it stands for (
Misfire.KindCatchUporFireOnce, plus the missed range and count), while a slot inside it produces an ordinary occurrence. See Durable Occurrences. - A run of more than one missed slot is reported whatever the threshold says.
FireOnceis about to collapse those slots andCatchUpto replay them, and neither may happen unreported just because the grid ticks faster than the tolerance. - A task dispatched to run immediately has no slot, so it can never be late.
- The one-second tolerance the recurring skip-forward path uses to avoid treating a just-scheduled occurrence as past is a separate rule, unchanged by this setting.
- See Task Creation › Execution Context.
SetDefaultScheduleTimeZone
Sets the time zone every calendar-anchored schedule or day/date exclusion is read on when it does not name one itself.
Signature:
SetDefaultScheduleTimeZone(TimeZoneInfo timeZone)
Parameters:
timeZone(TimeZoneInfo): a system time zone. Its IANA id is what gets persisted with each schedule, so the row resolves the same way on any host. A zone built withTimeZoneInfo.CreateCustomTimeZonehas no such id and throwsArgumentException.
Default: null. Schedules with no zone of their own are computed in UTC, as they always were.
Examples:
// One application, one zone
opt.SetDefaultScheduleTimeZone(TimeZoneInfo.FindSystemTimeZoneById("Europe/Rome"))
// A single schedule can still opt out
r.Schedule().EveryDay().AtTime(new TimeOnly(9, 0)).InTimeZone("Asia/Tokyo")
Notes:
- It applies at dispatch to calendar-anchored schedules and schedules carrying
Exceptday/date selectors, when they did not callInTimeZone. An explicitInTimeZonealways wins. - A plain cadence without calendar exclusions is never touched. A cadence with
Except(e => e.OnDays/OnDates)keeps its elapsed grid and uses the zone only to decide the excluded local date. - The zone is written into the definition when the schedule is built. Rows already stored keep whatever they were dispatched with, so changing this default later does not silently move existing schedules by an hour; re-register them under the same
taskKeyto move them. - See Recurring Tasks › Time Zones for daylight-saving behaviour and the id rules.
SetMaterializationConcurrency
Sets how many durable schedules may be turning due slots into occurrence rows at the same time.
Signature:
SetMaterializationConcurrency(int concurrency)
Parameters:
concurrency(int): maximum concurrent materializations. Must be at least 1.
Default: the value of SetMaxDegreeOfParallelism, which is also what bounds startup recovery. It is resolved lazily, so setting the parallelism after this call still takes effect.
Examples:
// A restart with many durable schedules puts more pressure on the database than the work itself
opt.SetMaterializationConcurrency(4)
Notes:
- Materialization is a short burst of storage writes, so this bounds how much of that the store sees at once.
- It is not how many occurrences execute concurrently (that is the queue’s parallelism), and not how many may be alive per schedule, which is
CatchUpOptions.MaxPendingOccurrences. - See Recurring Tasks › Durable Occurrences.
SetBacklogRetryInterval
Sets how long a durable schedule waits before trying again when it could not make progress.
Signature:
SetBacklogRetryInterval(TimeSpan interval)
Parameters:
interval(TimeSpan): the retry interval. Must be at least one second and at most one day; anything outside that range throwsArgumentOutOfRangeException.
Default: 1 minute
Examples:
// A schedule with a large backlog and a wide MaxPendingOccurrences drains faster on a shorter retry
opt.SetBacklogRetryInterval(TimeSpan.FromSeconds(15))
Notes:
- A schedule cannot make progress when its concurrency budget is full, or when one of its compare-and-swapped writes lost a race and the run has to look at the row again. The ordinary way it resumes is the kick each occurrence gives when it ends, which is immediate; this interval is the guarantee behind that kick.
- Startup recovery runs once, so without a retry a schedule whose kick was lost would wait for the next restart.
- A halted catch-up is the one thing this interval does not cover. A halt never releases itself, not by ageing and not by restarting, so retrying it would put the schedule row back through the worker queue every interval for ever: a status transition and an audit row each time, to produce nothing. Only an explicit schedule change releases one:
ResumeSchedule,Reschedule, or dispatching the series again after a cancel. - Below the scheduler’s own one-second tick it buys nothing, which is the lower bound.
- The upper bound is one day. This interval is the last thing between a blocked schedule and the next restart, so a value measured in weeks guarantees nothing. It is also added to a UTC instant at every re-park, including on the failure path, which repeats the same addition: an interval of centuries overflows both, and the schedule then sits parked nowhere at all.
- See Recurring Tasks › Durable Occurrences.
SetOccurrenceProviderRetry
Sets how long a live schedule waits before retrying an occurrence evaluation that could not answer: an INextOccurrenceProvider failure or exclusion-search budget exhaustion.
Signature:
SetOccurrenceProviderRetry(Action<OccurrenceProviderRetryOptions> configure)
Options:
InitialBackoff(TimeSpan): the wait after the first failure. Default: 1 minute.MaxBackoff(TimeSpan): the longest wait the doubling reaches. Default: 15 minutes.
Both must be positive and at most one day; anything else throws ArgumentOutOfRangeException. A MaxBackoff below InitialBackoff makes every wait that long.
Examples:
// A calendar read from a local table recovers in seconds, so waiting a minute is waiting for nothing
opt.SetOccurrenceProviderRetry(retry =>
{
retry.InitialBackoff = TimeSpan.FromSeconds(30);
retry.MaxBackoff = TimeSpan.FromMinutes(5);
})
Notes:
- A provider that throws is treated as TRANSIENT: the database a calendar is read from being briefly down must not end a series. Nothing is written (the row keeps its cursor and stays recoverable), and the schedule is parked to ask again after this wait.
- The wait doubles at each consecutive failure of the SAME schedule, up to
MaxBackoff, and one answer resets it. The counter is in memory and per host: a restart starts over atInitialBackoff, which costs nothing, because the row was never written. - The startup-recovery poison counter is not touched by a provider failure. A calendar down across five restarts would otherwise mark the series
Failedfor ever. - An unregistered provider key is NOT covered by this: it is a configuration error, refused at dispatch with
ArgumentExceptionand poisoned at recovery like a corrupt cron expression. - See Recurring Tasks › Occurrence Providers.
AddScheduleCalendar
Registers a reusable static exclusion set during the AddEverTask configuration callback.
opt.AddScheduleCalendar("it-holidays", calendar => calendar
.OnDates(new DateOnly(2026, 1, 1), new DateOnly(2026, 1, 6), new DateOnly(2026, 12, 25))
.OnDates(Pasquetta2026, Ferragosto2026));
Names are trimmed, case-sensitive and limited to 100 characters. Duplicate names, empty callbacks and invalid sets throw while the host is being configured. EverTask freezes a deep snapshot immediately after the callback; later mutations do not affect that host. Schedules reference it with .ExceptCalendar("it-holidays"). The persisted definition contains only the names, while days and dates use each schedule’s zone and ranges remain absolute. An unknown name is refused before an ingress write and poisons a recovered schedule.
AddOccurrenceProvider<T>
Registers an occurrence provider under the key schedules name it by. It is a method on the EverTaskServiceBuilder (what AddEverTask returns), not on the configuration object.
Signature:
AddOccurrenceProvider<TProvider>(string key) where TProvider : class, INextOccurrenceProvider
Examples:
services.AddEverTask(opt => opt.RegisterTasksFromAssembly(typeof(Program).Assembly))
.AddSqlServerStorage(connectionString)
.AddOccurrenceProvider<BusinessDaysProvider>("business-days");
Notes:
TProvideris registered as scoped withTryAdd, so an application that wants another lifetime (a singleton holding a cached calendar) registers it itself and keeps that registration. It is resolved in a fresh scope for every question.- The KEY is what every schedule using the provider persists, never a type name, which a rename would orphan. Treat it as part of the durable contract; it is matched ordinally.
- Registering the same type under the same key twice is a no-op, so a registration that runs at every startup is idempotent. A DIFFERENT type under a key already taken throws
ArgumentException. - See Recurring Tasks › Occurrence Providers.
Audit & Execution-Log Retention (AddAuditCleanup)
Configure automatic retention to prevent unbounded growth of the audit and execution-log tables. Retention is enforced by the optional AuditCleanupHostedService, registered with AddAuditCleanup(policy, cleanupIntervalHours), the single entry-point that actually applies the policy.
Note: retention is applied only by
AddAuditCleanup(policy, cleanupIntervalHours). An earlierSetAuditRetentionPolicy(...)on the builder never took effect (the service reads its policy only fromAuditCleanupOptions) and has been removed; if you used it, pass the policy toAddAuditCleanupinstead.
Non-positive knobs are disabled. Every day/count knob uses the same convention:
null= unlimited/disabled, and any value<= 0is also treated as disabled (a no-op, logged as a warning), never as a “now”/future cutoff. This keeps a typo or a missingIConfigurationbinding (an absent env var binds to0) from turning a cleanup cycle into a mass deletion.
Default: null (unlimited retention)
Factory Methods:
// Uniform retention: same TTL for all audit types
AuditRetentionPolicy.WithUniformRetention(int retentionDays)
// Error priority: keep errors longer than successful executions
AuditRetentionPolicy.WithErrorPriority(int successRetentionDays, int errorRetentionDays)
Examples:
Basic Setup (Uniform Retention):
var policy = AuditRetentionPolicy.WithUniformRetention(30);
builder.Services.AddEverTask(opt => opt
.RegisterTasksFromAssembly(typeof(Program).Assembly))
.AddSqlServerStorage(connectionString);
// AddAuditCleanup extends IServiceCollection (EverTask.Storage.EfCore package),
// NOT the EverTask builder: call it as a separate statement. It is the only
// entry-point that applies the policy.
builder.Services.AddAuditCleanup(policy, cleanupIntervalHours: 24);
Advanced Setup (Keep Errors Longer):
var policy = AuditRetentionPolicy.WithErrorPriority(
successRetentionDays: 7,
errorRetentionDays: 90);
builder.Services.AddEverTask(opt => opt
.RegisterTasksFromAssembly(typeof(Program).Assembly))
.AddSqlServerStorage(connectionString);
builder.Services.AddAuditCleanup(policy, cleanupIntervalHours: 24);
Custom Policy:
var policy = new AuditRetentionPolicy
{
StatusAuditRetentionDays = 14, // Status changes retained for 14 days
RunsAuditRetentionDays = 7, // Execution history retained for 7 days
ErrorAuditRetentionDays = 90, // Errors retained for 90 days
ExecutionLogRetentionDays = 30, // Captured execution logs trimmed after 30 days
MaxExecutionLogsPerTask = 1000, // Keep at most the latest 1000 logs per task
OccurrenceRetentionDays = 60, // Finished occurrences of durable schedules pruned after 60 days
DeleteCompletedTasksAfterRetention = true // Purge completed task rows once aged out (see below)
};
builder.Services.AddEverTask(opt => opt
.RegisterTasksFromAssembly(typeof(Program).Assembly))
.AddSqlServerStorage(connectionString);
builder.Services.AddAuditCleanup(policy, cleanupIntervalHours: 12);
Retention Policy Properties:
| Property | Type | Default | Description |
|---|---|---|---|
StatusAuditRetentionDays | int? | null | Days to retain status audit records (Queued → InProgress → Completed/Failed) |
RunsAuditRetentionDays | int? | null | Days to retain execution audit records (recurring task runs) |
ErrorAuditRetentionDays | int? | null | Days to retain error audit records (overrides above for failures) |
ExecutionLogRetentionDays | int? | null | Days to retain captured execution logs (TaskExecutionLog), trimmed independently of the parent task (anchored on TimestampUtc) |
MaxExecutionLogsPerTask | int? | null | Per-task, cross-run cap: keep at most the latest N execution logs per task and delete the oldest beyond N |
OccurrenceRetentionDays | int? | null | Days to retain the finished occurrences of a durable recurring schedule (the child rows it materializes, one per slot) |
DeleteCompletedTasksAfterRetention | bool | false | Hard-delete a completed non-recurring task once it is older than the longest retention window and has no audit rows |
DeleteCompletedTasksWithAuditsis[Obsolete]: a legacy alias that forwards toDeleteCompletedTasksAfterRetention. Don’t use it in new code; it remains only for source compatibility with pre-rename configs.When a completed task is deleted: when it is older than the longest of
StatusAuditRetentionDays/RunsAuditRetentionDays/ErrorAuditRetentionDays(measured fromLastExecutionUtc, falling back toCreatedAtUtc) and has no remaining StatusAudit/RunsAudit rows. If no retention window is configured, no completed tasks are deleted (a non-positive window counts as disabled). When a log-retention window or cap (ExecutionLogRetentionDays/MaxExecutionLogsPerTask) is active, a task that still has surviving logs is preserved, so its logs are never cascade-deleted before their own window expires; once those logs age out the task is purged. With no log retention configured, deleting the task cascades to everything it owns, captured execution logs included.Occurrence retention.
OccurrenceRetentionDaysprunes the finished occurrences of a durable recurring schedule, whatever terminal state they ended in: Completed, Failed and Cancelled alike. That is where it differs fromDeleteCompletedTasksAfterRetention, which only removes completed rows with no audit trail left; without a window of its own, a busy schedule’s failed and cancelled occurrences would pile up forever. Pruning them loses nothing, because a durable schedule is driven by its cursor and not by its past occurrence rows, so a pruned slot is never materialized again. The schedule row is recurring, and this pass never deletes it. When a log-retention window or cap is active, an occurrence that still owns execution logs is preserved, exactly as for completed tasks: the occurrence window is usually much shorter than the log one, and deleting the row would cascade to logs the log retention chose to keep. Each audit trail has a guard of its own: withStatusAuditRetentionDaysset, an occurrence whose status rows are still inside that window is preserved, andRunsAuditRetentionDaysguards the runs trail the same way. Deleting the row cascades both. An occurrence window of 7 days against an error window of 90 would otherwise erase a failure on day eight, and the cleanup line would report only an occurrence count. There is one guard per trail rather than one for both, because each audit pass runs only when its own knob is set: a window you never configured prunes nothing, so it has nothing to hold back. Defaultnull(unlimited); enforced byAddAuditCleanup(policy, …).Execution-log retention.
ExecutionLogRetentionDaysandMaxExecutionLogsPerTasktrimTaskExecutionLogrows on their own, without deleting the task, so a long-running service (recurring tasks especially) never accumulates logs without bound. Both default tonull(unlimited), so enabling persistent logging never starts deleting logs on its own. They are separate fromPersistentLoggerOptions.MaxLogsPerTask, which caps a single execution’s logs at capture time; these two trim logs across all past runs. When both are set, a log is deleted if it breaks either rule. Both are enforced byAddAuditCleanup(policy, …).
Cleanup Service Registration:
The AddAuditCleanup(policy, …) method (an IServiceCollection extension from the EverTask.Storage.EfCore package) registers a hosted service that periodically deletes old audit records:
builder.Services.AddAuditCleanup(
retentionPolicy, // The retention policy to apply (required)
cleanupIntervalHours: 24); // Cleanup frequency (default: 24 hours)
The service waits AuditCleanupOptions.InitialDelay (default 1 minute) after startup before its first sweep. InitialDelay is not a parameter of AddAuditCleanup; override it via services.Configure<AuditCleanupOptions>(o => o.InitialDelay = ...) if needed.
Important Notes:
- Single Entry-Point:
AddAuditCleanup(policy, ...)is the only place that applies the policy. (The formerSetAuditRetentionPolicy()builder method, which never applied it, has been removed.) - Cleanup Service Required: Retention is enforced by
AddAuditCleanup(policy, …)- without it, policy has no effect - Recurring Tasks: Never auto-deleted, even with
DeleteCompletedTasksAfterRetention = true(they need to reschedule) - Failed/Cancelled Tasks: Preserved for visibility, even with
DeleteCompletedTasksAfterRetention = true - Database Impact: Cleanup runs in background, deletes only tasks past the retention cutoff (no immediate hard-delete)
Monitoring Cleanup:
Check cleanup service logs:
[02:00:15 INF] AuditCleanupHostedService: Starting audit cleanup cycle
[02:00:16 INF] Deleted 1,543 status audit records older than 30 days
[02:00:16 INF] Deleted 8,921 runs audit records older than 30 days
[02:00:16 INF] Deleted 234 completed tasks with no remaining audits
[02:00:16 INF] AuditCleanupHostedService: Cleanup cycle completed in 1.2s
Recommended Settings by Workload:
| Workload Type | Success Retention | Error Retention | Cleanup Interval |
|---|---|---|---|
| Development | 7 days | 30 days | 24 hours |
| Production (Low Volume) | 30 days | 90 days | 24 hours |
| Production (High Volume) | 7 days | 90 days | 12 hours |
| Compliance/Audit | 365 days | 365 days | 24 hours |
SetThrowIfUnableToPersist
Controls what happens when a task can’t be saved to storage.
Signature:
SetThrowIfUnableToPersist(bool value)
Parameters:
value(bool): Whether to throw on persistence failure
Default: true
Examples:
// Throw on persistence failure (recommended)
opt.SetThrowIfUnableToPersist(true)
// Don't throw (tasks may be lost)
opt.SetThrowIfUnableToPersist(false)
Notes:
- When
true, the dispatch fails immediately if the task can’t be saved - When
false, the task might run but won’t be saved (risky!) - Keep this
trueunless you have a good reason not to
UseShardedScheduler
Enables a sharded scheduler that can handle extremely high loads by distributing work across multiple internal schedulers.
Signature:
UseShardedScheduler(int shardCount = 0)
Parameters:
shardCount(int): Number of shards;0(default) auto-scales toMath.Max(4, ProcessorCount)
Default: Not enabled (uses PeriodicTimerScheduler)
Examples:
// Auto-scale based on CPUs
opt.UseShardedScheduler()
// Fixed shard count
opt.UseShardedScheduler(8)
// Scale with CPUs
opt.UseShardedScheduler(Environment.ProcessorCount)
When to Use: You probably need this if you’re seeing:
- Sustained load above 10,000
Schedule()calls/second - Burst spikes above 20,000
Schedule()calls/second - More than 100,000 tasks scheduled at once
- High lock contention showing up in your profiler
RegisterTasksFromAssembly
Scans an assembly and registers all task handlers it finds.
Signature:
RegisterTasksFromAssembly(Assembly assembly)
Parameters:
assembly(Assembly): Assembly containing task handlers
Examples:
// Current assembly
opt.RegisterTasksFromAssembly(typeof(Program).Assembly)
// Specific assembly
opt.RegisterTasksFromAssembly(typeof(MyTask).Assembly)
// Assembly by name
opt.RegisterTasksFromAssembly(Assembly.Load("MyTasksAssembly"))
RegisterTasksFromAssemblies
Scans multiple assemblies and registers all task handlers from them.
Signature:
RegisterTasksFromAssemblies(params Assembly[] assemblies)
Parameters:
assemblies(Assembly[]): Assemblies containing task handlers
Examples:
opt.RegisterTasksFromAssemblies(
typeof(CoreTask).Assembly,
typeof(ApiTask).Assembly,
typeof(BackgroundTask).Assembly)
SetUseLazyHandlerResolution
Controls whether EverTask uses lazy handler resolution for scheduled and recurring tasks. When enabled (default), handlers are disposed after dispatch and recreated at execution time based on task scheduling characteristics.
Signature:
SetUseLazyHandlerResolution(bool enabled)
DisableLazyHandlerResolution() // Convenience method for disabling
Parameters:
enabled(bool): True to enable lazy resolution (default), false to disable
Default: true (enabled with adaptive algorithm)
Examples:
// Keep default (recommended - adaptive lazy resolution)
opt.RegisterTasksFromAssembly(typeof(Program).Assembly)
// Explicitly enable (same as default)
opt.SetUseLazyHandlerResolution(true)
// Disable lazy resolution (handlers kept in memory)
opt.SetUseLazyHandlerResolution(false)
// Convenience method for disabling
opt.DisableLazyHandlerResolution()
Adaptive Algorithm:
When enabled, EverTask automatically chooses the best resolution strategy:
- Immediate tasks: Lazy mode (v3.7+: the worker resolves a fresh handler in its per-task scope; an eager instance resolved at dispatch would stay pinned in the root container until shutdown)
- Recurring tasks with intervals ≥ 5 minutes: Lazy mode (memory efficient)
- Recurring tasks with intervals < 5 minutes: Eager mode (performance efficient)
- Delayed tasks with delay ≥ 30 minutes: Lazy mode
- Delayed tasks with delay < 30 minutes: Eager mode
Benefits:
- Memory Optimization: Handlers are disposed after dispatch, so long-running scheduled tasks hold less memory
- Fresh Dependencies: Handlers get fresh scoped services at execution time (important for DbContext, etc.)
- Automatic Tuning: The adaptive algorithm above picks the mode per task
When to Disable:
Only disable lazy resolution if:
- You have handlers with expensive initialization that should be cached
- Your environment has issues with lazy resolution (rare)
- You’re debugging handler lifecycle issues
Performance Impact:
- Memory: Up to 43,000 fewer handler allocations per day for high-frequency recurring tasks
- CPU: Negligible overhead (handler instantiation is fast with DI)
Notes:
- Handler dependencies are resolved at execution time, so the scoped services they get are fresh
- At dispatch time, a short-lived metadata instance is resolved (and disposed with its scope) to extract handler options
SetRateLimiterOptions
Configures the global infrastructure knobs of the keyed rate limiter (v3.7+). See the dedicated Rate Limiting Configuration section below for the full reference (global knobs, per-handler RateLimitPolicy, key source).
The Scheduling Clock (TimeProvider)
Not a builder method, but a DI registration. AddEverTask registers TimeProvider.System with TryAddSingleton, and that single instance is what answers “what time is it?” for dispatch delays, the occurrence grid of a recurring schedule, both schedulers, startup recovery, and the rate limiter with its gate and parking lot.
Signature:
services.AddSingleton<TimeProvider>(myProvider); // before AddEverTask, or on .Services afterwards
Default: TimeProvider.System
Examples:
// Production: nothing to do. AddEverTask registers the system clock.
builder.Services.AddEverTask(opt => opt.RegisterTasksFromAssembly(typeof(Program).Assembly));
// Tests: register a controllable clock and the whole pipeline follows it.
var clock = new FakeTimeProvider(new DateTimeOffset(2026, 5, 1, 12, 0, 0, TimeSpan.Zero));
services.AddSingleton<TimeProvider>(clock);
services.AddEverTask(opt => opt.RegisterTasksFromAssembly(typeof(Program).Assembly))
.AddMemoryStorage();
// ...dispatch a schedule, then move time forward instead of waiting for it:
clock.Advance(TimeSpan.FromHours(2));
Notes:
TryAddSingletonis what makes this a seam: register your own provider first andAddEverTaskleaves it alone. Registering it afterwards works too, as long as it is a plainAddSingletonthat replaces the entry.- The schedulers wait on
Task.Delay(delay, timeProvider), not on a wall-clock timeout, so a test clock that stands still keeps an occurrence pending no matter how much real time passes. A fake provider has to drive its timers as well asGetUtcNow()for that to hold. - Storage never resolves the clock on its own: the core passes the instant into
RetrievePendingandTrySetQueuedIfRecoverable, so the recovery filter judges a row against the same “now” the rest of the pipeline sees. A custom store that only implements the older signatures keeps working and reads the real clock (see Custom Storage). - Retry delays, audit timestamps and log timestamps stay on the real clock deliberately.
IRetryPolicyis a public interface that owns its own waits, and an audit row records when something really happened. Do not expect a fake clock to complete a retry delay.
Queue Configuration
You can set up multiple queues to isolate different types of work and give them different priorities or resource allocations.
The
EverTaskServiceBuilderreturned byAddEverTask(...)also exposes a public.Servicesproperty (the underlyingIServiceCollection), so you can register your own services mid-chain without breaking the fluent flow, e.g.builder.Services.AddSingleton<IKeyedRateLimiter, MyRedisLimiter>()or a customIGuidGenerator. There is alsoEnsureRecurringQueue()to create the recurring queue with defaults without a configure action (normally unnecessary: both thedefaultandrecurringqueues are auto-created during service registration, inRegisterQueueManager, if not configured). The well-known queue names are the public constantsQueueNames.Default("default") andQueueNames.Recurring("recurring").
ConfigureDefaultQueue
Customizes the default queue (used when you don’t specify a queue name for a task).
Signature:
ConfigureDefaultQueue(Action<QueueConfiguration> configure)
Example:
.ConfigureDefaultQueue(q => q
.SetMaxDegreeOfParallelism(10)
.SetChannelCapacity(1000)
.SetFullBehavior(QueueFullBehavior.Wait)
.SetDefaultTimeout(TimeSpan.FromMinutes(5))
.SetDefaultRetryPolicy(new LinearRetryPolicy(3, TimeSpan.FromSeconds(1))))
AddQueue
Creates a new named queue with its own configuration.
Signature:
AddQueue(string name, Action<QueueConfiguration>? configure = null)
Parameters:
name(string): Queue name (throwsArgumentExceptionwhen null/whitespace)configure(Action, optional): Queue configuration
Defaults for new queues (different from the auto-created default queue, which inherits the global settings with QueueFullBehavior.Wait):
MaxDegreeOfParallelism= 1 (sequential: set it explicitly for parallel consumption)- Channel capacity = 500 (
FullMode.Wait) QueueFullBehavior=FallbackToDefault(a full queue spills to the default queue)- Retry policy / timeout = unset (fall back to the global defaults)
Calling AddQueue again with the same name replaces the previous configuration (no error is raised).
Raw object defaults. The values above are what
AddQueueapplies. A barenew QueueConfiguration()(if you construct one directly rather than viaAddQueue) defaults to:Name = "default",MaxDegreeOfParallelism = 1,ChannelOptions = new BoundedChannelOptions(2000) { FullMode = Wait, SingleReader = false, SingleWriter = false, AllowSynchronousContinuations = false },QueueFullBehavior = FallbackToDefault, and null retry policy / timeout. Note the raw channel capacity is 2000, whereasAddQueuesets 500.
Example:
.AddQueue("high-priority", q => q
.SetMaxDegreeOfParallelism(20)
.SetChannelCapacity(500)
.SetFullBehavior(QueueFullBehavior.Wait))
.AddQueue("background", q => q
.SetMaxDegreeOfParallelism(2)
.SetChannelCapacity(100)
.SetFullBehavior(QueueFullBehavior.FallbackToDefault))
ConfigureRecurringQueue
Customizes the recurring queue (EverTask automatically creates this queue for recurring tasks).
Signature:
ConfigureRecurringQueue(Action<QueueConfiguration> configure)
Example:
.ConfigureRecurringQueue(q => q
.SetMaxDegreeOfParallelism(5)
.SetChannelCapacity(200)
.SetDefaultTimeout(TimeSpan.FromMinutes(10)))
EnsureRecurringQueue
Creates the recurring queue with default settings only if it doesn’t already exist. Normally unnecessary: both the default and recurring queues are auto-created during service registration (RegisterQueueManager). Use it only if you want to guarantee the recurring queue exists without supplying a configure action (it is idempotent: a no-op when the queue is already present).
Signature:
EnsureRecurringQueue() // returns EverTaskServiceBuilder for chaining
Behavior: when the recurring queue is absent, it clones the existing default queue configuration (or, if even that is absent, a fresh QueueConfiguration seeded from the global MaxDegreeOfParallelism / ChannelOptions / retry / timeout) and registers it under QueueNames.Recurring. When the queue already exists it does nothing.
QueueConfiguration Methods
Each queue supports these configuration methods:
// Parallelism
SetMaxDegreeOfParallelism(int parallelism)
// Capacity
SetChannelCapacity(int capacity)
// Full channel options replacement (FullMode, SingleReader/Writer, ...)
SetChannelOptions(BoundedChannelOptions options)
// Full behavior
SetFullBehavior(QueueFullBehavior behavior)
// Timeout (null reverts to the global default)
SetDefaultTimeout(TimeSpan? timeout)
// Retry policy (null reverts to the global default)
SetDefaultRetryPolicy(IRetryPolicy? policy)
Per-queue retry/timeout resolution chain (v3.7+): handler override → queue default → global default. The queue is the task’s declared queue: a task rerouted by FallbackToDefault keeps its declared queue’s retry/timeout.
QueueFullBehavior values (applies to immediate dispatches only; scheduler-triggered dispatches use a non-blocking write + backoff):
| Value | Behavior |
|---|---|
Wait | Block until space frees (cancellable via the dispatch CancellationToken). Default of the auto-created default queue. |
FallbackToDefault | First tries the target queue with a non-blocking write; if it’s full, logs a warning and re-routes the task to the default queue with blocking Wait backpressure (it does not throw unless the default queue itself is unavailable). Two consequences: (1) once on the default queue the task runs there, so it does not honor the target queue’s MaxDegreeOfParallelism/isolation; (2) if the target queue is the default queue, this degenerates to plain Wait (self-reference). Default for AddQueue-created queues. |
ThrowException | Throw QueueFullException; the task stays persisted as WaitingQueue and is re-enqueued by startup recovery. |
Rate Limiting Configuration
Keyed rate limiting (v3.7+) constrains how often tasks of a type execute per key (tenant, account, external resource). Behavior and edge cases are documented in Keyed Rate Limiting; this section covers the configuration surface.
Configuration lives in three places:
- Per-handler policy: the
RateLimitPolicyproperty on the handler declares the limit. - Key source: the task implements
IRateLimitedTask, or the handler overridesGetRateLimitKey. If the key is null/empty (or the key selector throws), the exception is caught, a warning is logged, and the task runs ungated (fail-open), never failing the task over a key-resolution error. - Global knobs:
SetRateLimiterOptionsbounds the limiter infrastructure.
RateLimitPolicy (per handler)
Declares the per-key execution budget for a task type.
Declaration:
public class SyncTenantHandler : EverTaskHandler<SyncTenantTask>
{
// 15 executions per minute PER KEY
public override RateLimitPolicy? RateLimitPolicy =>
new RateLimitPolicy(permits: 15, period: TimeSpan.FromMinutes(1))
{
Burst = 15,
ThrottleRetries = true,
StartEmpty = false,
MaxReservationHorizon = TimeSpan.FromHours(1),
MaxInSlotWait = TimeSpan.FromSeconds(1),
OverflowBehavior = RateLimitOverflowBehavior.WaitForCapacity
};
public override async Task Handle(SyncTenantTask task, CancellationToken ct) { ... }
}
Constructor:
permits(int): executions allowed perperiod. Must be > 0.period(TimeSpan): the budget window. Must be > 0.
Properties:
| Property | Type | Default | Description |
|---|---|---|---|
Permits | int | n/a (constructor, required) | Public get-only property set from the constructor: executions allowed per Period. Must be > 0 |
Period | TimeSpan | n/a (constructor, required) | Public get-only property set from the constructor: the rolling window for Permits. Must be > 0 |
Burst | int | Permits | Burst tolerance (≥ 1). 1 = strict even spacing (Period / Permits between executions); Permits = the full budget can front-load |
ThrottleRetries | bool | true | Retry attempts re-acquire the key’s budget through the gate (the re-acquire happens before the per-attempt timeout starts, so a budget wait never erodes it). This is not an inline wait between attempts: if the next free slot is far, the retry path stops the in-process retry loop and re-parks the task to the scheduler; it fires again via redelivery, and the retry attempt numbering restarts from the redelivered execution. false lets retries run without re-acquiring budget. |
StartEmpty | bool | false | When false (default), a fresh bucket starts full: the entire burst is available immediately (a restart can front-load up to Burst executions). Set to true to start at the steady rate from the first execution, capping the post-restart burst |
MaxReservationHorizon | TimeSpan | 1 hour | Slots farther than this are never parked: terminal rejection (one-shot → Failed + OnError; recurring → occurrence skipped) |
MaxInSlotWait | TimeSpan | 1 second | No-op, retained for binary compatibility only. The gate no longer waits inline on the consumer: every over-budget task (near or far slot) is re-parked to the scheduler and fires at its reserved slot via redelivery. An inline wait would head-of-line-block the single consumer (including unthrottled tasks behind it). |
OverflowBehavior | RateLimitOverflowBehavior | WaitForCapacity | WaitForCapacity defers over-budget tasks to their reserved slot; Discard terminally rejects them (one-shot → Failed + OnError; recurring → occurrence skipped) |
Notes:
- The policy is read once per handler type (first-wins cache); changing it requires a restart.
- A policy without a key (see below) logs a warning once per task type and executes ungated (fail-safe).
- Limiter outage fails open. If the
IKeyedRateLimiteritself throws while acquiring budget (most relevant for a custom/distributed implementation, e.g. Redis unreachable), the gate logs a warning and lets the task execute unthrottled rather than failing it (the never-lose-a-task contract). The same fail-open applies whenMaxTrackedKeysoverflows. OnlyOperationCanceledException(service shutdown) propagates, leaving the task in a recoverable status for next-startup recovery.
Rate-Limit Key Source
The key is derived per dispatch from the task:
// Option A: the task declares its key
public record SyncTenantTask(Guid TenantId) : IEverTask, IRateLimitedTask
{
public string RateLimitKey => TenantId.ToString();
}
// Option B: the handler derives the key (overrides IRateLimitedTask if both present)
public class SyncTenantHandler : EverTaskHandler<SyncTenantTask>
{
public override string? GetRateLimitKey(SyncTenantTask task) => task.TenantId.ToString();
}
Keep keys low-cardinality and stable (tenant ids, account ids); see Best Practices.
SetRateLimiterOptions (global knobs)
Bounds the limiter infrastructure process-wide. These are safety valves, not per-task limits.
Signature:
SetRateLimiterOptions(Action<RateLimiterOptions> configure)
Example:
opt.SetRateLimiterOptions(o =>
{
o.MaxParkedTasks = 5000;
o.MaxTrackedKeys = 100_000;
o.MaxKeyLength = 256;
o.EmitDeferralEvents = true;
});
Options:
| Option | Type | Default | Description |
|---|---|---|---|
MaxParkedTasks | int | min(5000, 2 × default-queue channel capacity) | Cap of DISTINCT rate-limited tasks parked waiting for budget; at the cap, consumers pause (bounded) before dequeued rate-limited tasks of the affected queues (unthrottled traffic keeps flowing), so backpressure reaches producers |
MaxTrackedKeys | int | 100,000 | Maximum (task type, key) buckets tracked in memory; new keys beyond the cap fail OPEN (execute unthrottled) with a warning and a monitoring event |
MaxKeyLength | int | 256 | Keys longer than this are hashed (SHA-256) before use |
EmitDeferralEvents | bool | true | Publish deferral monitoring events (aggregated at the source: first deferral per key per window plus periodic summaries) |
Notes:
- The
MaxParkedTasksdefault is computed at first resolution, after builder methods likeConfigureDefaultQueue(q => q.SetChannelCapacity(...))have run. - The rate limiter is per-instance (in-memory): N app instances each enforce the limit independently; see Multi-Instance.
Storage Configuration
Choose where EverTask saves task data.
AddMemoryStorage
Uses in-memory storage (fine for development/testing, but tasks won’t survive a restart).
Signature:
AddMemoryStorage()
Example:
builder.Services.AddEverTask(opt => opt.RegisterTasksFromAssembly(typeof(Program).Assembly))
.AddMemoryStorage();
Characteristics:
- No external dependencies
- Fast performance
- Tasks lost on restart
AddSqlServerStorage
Uses SQL Server for persistent storage.
Signature:
AddSqlServerStorage(string connectionString, Action<SqlServerTaskStoreOptions>? configure = null)
Parameters:
connectionString(string): SQL Server connection stringconfigure(Action, optional): Storage configuration options
Examples:
// Basic
.AddSqlServerStorage("Server=localhost;Database=EverTaskDb;Trusted_Connection=True;")
// With options
.AddSqlServerStorage(
connectionString,
opt =>
{
opt.SchemaName = "EverTask";
opt.AutoApplyMigrations = true;
})
SqlServerTaskStoreOptions Properties:
SchemaName(string?): Database schema name (default: “EverTask”);nullor empty falls back todbo(used for stored-procedure execution)AutoApplyMigrations(bool): Auto-apply EF Core migrations (default: true)
AddPostgresStorage
Uses PostgreSQL (via Npgsql) for persistent storage.
Signature:
AddPostgresStorage(string connectionString, Action<PostgresTaskStoreOptions>? configure = null)
Parameters:
connectionString(string): Npgsql connection string (e.g.Host=localhost;Database=evertask;Username=...;Password=...)configure(Action, optional): Storage configuration options
Examples:
// Basic
.AddPostgresStorage("Host=localhost;Database=evertask;Username=evertask;Password=***")
// With options
.AddPostgresStorage(
connectionString,
opt =>
{
opt.SchemaName = "evertask";
opt.AutoApplyMigrations = true;
})
PostgresTaskStoreOptions Properties:
SchemaName(string?): Database schema name (default: “evertask”, must be lowercase; null =publicschema)AutoApplyMigrations(bool): Auto-apply EF Core migrations (default: true)
AddMySqlStorage
Uses MySQL or MariaDB (via Microting.EntityFrameworkCore.MySql) for persistent storage. Targets net9.0/net10.0 only.
Signature:
AddMySqlStorage(string connectionString, Action<MySqlTaskStoreOptions>? configure = null)
Parameters:
connectionString(string): MySQL/MariaDB connection string (e.g.Server=localhost;Database=evertask;User=...;Password=...)configure(Action, optional): Storage configuration options
Examples:
// Basic
.AddMySqlStorage("Server=localhost;Database=evertask;User=evertask;Password=***")
// With options
.AddMySqlStorage(
connectionString,
opt =>
{
opt.AutoApplyMigrations = true;
opt.ServerVersion = new MariaDbServerVersion(new Version(10, 11)); // optional, skips auto-detect
})
MySqlTaskStoreOptions Properties:
AutoApplyMigrations(bool): Auto-apply EF Core migrations (default: true)ServerVersion(ServerVersion?): Explicit server version (default: null =ServerVersion.AutoDetect)SchemaName(string?): Defaults to""and must stay empty: MySQL/MariaDB have no sub-database schema (a “schema” is a database).
AddSqliteStorage
Uses SQLite for persistent storage.
Signature:
AddSqliteStorage(string connectionString = "Data Source=EverTask.db",
Action<SqliteTaskStoreOptions>? configure = null)
Parameters:
connectionString(string, optional): SQLite connection string; defaults to"Data Source=EverTask.db", so.AddSqliteStorage()with no arguments is validconfigure(Action, optional): Storage configuration options
Examples:
// Zero-config (Data Source=EverTask.db)
.AddSqliteStorage()
// Basic
.AddSqliteStorage("Data Source=evertask.db")
// With options
.AddSqliteStorage(
"Data Source=evertask.db;Cache=Shared;",
opt =>
{
opt.AutoApplyMigrations = true;
})
Notes:
SchemaNamemust remain an empty string (""): SQLite has no schema concept, do not change it
Logging Configuration
AddSerilog
Integrates Serilog for structured logging throughout EverTask.
Package: EverTask.Logging.Serilog
Signature:
AddSerilog(Action<LoggerConfiguration>? configure = null)
Parameters:
configure(Action, optional): Serilog logger configuration; calling.AddSerilog()with no arguments configures a Console-only sink
Example:
.AddSerilog(opt =>
opt.ReadFrom.Configuration(
configuration,
new ConfigurationReaderOptions { SectionName = "EverTaskSerilog" }))
appsettings.json Example:
{
"EverTaskSerilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"Microsoft.EntityFrameworkCore.Database.Command": "Information"
}
},
"WriteTo": [
{
"Name": "Console"
},
{
"Name": "File",
"Args": {
"path": "Logs/evertask-.txt",
"rollingInterval": "Day",
"retainedFileCountLimit": 10
}
}
],
"Enrich": ["FromLogContext", "WithMachineName", "WithThreadId"],
"Properties": {
"Application": "MyApp"
}
}
}
WithPersistentLogger
Available since: v3.0
Configures persistent handler logging options. When enabled, logs written via Logger property in handlers are stored in the database for audit trails.
Important: Logs are ALWAYS forwarded to ILogger infrastructure (console, file, Serilog, etc.) regardless of this setting. This option only controls database persistence.
Signature:
WithPersistentLogger(Action<PersistentLoggerOptions> configure)
Parameters:
configure(Action): Configuration action for persistent logger options
Default: Disabled
Example:
.AddEverTask(opt => opt
.WithPersistentLogger(log => log
.SetMinimumLevel(LogLevel.Information)
.SetMaxLogsPerTask(1000)))
Note: Calling .WithPersistentLogger() automatically enables database persistence. You don’t need to call .Enable().
PersistentLoggerOptions Methods:
Enable() / Disable()
Enable() turns on database persistence; Disable() turns it off (logs still flow to ILogger in both cases). WithPersistentLogger(...) already calls Enable() for you, so Enable() is rarely needed explicitly. There is also a settable Enabled (bool) property backing both.
.WithPersistentLogger(log => log.Disable()) // configured but persistence off
SetMinimumLevel(LogLevel level)
Sets the minimum log level for database persistence. Logs below this level are not stored in the database but are still forwarded to ILogger.
Parameters:
level(LogLevel): Minimum level to persist (Trace,Debug,Information,Warning,Error,Critical)
Default: LogLevel.Information
Example:
.WithPersistentLogger(log => log
.SetMinimumLevel(LogLevel.Warning)) // Only persist Warning and above
Note: This only affects database persistence. ILogger receives all log levels regardless of this setting.
SetMaxLogsPerTask(int? maxLogs)
Sets the maximum number of logs to persist per task execution. Once this limit is reached, additional logs are not persisted (but still forwarded to ILogger), except for a single appended truncation marker record noting that logs were dropped.
Parameters:
maxLogs(int?): Maximum logs to persist.null= unlimited (not recommended for production)
Default: 1000
Example:
.WithPersistentLogger(log => log
.SetMaxLogsPerTask(500)) // Limit to 500 logs
Performance: ~100 bytes per log in memory during execution. Single bulk INSERT to database after task completion.
Complete Example:
.AddEverTask(opt => opt
.RegisterTasksFromAssembly(typeof(Program).Assembly)
.WithPersistentLogger(log => log
.SetMinimumLevel(LogLevel.Information)
.SetMaxLogsPerTask(1000)))
Monitoring Configuration
AddMonitoringApi
Adds the EverTask Monitoring API with an optional embedded React dashboard for monitoring and managing tasks.
Package: EverTask.Monitor.Api
Signature:
AddMonitoringApi() // on EverTaskServiceBuilder
AddMonitoringApi(Action<EverTaskApiOptions> configure)
For apps that don’t use the EverTask builder chain, there is an IServiceCollection variant: services.AddEverTaskMonitoringApiStandalone(Action<EverTaskApiOptions>? configure = null): it does not auto-register SignalR monitoring and requires you to register ITaskStorage yourself.
Parameters:
configure(Action): Configuration options for the monitoring API
Examples:
Basic Setup (Default Settings):
.AddMonitoringApi()
// Dashboard: http://localhost:5000/evertask-monitoring
// API: http://localhost:5000/evertask-monitoring/api
// Credentials: admin / admin
Custom Configuration:
.AddMonitoringApi(options =>
{
options.EnableUI = true;
options.Username = "monitor_user";
options.Password = "secure_password_123";
options.EnableAuthentication = true;
options.EnableCors = true;
options.CorsAllowedOrigins = new[] { "https://myapp.com" };
})
API-Only Mode (No Dashboard):
.AddMonitoringApi(options =>
{
options.EnableUI = false; // Disable embedded dashboard
options.EnableAuthentication = false; // Open API for custom frontend
})
Environment-Specific Configuration:
.AddMonitoringApi(options =>
{
options.EnableUI = true;
if (builder.Environment.IsDevelopment())
{
// Development: No authentication
options.EnableAuthentication = false;
}
else
{
// Production: Secure credentials from environment
options.EnableAuthentication = true;
options.Username = Environment.GetEnvironmentVariable("MONITOR_USERNAME")
?? throw new InvalidOperationException("MONITOR_USERNAME not set");
options.Password = Environment.GetEnvironmentVariable("MONITOR_PASSWORD")
?? throw new InvalidOperationException("MONITOR_PASSWORD not set");
options.EnableCors = true;
options.CorsAllowedOrigins = new[] { "https://app.example.com" };
}
})
EverTaskApiOptions Properties:
| Property | Type | Default | Description |
|---|---|---|---|
EnableUI | bool | true | Enable embedded React dashboard |
EnableOpenApiDocument | bool | false | Serve the monitoring OpenAPI document (net9.0+; auto-enabled by the Scalar package) |
EnableSwagger | bool | false | Obsolete no-op since 4.0.0 (use EnableOpenApiDocument) |
Username | string | "admin" | JWT Authentication username |
Password | string | "admin" | JWT Authentication password (CHANGE IN PRODUCTION!) |
EnableAuthentication | bool | true | Enable JWT Authentication |
EnableManagementEndpoints | bool | false | Expose the management (write) endpoints under /evertask-monitoring/api/management. While false, every path under that prefix answers 404 |
ManagementUsername | string? | null | Username of the second, operate-level credential. Logging in with it returns a token carrying the operate role, which is what the management endpoints require |
ManagementPassword | string? | null | Password of the operate-level credential (compared in fixed time). Both halves must be set for the credential to exist |
ManagementAuthorization | Func<HttpContext, Task<bool>>? | null | Host-supplied authorization for the management endpoints. When set it replaces the role check; returning false answers 403 |
JwtSecret | string? | null | JWT signing key; when unset, a random 256-bit secret is generated per instance. Set it explicitly (≥ 32 bytes) for multi-instance deployments |
JwtIssuer | string | "EverTask.Monitor.Api" | JWT issuer claim |
JwtAudience | string | "EverTask.Monitor.Api" | JWT audience claim |
JwtExpirationHours | int | 8 | JWT token TTL in hours |
EnableCors | bool | true | Registers a named CORS policy (EverTaskMonitoringApi); EverTask does NOT apply it: your app must (app.UseCors(...)). See note below |
CorsAllowedOrigins | string[] | [] | Origins for the registered policy (empty = allow-any). Only effective once the policy is actually applied |
AllowedIpAddresses | string[] | [] | IP address whitelist (empty = allow all IPs). Supports IPv4, IPv6, and CIDR notation |
MagicLinkToken | string? | null | Static token for magic link authentication. When set, enables instant access via /evertask-monitoring/magic#token=... (exchanged with POST /api/auth/magic; the ?token= query form is deprecated since 4.0.0 because it lands in request logs) |
EventDebounceMs | int | 1000 | Debounce time in milliseconds for SignalR event-driven cache invalidation in the dashboard. Higher values reduce API load during task bursts but introduce slight UI update delays. Recommended: 300ms (very responsive), 500ms (balanced), 1000ms (conservative for high-volume) |
BasePath | string | /evertask-monitoring | Read-only computed property (fixed; cannot be set) |
ApiBasePath | string | /evertask-monitoring/api | Read-only computed property ({BasePath}/api) |
UIBasePath | string | /evertask-monitoring | Read-only computed property (= BasePath) |
SignalRHubPath | string | /evertask-monitoring/hub | Read-only computed property (fixed when using AddMonitoringApi/MapEverTaskApi) |
EnableUI
Controls whether the embedded React dashboard is served.
Examples:
// Full mode (default): API + Dashboard
options.EnableUI = true;
// API-only mode: REST API without dashboard
options.EnableUI = false;
Use Cases for API-Only Mode:
- Building custom frontend applications
- Mobile app integration
- Third-party monitoring system integration
- Headless server environments
EnableOpenApiDocument
Serves an OpenAPI document for the monitoring API, generated with the built-in ASP.NET Core generator (Microsoft.AspNetCore.OpenApi). Requires net9.0 or later: on net8.0 the setting is a no-op and the bundled analyzer reports ET0008.
Examples:
options.EnableOpenApiDocument = true;
How It Works:
- Document name:
evertask-monitoring - Document endpoint:
/evertask-monitoring/openapi/evertask-monitoring.json - Includes only EverTask monitoring controllers (they carry the
evertask-monitoringApiExplorer group) - Fully isolated from the host’s OpenAPI/Swagger/Scalar setup, with nothing to configure on the host side
- The
EverTask.Monitor.Api.Scalarpackage enables this automatically and adds an interactive API reference at/evertask-monitoring/scalar(.AddMonitoringApiScalar()afterAddMonitoringApi())
See monitoring-dashboard.md for the Scalar setup and the optional recipe to surface the document inside the host’s own Swagger UI.
EnableSwagger (obsolete)
No-op since 4.0.0: the Swashbuckle integration was removed (it broke .NET 10 hosts using the built-in OpenAPI stack, issue #20). Use EnableOpenApiDocument and optionally the EverTask.Monitor.Api.Scalar package instead.
Username / Password
JWT Authentication credentials for accessing the monitoring dashboard and API.
Examples:
// Development (not recommended for production)
options.Username = "admin";
options.Password = "admin";
// Production: Environment variables
options.Username = Environment.GetEnvironmentVariable("MONITOR_USERNAME") ?? "admin";
options.Password = Environment.GetEnvironmentVariable("MONITOR_PASSWORD") ?? "changeme";
// Production: Configuration
options.Username = configuration["Monitoring:Username"];
options.Password = configuration["Monitoring:Password"];
Security Notes:
- Always change default credentials in production
- Use environment variables or secure configuration systems
- Always use HTTPS when authentication is enabled
- Consider using anonymous read access for internal networks
EnableAuthentication
Controls whether JWT Authentication is required for API endpoints and SignalR hub.
Examples:
// Require authentication (default, recommended for production)
options.EnableAuthentication = true;
// No authentication (development only)
options.EnableAuthentication = false;
// Environment-specific
options.EnableAuthentication = !builder.Environment.IsDevelopment();
Protection Scope:
- API endpoints: All
/api/*endpoints (except login and config) - SignalR hub: Real-time monitoring hub at
/evertask-monitoring/hub - UI: Not protected by JWT (only IP whitelist, see
AllowedIpAddresses)
Behind a path base. The checks are enforced inside routing, so they hold when the application runs under app.UsePathBase("/tenant"): what is judged is the path routing resolved, which is the monitoring path without the base. Before 4.0.0 they were enforced only by a middleware that runs before UsePathBase, so on such a host every layer was skipped at once: the read endpoints answered anonymously, the IP whitelist never ran and the SignalR handshake was granted (issue #46). Only the monitoring CORS policy still keys off the pre-UsePathBase path, so under a path base you may need your own CORS setup for cross-origin dashboards.
Always Accessible (No JWT Required):
/api/config- Dashboard configuration endpoint/api/auth/login- Login endpoint for obtaining JWT/api/auth/validate- Token validation endpoint/api/auth/magic- Magic-link token exchange,POSTwith the token in the body (theGET ?token=form is deprecated); returns 404 whenMagicLinkTokenis not configured- UI static files (HTML, JS, CSS)
JWT Authentication Flow:
- Client authenticates via
/api/auth/loginwith username/password - Server returns JWT token
- Client includes token in subsequent requests:
- API:
Authorization: Bearer <token>header - SignalR:
accessTokenFactoryoption or?access_token=<token>query string
- API:
Notes:
- When disabled, all API and hub endpoints are publicly accessible (only IP whitelist applies)
- UI is always accessible (relies on IP whitelist for protection)
- JWT tokens expire after 8 hours by default (see
JwtExpirationHours)
EnableManagementEndpoints, ManagementUsername / ManagementPassword, ManagementAuthorization
The monitoring API is read-only by construction. These four options are what opens the one exception to that (the management endpoints, which requeue a terminal occurrence, resume a halted catch-up or cancel a schedule), and they start from the authorization, not from the endpoints.
Why a second credential. Username/Password is the dashboard credential: everyone who looks at the dashboard shares it, and looking is all it is for. A requeue puts a handler with side effects back into execution, so it does not travel on that credential. ManagementUsername/ManagementPassword is a separate account, and logging in with it returns a token carrying the operate role; every other login (the dashboard credential and every magic link, which is a URL and gets forwarded) returns a read-only one.
Examples:
// Default: no write surface at all. A host that upgrades gains nothing it did not ask for.
options.EnableManagementEndpoints = false;
// Opened, behind a second credential
options.EnableManagementEndpoints = true;
options.ManagementUsername = "evertask-operator";
options.ManagementPassword = builder.Configuration["EverTask:OperatePassword"];
// Or decided by the application's own authorization, whatever it is. The hook runs inside routing, after
// the host's UseAuthentication, so context.User is the principal the application authenticated.
options.EnableManagementEndpoints = true;
options.ManagementAuthorization = context =>
Task.FromResult(context.User.IsInRole("BackgroundJobsOperator"));
How a request is decided (/evertask-monitoring/api/management/* only):
EnableManagementEndpointsis false → 404. The prefix does not exist; an API that never opened a write surface does not advertise one.- Authentication is enabled and no valid token is presented → 401, as everywhere else.
ManagementAuthorizationis set → the host decides. It replaces the role check, so holding the operate credential does not bypass it. Returning false → 403.- Otherwise the session must carry the operate role → 403 without it.
With EnableAuthentication = false there is no session and therefore no role: the management endpoints are refused (403) unless ManagementAuthorization says otherwise. Opening the read API must not silently mean “anyone may cancel a schedule”.
Where the decision runs. Inside routing, as an MVC authorization filter on the management routes, not in the monitoring middleware. Two things follow, and both matter:
- It sees the request as routing does, so a host that calls
app.UsePathBase("/tenant")is covered.UsePathBasemoves the prefix out ofRequest.Pathafter the monitoring middleware has run, so a check living only there would miss the very request that routing then resolves to the action. - It runs after the host’s
UseAuthentication, socontext.UserinsideManagementAuthorizationis the principal your application authenticated.context.User.IsInRole(...), a claims check or anything else you already use answers exactly what it answers in your own controllers.
The management credential must really be a second one. Registration throws InvalidOperationException when ManagementPassword equals Password or MagicLinkToken, and when only one half of ManagementUsername / ManagementPassword is set. A username is not a secret: an operate password the host already hands out for reading is not a second credential, it is the shared one with a different name on it.
CSRF. These endpoints need no anti-forgery token: the API authenticates a session with a Bearer token in the Authorization header, never with a cookie (the ?access_token= fallback exists on the SignalR hub path alone). A browser attaches neither to a cross-site request, so a page the operator did not open cannot make one of these calls in their name. That also means the dashboard’s token must stay out of cookies.
The endpoints themselves are documented in Monitoring API Reference. The application-side road is unchanged and still the right one for anything programmatic: ITaskScheduleManager, called behind the application’s own authorization (see Managing schedules at runtime).
SignalRHubPath
The SignalR hub path is now fixed to /evertask-monitoring/hub and cannot be changed.
Notes:
- The hub path is readonly and set to
/evertask-monitoring/hub - SignalR monitoring is automatically configured if not already registered
- Dashboard automatically uses this fixed path for real-time updates
EnableCors
When true, registers the EverTaskMonitoringApi CORS policy and applies it to requests under /evertask-monitoring (since 4.0.0). The host pipeline is untouched: no global UseCors and nothing to wire manually. With CorsAllowedOrigins empty the policy allows any origin; with origins set it restricts to them and adds AllowCredentials.
Examples:
// Apply the monitoring CORS policy (default)
options.EnableCors = true;
// No CORS handling on the monitoring endpoints
options.EnableCors = false;
Login rate limiting
POST /evertask-monitoring/api/auth/login carries the evertask-monitoring-login rate-limit policy (EverTaskApiOptions.LoginRateLimitPolicyName): 5 attempts per 15 minutes per client IP, 429 on rejection. The package registers the policy; ASP.NET Core only enforces rate limiting when the host pipeline runs the middleware:
app.UseRateLimiter(); // after UseRouting()
The name is namespaced so it cannot merge with a login policy the host may define.
⚠ Important: EverTask only registers this policy; it does not apply it (
MapEverTaskApi/the startup filter never callUseCorsorRequireCors). For cross-origin requests to actually be permitted, your application must apply the policy itself, e.g.app.UseCors("EverTaskMonitoringApi")in the pipeline. With API and dashboard on the same origin (the default embedded-UI setup) no CORS is needed.
Notes:
- Relevant when the dashboard/frontend is hosted on a different origin from the API
- Not needed when API and frontend are on the same origin (default embedded UI)
CorsAllowedOrigins
Specifies allowed origins for CORS requests.
Examples:
// Allow all origins (default, useful for development)
options.CorsAllowedOrigins = Array.Empty<string>();
// Restrict to specific origins (production)
options.CorsAllowedOrigins = new[]
{
"https://myapp.com",
"https://dashboard.myapp.com"
};
// Environment-specific origins
options.CorsAllowedOrigins = builder.Environment.IsDevelopment()
? Array.Empty<string>() // Allow all in development
: new[] { "https://app.example.com" }; // Restrict in production
Security Notes:
- Empty array = allow all origins (convenient for development)
- Always restrict origins in production
- Use HTTPS origins in production
AllowedIpAddresses
Restricts monitoring access to specific IP addresses or CIDR ranges. Applies to both API endpoints and SignalR hub.
Examples:
// Allow all IPs (default)
options.AllowedIpAddresses = Array.Empty<string>();
// Restrict to specific IPs (production)
options.AllowedIpAddresses = new[]
{
"192.168.1.100", // Specific admin workstation
"10.0.0.0/8", // Internal network (CIDR notation)
"172.16.0.0/12", // Another internal range
"::1" // IPv6 localhost
};
// Reverse proxy scenario (public IP ranges)
options.AllowedIpAddresses = new[]
{
"203.0.113.0/24" // Office public IP range
};
Features:
- Supports IPv4 and IPv6 addresses
- Supports CIDR notation (e.g.,
192.168.0.0/24) - The client address is
Connection.RemoteIpAddress: no header is trusted (see below) - Returns 403 Forbidden if IP not in whitelist
- IP check runs before authentication (more efficient)
Security Notes:
- Empty array = allow all IPs (default, suitable for internal networks)
- Always configure in production when exposed to internet
- Protects the API, the SignalR hub and the dashboard files (which no JWT covers)
- More efficient than firewall rules at application level
Behind a reverse proxy (changed in 4.0.0, see below)
The whitelist compares the address of the connection EverTask actually sees. Behind a proxy that address is the proxy’s, so the host must let ASP.NET Core replace it first, with the standard forwarded headers middleware:
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor;
// Only these peers may be believed. Without them nothing is forwarded.
options.KnownProxies.Add(IPAddress.Parse("10.0.0.7"));
// options.KnownNetworks.Add(new IPNetwork(IPAddress.Parse("10.0.0.0"), 8));
});
var app = builder.Build();
app.UseForwardedHeaders(); // before UseRouting
and the proxy must send the header:
# Nginx example
location /evertask-monitoring {
proxy_pass http://localhost:5000/evertask-monitoring;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
BREAKING (4.0.0), security. Before 4.0.0 EverTask read
X-Forwarded-Foritself and believed it unconditionally, so any direct caller could bypass the whitelist by sending a whitelisted address in that header (issue #47). It no longer reads the header at all. If you are behind a proxy and relied on the old behavior, configureUseForwardedHeadersas above, withKnownProxiesorKnownNetworksset, which is what decides whether the header may be believed. Hosts not behind a proxy need no change.
MagicLinkToken
Enables instant authentication via a static token URL. Useful for embedding the dashboard in other systems or providing quick access without credential management.
Examples:
// Enable magic link access
options.MagicLinkToken = "your-very-long-secret-token-here-min-32-chars";
// Combined with IP whitelist for extra security
options.MagicLinkToken = "your-secret-token";
options.AllowedIpAddresses = new[] { "10.0.0.0/8" };
Access URL (since 4.0.0, token in the URL fragment):
https://your-server/evertask-monitoring/magic#token=your-very-long-secret-token-here-min-32-chars
How it works:
- User visits the magic link URL; the fragment stays in the browser and is never sent to the server
- The dashboard reads the token from the fragment and exchanges it with
POST /api/auth/magic(token in the request body) - Backend validates the token against
MagicLinkToken(fixed-time comparison) - If valid, generates a standard JWT session token
- User is redirected to the dashboard, fully authenticated
Security Notes:
- Use a long, random token (32+ characters recommended)
- Token never expires - change it in configuration to revoke all magic link access
- Use the
#token=fragment form. The older?token=query form still works, but every component that logs request URLs (SerilogUseSerilogRequestLogging()throughRawTarget, reverse proxies, Azure App Service HTTP logs, browser history) stores the token verbatim, and it does not expire. See Magic Link Access for host-side mitigations GET /api/auth/magic?token=is deprecated and kept only for existing integrations; the dashboard uses the POST form- Both exchange endpoints share the
evertask-monitoring-loginrate-limit policy (5 attempts per 15 minutes per client IP, enforced when the host runsUseRateLimiter()) - Combine with
AllowedIpAddressesfor defense in depth - If
MagicLinkTokenis not set, the endpoint returns 404
Token Generation:
# PowerShell - generate secure random token
[Convert]::ToBase64String((1..32 | ForEach-Object { Get-Random -Maximum 256 }) -as [byte[]])
API Endpoints
Once configured, the monitoring API exposes REST endpoints for querying tasks and reading statistics. All endpoints are relative to {BasePath}/api (default: /evertask-monitoring/api).
Main endpoints:
GET /tasks- Paginated task list with filtering, including theparentTaskId,onlyOccurrencesandonlyCatchUpfilters for durable occurrencesGET /tasks/{id}- Task detailsGET /tasks/counts- Task counts by category (all, standard, recurring, failed, occurrences)GET /tasks/{id}/status-audit- Status change historyGET /tasks/{id}/runs-audit- Execution historyGET /tasks/{id}/execution-logs- Persisted handler logs (when persistent logging is enabled)GET /tasks/{id}/occurrences- The occurrences a durable schedule has materialized, newest slot first, paged by the storage itselfGET /dashboard/overview- Dashboard statistics, including the catch-up backlog of every durable schedule by stateGET /queues- Queue metricsGET /statistics/success-rate-trend- Success rate trendsGET /rate-limits- Keyed rate-limit state (per-key parked count, next slot, tracked keys, fail-open count; in-memory, single-node)
Every endpoint is read-only: nothing here changes a task or a schedule. Changing a schedule while the application runs is ITaskScheduleManager, called from your own code behind your own authorization: the dashboard credentials are one read credential shared by everyone who looks at it.
See Monitoring Dashboard for complete API documentation.
Dashboard Features
When EnableUI is true, the embedded React dashboard provides:
- Overview Dashboard: Total tasks, success rate, active queues, execution times
- Catch-up Backlog: The occurrences of every durable schedule by state (pending, active, failed, skipped, completed), how far behind the oldest pending slot is, and how many schedules stopped themselves over their catch-up cap. Shown only when a durable schedule exists
- Task List: Filtering, sorting, pagination, status filters, plus a catch-up badge and a lateness badge on the rows that stand for missed work
- Task Details: Complete information, execution history, error details
- Occurrences: A tab on a durable schedule listing the occurrences it materialized, newest slot first, with page controls
- Queue Metrics: Per-queue statistics and health monitoring
- Analytics: Success rate trends, task type distribution, execution times
- Real-Time Updates: Live task updates via SignalR
Mapping Endpoints
After configuring the monitoring API, map the endpoints in your application:
var app = builder.Build();
// Map EverTask monitoring endpoints (includes SignalR hub automatically)
app.MapEverTaskApi();
app.Run();
MapEverTaskApi() maps endpoints only:
- Maps SignalR monitoring hub (at
/evertask-monitoring/hub) with automatic JWT authentication - Maps all API controllers
- Serves embedded dashboard (if
EnableUIis true)
MapEverTaskApi()does not wire JWT authentication middleware or apply a CORS policy. The JWT middleware is wired automatically byAddMonitoringApi()(via anIStartupFilter); the CORS policy is only registered byAddMonitoringApi()(AddCors) and is not applied: if you need it enforced, callapp.UseCors("EverTaskMonitoringApi")yourself. No manualUseEverTaskApiMiddleware()call is needed (that method is obsolete).
Important Notes:
- The monitoring API handles SignalR setup completely autonomously:
AddMonitoringApi()automatically registers SignalR monitoring services (if not already registered)MapEverTaskApi()automatically maps the SignalR hub endpoint with authentication- No additional SignalR configuration is required unless you want to customize hub options
- To customize hub options, pass an
Action<HttpConnectionDispatcherOptions>toMapEverTaskApi():app.MapEverTaskApi(hubOptions => { // Custom SignalR hub configuration hubOptions.TransportMaxBufferSize = 1024 * 1024; // 1MB buffer hubOptions.ApplicationMaxBufferSize = 1024 * 1024; });
Integration with SignalR
The monitoring API automatically configures SignalR monitoring if it hasn’t been added:
// This is sufficient - SignalR is auto-configured
.AddMonitoringApi()
// Manual SignalR configuration (if you need more control)
.AddSignalRMonitoring(opt =>
{
opt.IncludeExecutionLogs = true; // Include logs in SignalR events
})
.AddMonitoringApi()
// Note: SignalRHubPath is now fixed to "/evertask-monitoring/hub" and cannot be changed
AddSignalRMonitoring
Enables real-time task monitoring via SignalR.
Package: EverTask.Monitor.AspnetCore.SignalR
Signature:
AddSignalRMonitoring()
AddSignalRMonitoring(Action<SignalRMonitoringOptions> monitoringConfiguration)
AddSignalRMonitoring(Action<HubOptions> hubConfiguration)
AddSignalRMonitoring(Action<HubOptions> hubConfiguration, Action<SignalRMonitoringOptions> monitoringConfiguration)
Parameters:
configure(Action): Monitoring configuration options (SignalRMonitoringOptions)hubOptions(Action): SignalRHubOptionscustomization
Examples:
// Basic (default configuration)
.AddSignalRMonitoring()
// With execution log streaming enabled
.AddSignalRMonitoring(opt =>
{
opt.IncludeExecutionLogs = true; // Stream logs to SignalR clients (increases bandwidth)
})
SignalRMonitoringOptions Properties:
| Property | Type | Default | Description |
|---|---|---|---|
IncludeExecutionLogs | bool | false | Include execution logs in SignalR events (increases message size) |
Standalone usage (without Monitor.Api):
When using the SignalR package without AddMonitoringApi()/MapEverTaskApi(), you MUST map the hub yourself or no events are broadcast:
app.MapEverTaskMonitorHub(); // default route /evertask-monitoring/hub
app.MapEverTaskMonitorHub("/custom/hub"); // custom route
app.MapEverTaskMonitorHub("/custom/hub", hub => // custom route + SignalR hub dispatcher options
{
hub.TransportMaxBufferSize = 1024 * 1024;
});
MapEverTaskMonitorHub maps the hub and subscribes the monitor, so it is required in standalone mode. Overloads: () (default pattern), (string pattern), and (string pattern, Action<HttpConnectionDispatcherOptions>).
Important Notes:
- Hub Route: When mapped by
MapEverTaskApi()the route is fixed atEverTaskApiOptions.SignalRHubPath(/evertask-monitoring/hub, read-only). In standalone mode the route is configurable:MapEverTaskMonitorHub(pattern)accepts any pattern (defaulting to/evertask-monitoring/hub); if you choose a custom pattern, point your client at the same path. - Log Streaming: Execution logs are always available via ILogger and database persistence (if enabled)
- Performance Impact: Enabling
IncludeExecutionLogsincreases SignalR message size and network bandwidth - Use Case: Enable only when you need real-time log streaming to monitoring dashboards
Client-Side Setup:
<!-- Add SignalR client library -->
<script src="https://cdn.jsdelivr.net/npm/@microsoft/signalr@latest/dist/browser/signalr.min.js"></script>
<script>
const connection = new signalR.HubConnectionBuilder()
.withUrl("/evertask-monitoring/hub") // must match the mapped hub route (fixed under MapEverTaskApi; configurable under standalone MapEverTaskMonitorHub)
.withAutomaticReconnect()
.build();
connection.on("EverTaskEvent", (eventData) => {
console.log("Task event:", eventData);
// eventData.TaskId, eventData.Severity, eventData.Message, eventData.Exception, etc.
});
connection.start()
.then(() => console.log("SignalR connected"))
.catch(err => console.error("SignalR connection error:", err));
</script>
Event Data Structure:
{
"TaskId": "dc49351d-476d-49f0-a1e8-3e2a39182d22",
"EventDateUtc": "2024-10-19T16:10:20Z",
"Severity": "Information", // "Information" | "Warning" | "Error"
"TaskType": "MyApp.Tasks.SendEmailTask",
"TaskHandlerType": "MyApp.Tasks.SendEmailHandler",
"TaskParameters": "{\"Email\":\"user@example.com\"}",
"Message": "Task completed successfully",
"Exception": null // Stack trace if task failed
}
Severity Levels:
Information: Task started, completed, or scheduledWarning: Task cancelled or timed outError: Task failed with exception
Storage Provider Details
SQL Server Storage Options
Package: EverTask.Storage.SqlServer
Advanced Configuration:
.AddSqlServerStorage(connectionString, opt =>
{
// Schema name (default: "EverTask"); null/empty falls back to dbo
opt.SchemaName = "EverTask";
// Auto-apply migrations (default: true)
opt.AutoApplyMigrations = true;
})
// Note: there are only two configurable options (SchemaName, AutoApplyMigrations).
// DbContext pooling (via AddPooledDbContextFactory) and the status-update stored
// procedures are always on: baked into the provider/migrations, not user-toggleable.
Manual Migrations:
For production environments, apply migrations manually:
# Generate migration script (run from src/Storage/EverTask.Storage.SqlServer/)
dotnet ef migrations script --context SqlServerTaskStoreContext --output migration.sql
# Apply via your deployment pipeline
sqlcmd -S localhost -d EverTaskDb -i migration.sql
Stored Procedures:
EverTask uses stored procedures for critical operations:
[EverTask].[usp_SetTaskStatus](v2.0+): status update + audit insert in one round-trip and one transaction[EverTask].[usp_UpdateCurrentRun](v3.6+): single-round-trip recurring-run update- The procs do the audit insert and status update in one round-trip instead of two statements, kept atomic. That saves a round-trip on the status-change path; it is not a task-throughput multiplier
Connection String Options:
// Basic
"Server=localhost;Database=EverTaskDb;Trusted_Connection=True;"
// With pooling (recommended)
"Server=localhost;Database=EverTaskDb;Trusted_Connection=True;Min Pool Size=5;Max Pool Size=100;"
// Azure SQL
"Server=tcp:yourserver.database.windows.net,1433;Database=EverTaskDb;User ID=user;Password=pass;Encrypt=True;"
Schema Customization:
-- Custom schema
CREATE SCHEMA [CustomSchema]
GO
-- Configure in code
opt.SchemaName = "CustomSchema";
SQLite Storage Options
Package: EverTask.Storage.Sqlite
Advanced Configuration:
.AddSqliteStorage(connectionString, opt =>
{
// Auto-apply migrations (default: true)
opt.AutoApplyMigrations = true;
// Note: SchemaName must remain "" (empty string): SQLite has no schema concept
})
Connection String Options:
// Basic
"Data Source=evertask.db"
// In-memory (for testing)
"Data Source=:memory:"
// Shared cache
"Data Source=evertask.db;Cache=Shared;"
// Full options
"Data Source=evertask.db;Mode=ReadWriteCreate;Cache=Shared;Foreign Keys=True;"
Performance Tuning:
-- WAL mode for better concurrency
PRAGMA journal_mode=WAL;
-- Optimize for performance
PRAGMA synchronous=NORMAL;
PRAGMA cache_size=10000;
PRAGMA temp_store=MEMORY;
Limitations:
- No schema support (unlike SQL Server)
- Single writer: tops out around a couple hundred tasks/sec on this hardware, and parallelism does not help
- Best for: Single-server deployments, development, small workloads
PostgreSQL Storage Options
Package: EverTask.Storage.Postgres
Advanced Configuration:
.AddPostgresStorage(connectionString, opt =>
{
// Schema name (default: "evertask"). MUST be lowercase (matches ^[a-z_][a-z0-9_]*$):
// Npgsql always double-quotes generated identifiers, so a mixed-case schema becomes
// permanently case-sensitive. null = the "public" schema.
opt.SchemaName = "evertask";
// Auto-apply migrations (default: true). Disable for DBA-controlled / staged deploys.
opt.AutoApplyMigrations = true;
})
// Note: there are only two configurable options (SchemaName, AutoApplyMigrations).
// DbContext pooling is always on. Status/run updates use single-statement data-modifying
// CTEs (the Postgres analog of SQL Server's stored procedures): versioned in C#, no DB objects.
Connection String Examples:
// Basic
"Host=localhost;Database=evertask;Username=evertask;Password=***"
// With port + SSL
"Host=db.example.com;Port=5432;Database=evertask;Username=app;Password=***;SSL Mode=Require;Trust Server Certificate=true"
Manual Migrations: same pattern as SQL Server, using --context PostgresTaskStoreContext.
Notes / limitations:
SchemaNamelowercase-only (see above).- All
DateTimeOffsetvalues map totimestamptz(UTC). - High-write-concurrency support on one active EverTask host per store; an inactive standby is fine. See Horizontal Scaling.
MySQL / MariaDB Storage Options
Package: EverTask.Storage.MySql (targets net9.0/net10.0 only)
Advanced Configuration:
.AddMySqlStorage(connectionString, opt =>
{
// Auto-apply migrations (default: true). Disable for DBA-controlled / staged deploys.
opt.AutoApplyMigrations = true;
// Optional explicit server version. Default null -> ServerVersion.AutoDetect(connectionString)
// (one short connect at startup). Set to skip the probe.
opt.ServerVersion = new MariaDbServerVersion(new Version(10, 11));
})
// Note: there is NO SchemaName option. MySQL/MariaDB have no sub-database schema (a "schema" IS a
// database, chosen by the connection string), so the tables live in the connection's database.
// DbContext pooling is always on. The provider inherits the optimized, server-side EF Core base, and the
// hot writes (SetStatus / UpdateCurrentRun / CompleteRecurringRun) use stored procedures: single-statement,
// atomic, one round-trip (the SQL Server analog; MySQL has no writable CTE / UPDATE...RETURNING).
Connection String Examples:
// Basic
"Server=localhost;Database=evertask;User=evertask;Password=***"
// With port + SSL
"Server=db.example.com;Port=3306;Database=evertask;User=app;Password=***;SslMode=Required"
Manual Migrations: same pattern as SQL Server, using --context MySqlTaskStoreContext.
Notes / limitations:
- No schema concept (see above).
- Built on Microting.EntityFrameworkCore.MySql (maintained Pomelo fork); MySQL 8.0+ and MariaDB 10.11+.
- All
DateTimeOffsetvalues map todatetime(6)(UTC). - High-write-concurrency support on one active EverTask host per store; an inactive standby is fine. See Horizontal Scaling.
Handler Configuration
You can configure behavior at the handler level to override global defaults.
Handler Properties
The active handler-level settings (Timeout, RetryPolicy, QueueName, RateLimitPolicy) are virtual properties you override (expression-bodied / get-only: you don’t assign them in a constructor). The obsolete CpuBoundOperation is the exception: a plain settable, non-virtual, no-op property (don’t use it).
public class MyHandler : EverTaskHandler<MyTask>
{
// Timeout
public override TimeSpan? Timeout => TimeSpan.FromMinutes(10);
// Retry policy
public override IRetryPolicy? RetryPolicy => new LinearRetryPolicy(5, TimeSpan.FromSeconds(2));
// Queue routing
public override string? QueueName => "high-priority";
// Per-key rate limiting (v3.7+), see rate-limiting.md
public override RateLimitPolicy? RateLimitPolicy =>
new RateLimitPolicy(15, TimeSpan.FromMinutes(1));
public override async Task Handle(MyTask task, CancellationToken cancellationToken)
{
// Handler logic
}
}
Available Properties:
Timeout(TimeSpan?): Handler-specific timeout (falls back to queue, then global default)RetryPolicy(IRetryPolicy?): Handler-specific retry policy (falls back to queue, then global default)QueueName(string?): Target queue for this handler. If the name is not registered (typo, or a queue you never added viaAddQueue), routing logs a warning (Queue '{name}' not found, falling back to 'default' queue) and the task runs on thedefaultqueue; the per-queue retry/timeout resolution falls back to thedefaultqueue’s config the same way, so an unknown name never throws and never silently drops the task.RateLimitPolicy(RateLimitPolicy?): Per-key execution frequency constraint; the key comes fromIRateLimitedTaskon the task or aGetRateLimitKeyoverride on the handler (see Rate Limiting Configuration)CpuBoundOperation(bool): OBSOLETE, no effect. Deprecated; EverTask’s async execution is already non-blocking. For CPU-intensive synchronous work, useTask.RuninsideHandle.
Overridable methods:
GetRateLimitKey(TTask task): derive the rate-limit bucket key from task data (e.g.task.TenantId.ToString()) without implementingIRateLimitedTask. Default readsIRateLimitedTask.RateLimitKey.- Lifecycle callbacks:
OnStarted(Guid),OnCompleted(Guid),OnError(Guid, Exception?, string?),OnRetry(Guid, int attemptNumber, Exception, TimeSpan delay), andDisposeAsyncCore(). See Resilience › Error Observation and Retry Callbacks.
Injected per delivery (read, don’t override):
Logger(ITaskLogCapture): task-scoped logging, persisted whenWithPersistentLoggeris configured.Context(ITaskExecutionContext): the identity of the delivery being executed.TaskId,ScheduleId,TaskKey,ScheduledAtUtc(the nominal slot; a rate-limit deferral moves the delivery, not this),ScheduledAtLocal,TimeZoneId,StartedAtUtc,Attempt,RunNumber(durable across restarts),ScheduleVersion,IsRecurring,IsOccurrenceandMisfire. Both are injected beforeOnStarted, so they are readable inHandleand in every callback, and readingContextfrom a constructor throwsInvalidOperationException. Services that are not the handler read the same instance throughITaskExecutionContextAccessor(singleton;Currentfollows the delivery’s asynchronous flow and is null outside one). Full walkthrough: Task Creation › Execution Context.
Dispatch Parameters
Every ITaskDispatcher.Dispatch(...) overload accepts these optional parameters (see Task Dispatching for full behavior):
| Parameter | Type | Default | Behavior |
|---|---|---|---|
auditLevel | AuditLevel? | null → the global SetDefaultAuditLevel (default Full) | Per-dispatch override of the audit level for this task |
taskKey | string? | null (no deduplication) | Idempotency key (≤ 200 chars, stored-column length-limited). Non-recurring: InProgress → no-op; an immediate one-shot whose delivery is already in flight → no-op (returns existing id, before any status update); Pending/Queued/WaitingQueue → update; terminal (Completed/Failed/Cancelled/ServiceStopped) → remove + recreate. Recurring: InProgress → no-op; every other status incl. Completed/Failed → update in place (a recurring row is never “terminated”/replaced), preserving NextRunUtc + CurrentRunCount only when NextRunUtc.HasValue: an exhausted series (no stored next run) is recalculated instead of preserved; a re-dispatch with no recurring config (recurring to one-shot) is discarded to avoid destroying the schedule. Essential for idempotent recurring registration across restarts |
cancellationToken | CancellationToken | default | Cancels the dispatch operation (e.g. a blocking enqueue on a full Wait queue), not the task’s execution |
The scheduling discriminator (TimeSpan delay, DateTimeOffset time, or Action<IRecurringTaskBuilder>) is a positional argument that selects the overload.
Recurring Task Builder
The Action<IRecurringTaskBuilder> overload of Dispatch configures a recurring schedule via a fluent builder (src/EverTask.Abstractions/Recurring/IRecurringTaskBuilder.cs). All times are UTC unless the schedule names a zone. Full feature docs: Recurring Tasks.
Entry / first run:
Schedule(): pure recurring, no initial one-off run.RunNow()/RunDelayed(TimeSpan)/RunAt(DateTimeOffset)→.Then(): run once first (now / after a delay / at a time), then follow the recurring schedule.
Interval:
Every(int n)followed by.Seconds()/.Minutes()/.Hours()/.Days()/.Weeks()/.Months().EverySecond()/EveryMinute()/EveryHour()/EveryDay()/EveryWeek()/EveryMonth().OnDays(params DayOfWeek[]): specific weekdays;OnMonths(params int[]): specific months.- There is no hourly counterpart of those two.
OnHours()is on the concreteIntervalSchedulerBuilderbut not onIIntervalSchedulerBuilder, soSchedule().OnHours()does not compile, and it selects no hours in any case: it buildsEveryHour()’s plain cadence, which a time zone does not govern. For specific hours of the day useEveryDay().AtTimes(...).
Refinement:
- Hour →
.AtMinute(0–59); minute →.AtSecond(0–59). - Day →
.AtTime(TimeOnly)or.AtTimes(params TimeOnly[]). - Week →
.OnDay(DayOfWeek)/.OnDays(params DayOfWeek[])→ then.AtTime(...). - Month →
.OnDay(1–31)/.OnDays(params int[])/.OnFirst(DayOfWeek)→ then.AtTime(...).
Exclusions:
.Except(Action<IExclusionBuilder>)subtracts any union of whole weekdays (OnDays), whole dates (OnDates) and absolute half-open windows (Between(from, to), wherefromis included andtois not). Calls are additive and repeatable..ExceptWeekends()is exactly.Except(e => e.OnDays(DayOfWeek.Saturday, DayOfWeek.Sunday))..ExceptCalendar(params string[] names)adds reusable sets registered byAddScheduleCalendar; calls and names are additive, with at most 16 distinct names per schedule.- Excluded grid slots do not exist: they consume no run, misfire count, durable row, audit or event. First-run overrides from
RunNow,RunDelayedandRunAtare explicit instants and are not filtered. - Day/date exclusions use the persisted schedule zone, or UTC when none is named. This makes
Every(4).Hours().InTimeZone("Europe/Rome").ExceptWeekends()legal: the cadence stays elapsed while the zone governs only which local dates are excluded. AbsoluteBetweenwindows compare instants. - Allowed with built-in intervals and cron; refused with
INextOccurrenceProvider.MaxRuns,RunUntil, misfire policies,SkipOldest, backfill and durable occurrences operate on the filtered grid. RescheduleMode.RebaseFromCursoris refused when either definition has exclusions; useRecalculateFromNow. Evaluation is bounded, and a definition whose filtered grid cannot be found surfaces an error instead of being mistaken for a finished series.- Calendar configuration is frozen for one host lifetime. An edit applies after the next host start and only looking forward: future slots follow the new calendar; nothing already passed, materialized or halted changes.
Cron: UseCron("expr"): 5-field (min hour dom month dow) or 6-field (with seconds), via Cronos. Overrides every other interval call; invalid expressions throw ArgumentException on the first schedule calculation.
Time zone: .InTimeZone(TimeZoneInfo) / .InTimeZone(string), accepted before the interval (on Schedule()), on the interval builder itself (EveryDay().InTimeZone(z).AtTime(...)) and after the final refinement: every position but between Every(n) and its unit. The id may be IANA or Windows; the IANA form is what gets persisted, inside the schedule definition, with no new column. It governs calendar-anchored schedules and the local dates read by day/date exclusions. A plain cadence without calendar exclusions still refuses it. Across daylight saving, a skipped local time fires at the gap’s exit and a repeated one fires on its first pass. Global default: SetDefaultScheduleTimeZone. Full rules: Time Zones.
Occurrence provider: .UseOccurrenceProvider(string key, string? config = null) on Schedule(), for a calendar no interval and no cron can express. The grid then comes from the INextOccurrenceProvider registered as AddOccurrenceProvider<T>(key), which answers “which occurrence comes strictly after this instant” in UTC; null ends the series. Exclusive with every interval and with cron: a provider replaces the grid instead of refining it, and naming both throws InvalidOperationException at build. Only the key and the opaque config string are persisted (never a type name), and the schedule’s InTimeZone id travels to the provider, which is what reads the calendar on it. Everything else applies unchanged: misfire policies, durable occurrences, MaxRuns/RunUntil, the skip-forward after a downtime, and ReevaluateSchedule as the way to say the calendar changed. Two exceptions: CatchUpOverflowPolicy.SkipOldest needs IsDeterministic => true on the provider (refused at dispatch otherwise) and RescheduleMode.RebaseFromCursor is refused, because a provider exposes no nominal period. An unknown key is a configuration error (ArgumentException at dispatch, terminal poison at recovery); a provider that throws is transient. Nothing is written, the schedule is re-parked after SetOccurrenceProviderRetry’s backoff, and it surfaces as OccurrenceProviderException only where a caller is holding the call: a dispatch, and the ITaskScheduleManager methods that decide a new cursor (Reschedule, ReevaluateSchedule). Full rules: Occurrence Providers.
Limits: .RunUntil(DateTimeOffset) (must be future) and .MaxRuns(int) (counts real executions only; occurrences skipped to realign after downtime do not consume the budget). Stops at whichever is reached first. On a durable schedule MaxRuns counts materializations instead: an occurrence that later fails or is cancelled still spent a run, because the schedule did produce it.
Durable occurrences: .WithDurableOccurrences(), .OnMisfire(Action<IMisfirePolicyBuilder>) and .BackfillFrom(DateTimeOffset), accepted in the same positions as InTimeZone. They turn every due slot into its own one-shot row (its own status, retries, audit trail and rate-limit budget), and the schedule row stops running the handler.
.OnMisfire(m => m.Skip())is the default written out: missed slots are dropped, at most the one still current runs, and no occurrence rows are created..OnMisfire(m => m.FireOnce(options))collapses a whole run of missed slots into ONE occurrence at the most recent of them, with the range it covers inITaskExecutionContext.Misfire.FireOnceOptions.MaxAge(defaultnull) drops the run entirely when even its newest slot is older than the window..OnMisfire(m => m.CatchUp(options))replays every missed slot, oldest first.CatchUpOptions(maxAge, maxOccurrences)requires both caps;MaxPendingOccurrences(default1) is how many occurrences may be alive at once;OverflowPolicyisHalt(default: nothing is materialized, a durable marker is written, the schedule stops being parked so it costs no further deliveries or writes, and neither time nor a restart releases it; only an explicit schedule change does, eitherResumeScheduleorRescheduleor dispatching the series again after a cancel) orSkipOldest, which keeps the most recentMaxOccurrences.- Both replaying policies imply durable occurrences;
.WithDurableOccurrences()gives the rows without the replay. .BackfillFrom(startUtc)starts the cursor at the first occurrence on or afterstartUtcinstead of after the dispatch. New registrations only, and still bounded by the caps above.- Requires a storage that implements the atomic occurrence operations. Every built-in provider does; a custom one that does not is refused at dispatch with
NotSupportedException. - Contracts: at-least-once (write idempotent handlers) and one active host. Full rules: Durable Occurrences.
OnLast(DayOfWeek)is not implemented (onlyOnFirst). For idempotent registration across restarts, pass a stabletaskKey(see Dispatch Parameters).
Runtime Schedule Management
ITaskScheduleManager changes a schedule that is already registered. AddEverTask registers it next to ITaskDispatcher, which is untouched: a dispatch registers a schedule, this manages the one already registered. Schedules are addressed by the taskKey they were dispatched with; occurrences by their own id.
public class ScheduleAdmin(ITaskScheduleManager schedules)
{
public Task<ScheduleUpdateResult> MoveDailyReport(TimeOnly at) =>
schedules.Reschedule(
"daily-report",
r => r.Schedule().EveryDay().AtTime(at).InTimeZone("Europe/Rome"),
RescheduleMode.RebaseFromCursor);
}
| Method | Returns | Purpose |
|---|---|---|
Reschedule(taskKey, configure, mode, ct) | ScheduleUpdateResult | Replace the definition and choose a new cursor |
ReevaluateSchedule(taskKey, ct) | ScheduleUpdateResult | Keep the definition, recompute the cursor from now; a durable backlog is discarded |
ResumeSchedule(taskKey, ct) | ScheduleUpdateResult | Release a durable catch-up halt, keeping the cursor |
RequeueFailedOccurrence(occurrenceId, ct) | bool | Put one terminal occurrence back in the queue |
CancelSchedule(taskKey, ct) | Task | Cancel the schedule and every pending occurrence of it |
Requirements. Every method needs a registered storage, and beyond that each one asks for what it actually uses. Reschedule, ReevaluateSchedule and ResumeSchedule rewrite a schedule row and need SupportsScheduleVersioning; RequeueFailedOccurrence addresses a child row and needs SupportsDurableOccurrences; CancelSchedule needs neither, because writing a cancellation is something every storage has always done. What a call is asked to WRITE counts too: a Reschedule whose new definition turns the schedule durable (WithDurableOccurrences(), or an OnMisfire policy of FireOnce/CatchUp) needs SupportsDurableOccurrences on top of the versioning, and is refused before anything is written, exactly as a dispatch of the same definition would be. Both capabilities are true for all built-in providers. A storage without the one a call needs throws NotSupportedException rather than degrading: without a real compare-and-swap a reschedule could report success while a run finishing at the same moment silently overwrote it, and there is no half-atomic emulation of the occurrence operations to fall back on.
Storage is the source of truth. Each schedule row carries a ScheduleVersion. A reschedule writes the definition, the cursor, the bounds and the version in one conditional update, guarded by the version it read; every advance of a managed schedule carries the version its run belonged to, so a completion that lands after a reschedule loses the guard, records its run against the row’s own cursor and lets the new definition stand.
RescheduleMode
RecalculateFromNow (the default) points the cursor at the new definition’s first occurrence after now. On a durable schedule, whatever the old definition still owed is dropped and reported: DiscardedBacklog, DiscardedBacklogIsExact and a monitoring event naming the count.
RebaseFromCursor keeps the schedule inside the calendar period it was already in. The day, week or month the old cursor fell in is read on the old definition’s clock, and the new cursor is the new definition’s occurrence at the same POSITION inside that period, read on the new one. That is what preserves the logical date when the time of day or the zone changes. Position matters as soon as a period holds more than one slot: with OnDays(Monday, Wednesday).AtTimes(09:00, 15:00), a cursor standing on the afternoon run rebases onto the new afternoon time and never back onto the morning one that has already run, which would replay it and spend one more of MaxRuns, where RecalculateFromNow on the same definition answers the later slot. It is refused, with InvalidOperationException and no write, when:
- the two definitions have different shapes (a different cadence, different weekday or month selectors, a different period kind; only the time of day, the zone,
RunUntil/MaxRunsand the misfire settings may move); - either side is a cron schedule or a schedule driven by an occurrence provider, neither of which states a nominal period;
- the period holds no slot of the new definition, or fewer slots than the cursor had already passed, so there is no position to land on. A rebase never crosses into the next period: doing so would skip a period of work or replay one;
- the new definition’s bounds are already past.
RunUntilandMaxRunsare what an operator changes to wind a series down, and the period arithmetic applies neither: a plain cadence keeps its cursor verbatim and the day-carrying cadences place their slot by hand, so neither ever asks the grid, which is the only thing that appliesRunUntil. Both bounds are checked here instead. A definition one mode would refuse is refused by the other too, rather than running one occurrence past the end just set.
A plain cadence has no calendar structure to preserve, so its cursor is carried over unchanged, which is how a halted catch-up keeps its backlog while its caps are widened. A week or month cadence that names no day inside its period (EveryWeek() and EveryMonth() without OnDay/OnDays/OnFirst) carries that day on the cursor rather than in the definition, so the day it was already on is what the rebase keeps, and only the time of day and the zone move.
Linearization
A reschedule takes effect immediately for occurrences that have not fired: the scheduler’s registration is replaced in place, with no window in which the schedule is parked nowhere. A delivery already handed to a worker queue is past the scheduler’s reach and is considered fired; inside the process that rescheduled it, EverTask drops that delivery rather than running the definition just replaced. After a restart nothing has been published, so a delivery recovered from storage always runs and its advance is what applies the new definition.
If the re-park itself fails, nothing is published and the update still stands: the previous occurrence runs once more, and its advance loses the compare-and-swap, applies the new definition and parks the schedule from the row it has just read, so a re-park that threw costs one extra run of the old definition, never a series that stops.
ScheduleUpdateResult
| Property | Meaning |
|---|---|
TaskId | The schedule row that was updated |
ScheduleVersion / PreviousScheduleVersion | The version now on the row, and the one it replaced |
NextRunUtc / PreviousNextRunUtc | Where the schedule now stands, and where it stood |
Mode | The mode the cursor was decided with |
DiscardedBacklog / DiscardedBacklogIsExact | Due slots this call dropped, and whether that number is a total or a lower bound |
ReleasedHalt | Whether this call cleared a durable catch-up halt |
What it refuses
InvalidOperationException, always before anything is written:
- no task carries that key, or the task it names is a one-shot;
- the schedule was cancelled: a cancellation is terminal, dispatch it again;
- the new definition has no occurrence left to run (its bounds are already past). Use
CancelScheduleto end a series on purpose, instead of leaving a row nothing can finish; - the row’s payload or definition cannot be rebuilt by this build;
- a rebase that cannot map the cursor (see above);
- the row changed under the call and the conditional update lost. Read it again and retry.
Complete Examples
Basic Configuration
The simplest setup for getting started:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddEverTask(opt =>
{
opt.RegisterTasksFromAssembly(typeof(Program).Assembly);
})
.AddMemoryStorage();
var app = builder.Build();
app.Run();
Production Configuration
A fuller setup with SQL Server storage, retry policies, and logging:
builder.Services.AddEverTask(opt =>
{
opt.SetChannelOptions(5000)
.SetMaxDegreeOfParallelism(Environment.ProcessorCount * 4)
.SetDefaultTimeout(TimeSpan.FromMinutes(5))
.SetDefaultRetryPolicy(new LinearRetryPolicy(3, TimeSpan.FromSeconds(1)))
.SetThrowIfUnableToPersist(true)
.RegisterTasksFromAssembly(typeof(Program).Assembly);
})
.AddSqlServerStorage(
builder.Configuration.GetConnectionString("EverTaskDb")!,
opt =>
{
opt.SchemaName = "EverTask";
opt.AutoApplyMigrations = false; // Manual migrations in production
})
.AddSerilog(opt =>
opt.ReadFrom.Configuration(
builder.Configuration,
new ConfigurationReaderOptions { SectionName = "EverTaskSerilog" }));
Multi-Queue Configuration
This setup isolates different workloads into separate queues:
builder.Services.AddEverTask(opt =>
{
opt.RegisterTasksFromAssembly(typeof(Program).Assembly);
})
.ConfigureDefaultQueue(q => q
.SetMaxDegreeOfParallelism(10)
.SetChannelCapacity(1000))
.AddQueue("critical", q => q
.SetMaxDegreeOfParallelism(20)
.SetChannelCapacity(500)
.SetFullBehavior(QueueFullBehavior.Wait)
.SetDefaultTimeout(TimeSpan.FromMinutes(2))
.SetDefaultRetryPolicy(new LinearRetryPolicy(5, TimeSpan.FromSeconds(1))))
.AddQueue("email", q => q
.SetMaxDegreeOfParallelism(10)
.SetChannelCapacity(10000)
.SetFullBehavior(QueueFullBehavior.FallbackToDefault))
.AddQueue("reports", q => q
.SetMaxDegreeOfParallelism(2)
.SetChannelCapacity(50)
.SetDefaultTimeout(TimeSpan.FromMinutes(30)))
.ConfigureRecurringQueue(q => q
.SetMaxDegreeOfParallelism(5)
.SetChannelCapacity(200))
.AddSqlServerStorage(connectionString);
High-Performance Configuration
Tuned for very large workloads:
builder.Services.AddEverTask(opt => opt
.RegisterTasksFromAssembly(typeof(Program).Assembly)
.UseShardedScheduler(shardCount: Environment.ProcessorCount)
.SetMaxDegreeOfParallelism(Environment.ProcessorCount * 4)
.SetChannelOptions(10000)
.SetDefaultTimeout(TimeSpan.FromMinutes(10))
)
.AddSqlServerStorage(connectionString, opt =>
{
opt.SchemaName = "EverTask";
opt.AutoApplyMigrations = false;
});
Multi-Assembly Configuration
When your task handlers are spread across multiple assemblies:
builder.Services.AddEverTask(opt =>
{
opt.RegisterTasksFromAssemblies(
typeof(CoreTasks.MyTask).Assembly,
typeof(ApiTasks.MyTask).Assembly,
typeof(BackgroundTasks.MyTask).Assembly)
.SetMaxDegreeOfParallelism(20);
})
.AddSqlServerStorage(connectionString);
Environment-Specific Configuration
Here the configuration changes based on your environment:
var builder = WebApplication.CreateBuilder(args);
// Storage methods extend the EverTask builder returned by AddEverTask,
// NOT IServiceCollection: keep a reference when branching by environment
var everTask = builder.Services.AddEverTask(opt =>
{
opt.RegisterTasksFromAssembly(typeof(Program).Assembly);
if (builder.Environment.IsProduction())
{
opt.SetMaxDegreeOfParallelism(Environment.ProcessorCount * 4)
.SetChannelOptions(10000)
.SetDefaultTimeout(TimeSpan.FromMinutes(10));
}
else
{
opt.SetMaxDegreeOfParallelism(2)
.SetChannelOptions(100);
}
});
if (builder.Environment.IsProduction())
{
everTask.AddSqlServerStorage(
builder.Configuration.GetConnectionString("EverTaskDb")!,
opt => opt.AutoApplyMigrations = false);
}
else
{
everTask.AddMemoryStorage();
}
Configuration Validation
What EverTask actually checks at startup:
Errors:
- No assemblies registered for handler scanning:
AddEverTaskthrowsArgumentException - Channel capacity < 1:
ArgumentOutOfRangeException(raised by the BCLBoundedChannelOptionsconstructor)
Warnings:
- Global
MaxDegreeOfParallelism == 1: a startup warning is logged (a single consumer is usually a bad idea in production); the value is honored as-is - Per-queue
MaxDegreeOfParallelism < 1: clamped to 1 consumer at startup with a warning (prevents a zero-consumer deadlock), never treated as “unlimited”. (The per-queue path clamps< 1; the global-level warning fires specifically at== 1.)
Behaviors to be aware of (no error raised):
- Re-adding a queue with an existing name silently replaces the previous configuration
SetMaxDegreeOfParallelismperforms no validation at configuration time; the worker clamps any value< 1to 1 at startup (see above)
Performance Tuning Guidelines
CPU-Bound Tasks
If your tasks do heavy computation, match your parallelism to your CPU cores:
opt.SetMaxDegreeOfParallelism(Environment.ProcessorCount) // Match CPU cores
.SetChannelOptions(100); // Small queue
I/O-Bound Tasks
If your tasks spend most of their time waiting on I/O (database, APIs, files), you can run many more in parallel:
opt.SetMaxDegreeOfParallelism(Environment.ProcessorCount * 4) // Higher parallelism
.SetChannelOptions(5000); // Larger queue
Mixed Workloads
When you have different types of tasks, use separate queues:
.ConfigureDefaultQueue(q => q
.SetMaxDegreeOfParallelism(Environment.ProcessorCount * 2))
.AddQueue("cpu-intensive", q => q
.SetMaxDegreeOfParallelism(Environment.ProcessorCount))
.AddQueue("io-intensive", q => q
.SetMaxDegreeOfParallelism(Environment.ProcessorCount * 4))
Extreme High Load
For very large workloads, enable the sharded scheduler:
opt.UseShardedScheduler(Environment.ProcessorCount)
.SetMaxDegreeOfParallelism(Environment.ProcessorCount * 4)
.SetChannelOptions(10000);
Next Steps
- Getting Started - Setup guide
- Scalability - Multi-queue and sharded scheduler
- Resilience - Retry policies and timeouts
- Storage - Storage options and configuration