Skip to main content

One post tagged with "milvaion"

View All Tags

Why I Wrote a Distributed Job Scheduler

· 14 min read
Ahmet Buğra Kösen
Software Developer

When it comes to background jobs in .NET, the first two names that come to mind are obvious: Hangfire and Quartz.NET. Both are mature, both have been in production for years. So why did I sit down and write a job scheduler when those already exist?

Short answer: I had no problem with either of them. The problem started with where the jobs run. But it didn't end there — the longer story is about the gaps I discovered after solving that first problem.

In this post I'll walk through that initial problem first, then look at how Milvaion answers it.


The Problem: Jobs Run Inside Your Application

When you add Hangfire or Quartz to a project, the scheduler lives inside your application's process. A job whose time has come runs in the same process, on the same thread pool.

That's perfectly fine for most workloads. Until one of these happens:

  • A long-running job holds everything else up. If your nightly report job takes two hours, it takes a share of the thread pool for those two hours.
  • A crashing job takes the scheduler down with it. An unhandled exception or a memory leak inside a job affects not just that job, but your entire application.
  • Different jobs need different hardware. A job running an ML model wants a GPU; a job sending emails is happy with 128 MB of RAM. If both live in the same process, you scale to the needs of the most expensive one.
  • Jobs scale together with the API. When API traffic spikes and you bump your pod count to three, your jobs go to three as well. That's usually not what you want.

In my case, all four applied. At some point I realized what I actually wanted was to decide where jobs run, separately.


What Is Milvaion?

Milvaion is an open-source (Apache 2.0) distributed job scheduling system built on .NET 10. Its core idea in one sentence:

The place that decides when a job runs doesn't have to be the same process that runs it.

It splits into two parts:

  • Scheduler (API): Reads cron expressions, detects jobs that are due, and enqueues them. It also hosts the dashboard.
  • Worker: Picks up the message from the queue, executes your IAsyncJob code, and reports the result back.

RabbitMQ, Redis, and PostgreSQL sit in between.

Milvaion Dashboard

In practice this means: you deploy the worker separately, scale it separately, and put it on separate hardware. A GPU-backed worker for GPU-hungry jobs, a 128 MB worker for email. If a worker crashes, the scheduler is unaffected and the message waits in the queue.


Core Concepts

There are four words you'll run into while navigating Milvaion — let's define them up front:

ConceptWhat It Means
JobA recurring or one-off work definition. "Send a report every morning at 9" is a job.
Worker JobYour C# class implementing IAsyncJob. The code that does the actual work.
OccurrenceA single run of a job. It has a status, a duration, and logs.
WorkerThe process that executes jobs.

A job is a definition; an occurrence is one run of that definition. When the dashboard says "this job ran 340 times, 3 of them failed," it's talking about occurrences.


How Does It Work?

The flow goes like this:

  1. Worker Auto Discovery automatically finds your worker and the job classes inside it.
  2. You create a job from the dashboard or the REST API.
  3. The scheduler writes it to PostgreSQL and adds it to a Redis ZSET with its next run time.
  4. The Dispatcher checks Redis and finds the jobs that are due.
  5. Due jobs are published to RabbitMQ with a routing key.
  6. The worker receives the message and runs your IAsyncJob code.
  7. The worker reports status and logs back over RabbitMQ.
  8. The scheduler records the result and pushes it to the dashboard in real time via SignalR.

I used a Redis ZSET for scheduling because the query "give me every job due up to now" is extremely cheap on a ZSET. Instead of polling the database every second, I ask Redis.


Writing a Worker

Enough theory — let's look at code. First, install the template:

dotnet new install Milvasoft.Templates.Milvaion
dotnet new milvaion-console-worker -n MyCompany.MyWorker

Then write a job:

using Milvasoft.Milvaion.Sdk.Worker.Abstractions;

public class SendReportJob(IReportService reportService) : IAsyncJob
{
public async Task ExecuteAsync(IJobContext context)
{
context.LogInformation("Preparing report...");

// The JSON payload entered from the dashboard, strongly typed
var data = context.GetData<ReportRequest>();

await reportService.GenerateAsync(data, context.CancellationToken);

context.LogInformation("Report sent.");
}
}

Three things worth noting:

  • Constructor injection works. The worker is a regular .NET host, so whatever you put in the DI container is available inside your job.
  • context.LogInformation lines show up live in the dashboard under that occurrence. Technical logs also go to Seq.
  • context.CancellationToken matters. This is the token triggered when you cancel a job from the dashboard or when it hits its timeout. If you don't pass it down in long-running work, the job can't be cancelled.

When you run the worker, auto discovery kicks in and SendReportJob becomes selectable in the dashboard. No registration step needed.


The Real Point Wasn't Being Distributed

Everything so far was Milvaion's starting point. But let me be honest: if all it did was run jobs in a separate process, this post probably wouldn't be worth writing.

What I noticed after separating the scheduler was this — the part that actually eats your time isn't where a job runs, it's what happens after it runs. The job failed, an email arrived, now what? Where's the log? Which worker was it on? Did it fail yesterday too? Who changed this?

In Hangfire and Quartz, the answer to those questions is usually "check Seq" or "query the database" — answers aimed at people with deeper technical knowledge. At my current company, for example, I watched our IT Operations team struggle to understand and use our existing Hangfire-based scheduler. Most of the features that emerged over time were written to close that gap. The only tool I've seen in the market with this kind of end-to-end coverage is temporal.io. Rather than adopting and learning that enormous ecosystem, I decided to build something from scratch that's lighter, developer-friendly, and fully under my control :)

Dashboard

This is one of the parts I spent the most time on. Quartz.NET has no built-in UI — you install something third-party or write your own. Hangfire has one and it does the job, but its scope is fixed: job list, retry, recurring jobs.

Milvaion's dashboard does the following:

  • Live log streaming. While a job runs, context.LogInformation lines land on screen over SignalR. You don't wait for it to finish and go dig in Seq.
  • Occurrence history. Duration, status, exception, and which worker ran it, for every run. Filterable, cursor-paginated — it still opens on a job with 30,000 records.
  • Worker health. Which workers are up, how many jobs they're running, their capacity, when their last heartbeat arrived. With the MCP server you can get answers to these questions through AI and manage the whole system ;)
  • Editing jobs from the UI. Change the cron, pause, trigger, update job data. No deployment required.
  • Enterprise management. RBAC-based role management, user management, auditing, API key management, MCP server, failure tracking — plenty of screens that let you manage the whole system no-code through the UI.

None of these individually makes you say "wow." But when a job fails at 3 a.m., having all of them on the same screen versus not makes a serious difference.

Workflow Engine (DAG)

Hangfire has ContinueJobWith, so you can chain "run this when that finishes." Quartz has no equivalent; you build it yourself.

I honestly didn't want to build a workflow engine — robust tools like n8n already exist. But job chaining had to be part of the system, and job chaining eventually leads here anyway. So if we're doing chaining, let's do it properly :)

Milvaion has a visual DAG builder. You drag and connect steps; a condition node branches on a condition, a merge node brings branches back together, and data mapping wires one step's output into the next. When a step fails, you can see on screen exactly which steps didn't run.

A flow like "fetch the data first, process it if successful, raise an alarm on error, send the report after both" gets built without three separate jobs and hand-written control code between them.

Alerting

When a job fails, someone needs to know. Milvaion has alert channels built in: Google Chat, Microsoft Teams, Slack, email, and in-app notifications.

On the Hangfire and Quartz side you typically write this yourself — an IJobFilter or a listener, then an HTTP client, then rate limiting, and before long it's a small project.

Auto Disable

This is my favorite small feature. It automatically disables a job that keeps failing, once it crosses a threshold you set.

Why it matters: a job with a broken connection string running every 5 minutes fails 96 times overnight and generates 96 alerts. You arrive in the morning to 96 emails and can't find the one that actually matters. Auto disable says "if it failed 3 times in a row, stop and tell someone."

You configure the threshold and the failure window per job — so you can say "3 times within the last 30 minutes," and errors from last week don't count toward the total.

Zombie Detection and Timeouts

When a worker crashes, occurrences get stuck in Running status. Milvaion detects these and moves them to Failed. There are two separate timeouts:

  • Execution timeout: If a job runs longer than N seconds, the worker triggers the cancellation token.
  • Zombie timeout: If a job has been queued or appears to be running for N minutes, it's considered dead.

Concurrent Execution Policy

What should happen when a job is due again while its previous run is still going? In Milvaion you choose this per job: skip the new one, queue it, or run them in parallel.

In Hangfire you do this in code with the DisableConcurrentExecution attribute, in Quartz with [DisallowConcurrentExecution]. Both are compile-time decisions; in Milvaion you change it from the UI.

RBAC, Users, and API Keys

The moment you open the dashboard to your team, the "who can delete what" question arrives. Milvaion has role-based authorization with granularity at the job/worker/dashboard level. You can give someone view-only access and be sure they can't touch production.

There's also API key support for CI pipelines, scripts, and automation — no user account needed. You scope what a key can do by permission, and keys can be revoked or given an expiry date.

Hangfire's dashboard ships without authorization by default; you write an IDashboardAuthorizationFilter. In Quartz there's no dashboard, so there's no question to answer.

Metric Reports

A reporter worker running in the background produces regular reports: error rate trends, duration percentiles (p50/p95/p99), slowest jobs, worker throughput, and utilization.

These show up as charts in the dashboard. You can answer "did this job get slower over the past week" by looking, without writing a query.

MCP Server — Asking Your Scheduler Questions

This is the newest addition. Milvaion also runs as an MCP (Model Context Protocol) server. You can connect Claude Code, Cursor, or GitHub Copilot to Milvaion and ask your scheduler questions in plain language.

There are 40+ tools: listing jobs, execution history, reading logs, dead letter records, worker health, triggering, pausing, editing. Each one sits behind its own permission — with a read-only API key, the assistant can inspect everything and change nothing.

One important note: Milvaion is the data source here. No model provider keys are stored server-side, and no outbound calls are made. The model runs in your editor, on your subscription.

In practice it looks like this: you ask "which jobs failed last night and why," and the assistant starts with list_failures, pulls logs with get_occurrence, and summarizes for you.

Monitoring Hangfire and Quartz

The last one is a bit of a twist: Milvaion can monitor your Hangfire and Quartz.NET installations.

With two lines of code you connect your existing scheduler to Milvaion read-only. Your jobs keep running in Hangfire, but execution history, logs, metrics, and alerts all collect in the Milvaion dashboard. No migration, no changes to your job code.

Here's why I built it: Milvaion's goal was never to compete with Quartz and Hangfire, which are industry standards. For a brand-new open-source tool, entering that competition makes no sense anyway. Migrating to Milvaion will be hard for people already running these libraries in their systems, so this integration exists to make that transition organic and to serve as an onboarding path that shows users what Milvaion can do.


What About Reliability?

Once you go distributed, the "did the message get lost?" question inevitably arrives. Milvaion's answers:

  • At-least-once delivery: RabbitMQ manual ACK. The worker doesn't ACK until the work is done, so if it crashes mid-job the message goes back on the queue.
  • Automatic retry: With exponential backoff. You configure the retry count per job.
  • Dead Letter Queue: Jobs that exhaust their retries land in the DLQ rather than disappearing. They're listed on a separate dashboard screen.
  • Offline resilience: If a worker can't reach RabbitMQ, it writes results to a local SQLite database and sends them when the connection comes back.

Things to Watch Out For

To be honest, Milvaion isn't the right tool for every scenario. Use something else in these cases:

  1. Single application, single server. If the cost of standing up PostgreSQL + Redis + RabbitMQ outweighs what you gain, Hangfire is the far better choice.
  2. Sub-second scheduling. The dispatcher checks at most once per second. If you need millisecond precision, look elsewhere.
  3. Event processing. If you need "run this when that event arrives," that's not a scheduler's job; Kafka or something similar fits better.
  4. .NET Framework. Milvaion targets .NET 10. If you have a legacy application, Hangfire and Quartz offer much broader TFM support.

On the other hand, it genuinely helps when your jobs are spread across multiple services, when you have long-running work, when different jobs need different hardware, when you want a modern application and architecture, or when you need a complete execution history for auditing.


Conclusion

I didn't write Milvaion to build "a better Hangfire." I wanted to separate scheduling from execution, because that was exactly the problem in front of me.

But what the work taught me was this: making that separation was only the beginning. The real value is in being able to see what happens to jobs after they run — in the dashboard, the alerts, the reports, and small details like auto disable that save your sleep.

What came out of it runs on .NET 10, is Apache 2.0 licensed, and is open source on GitHub. It comes up in five minutes with Docker Compose:

git clone https://github.com/Milvasoft/milvaion.git
cd milvaion
docker compose up -d

The dashboard will be waiting for you at http://localhost:5000.

You can spin it up right now and explore every feature yourself, or take a look at the documentation.

In this post I covered the core architecture and features. In the next post in the series, we'll look at how to add Milvaion's monitoring capabilities without changing your existing Hangfire or Quartz.NET setup at all — because you don't have to migrate to adopt Milvaion.

See you in the next one…