A background worker
A partner drops files into an outbox directory on their SFTP server. A worker service fetches new files every five minutes, saves
them locally, and moves each one to done on the server once it has it. It runs for months: connections drop, the partner's server
restarts, and the worker carries on.
The approach
- A hosted service (
BackgroundService) with aPeriodicTimer, on the generic host. - The shared connection from dependency injection (
ISshConnectionProvider): opened on first use and again after a drop, with a growing delay after failures, so a server that is down is not hammered. - An SFTP session per run, closed at the end of the run; the SSH connection stays open between runs.
- At-least-once processing. A file is moved to
doneonly after it is saved, so a failure in between means it is fetched again at the next run, never lost. Make the downstream processing idempotent.
The code
// Program.cs
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddSshClient("partner")
.BindConfiguration("Ssh:Partner")
.ValidateOnStart();
builder.Services.AddHostedService<IngestWorker>();
await builder.Build().RunAsync();
/// <summary>Every few minutes, downloads new files from a partner's outbox and moves them to "done" on the server.</summary>
internal sealed class IngestWorker(ISshConnectionProvider connections, IConfiguration configuration, ILogger<IngestWorker> logger)
: BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var inbox = configuration["Ingest:LocalDirectory"] ?? "inbox";
Directory.CreateDirectory(inbox);
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(5));
do
{
try
{
var count = await RunOnceAsync(inbox, stoppingToken);
logger.LogInformation("Fetched {Count} files", count);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
return;
}
catch (Exception ex) when (ex is SshException or IOException or TimeoutException)
{
// The connection dropped or could not be opened; the provider opens a new one on the next run, backing off
// after failures. Files not yet moved to "done" are fetched again then.
logger.LogWarning(ex, "Fetching failed; trying again at the next run");
}
}
while (await timer.WaitForNextTickAsync(stoppingToken));
}
private async Task<int> RunOnceAsync(string inbox, CancellationToken cancellationToken)
{
var client = await connections.GetClientAsync("partner", cancellationToken); // shared, reconnecting: do not dispose
await using var sftp = await client.OpenSftpAsync(cancellationToken); // a session per run
var count = 0;
await foreach (var entry in sftp.ListDirectoryAsync("outbox", cancellationToken))
{
if (!entry.Attributes.IsRegularFile || entry.Name.EndsWith(".partial", StringComparison.Ordinal))
{
continue; // still being written by the partner
}
var local = Path.Combine(inbox, Path.GetFileName(entry.Name));
await sftp.DownloadFileAsync(entry.FullPath, local, new SftpTransferOptions { Preserve = true }, cancellationToken);
await sftp.RenameAsync(entry.FullPath, $"done/{entry.Name}", overwrite: true, cancellationToken);
count++;
}
return count;
}
}
appsettings.json:
{
"Ssh": {
"Partner": {
"Host": "sftp.example.com",
"UserName": "user",
"PrivateKeyFiles": [ "/run/secrets/partner_ed25519" ],
"KnownHostsFiles": [ "/etc/ingest/known_hosts" ],
"KeepAliveInterval": "00:00:30"
}
},
"Ingest": { "LocalDirectory": "/var/lib/ingest/inbox" }
}
For production
- Secrets. Mount the key (and any passphrase) from your platform's secret store, as above; do not bake it into the image.
- Host key. Ship the partner's host key in a
known_hostsfile.AcceptNewHostKeyswould trust whatever answers first. - Health. Add
builder.Services.AddHealthChecks().AddSsh("partner")and expose it to your orchestrator. - Partial files. The example skips names ending in
.partial; agree on a convention with the partner (upload under a temporary name, then rename), or wait for a file's size to stop changing. - Large files. Add
Resume = trueto continue interrupted downloads, orSegmentsfor faster downloads over long distances.
See also
- ASP.NET Core and dependency injection
- Keep-alive and reconnecting
- The runnable worker service sample, which follows a remote log file across dropped connections