Kanject.Core.Recurring
Attribute-driven recurring method invocation with a drift-corrected engine and an AOT-safe source generator. Annotate a method with a rate, and a {Method}RecurringAsync extension is generated — call it from a console app, a Lambda handler, or an IHostedService. Cadence and lifecycle live on the declaration; the call site is just await .RecurringAsync(...).
Install
dotnet add package Kanject.Core The attribute and engine ship in Kanject.Core — no separate package, and it works immediately after adding the reference.
Annotate a method
using Kanject.Core.Annotations.Attributes.Recurring;public sealed class HeartbeatService(IPingClient client){ // Cadence lives on the method. Units: Seconds | Minutes | Hours | Days. [Recurring(5, RecurringRateUnit.Seconds)] public async Task SendHeartbeatAsync(CancellationToken cancellationToken) => await client.PingAsync(cancellationToken);} The rate is a value plus a RecurringRateUnit (Seconds, Minutes, Hours, Days). The engine uses Stopwatch.GetTimestamp for drift-corrected scheduling — if one iteration runs long, the next still fires on the original cadence rather than accumulating lag.
Call it
// The generator emits {Method}RecurringAsync — call it anywhere: a console// app, a Lambda handler, or an IHostedService. No timer, no host required.var service = new HeartbeatService(client);RecurringResult result = await service.SendHeartbeatRecurringAsync( cancellationToken: ct);Console.WriteLine($"Ran {result.Iterations} times, stopped because {result.StopReason}"); The generated extension runs the loop and returns a RecurringResult (Iterations, StopReason, LastException). Value-returning methods get three overloads — including a …RecurringToLastAsync that returns the final value and a streaming variant. There is no reflection and no DI requirement; the generator wires everything at compile time.
Bound and tune a run
// Bound a run by count or wall-clock, and add jitter to de-sync fleets.[Recurring(1, RecurringRateUnit.Hours, MaxIterations = 24, // stop after 24 runs MaxDurationSeconds = 3600, // …or after an hour, whichever first MaxJitterMs = 500, // spread ticks across instances StopOnException = true)] // surface faults instead of swallowing thempublic Task ReconcileAsync(CancellationToken ct) => _ledger.ReconcileAsync(ct); What ships with it
[Recurring(rateValue, RecurringRateUnit.Seconds|Minutes|Hours|Days)]on a method — cadence as declaration, not embedded config- Generated
{Method}RecurringAsync(+ value-returning and streaming overloads) — runs in Lambda, console, orIHostedServiceunchanged - Drift-corrected scheduling via
Stopwatch.GetTimestamp— no accumulated lag MaxIterations,MaxDurationSeconds,MaxJitterMs,StopOnExceptionbounds; overrun and exception policies with per-iteration and global hooksRecurringResult(Iterations/StopReason/LastException) instead of silently-swallowed loop errors