Delayed / One-Off Jobs

The Problem

Some work doesn't fit a recurring schedule. You need to send a reminder email in 30 minutes, process a refund after a 24-hour cooling period, or run a one-time data migration 5 minutes from now. These are fire-once jobs with a delay — not periodic, not dependent on another manifest, just "run this once, later."

Trax supports two approaches: delayed triggers on existing manifests, and standalone one-off manifests that auto-disable after completion.

Delayed Trigger: TriggerAsync with Delay

If a manifest already exists — registered at startup or created via ScheduleAsync — you can trigger it with a delay. This creates a work queue entry with a future ScheduledAt timestamp. The JobDispatcher skips the entry until that time arrives.

// Trigger an existing manifest 30 minutes from now
await scheduler.TriggerAsync("send-reminder", TimeSpan.FromMinutes(30));

The manifest's regular schedule (if any) continues unaffected. The delayed trigger is an independent execution — it doesn't reset or interfere with the normal cadence.

This is useful when you already have a manifest for the train and want to queue an extra execution at a future time. The manifest must exist; TriggerAsync throws InvalidOperationException if it doesn't.

One-Off Jobs: ScheduleOnceAsync

For work that has no pre-existing manifest — a transient job that should run once and never again — use ScheduleOnceAsync. It creates a manifest with ScheduleType.Once, sets ScheduledAt to the current time plus the delay, and auto-disables the manifest after the first successful execution.

Runtime API (ITraxScheduler)

// Auto-generated externalId (format: "once-{guid}")
await scheduler.ScheduleOnceAsync<ISendReminderTrain, SendReminderInput>(
    new SendReminderInput { UserId = userId, Message = "Your trial expires tomorrow" },
    TimeSpan.FromHours(24));
 
// Explicit externalId for tracking or idempotency
await scheduler.ScheduleOnceAsync<IProcessRefundTrain, ProcessRefundInput>(
    "refund-order-12345",
    new ProcessRefundInput { OrderId = 12345 },
    TimeSpan.FromHours(24));

When no externalId is provided, one is auto-generated in the format once-{guid}. If you need to cancel or query the job later, pass an explicit ID.

Startup Configuration (Builder Pattern)

For one-off jobs that should be scheduled when the application starts — such as a post-deployment migration or a delayed initialization task — use the builder:

services.AddTrax(trax => trax
    .AddScheduler(scheduler => scheduler
        .ScheduleOnce<IPostDeployTrain>(
            "post-deploy-v2.5",
            new PostDeployInput { Version = "2.5" },
            TimeSpan.FromMinutes(5))
    )
);

Like Schedule, the builder captures the manifest and seeds it on startup with upsert semantics. If a manifest with the same externalId already exists, it is updated rather than duplicated — so restarting the application doesn't create duplicate jobs.

Auto-Disable Behavior

When a ScheduleType.Once manifest completes successfully, the UpdateManifestSuccessJunction sets IsEnabled = false on the manifest. The manifest stays in the database for audit purposes — you can see its execution history in the dashboard — but the ManifestManager skips it on subsequent polling cycles.

If the job fails, normal retry logic applies. Retries continue until the job succeeds (and auto-disables) or exceeds MaxRetries (and is dead-lettered). A dead-lettered once-manifest can be retried from the dashboard like any other dead letter.

How It Works Internally

  1. Manifest creation: ScheduleOnceAsync creates a manifest with ScheduleType = Once and ScheduledAt set to DateTime.UtcNow + delay.
  2. Queuing: The ManifestManager's DetermineJobsToQueueJunction evaluates Once manifests via ShouldRunOnce — it queues the manifest when the current time is at or past ScheduledAt.
  3. Dispatch filtering: The JobDispatcher's LoadQueuedJobsJunction filters out work queue entries whose ScheduledAt is still in the future. This applies to both delayed triggers and Once manifests.
  4. Auto-disable on success: The UpdateManifestSuccessJunction checks if the manifest is ScheduleType.Once. If so, it sets IsEnabled = false after recording the successful execution.

When to Use Which

ScenarioAPIWhy
Extra run of an existing scheduled job, delayedTriggerAsync(externalId, delay)The manifest already exists. You just want an additional execution at a future time.
One-time job, no existing manifestScheduleOnceAsync(input, delay)No manifest exists yet. The job runs once and auto-disables.
One-time job you need to track or cancelScheduleOnceAsync(externalId, input, delay)Same as above, but with a predictable ID for CancelAsync or dashboard lookup.
Post-deployment one-time taskscheduler.ScheduleOnce<TTrain>(...) at startupDeclarative, upsert-safe, runs once after a delay on app start.
Immediate execution of an existing jobTriggerAsync(externalId) (no delay)Use the existing zero-delay overload.

Configuration

One-off jobs accept the same ScheduleOptions as recurring manifests:

await scheduler.ScheduleOnceAsync<ICleanupTrain, CleanupInput>(
    "cleanup-temp-files",
    new CleanupInput { OlderThan = TimeSpan.FromDays(7) },
    TimeSpan.FromMinutes(10),
    options => options
        .MaxRetries(1)
        .Timeout(TimeSpan.FromMinutes(5))
        .Group("maintenance"));

SDK Reference

> TriggerAsync / ScheduleOnceAsync | Schedule