Quartz.NETQuartz.NET
Home
Features
Blog
Discussions
NuGet
GitHub
Home
Features
Blog
Discussions
NuGet
GitHub
  • Getting Started

    • Overview
    • Quartz 4 Quick Start
    • Tutorial
      • Using Quartz
      • Jobs And Triggers
      • More About Jobs & JobDetails
      • Job Data
      • More About Triggers
      • Querying Jobs and Triggers
      • Simple Triggers
      • Cron Triggers
      • RecurrenceTrigger
      • Time and TimeProvider
      • Trigger and Job Listeners
      • Scheduler Listeners
      • Job Execution Middleware
      • Job Stores
      • Configuration, Resource Usage and Building a Scheduler
      • Building a Scheduler Without a Host
      • Clustering
      • Execution Groups
      • Node Affinity (Preferred Node)
      • Testing
      • Compile-Time Checks
      • Declaring Jobs with Attributes
      • Delegate Jobs
    • Configuration Reference
    • JSON Configuration
    • Cron Expression Reference
    • Multi-Tenancy
    • Comparison
    • Frequently Asked Questions
    • Best Practices
    • Before You Go Live
    • Operating a Cluster
    • Log Events
    • Tenancy Patterns
    • Database Schema
    • Database Schema Changes
    • Migration Guide
    • Troubleshooting
    • API Documentation
  • How To's
    • One-Off Job
    • Rescheduling Jobs
    • Backfill
    • Retrying Failed Jobs
    • Pausing with a Reason
    • Job Continuations
    • Overlap Policy
    • Progress and Execution Logs
    • Job Outcomes
    • Multiple Triggers
    • Job Template
    • Running Quartz under Aspire
    • Quartz.NET with Wolverine
    • Coming from Hangfire
    • Coming from TickerQ
    • Embedding Quartz in a Library
    • Running under an External Leader Election
    • Publishing Trimmed and Native AOT
    • Extending Quartz: what is open, what is closed, and how to ask
    • A Job Store of Your Own
    • A Driver Delegate for a New Database
    • Persisting a Custom Trigger Type
    • A Lock Handler of Your Own
  • Packages

    • Quartz Core Additions

      • Jobs
      • Serialization (System.Text.Json)
      • JSON Serialization
      • Plugins
    • Integrations

      • Aspire Integration
      • ASP.NET Core Integration
      • HTTP API
      • HTTP Client
      • Dashboard
      • Hosted Services Integration
      • Microsoft DI Integration
      • Multiple Schedulers with Microsoft DI
      • Observability
      • Redis Lock Handler
      • TimeZoneConverter Integration
      • Weasel Schema Management
    • 3rd Party Plugins for Quartz
  • Quartz 3.x

    • Getting Started

      • Quartz 3 Quick Start
      • Tutorial
        • Using Quartz
        • Library Overview
        • Jobs And Triggers
        • More About Jobs
        • More About Triggers
        • Execution Groups
        • Node Affinity (Preferred Node)
        • Simple Triggers
        • Cron Triggers
        • RecurrenceTrigger
        • Trigger and Job Listeners
        • Scheduler Listeners
        • Job Stores
        • Tuning the Scheduler
        • Configuration, Resource Usage and SchedulerFactory
        • Advanced (Enterprise) Features
      • Configuration Reference
      • JSON Configuration
      • Multi-Tenancy
      • Frequently Asked Questions
      • Best Practices
      • Tenancy Patterns
      • Troubleshooting
      • API Documentation
      • Database Schema
      • Database Schema Changes
      • Migration Guide
      • Miscellaneous Features
    • How To's

      • One-Off Job
      • Multiple Triggers
      • Job Template
      • Using the CronTrigger
      • Rescheduling Jobs
    • Packages

      • Quartz Core Additions

        • Dashboard
        • Jobs
        • Serialization (System.Text.Json)
        • Serialization (Newtonsoft Json.NET)
        • Plugins
      • Integrations

        • ASP.NET Core Integration
        • Hosted Services Integration
        • Microsoft DI Integration
        • Multiple Schedulers with Microsoft DI
        • OpenTelemetry Integration
        • OpenTracing Integration
        • Redis Lock Handler
        • TimeZoneConverter Integration
      • 3rd Party Plugins for Quartz
  • Old Releases

    • Quartz 2.x
      • Quartz 2 Quick Start
      • Tutorial
        • Lesson 1: Using Quartz
        • Lesson 2: Jobs And Triggers
        • Lesson 3: More About Jobs & JobDetails
        • Lesson 4: More About Triggers
        • Lesson 5: SimpleTrigger
        • Lesson 6: CronTrigger
        • Lesson 7: TriggerListeners and JobListeners
        • Lesson 8: SchedulerListeners
        • Lesson 9: JobStores
        • Lesson 10: Configuration, Resource Usage and SchedulerFactory
        • Lesson 11: Advanced (Enterprise) Features
        • Lesson 12: Miscellaneous Features of Quartz
        • CronTrigger Tutorial
      • Configuration Reference
      • Migration Guide
      • API Documentation
    • Quartz 1.x
      • Tutorial
        • Lesson 1: Using Quartz
        • Lesson 2: Jobs And Triggers
        • Lesson 3: More About Jobs & JobDetails
        • Lesson 4: More About Triggers
        • Lesson 5: SimpleTrigger
        • Lesson 6: CronTrigger
        • Lesson 7: TriggerListeners and JobListeners
        • Lesson 8: SchedulerListeners
        • Lesson 9: JobStores
        • Lesson 10: Configuration, Resource Usage and SchedulerFactory
        • Lesson 11: Advanced (Enterprise) Features
        • Lesson 12: Miscellaneous Features of Quartz
      • API Documentation
  • License

Tenancy Patterns

Quartz.NET has no Tenant type and will not get one. You build tenancy from three separations: a scheduler, a group and a SCHED_NAME. Choose between them before you have twelve tenants in production and a migration to write. The mechanics are in the per-version guides:

  • Multi-Tenancy (Quartz 4.x)
  • Multi-Tenancy (Quartz 3.x)

Isolation is not authorization, and partitioning is not isolation

  • Isolation is not authentication and authorization. An authenticated, authorized user can still reach another tenant's resources. Isolation is the enforced use of tenant context to bound which resources a request can reach at all.
  • Partitioning is not isolation. A tenant id in a column, a key prefix or a group name arranges data by tenant. It does not stop code reading across the boundary.
  • Isolation is chosen per layer. For a scheduler the question is which of the scheduling loop, the thread pool, the job store and the database are shared, and which are dedicated. They can be answered differently, and usually should be.

Two vocabularies for the same thing

AWS (silo, pool and bridge) and Microsoft (tenancy models) use different words for the same models:

AWSMicrosoftMeaning
SiloAutomated single-tenant deploymentsEach tenant gets a dedicated stack
PoolFully multitenant deploymentsEvery tenant shares one set of infrastructure
BridgeVertically partitioned deploymentsSome tenants siloed, some pooled; usually a premium tier
Bridge (applied per layer)Horizontally partitioned deploymentsShared compute, dedicated data store per tenant
CellDeployment stampA whole copy of the system serving a bounded set of tenants

A cell or stamp holds many tenants, not one. A silo bounds access; a cell bounds blast radius. You can want one without the other.

How other systems partition tenants

In almost every system the tenancy primitive is a naming, configuration and access boundary, not a fairness boundary. Quotas and rate limits usually attach one level up (the account, the cluster or the process), and per-tenant fairness is often a paid tier.

SystemPrimitiveEnforced byQuota attaches toTenant added at runtime?
TemporalNamespaceServerNamespace and clusterYes, RegisterNamespace
CadenceDomainServerDomain and task listYes
Kubernetes CronJobNamespaceAPI server + RBACNamespace, via ResourceQuotaYes, one API call
AWS EventBridge SchedulerSchedule groupIAM, via the ARNAccount and Region, never the groupYes, CreateScheduleGroup
Azure Durable FunctionsTask hubStorage naming, or RBAC on the managed providerScheduler resource, not the hubYes, implicitly
HashiCorp NomadNamespaceServer + ACLNamespace; quotas are Enterprise-onlyYes
Google Cloud SchedulerProjectIAMProjectOnly by creating a project
HangfireQueue, server, storageConventionServer processStorage yes, queue set no
CeleryQueue, broker vhostBrokerWorker process, not the clusterYes, add_consumer
SidekiqQueue, Redis instanceConventionProcess, or capsuleQueue set fixed at start
AirflowTeam, pool, queuePools: the metadata database. Teams: the API layerPool, cluster-wideA pool yes; a team needs new processes
Quartz (Java)instanceName, groupSCHED_NAME in every statementScheduler, not the groupYes, DirectSchedulerFactory

What carries over to Quartz.NET

  • Where a limit is counted matters most. Celery's rate_limit is per worker, so 10/s across four workers is 40/s. Airflow's pools hold cluster-wide, but [core] parallelism is per scheduler. Quartz.NET's execution limits are per node by default; see What Quartz.NET does not give you.
  • A shared partition with a tenant discriminator scales further than a boundary per tenant. Temporal recommends a task queue per tenant over a namespace per tenant, and suggests namespaces per tenant only below about 50 tenants. A group per tenant is the same trade.
  • Two users of one partition name collide. Azure Durable Functions apps that share a task hub compete for messages and can get stuck, so Azure derives the default hub name from the app name. Two unrelated schedulers with one SCHED_NAME fail the same way.
  • A prefix buys naming and nothing else. Sidekiq 7.0 removed redis-namespace: a key prefix added cost to every operation and gave no separate tuning, durability or failure domain. A per-tenant table prefix has the same shape.
  • Ordering that falls out of an index is not fairness. Hangfire on SQL Server fetches queues in name order, so a busy acme can starve zeta. Its per-tenant throttling is in the paid Hangfire Ace set, best-effort, and scoped to its storage.
  • Java Quartz has no tenancy guidance. Its groups are categories with no per-group concurrency limit, and its answer to different concurrency for different jobs is a second scheduler. It recommends splitting thousands of short jobs across several schedulers because the cluster-wide lock degrades beyond about three nodes.

The axes that decide

Ordered roughly by how often each turns out to decide:

AxisAsk yourselfPushes toward sharedPushes toward dedicated
Onboarding cadenceDoes a tenant appear while the process runs?Runtime arrivalTenants known at deploy time
Tenant countHow many, and how skewed?Hundreds or thousandsTens, or a few whales
Blast radiusWhat is the cost of one bad tenant taking everything down?TolerableUnacceptable
Noisy neighboursCan one tenant's work starve another's?Workloads are small and similarWorkloads are bursty or heavy
Per-tenant quotas and SLAsDo you sell tiers with different guarantees?One tierContractual per-tenant limits
Data residencyMust a tenant's data live somewhere specific, or under its own key?No constraintRegulated or sovereign data
Cost per idle tenantWhat does a tenant cost when it schedules nothing?Must be ~zeroAmortised by the contract
CustomisationDo tenants differ in configuration, calendars, or schema?UniformDivergent
ObservabilityMust you answer "what is tenant X doing right now?"Group dimension is enoughNeeds its own dashboards

Onboarding cadence decides more than anything else

Evaluate this first: it often rules an option out. With a group per tenant, onboarding is writing a trigger. With a scheduler per tenant it is closer to a deployment. Neither version needs a redeploy for it (see Onboarding a tenant while the process runs), but it is far more work than a ScheduleJob call.

Tenant count, and the shape of the distribution

A scheduler per tenant costs a scheduling loop, a thread pool and, with a persistent store, a connection pool and a cluster check-in. Tens of schedulers in a process is ordinary; thousands is a different program. AWS calls 20 siloed tenants manageable and a thousand a burden to operate.

For a skewed distribution, use the bridge: the long tail on one shared scheduler with a group per tenant, and a dedicated scheduler for each large tenant. Microsoft calls this a vertically partitioned deployment, with the dedicated tier often sold at a higher rate.

Blast radius and noisy neighbours are different problems

ProblemKindRemedy
Noisy neighboursCapacity: one tenant takes a disproportionate shareQuotas, throttling, priorities
Blast radiusFailure: one tenant's failure takes others downIndependent failure domains: cells, stamps, separate deployments

Moving work that is not time-sensitive to off-peak hours needs only a cron expression, and is often cheaper than any isolation mechanism. If you will ever run more than one scheduler, run two from the start, so no code assumes there is only one.

Cost per idle tenant

A tenant with one nightly job is idle 99.9% of the time. A tenant that is a group costs a row. A tenant that is a scheduler costs a scheduling loop that wakes on its own idle timer whether or not it has work, plus its pools. Multiply by the number of dormant tenants before choosing.

Observability

  • With a group per tenant, the group is the tenant dimension. Make the group the raw tenant id, not a decorated string. With a scheduler per tenant, the scheduler name is the dimension.
  • Watch cardinality. A tenant dimension times job and trigger names is a series per tenant per trigger. Drop the name tags in a view before they reach the backend, unless you need them.
  • The group is a tag on execution traces on both versions, and on metrics on 4.x (3.x has no metrics). Neither version puts it into a logging scope; add per-tenant log correlation yourself.

Mapping the axes onto Quartz.NET

The three separations compose, and useful designs use more than one.

  • The scheduler is the strongest boundary in the process. Each owns its job store, thread pool, listeners, plugins and calendars, and with a persistent store its connection pool and cluster check-in. A scheduler per tenant is siloed compute.
  • The group is the group half of every JobKey and TriggerKey. It is a logical partition, not an enforced one: nothing stops a job in group acme from touching group initech. It makes every matcher-taking API (listing, pausing, resuming, deleting) tenant-scoped, and it is already a tag on the execution traces. A group per tenant is pooled compute with a tenant discriminator.
  • SCHED_NAME is the first column of every Quartz table's primary key, and every statement filters on it. Two schedulers with different names share tables without seeing each other's rows. This is a property of the schema, not of the code paths.
  • The table prefix gives separate table sets in one database. It is a backup, restore and permissions decision, not an isolation one.

Which mechanism exists on which version

Mechanism3.x4.x
Multiple schedulers in one processYesYes
Named schedulers through Microsoft DIYes, AddQuartz(name, …)Yes, AddQuartz(name, …)
Resolving one by nameISchedulerRepositoryKeyed IScheduler, or ISchedulerRepository
Groups and group matchersYes, GroupMatcher<T>Yes, plus the paged query API
SCHED_NAME row separationYesYes
Per-scheduler table prefixYesYes
Startup schema validationYes, PerformSchemaValidation on by defaultYes, SchemaProvisioning.Validate by default
Shared database, mismatched table prefixNo: silent, each scheduler sees an empty table setYes, warns naming both schedulers and both prefixes
Listing tenants without starting themNo: the repository lists live schedulers onlyYes, ISchedulerRegistry.QuerySchedulers()
Execution groups and per-node limitsYesYes
Trigger group as the execution groupNo: tag every trigger explicitlyYes, UseTriggerGroupWhenUnset()
Cluster-wide concurrency quotaNoYes, ExecutionLimitScope.Cluster; approximate unless AcquireTriggersWithinLock
Rate limiting (N per window)NoPer node, as a job middleware you register
Node affinity (persisted, cluster-aware)Yes, WithPreferredNodeYes, WithPreferredNode
Per-scheduler job type registrationNo: one unkeyed registration, first winsYes, AddJobType<TJob, TImplementation>()
Per-scheduler plugin instance from quartz.plugin.*Yes for an activated type, no for a registered oneYes; the probe is keyed by scheduler
Preparing the job's DI scopeSubclass and override ConfigureScopeConfigureJobScope(…) delegate
Reading the current firing without being handed itNo: your own AsyncLocalYes, IJobExecutionContextAccessor
Per-scheduler health checkNo: one check, on the default schedulerYes, q.AddQuartzHealthChecks() per scheduler
MetricsNoYes
Runtime tenant onboarding without a containerYes, StdSchedulerFactory / DirectSchedulerFactoryYes, QuartzSchedulerBuilder
Runtime tenant onboarding into the application's containerNoYes, ISchedulerRuntime.Add / Remove

Choosing

Stop at the first that applies:

  1. Tenants' data must be physically separate, or in a particular place. A database per tenant, and so a scheduler per tenant, because a job store binds to one data source. This is the silo, with the silo's onboarding cost.
  2. A few tenants need isolation and the rest do not. The bridge: one shared scheduler with a group per tenant for the long tail, and a dedicated scheduler for each tenant that bought isolation. The most common shape for SaaS, and the default when 3 and 4 do not clearly apply.
  3. Tenants arrive while the process runs, and there are many. A group per tenant. Onboarding is a ScheduleJob call, with no registration, no restart and no per-tenant cost beyond the rows.
  4. Tenants are few, known at deployment, and differ in configuration. A named scheduler per tenant, with per-scheduler options and health checks.

Then decide the database separately:

  • one SCHED_NAME per tenant is usually enough;
  • a table prefix per tenant if backup or permissions need separate tables;
  • a separate database only if the data must not sit beside another tenant's.

What Quartz.NET does not give you

These apply to both 3.x and 4.x unless noted.

Concurrency limits are per node unless you say otherwise; on 3.x that is the only option. By default an execution group's running count lives in memory on the scheduler thread. Nothing is persisted and nodes do not coordinate, so a group limited to 3 can run up to 3×N across an N-node cluster. On 3.x the closest approximation is dividing the cap by the node count, which is wrong whenever a node is down.

On 4.x, ForGroup("acme", 8, ExecutionLimitScope.Cluster) is counted from QRTZ_FIRED_TRIGGERS, the cluster's reservation ledger. Know its limits:

  • The ceiling holds within one acquisition round, but can overshoot by up to nodes − 1 (one trigger per node). The lock-free acquisition path that allows the overshoot is taken only when a round acquires a single trigger.
  • It fails closed: a node that cannot reach the store fires nothing rather than firing unmetered.

There is no built-in rate limiting. Execution limits cap concurrency, not throughput. On 3.x, build "this tenant may run 100 jobs an hour" into the job, or into what the job calls. On 4.x, a job middleware can hold each start until a System.Threading.RateLimiting permit is free, counted per node: see Rate limiting.

A starved group misfires; it does not queue. A group at its limit has its triggers skipped at acquisition. They keep their original next fire time, so if the starvation outlasts the misfire threshold they misfire, and the trigger's misfire instruction, not the limit, decides whether the occurrence is skipped or rescheduled. Choose misfire instructions for triggers in limited groups with that in mind.

Pausing by group prefix does not catch groups added later. Quartz.NET records the groups a prefix matched, and those stay paused; a group that held nothing when the prefix ran was never matched. Paused job groups have a second caveat: 3.x's ADO store does not persist them, and 4.x persists them but does not impose the pause on jobs added afterwards. Suspend a tenant by trigger group.

On 3.x, job types are not keyed by scheduler. Jobs resolve from the one container by type, with no scheduler key. Two 3.x schedulers in one container cannot have different implementations or lifetimes of the same job type: whatever the application registered is what every scheduler gets. 4.x keeps the unkeyed registration as the default (AddJob<T> still registers the type with TryAdd semantics), but AddJobType<TJob, TImplementation>(), AddJobType<TJob>(lifetime) and AddJobType<TJob>(factory) register under one scheduler's key, and the job factory reads that key first. On either version, a job type per tenant stops scaling long before groups do. Prefer one job type that reads its tenant from the firing and resolves what it needs inside Execute, by key if you like.

Nothing stops a job reaching another tenant's data. Groups are a naming partition. Quartz.NET gives you partitioning; isolation is your application's job: a tenant id read from the firing and passed through every query.

On 3.x, dashboard authorization is per process, all or nothing. One policy, one read-only flag, no per-scheduler policy and no scheduler-name claim check. If 3.x tenants must reach only their own scheduler, enforce it outside Quartz.NET: a process per tenant, or middleware that authorizes on the scheduler-name route segment.

4.x holds each caller to its own scheduler on both surfaces. QuartzDashboardOptions.SchedulerAuthorizationPolicy and QuartzHttpApiOptions.SchedulerAuthorizationPolicy name a policy evaluated per request against a SchedulerResource carrying the scheduler's name, so one AuthorizationHandler<TRequirement, SchedulerResource> covers every route and page. What a caller may do to the scheduler it reaches is still process-wide (the dashboard's read-only flag), so "this tenant may look, that one may act" stays outside Quartz.NET on either version.

A shut-down scheduler is not restarted in place. Standby() / Start() pause and resume. Shutdown is terminal: the scheduler refuses to start again and every other operation throws. You can build a new scheduler with the same name. On 4.1 and later, ISchedulerRuntime.Restart does that: it replays the recipe the scheduler was registered with, waits for the outgoing one's jobs, and binds the result under the same name.

The tenant does not reach your logs by itself. The job and trigger group are tags on execution traces, and on 4.x's metrics, but neither version puts them into a logging scope. A tenant carried only in an execution group is invisible to those signals, which is one more reason to make the trigger group the tenant.

Onboarding a tenant while the process runs

With a group per tenant, onboarding is an ordinary ScheduleJob call for a new group.

With a scheduler per tenant, the DI path closes once the container is built: AddQuartz changes IServiceCollection, and the hosted service enumerates schedulers once, at start. Both versions can still build a scheduler at runtime outside the container: 3.x through StdSchedulerFactory or DirectSchedulerFactory, 4.x through QuartzSchedulerBuilder, which creates and owns a container of its own. A scheduler built this way:

  • gets no hosted-service lifetime (you start and dispose it);
  • resolves its jobs from its own container, not the application's, unless you give it a job factory that bridges;
  • is not covered by health checks registered at startup.

4.1 fixes all three with ISchedulerRuntime.Add(name, configure). It builds the tenant into a container of its own that resolves the application's services, jobs and options from the application's. The tenant's jobs are ordinary application components, the host drains it when it stops, and a health check registered under its name finds it. Remove shuts it down and releases everything built for it. The per-version guides cover the API and the trade-offs.

Anti-patterns

Putting the tenant in a job name and parsing it back out. JobKey("nightly-report-acme") looks harmless until something needs every job for a tenant, and the only way is to fetch every key and split strings. Use the group half of the key for the tenant, and the name for what the job is.

One scheduler per tenant at thousands of tenants. Each scheduler is a scheduling loop that wakes on its own timer, a thread pool, a connection pool and a cluster check-in, whether or not the tenant has anything to run. Fine at twenty, a serious operational burden at a thousand. Groups scale where schedulers do not.

A shared database with a mismatched table prefix. Nothing derives the prefix from the DDL or the DDL from the prefix; you run the scripts with the prefix substituted. A prefix pointing at tables that do not exist is caught at startup (3.x PerformSchemaValidation, 4.x SchemaProvisioning.Validate, both on by default), and the error names the missing table. A prefix pointing at tables that do exist and belong to another tenant is not caught: it looks correct and runs on the wrong data. Derive the prefix from the tenant id in code, not from per-environment configuration.

Two unrelated schedulers sharing a database with the same SCHED_NAME. To the schema they are two nodes of one cluster, and they will take each other's triggers. Duplicate-name checks work only within one container; across processes, keeping names distinct is up to you. Derive the scheduler name from the tenant id rather than from a configuration file someone can copy.

Assuming a per-node limit is a per-cluster limit. See above. It fails only in production, after the second node is added, and it is still the default on 4.x: a limit is cluster-wide only when it says ExecutionLimitScope.Cluster.

Letting per-tenant metrics multiply without a view. A tenant dimension times a trigger-name dimension is a series per tenant per trigger. Aggregate before the data leaves the process.

See also

  • Multi-Tenancy (Quartz 4.x): the 4.x mechanics in full
  • Multi-Tenancy (Quartz 3.x): the 3.x mechanics in full
  • Best Practices: why never to point two non-clustered schedulers at one database
  • Troubleshooting
Help us by improving this page!
Last Updated: 10/6/26, 2:13 PM
Contributors: Marko Lahma, Claude Opus 5.5
Prev
Log Events
Next
Database Schema