Background
Recently, I developed an ASP.NET Core application that can query a local data file for IP address information. The application is deployed to Azure Container Apps, and the data file is mounted in Azure File Share as a volume for the container. There is another job that will update the data file every day on schedule. The application would not read the entire data file on each request for performance concerns, it would load the data into memory on start up. So, the problem is, when the data file is updated, how to reload it in my application?
The most easy way is to restart the container. But this approach clearly would cause downtime for my app, because there's currently only one instance running. So, I need a way to reload the data on the fly without downtime. Let's see how to do it.
The Design
The entire design has 3 core files:
- QqwryDb: Immutable Snapshot of the File
- QqwryDbProvider: The Thread-Safe Pointer Swap
- QqwryDbWatcher: The Polling Engine
Let me explain them one by one.
QqwryDb
The entire .dat file is eagerly read into a byte[] in the constructor. After construction the object never touches the filesystem again. It is a completely immutable, self-contained snapshot.
public sealed class QqwryDb(string path)
{
private readonly byte[] _data;
// ...
public QqwryDb(string path)
{
_data = File.ReadAllBytes(path); // entire file read into memory at construction time
_indexStart = ReadUInt32LE(0);
_indexEnd = ReadUInt32LE(4);
}
}
This is the foundational design decision that makes safe hot-reload possible: once a QqwryDb instance exists, it can be queried concurrently from any number of threads without any locks, because its data never changes.
QqwryDbProvider
This is the heart of the hot-reload design.
public sealed class QqwryDbProvider
{
private volatile QqwryDb? _current;
// ...
internal void TryReload()
{
// Guard 1: file disappeared
if (!File.Exists(_path)) { _current = null; return; }
// Guard 2: write time hasn't changed — skip
if (_current is not null && newWriteTime == _lastWriteTime) return;
// Guard 3: file is suspiciously small — likely mid-write, skip
if (fileSize < MinValidFileSizeBytes) return;
var newDb = new QqwryDb(_path); // construct new snapshot off to the side
Interlocked.Exchange(ref _current, newDb); // atomically publish it
_lastWriteTime = newWriteTime;
}
public IpLocation Query(IPAddress ip)
{
var db = _current ?? throw ...;
return db.Query(ip); // reads from the snapshot captured at method entry
}
}
volatileon _current ensures every thread always reads the freshest pointer value from main memory, not a CPU-cache copy.Interlocked.Exchangemakes publishing the newQqwryDbinstance a single atomic operation. There is no window where another thread can see a half-initialized state.- The new instance is fully constructed before it is published. The call to new
QqwryDb(_path)reads the file and builds all internal state; only then does the swap happen. This means the old instance stays live and queryable right up to the moment the new one takes over. - The file-size guard (
MinValidFileSizeBytes = 1 MB) handles the classic race between the writer and the reader: If the external process is still writing the new file when the watcher fires, the file will be smaller than a valid database, so the reload is skipped and retried on the next tick. - The last-write-time guard means the file is only re-read when the OS timestamp actually changed, so normal ticks where nothing happened are completely free.
In-flight requests that captured _current before the swap continue to use the old QqwryDb object until they finish. Because QqwryDb holds its data in a plain managed array, the GC will collect the old instance automatically once no threads hold a reference to it. Zero manual cleanup required!
QqwryDbWatcher
This is a standard ASP.NET Core BackgroundService. It uses PeriodicTimer, which is preferable to Timer + callbacks because it is non-reentrant by design. If TryReload() takes longer than the interval, the next tick simply waits instead of firing a second concurrent reload. The interval is configurable via appsettings.json.
public sealed class QqwryDbWatcher(
QqwryDbProvider provider,
IConfiguration configuration,
ILogger<QqwryDbWatcher> logger) : BackgroundService
{
private readonly TimeSpan _interval = TimeSpan.FromSeconds(configuration.GetValue("IpDb:ReloadIntervalSeconds", 60));
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(_interval);
while (await timer.WaitForNextTickAsync(stoppingToken))
{
try
{
provider.TryReload();
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to reload QQWry database.");
}
}
}
}
DI Registration
Finally, register them in DI container. Notice, QqwryDbProvider is a singleton, there is exactly one pointer in the process, shared by all requests.
builder.Services.AddSingleton(sp =>
new QqwryDbProvider(qqwryPath, sp.GetRequiredService<ILogger<QqwryDbProvider>>()));
builder.Services.AddHostedService<QqwryDbWatcher>();
Why Polling Instead of FileSystemWatcher?
It's because FileSystemWatcher is unreliable on Linux (which this Docker-targeted app is designed for) and especially inside containers, where bind-mounts and volume drivers do not always propagate INotify events correctly. A periodic poll with a last-write-time check is simpler, portable, and nearly as fast (the cost of File.GetLastWriteTimeUtc is acceptable compared to the interval).
Summary of the Data Flow
[External updater replaces qqwry.dat on disk]
↓
[QqwryDbWatcher fires every N seconds]
↓
[QqwryDbProvider.TryReload()]
→ checks last-write-time (cheap, skip if same)
→ checks file size (guard against partial writes)
→ new QqwryDb(_path) (reads full file into byte[])
→ Interlocked.Exchange (atomic pointer swap)
↓
[All new requests pick up the new QqwryDb]
[In-flight requests finish against the old QqwryDb, then it is GC'd]
Conclusion
Hot-reloading a binary database file in a live ASP.NET Core can be solved without restarting your server, this three-layer approach of immutable snapshot, atomic pointer swap, background poller is a solid template to do it!
Comments