Nightly billing with cron and fan-out
Billing thousands of accounts in one loop has two problems: one failing account stops or delays the rest, and a retry would bill the early ones again. Splitting the work fixes both. A scheduled function only decides what to do and sends one event per account; a second function bills one account per run.
using System.Text.Json;using Inngest;
public sealed record AccountDue(string AccountId, string BillingDate) : IInngestEventData{ public static string EventName => "billing/account.due";}
public sealed class StartBillingRun(IAccounts accounts, TimeProvider time) : InngestFunction<JsonElement>{ private const int ChunkSize = 500;
public override async Task<object?> ExecuteAsync(FunctionContext<JsonElement> context, CancellationToken cancellationToken) { // Both values come from steps, so every call of this run sees the same date and the same list. var billingDate = await context.Step.RunAsync("pick-billing-date", () => DateOnly.FromDateTime(time.GetUtcNow().UtcDateTime).ToString("yyyy-MM-dd"));
var due = await context.Step.RunAsync("load-accounts-due", ct => accounts.GetDueAsync(DateOnly.Parse(billingDate), ct));
var chunks = due.Chunk(ChunkSize).ToArray(); for (var i = 0; i < chunks.Length; i++) { InngestEvent[] events = [ .. chunks[i].Select(accountId => new InngestEvent(AccountDue.EventName, new AccountDue(accountId, billingDate)) { // One event per account and day, however often this function runs. Id = $"billing-{billingDate}-{accountId}", }), ]; await context.Step.SendEventAsync($"send-batch-{i}", events); }
return new { billingDate, accounts = due.Count }; }}
public interface IAccounts{ Task<IReadOnlyList<string>> GetDueAsync(DateOnly date, CancellationToken cancellationToken);}using Inngest;
public sealed record Bill(string InvoiceId, decimal Amount);
public sealed class BillAccount(IBillingEngine billing) : InngestFunction<AccountDue>{ public override async Task<object?> ExecuteAsync(FunctionContext<AccountDue> context, CancellationToken cancellationToken) { var (accountId, billingDate) = context.Event.Data;
var bill = await context.Step.RunAsync("create-invoice", ct => billing.CreateInvoiceAsync(accountId, billingDate, ct));
if (bill.Amount == 0) { return new { accountId, charged = false }; }
await context.Step.RunAsync("collect-payment", ct => billing.CollectAsync(bill.InvoiceId, ct)); await context.Step.RunAsync("email-invoice", ct => billing.EmailAsync(bill.InvoiceId, ct));
return new { accountId, bill.InvoiceId, charged = true }; }}
public interface IBillingEngine{ Task<Bill> CreateInvoiceAsync(string accountId, string billingDate, CancellationToken cancellationToken);
Task CollectAsync(string invoiceId, CancellationToken cancellationToken);
Task EmailAsync(string invoiceId, CancellationToken cancellationToken);}The app registers both with their options as attributes on the classes, or with the options in code:
On StartBillingRun:
[InngestFunction("start-billing-run")][CronTrigger("0 2 * * *", Timezone = "Europe/Prague")][Singleton(SingletonMode.Skip)]On BillAccount, with durations as time strings such as "1m":
[InngestFunction("bill-account", Retries = 10, Idempotency = "event.data.accountId + '-' + event.data.billingDate")][EventTrigger<AccountDue>][Concurrency(20)][Throttle(200, "1m")]AddFunction reads the attributes of both classes once at startup:
inngest .AddFunction<StartBillingRun>() .AddFunction<BillAccount>();inngest .AddFunction<StartBillingRun>(new FunctionOptions { Id = "start-billing-run", Triggers = [Trigger.Cron("0 2 * * *", timezone: "Europe/Prague")], Singleton = new Singleton(SingletonMode.Skip), }) .AddFunction<BillAccount>(new FunctionOptions { Id = "bill-account", Triggers = [Trigger.Event<AccountDue>()], Retries = 10, Concurrency = [new ConcurrencyLimit(20)], Throttle = new Throttle(200, TimeSpan.FromMinutes(1)), Idempotency = "event.data.accountId + '-' + event.data.billingDate", });TimeProvider is registered by AddInngest if the app has not registered one.
How it works
Section titled “How it works”- Decide once, in steps. The billing date and the list of accounts come from steps. If sending a batch fails and the function is retried, it sends the same accounts for the same date instead of reading the clock and the database again.
- Fan out with events. Each batch of up to 500 events goes out in one
SendEventAsyncstep. The events are built asInngestEventso that each carries an id derived from the account and the date, and a repeated run produces duplicates that Inngest ignores. Inngest accepts up to 5,000 events per request. - One run per account.
BillAccountruns once per event, with its own ten retries. A failing account does not hold up the others, and its failure is visible on its own. - Limits on the downstream system. The concurrency limit bills at most 20 accounts at once and the throttle starts
at most 200 per minute, whatever the size of the batch.
Idempotencystops a second run for the same account and date within a day. - No overlap. The singleton option skips a scheduled run while the previous one is still going.
The scheduled run’s own event is inngest/scheduled.timer, which is why StartBillingRun reads its event as
JsonElement and ignores it.
inngest-dotnet is a community Inngest SDK for .NET. It is not affiliated with or endorsed by Inngest Inc.