<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[When a Cron Job Isn't Enough: Building a Resilient Background Sync System in .NET]]></title><description><![CDATA[How I replaced a fragile nightly cron job with a priority queue, stale lock
recovery, CPU gates, and auto-scheduling in .NET and Quartz.NET.]]></description><link>https://devwithanurag.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/69c0f4edd9da55a9a5593cd0/32ee55cf-e36d-4faa-be19-b19a58371e25.svg</url><title>When a Cron Job Isn&apos;t Enough: Building a Resilient Background Sync System in .NET</title><link>https://devwithanurag.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Sat, 12 Sep 2026 08:24:06 GMT</lastBuildDate><atom:link href="https://devwithanurag.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[When a Cron Job Isn't Enough: Building a Resilient Background Sync System in .NET]]></title><description><![CDATA[A doctor sees the wrong price. The patient waits. The team gets blamed. Here's how I fixed the root cause.


The Problem Nobody Talks About
It started with a support ticket.
A doctor at one of our cli]]></description><link>https://devwithanurag.hashnode.dev/when-a-cron-job-isn-t-enough-building-a-resilient-background-sync-system-in-net</link><guid isPermaLink="true">https://devwithanurag.hashnode.dev/when-a-cron-job-isn-t-enough-building-a-resilient-background-sync-system-in-net</guid><category><![CDATA[dotnet]]></category><category><![CDATA[C#]]></category><category><![CDATA[System Design]]></category><category><![CDATA[background jobs]]></category><category><![CDATA[Quartz]]></category><category><![CDATA[MongoDB]]></category><category><![CDATA[backend]]></category><dc:creator><![CDATA[Anurag Sharma]]></dc:creator><pubDate>Mon, 23 Mar 2026 11:02:13 GMT</pubDate><content:encoded><![CDATA[<blockquote>
<p>A doctor sees the wrong price. The patient waits. The team gets blamed. Here's how I fixed the root cause.</p>
</blockquote>
<hr />
<h2>The Problem Nobody Talks About</h2>
<p>It started with a support ticket.</p>
<p>A doctor at one of our clinics had finished a consultation and opened the billing screen. The tariff for a blood test had been updated in the central lab system two days ago. But the clinic's software still showed the old price.</p>
<p>The front desk had to call the lab. The lab blamed the software. The software blamed the sync. And the patient sat there waiting.</p>
<p>This is the kind of bug that doesn't throw an exception. It doesn't show up in your error logs. It just quietly erodes trust — one wrong number at a time.</p>
<p>Our platform connects multiple clinics to a central lab system. Each clinic manages hundreds of clients. Each client has test tariffs that need to stay in sync with a master record. The stakes are real: wrong tariffs mean wrong bills, disputes, and in a healthcare context, compliance issues.</p>
<p>We had a cron job. It ran every night. It "synced everything."</p>
<p>Until it didn't.</p>
<hr />
<h2>Why the Cron Job Failed Us</h2>
<p>The cron job worked fine in staging. It worked fine in the first month of production. Then the edge cases showed up:</p>
<p><strong>What if a doctor manually updates a client's tariff mid-day and needs it reflected immediately?</strong> The cron job said: wait until midnight.</p>
<p><strong>What if the sync job crashes halfway through 300 clients?</strong> The cron job said: nothing — it just silently moved on.</p>
<p><strong>What if the server is already under heavy load when the job fires?</strong> The cron job said: run anyway, make things worse.</p>
<p><strong>What if someone accidentally disables the scheduler and nobody notices for a week?</strong> The cron job said: no record of this ever happening.</p>
<p>Every one of these scenarios happened in production. Each one eroded confidence in the system. I needed something that could handle the happy path <em>and</em> survive the chaos.</p>
<hr />
<h2>Rethinking the Architecture</h2>
<p>The insight that changed everything: <strong>the problem wasn't that sync was slow. The problem was that nobody knew when sync had failed, or why, or for how long.</strong></p>
<p>So I rebuilt it around three principles:</p>
<ol>
<li><p><strong>Every sync request is a first-class record</strong> — not a fire-and-forget task, but a tracked entity with a status, a priority, a retry count, and an owner.</p>
</li>
<li><p><strong>The system must recover itself</strong> — no human should need to intervene when something stalls.</p>
</li>
<li><p><strong>The system must know when to do nothing</strong> — an overloaded server running a sync job is worse than a delayed sync.</p>
</li>
</ol>
<p>Here's what I built.</p>
<hr />
<h2>The Architecture</h2>
<img src="https://cdn.hashnode.com/uploads/covers/69c0f4edd9da55a9a5593cd0/389195bf-f781-45c4-a030-e19b2ba9e3ed.jpg" alt="" style="display:block;margin:0 auto" />

<h3>1. The Priority Queue</h3>
<p>Every sync request — whether triggered manually by a doctor or scheduled automatically — gets enqueued as a record with two key fields: a <strong>priority number</strong> and a <strong>timestamp</strong>.</p>
<p>Manual triggers from the frontend get <strong>high priority</strong>. A doctor can't wait until midnight. Scheduled background jobs run at normal priority and fill the gaps.</p>
<p>When it's time to dequeue, the rule is simple:</p>
<ul>
<li><p>Pick the highest priority first</p>
</li>
<li><p>Tie? Oldest one wins — pure FIFO</p>
</li>
</ul>
<p>This means urgent requests always jump the queue, but nothing starves indefinitely.</p>
<pre><code class="language-csharp">// Manual sync from frontend — HIGH priority
await dbLabAPI.EnqueueSyncRequestAsync(clinicId, clientCode, priority: High);

// Scheduled background sync — Normal priority
await dbLabAPI.EnqueueScheduledSyncAsync(); // normal priority, all clinics
</code></pre>
<hr />
<h3>2. Stale Lock Recovery</h3>
<p>This is the piece most background job implementations skip — and it's the one that bites you hardest.</p>
<p>When a sync job picks up a request, it marks it as <code>Processing</code>. But what if the job crashes? Or the pod restarts? The record stays <code>Processing</code> forever. Nobody picks it up again. The sync silently never happened.</p>
<p>Every cycle, before doing anything else, the background service checks for stale locks:</p>
<pre><code class="language-csharp">await dbLabAPI.RecoverStaleLocksAsync(staleAfterMinutes: 30);
</code></pre>
<p>The logic:</p>
<ul>
<li><p>If a request has been <code>Processing</code> for more than 30 minutes → something went wrong</p>
</li>
<li><p>Reset it to <code>Pending</code>, increment <code>retryCount</code></p>
</li>
<li><p>If <code>retryCount</code> hits 3 → mark as <code>PermanentlyFailed</code> and stop retrying</p>
</li>
</ul>
<pre><code class="language-plaintext">Processing (&gt;30 min) → retryCount &lt; 3 → Pending (retryCount++)
Processing (&gt;30 min) → retryCount = 3 → PermanentlyFailed
</code></pre>
<p><code>PermanentlyFailed</code> is important. It means the record is still there, visible, queryable. It's not lost. You can investigate it, alert on it, and manually retry it if needed. Nothing disappears silently.</p>
<hr />
<h3>3. The Automatic Fallback</h3>
<p>Manual syncs cover urgent cases. But what about the normal flow — a tariff update that no doctor noticed, that just needs to propagate overnight?</p>
<p>The background service checks: <strong>has any sync been triggered manually today?</strong></p>
<p>If not, it automatically enqueues all clients for all clinics at <strong>06:00 and 18:00</strong>.</p>
<pre><code class="language-csharp">private static readonly TimeSpan[] ScheduledSyncTimes = {
    new TimeSpan(6, 0, 0),
    new TimeSpan(18, 0, 0),
};
</code></pre>
<p>This is a safety net, not the primary mechanism. It ensures that even if the frontend is never touched, data stays fresh within half a day. The 12-hour window was chosen based on how frequently tariffs actually change in our domain — tune this for your own context.</p>
<hr />
<h3>4. The CPU Gate</h3>
<p>This one saved us from a cascading failure.</p>
<p>Before the service dequeues anything, it checks two resource metrics:</p>
<p><strong>System-wide CPU</strong> (via <code>/proc/stat</code> on Linux):</p>
<pre><code class="language-csharp">var systemCpu = await GetCpuUsageAsync();
if (systemCpu &gt; 75.0) {
    _logger.LogWarning("System CPU at {Cpu:F1}% — skipping cycle.", systemCpu);
    return;
}
</code></pre>
<p><strong>Process-level CPU</strong> (via <code>Process.TotalProcessorTime</code>):</p>
<pre><code class="language-csharp">var processCpu = await GetProcessCpuUsageAsync();
if (processCpu &gt; 50.0) {
    _logger.LogWarning("Process CPU at {Cpu:F1}% — skipping cycle.", processCpu);
    return;
}
</code></pre>
<p>Why two separate checks?</p>
<ul>
<li><p><strong>System CPU</strong> catches when the <em>host</em> is struggling — maybe the database is hammering the CPU, or another service is spiking. No point adding more load.</p>
</li>
<li><p><strong>Process CPU</strong> catches when <em>our own service</em> is already busy — maybe a previous job is still running cleanup tasks. This is a secondary guard.</p>
</li>
</ul>
<p>Both checks sample over a <strong>500ms window</strong>, not instantaneously — a single spike shouldn't block the entire queue.</p>
<p>The thresholds I settled on after tuning:</p>
<table>
<thead>
<tr>
<th>Metric</th>
<th>Skip threshold</th>
<th>Reasoning</th>
</tr>
</thead>
<tbody><tr>
<td>System CPU</td>
<td>&gt; 75%</td>
<td>Server is under real stress</td>
</tr>
<tr>
<td>Process CPU</td>
<td>&gt; 50%</td>
<td>Our service is already working hard</td>
</tr>
<tr>
<td>Free RAM</td>
<td>&lt; 15% <em>(recommended)</em></td>
<td>OOM risk on heavy payloads</td>
</tr>
</tbody></table>
<hr />
<h3>5. Dequeue and Execute</h3>
<p>Once all checks pass, the service dequeues the next request and calls the sync API:</p>
<pre><code class="language-csharp">var request = await dbLabAPI.DequeueNextAsync();

var syncResponse = await dbLabAPI.SyncAttuneTestMasterTariff(
    request.ClinicId,
    request.ClientCode
);

var status = syncResponse.Result
    ? QueueRequestStatus.Completed
    : QueueRequestStatus.Failed;

await dbLabAPI.UpdateSyncRequestStatusAsync(request.Id, status, syncResponse.Message);
</code></pre>
<p>Notice the separation: the sync API is just an API call. It doesn't know anything about queues, priorities, or retries. The background service owns all of that complexity. This keeps each layer focused.</p>
<hr />
<h3>6. DisallowConcurrentExecution</h3>
<p>The entire job runs inside a Quartz.NET <code>IJob</code> with one critical attribute:</p>
<pre><code class="language-csharp">[DisallowConcurrentExecution]
public class BackgroundSyncClientMaster : IJob
</code></pre>
<p>No matter how frequently Quartz fires the trigger, only one instance runs at a time. Combined with the process CPU check, this is a hard guarantee against runaway concurrency.</p>
<hr />
<h2>The Full Flow</h2>
<pre><code class="language-plaintext">Frontend: Sync Client / Sync All Clients
         ↓
[HIGH PRIORITY] → Enqueue request (Pending)
         ↓
Background Service (every N seconds):
  1. RecoverStaleLocksAsync()        ← rescue stuck jobs
  2. TryEnqueueScheduledSyncAsync()  ← auto-fill if no manual sync today
  3. Check system CPU &gt; 75%?         ← skip if yes
  4. Check process CPU &gt; 50%?        ← skip if yes
  5. DequeueNextAsync()              ← highest priority, then FIFO
  6. Call SyncAttuneTestMasterTariff()
  7. Update status → Completed / Failed
</code></pre>
<hr />
<h2>What I'd Do Differently</h2>
<p>Looking back, there are two things I'd add from day one:</p>
<p><strong>A RAM check.</strong> CPU gates without memory gates are incomplete. A sync job that loads large payloads can cause GC pressure and OOM crashes that don't show up in CPU metrics. Add a check against <code>/proc/meminfo</code> and skip if free memory drops below 15%.</p>
<p><strong>Persistent last-sync timestamp.</strong> The <code>_lastScheduledSync</code> field is currently static — it resets on every app restart. If the service restarts at 07:00, it might re-trigger the 06:00 scheduled sync. Storing this in MongoDB or a distributed cache would make it restart-safe.</p>
<hr />
<h2>Key Takeaways</h2>
<p>The cron job failed not because it was poorly written, but because it was designed only for the happy path. The rewrite was successful not because of clever algorithms, but because it was designed to <strong>fail gracefully</strong>.</p>
<p>A few principles that carry beyond this specific problem:</p>
<ul>
<li><p><strong>Every async task is a record.</strong> If it doesn't have a status you can query, it doesn't exist when it fails.</p>
</li>
<li><p><strong>Recovery is not an afterthought.</strong> Stale lock recovery was the single highest-value addition to this system.</p>
</li>
<li><p><strong>Know when to do nothing.</strong> The CPU gate prevented two production incidents I can directly point to.</p>
</li>
<li><p><strong>Visibility over cleverness.</strong> <code>PermanentlyFailed</code> is more valuable than a retry loop that silently swallows errors.</p>
</li>
</ul>
<p>The doctor's billing screen is now accurate. The patient doesn't wait anymore.</p>
<p>And the support tickets stopped.</p>
<hr />
<h2>Stack</h2>
<ul>
<li><p><strong>.NET 7</strong> — background service</p>
</li>
<li><p><strong>Quartz.NET</strong> — job scheduling with <code>[DisallowConcurrentExecution]</code></p>
</li>
<li><p><strong>MongoDB</strong> — sync request queue storage</p>
</li>
<li><p><strong>Linux /proc/stat</strong> — system CPU monitoring</p>
</li>
</ul>
<hr />
<p><em>Have questions about the queue implementation or the CPU sampling approach? Drop a comment below — happy to go deeper on any part of this.</em></p>
]]></content:encoded></item></channel></rss>