AnnouncementsIntroducing Arkoren

    Menu

    Deferred Tasks

    Register typed one-off handlers, defer payloads until later, and interpret submission and cancellation outcomes.

    What's on this page
    1. Define typed task payloads and register bounded handlers before startup.
    2. Submit relative or absolute deferrals without confusing acceptance with execution.
    3. Cancel by driver identifier and test submitted payloads separately from handler effects.
    On this page

    A reminder created after a user action needs one future execution, not a recurring calendar entry. A task fixes the name and payload type for that work. Register its handler on a scheduler, then submit individual jobs with their payloads and due times.

    The examples assume a configured scheduler named scheduler. Memory loses jobs on restart; Temporal and Restate retain them in engine state. Choose that durability boundary before accepting work from users.

    Defining Tasks

    Keep the reminder’s payload and task definition in a package shared by producers and workers. Import arkoren.dev/core/service/schedule:

    type Reminder struct {
    	AccountID string `json:"account_id"`
    }
    
    var RemindAccount = schedule.NewTask[Reminder]("remind-account")

    The definition does not register a handler or schedule anything. Names share the entry namespace and contain 1–64 lowercase letters, digits, or hyphens, without edge hyphens. Handle validates the name; definitions using one name must agree on their payload type.

    Payloads round-trip through encoding/json/v2. Prefer a small identifier over a snapshot of account data: the handler can load current state and decide whether the reminder is still appropriate. Keep pending payloads compatible with future worker deployments; a decode failure occurs before the handler runs.

    Registering Handlers

    Before scheduler.Run, register the application function that sends the reminder. Here, sendReminder has type func(context.Context, string) error. Import context and time to bound its execution:

    err := scheduler.Handle(RemindAccount, func(ctx context.Context, reminder Reminder) error {
    	ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
    	defer cancel()
    
    	return sendReminder(ctx, reminder.AccountID)
    })

    Tasks have no entry-style Timeout setting, so bound the handler’s work and pass its context into I/O. This deadline is cooperative, not forced termination. In particular, memory drains admitted tasks on contexts detached from runner cancellation and has no driver drain timeout. Keep captured services alive until handlers finish, even if the application’s shutdown wait expires.

    Handle rejects nil handlers and duplicate names. Registration closes when Run starts. The facade also requires this registration before At or After, including on a process that only submits jobs; otherwise it returns schedule.ErrUnknownName. Register compatible names and handlers on all execution workers. Only the name and JSON payload travel, never the closure.

    Make the handler idempotent and recheck current business state before effects. One-off means one submitted job, not a guarantee that its effects happen once. Driver retry and delivery policies differ.

    Deferring Work

    After the application has validated and authorized an account action, defer its reminder relative to the current time:

    id, err := scheduler.After(ctx, RemindAccount, Reminder{AccountID: "acct-42"}, 10*time.Minute)

    Success returns a driver-chosen job identifier and means accepted for later execution, not that the handler ran or a message was delivered. Retain the ID if you might cancel the job. A zero or negative delay makes it due now or in the past; the driver runs it at its next opportunity, not synchronously.

    If the due instant is already known, use At with a nonzero time.Time named due instead:

    id, err := scheduler.At(ctx, RemindAccount, Reminder{AccountID: "acct-42"}, due)

    These are alternative submissions; calling both creates two jobs. At rejects zero time. Encoding and driver errors retain their causes, and the facade does not retry failed submissions automatically. A context or transport failure is not proof that an engine accepted nothing; reconcile uncertain outcomes before blindly submitting another job when duplicate effects matter.

    Deferring a job and committing an application database change are separate operations. If that boundary must be reliable, use an application-owned durable handoff or reconciliation design rather than assuming the scheduler joins the transaction.

    Temporal needs a connected host for submission. Restate needs a registered deployment known to ingress; its initial calendar-publication retry loop does not apply to deferred submissions. Memory holds pre-run submissions until Run and loses them on process exit. See Drivers for those readiness and shutdown boundaries.

    With events configured, successful At and After calls publish schedule.task.deferred with name, ID, and due time, never the payload. This is a submission observation, not a delivery receipt.

    Canceling Jobs

    When a reminder is no longer needed, pass its returned identifier to Cancel:

    err = scheduler.Cancel(ctx, id)

    Unknown and finished IDs succeed. A successful call therefore means the cancellation request was accepted, not proof that pending work existed or was removed. The configured schedule.task.canceled event has that same boundary.

    Memory cannot interrupt an admitted dispatch. Temporal terminates the native deferred workflow; Restate requests invocation cancellation. Neither can forcibly stop arbitrary Go code already executing. Recheck business state in the handler when a stale reminder would cause harm.

    Testing Submissions

    To test that application code submits the right reminder without sending mail, use scheduletest.New() from arkoren.dev/core/service/schedule/scheduletest. It records jobs without firing them. In a test with t *testing.T, construct a separate scheduler and register an inert handler:

    fake := scheduletest.New()
    scheduler := schedule.New(fake)
    err := scheduler.Handle(RemindAccount, func(context.Context, Reminder) error {
    	return nil
    })
    if err != nil {
    	t.Fatal(err)
    }

    Invoke the application operation under test with this scheduler. For a focused submission assertion, call After directly:

    _, err = scheduler.After(t.Context(), RemindAccount, Reminder{AccountID: "acct-42"}, 10*time.Minute)
    if err != nil {
    	t.Fatal(err)
    }
    
    fake.AssertDeferred(t, RemindAccount, func(reminder Reminder) bool {
    	return reminder.AccountID == "acct-42"
    })

    This proves the submitted payload, not handler execution or mail delivery. Test the handler separately with controlled application dependencies. For real timer behavior, see Controlling Time.

    To execute due fake jobs through the scheduler, start scheduler.Run and then call fake.Drain(ctx). Before Run installs dispatch, Drain returns scheduletest.ErrNotRunning; inside a synctest bubble, wait with synctest.Wait() before draining. Future-due jobs remain pending until due.

    For every function and option, see the schedule API reference.

    Open a bulk documentation archive?

    This file contains every guide in its scope, not API declarations. Loading it all into an agent can exhaust its context window and increase processing time and cost. For coding tasks, start with the index and read only the relevant pages.

    Use the full archive for offline indexing, bulk processing, or deliberate large-context use.

    Browse the index instead
    Open archive