Choose memory for independent process-local timers and disposable deferred work. Choose Temporal or Restate when calendar delivery must be coordinated across workers or deferred jobs must survive worker restarts. Prefer an engine your team already operates, then check its calendar and failure behavior below.
Compatibility Matrix
This compares behavior exposed through Arkoren, not the engines’ full feature sets. Restart survival depends on retained engine state. Shared calendar timing does not guarantee exclusive application effects or exactly-once execution.
| Driver | Shared Calendar Timing | Deferred Tasks | Worker Restart Survival | Non-UTC Entries | Missed Ticks | Workflow Host |
|---|---|---|---|---|---|---|
| Memory | No | Yes | No | Yes | No | No |
| Temporal | Yes | Yes | Yes | Yes | ||
| Restate | Yes | Yes | Yes | Yes |
Memory runs every entry in every replica. Memory and Restate use the core
calendar; Temporal evaluates its own calendar and runs an autumn repeated time
twice. See Time Zones before using local wall-clock times.
Memory does not retry failed handlers; Temporal configures one attempt; Restate
treats ordinary handler errors as terminal but retries missing dispatch and
errors matching schedule.ErrUnknownName. Design effects to tolerate repetition.
Engine drivers attach to the vendor’s Worker Hosts. Construct the schedule driver before starting the host, register matching entries and handlers on every worker, and run the scheduler using the ordinary runner setup. Host connection, credentials, endpoint security, and cleanup belong in that shared guide.
Run installation commands in your application’s module directory. Constructor
snippets use schedule from arkoren.dev/core/service/schedule; timing examples
also import time. Handle constructor errors before using the returned driver.
scheduler.Driver() exposes the raw driver, and DriverAs[T]() finds a concrete
type through wrappers exposing Unwrap() schedule.Driver. Raw operations bypass
the facade’s registration and observations; use ordinary scheduler methods for
application work.
Memory
The bundled memoryschedule driver uses Go timers and needs no separate
installation. Use it for tests, development, or a single replica when losing
deferred jobs is acceptable.
Creating Timers
Construction starts nothing and cannot fail:
import "arkoren.dev/core/service/schedule/memoryschedule"
driver := memoryschedule.New()
scheduler := schedule.New(driver)
scheduler.Run starts timers; the driver has no separate Start or Stop.
Add a calendar entry, or register a
deferred handler and call After.
Deferrals accepted before Run remain pending; overdue jobs run at its next
opportunity. There is no missed-calendar-tick recovery.
The only optional driver setting is Builder.Logger, which defaults to
slog.Default() and receives driver failure and overlap-skip records.
Shutdown Boundaries
Runner cancellation stops timer admission and discards unstarted work belonging
to that run. Process exit loses all jobs. Already admitted callbacks retain
context values but not the runner’s cancellation or deadline; Run waits for
them without a drain timeout.
Set positive entry timeouts and bound task I/O. A callback waiting only for its detached context can prevent the driver from returning forever. Application shutdown budgets limit runner waiting, not callback lifetime, so cleanup can start while callbacks still hold dependencies. No timeout kills Go code.
Deferrals accepted after admission closes, including during draining, remain
pending for a later raw-driver run. The raw driver rejects concurrent Run
calls through the drain with memoryschedule.ErrAlreadyRunning, and can run
again after it returns. The scheduler facade itself remains single-use.
The driver copies submitted payload bytes. An already-canceled submission
returns the context error and no ID; an already-canceled cancellation leaves
pending work unchanged. Canceling a submission’s context after acceptance does
not withdraw its job. Cancel removes pending work but cannot stop an admitted
dispatch.
For timer tests, use Controlling Time. RunNow checks a
handler without testing timer delivery.
For every function and option, see the memoryschedule API reference.
Temporal
Temporal stores recurring entries as Schedules and deferred tasks as delayed workflows. It retains them across worker restarts while its engine state is retained. Install the module:
go get arkoren.dev/driver/temporal
Connecting Workers
With a configured Temporal host,
construct the driver before host.Start. It registers a workflow and activity
without making network calls.
Rundeletes Temporal Schedules beginning with<prefix>-that are absent from your declarations. Give each application a unique prefix on a shared namespace. Workers sharing that prefix must declare the same schedule set; conflicting deployments can overwrite or remove each other’s schedules.
import "arkoren.dev/driver/temporal/temporalschedule"
driver, err := temporalschedule.New(host, func(clock *temporalschedule.Builder) {
clock.Prefix("accounts")
})
scheduler := schedule.New(driver)
The prefix defaults to arkoren and names workflows, activities, and schedules.
Run creates or updates <prefix>-<entry-name> schedules, prunes undeclared
ones, then waits. Deferred workflows named <prefix>-deferred-* are not pruned.
Register calendar entries and
task handlers before running this scheduler.
A dialing host must connect first: live-context Run, Defer, and Cancel
otherwise return temporalschedule.ErrNotStarted. An already-canceled driver
Run returns nil without connecting. A host wrapping an existing native client
can submit deferrals before local Run. A client-only host needs matching
workers elsewhere.
Existing activities can arrive between host startup and scheduler startup.
They wait for dispatch readiness within their activity timeout, so start
Run promptly: readiness waiting consumes that execution budget.
Timing Policy
If a brief outage must recover more than the default minute of missed ticks, construct the driver with an explicit catch-up window. This is an alternative construction using the same host, not a second registration on it:
driver, err := temporalschedule.New(host, func(clock *temporalschedule.Builder) {
clock.Prefix("accounts")
clock.CatchupWindow(10 * time.Minute)
clock.MaxRunTime(20 * time.Minute)
})
MaxRunTime defaults to one hour and sets the activity bound when an entry has
no positive timeout; deferred tasks use that fallback too. A positive entry
timeout overrides it. Nonpositive MaxRunTime and CatchupWindow restore their
defaults. Driver Logger defaults to slog.Default(). Native timing bounds do
not forcibly terminate application callbacks.
Each run has one activity attempt. WithoutOverlapping maps to Temporal’s
skip policy; otherwise all overlaps are allowed. Temporal has approximately
second-level schedule resolution, and actual firing can lag. Its own calendar
skips spring-gap times and fires autumn-overlap times twice.
Shutdown Boundaries
Every driver Run exit removes its dispatcher and cancels active dispatch
contexts, including on reconciliation failure. It does not join callbacks or
stop code that ignores cancellation. Later activities wait for a new raw-driver
run instead of reusing the stopped dispatcher; a facade cannot be restarted.
The host’s worker-stop grace is native policy, not a hard callback deadline. Its early stop notification does not immediately cancel active callbacks. See host shutdown for grace, native option precedence, and client ownership.
Native Access
Use the shared host’s Client() for SDK operations outside the facade:
native := host.Client()
A dialing host exposes it only after connection; temporal.NewFrom borrows a
supplied client and exposes it immediately. Configure native TLS, authentication,
and logger policy through the host.
Keep independent native schedules outside the driver’s prefix: reconciliation
can overwrite native edits to managed schedules.
Local Engine
Use the Temporal local engine in your
application. Construct this driver before starting that host and register
scheduler.Run. Check a handler’s effect after a calendar tick to verify
engine delivery; RunNow verifies only the handler. The linked profile includes
health verification and disposable-engine cleanup.
For every function and option, see the temporalschedule API reference.
Restate
Restate stores each recurring entry in a virtual object whose tick schedules the next tick before dispatching the current work. Deferred tasks use delayed sends. Choose it when you operate a Restate server with retained state:
go get arkoren.dev/driver/restate
Connecting Workers
Configure the Restate host, including
a reachable endpoint, request-identity verification, and deployment registration.
Construct this driver before host.Start because it binds native services.
Use a distinct ownership prefix per application. Startup reconciliation removes undeclared entries from that prefix’s index. Service names drop separators and capitalize the following letter, so prefixes must also have distinct normalized names; different strings alone do not ensure isolation.
import "arkoren.dev/driver/restate/restateschedule"
driver, err := restateschedule.New(host, func(clock *restateschedule.Builder) {
clock.Prefix("accounts")
})
scheduler := schedule.New(driver)
The default prefix arkoren creates ArkorenSchedule, ArkorenScheduleIndex,
and ArkorenRunner. The index key preserves the exact prefix; ingress requests
escape it as one path segment. Run scheduler.Run alongside host.Serve using
the shared host’s lifecycle setup. Run retries initial ingress publication
every second until accepted or canceled, including while the deployment is not
yet registered. It does not fail immediately on an unreachable ingress.
Register calendar entries and
task handlers before Run. Deferrals can be accepted before
local Run if ingress knows the deployment; delivery retries when dispatch is
not ready. Unlike initial publication, Defer does not retry submission errors.
The returned job ID is a Restate invocation ID accepted by Cancel.
Timing Policy
For a different native handler budget, select MaxRunTime during construction:
driver, err := restateschedule.New(host, func(clock *restateschedule.Builder) {
clock.Prefix("accounts")
clock.MaxRunTime(20 * time.Minute)
})
This alternative construction sets abort and inactivity timeouts on native Tick
and Run handlers. Zero restores the default hour; negative values leave server
defaults, not an unlimited-execution guarantee. Driver Logger defaults to
slog.Default(). Entry timeouts remain cooperative application-context bounds.
WithoutOverlapping runs inline in the entry’s serialized tick invocation;
ordinary entries dispatch through the runner service. A delayed pending tick
runs once and computes its next match after the current time, rather than
replaying every missed calendar match. Ordinary application failures are terminal;
missing dispatch and errors matching schedule.ErrUnknownName are retryable.
Runs can repeat, so keep application effects idempotent.
Deployment Safety
Changing an expression, zone, or overlap flag starts a new schedule generation. Removing an entry clears its active spec but retains its generation. Adding the same name later advances the generation so old queued ticks cannot revive it. Preserve that retained generation if you manage native state manually; it does not prevent duplicate application effects.
Before upgrading existing schedule state, let in-flight
Removeinvocations finish on the old deployment. Removal records individual state clears rather thanClearAll; replaying an incompatible recorded history can fail. If an older deployment already removed a name, drain its pending ticks before reintroducing it because its deleted generation history cannot be recovered.
Task JSON values, including explicit null, are preserved. Upgrade serving
workers before relying on null-payload preservation; older workers discard
those bytes. Recurring messages still dispatch with nil payload bytes,
including legacy recurring messages carrying Payload:null.
Each rollout creates a deployment. Keep handlers compatible with in-flight
work and use the host’s deployment and shutdown policy.
Cancel requests native invocation cancellation; it cannot forcibly stop
application code already executing. Keep captured dependencies alive until
callbacks finish.
Native Access
The host’s Query method exposes the admin SQL API for inspection. Keep admin
credentials restricted and do not expose arbitrary queries to untrusted callers.
For an existing host and operation context:
rows, err := host.Query(ctx, "SELECT * FROM sys_invocation LIMIT 10")
This reads native invocation records, not the facade’s registered definitions. The workflow driver can bind to the same host; its separate cancellation retention policy is not a prerequisite for recurring schedules.
Local Engine
Use the Restate local engine, construct
this driver before host startup, and run both host.Serve and scheduler.Run.
Check the handler’s effect after a tick to test durable delivery. The linked
isolated development profile disables request-identity verification; do not use
that setting on an exposed endpoint. Follow its cleanup instructions to remove
the disposable server and schedule state.
For every function and option, see the restateschedule API reference.
Hatchet
Hatchet integration is pending and unpublished. It is not an installable scheduler option and is excluded from the available-driver matrix. Use memory, Temporal, or Restate instead.
For the portable contract, see the schedule API reference.