Run scheduled jobs
This recipe runs two jobs in one service on one machine: a backup of a folder every night at 02:00 and an invoice for a customer every Monday at 08:00, both on Oslo time. Each job is a workflow, and a schedule starts it at each due time. The schedules, the runs, and their results are stored in the service's journal, a SQLite file next to the executable.
A service like this can also run its jobs on a separate workflow server, such as Temporal, which keeps the schedules and the run history in its own process and database. Here the service's own process holds them, and its journal stores them. What replaces the workflow server lists where that state lives and what the service must do instead.
Motivation: Use schedules when work must start at local times of day, survive restarts, and catch up on the due times that passed while the machine was off.
Write a workflow explains workflows, steps, and idempotency keys. The
complete programs are in samples/schedules/fsharp and samples/schedules/csharp. They reference
the projects under src/; Schedules.createLatest, and C#'s CreateAsync without a catch-up
policy, need FCQRS.Workflows 7.7.0 or later.
Give each job's external system a key
The backup store and the billing system stand outside the service. Each call carries a key:
Shared setup
module Jobs =
open System
open System.Globalization
open System.Threading.Tasks
open FCQRS.Workflows
Fcqrs_550-how-to_009-run-scheduled-jobs.md_page.JobsSystemGlobalizationThreadingTasksFCQRSWorkflows// The systems the jobs call. Each does its work once per key, so a repeated
// call with the same key changes nothing. Both return whether they did it.
type IBackupStore =
abstract Copy: key: string * folder: string -> Task<bool>
type IBilling =
abstract Issue: invoice: string * customer: string -> Task<bool>
Fcqrs_550-how-to_009-run-scheduled-jobs.md_page.Jobs.IBackupStoreCopy: IBackupStore -> string * string -> Task<bool>keystringAn abbreviation for the CLI type . Basic Types
folderSystem.Threading.Tasks.Task`1Represents an asynchronous operation that can return a value. The type of the result produced by this .
boolAn abbreviation for the CLI type . Basic Types
Fcqrs_550-how-to_009-run-scheduled-jobs.md_page.Jobs.IBillingIssue: IBilling -> string * string -> Task<bool>invoicecustomer// The systems the jobs call. Each does its work once per key, so a repeated
// call with the same key changes nothing. Both return whether they did it.
public interface IBackupStore
{
Task<bool> Copy(string key, string folder);
}
public interface IBilling
{
Task<bool> Issue(string invoice, string customer);
}
The stand-ins in the samples keep the keys in memory. A real backup store or billing system must keep them where a restart does not lose them.
Write each job as a workflow
// A run's input is a ScheduledRun: the schedule's ID, the due time the run
// serves, and the schedule's input, here the folder to back up.
let backup (store: IBackupStore) =
Workflow.define "backup" (fun (run: ScheduledRun<string>) -> workflow {
// The key holds the run's ID, which holds the due time. It is the same
// for every attempt of one run and differs between runs.
return!
activity "copy" (Retry.times 3) (fun attempt ->
store.Copy(attempt.IdempotencyKey, run.Input))
})
backup: IBackupStore -> WorkflowDefinition<ScheduledRun<string>,bool>store: IBackupStoreFcqrs_550-how-to_009-run-scheduled-jobs.md_page.Jobs.IBackupStoreFCQRS.Workflows.WorkflowDefines workflows.
define: string -> ('Input -> Workflow<'Result>) -> WorkflowDefinition<'Input,'Result>A workflow named `name` whose instances run `body` on their input.
run: ScheduledRun<string>FCQRS.Workflows.ScheduledRun`1The input of a run that a schedule starts. A workflow body cannot read its own ID, so the run gets the ID of its schedule and the due time it serves.
stringAn abbreviation for the CLI type . Basic Types
workflow: WorkflowBuilderBuilds a workflow: `workflow { let! x = activity ... }`.
activity: string -> RetryPolicy -> (AttemptContext -> Task<'T>) -> Workflow<'T>Calls an external system. The call runs at least once: a crash after the call and before its result is stored repeats it, so pass `IdempotencyKey` to the external system. `name` is stored and must not change while instances are running.
FCQRS.Workflows.RetryCommon retry policies.
times: int -> RetryPolicyAt most `attempts` attempts, with backoff from one second to one minute.
attempt: AttemptContextCopy: string * string -> Task<bool>IdempotencyKey: stringThe same for every attempt of this step, across restarts: workflow name, workflow ID, and step path. Pass it to the external system so a repeated call is recognised.
Input: 'InputThe schedule's input.
// A run's input is a ScheduledRun: the schedule's ID, the due time the run
// serves, and the schedule's input, here the folder to back up.
public sealed class BackupWorkflow(IBackupStore store)
: Workflow<ScheduledRun<string>, bool>("backup")
{
// The key holds the run's ID, which holds the due time. It is the same
// for every attempt of one run and differs between runs.
public override async WorkflowTask<bool> Run(
WorkflowContext ctx, ScheduledRun<string> run) =>
await ctx.Activity("copy", Retry.times(3), attempt =>
store.Copy(attempt.IdempotencyKey, run.Input));
}
A workflow that a schedule starts takes a ScheduledRun<'Input> (ScheduledRun<TInput> in C#). It
holds the schedule's ID, the due time the run serves in UTC, and the schedule's input. A workflow
body cannot read its own ID, so this is how a run knows which due time it serves.
Each run is a workflow instance whose ID is the schedule's ID, @, and the due time in UTC in ISO
8601 form. An activity's idempotency key is the workflow's name, the instance's ID, and the step's
position, so the backup due at 02:00 Oslo time on 16 November 2026 gets the key
backup/nightly-backup@2026-11-16T01:00:00.0000000Z/0. The key differs every night. It stays the
same for every attempt of one run and after a restart, so a store that honours it copies the folder
once per run.
The invoice uses a key of its own:
// The time zone of both schedules.
let zone = "Europe/Oslo"
// The invoice number: the customer and the ISO week of the due time in the
// schedule's zone, such as "Ada-2026-W47".
let invoiceNumber (run: ScheduledRun<string>) =
let local =
TimeZoneInfo.ConvertTimeFromUtc(
run.Due, TimeZoneInfo.FindSystemTimeZoneById zone)
let week = ISOWeek.GetWeekOfYear local
$"{run.Input}-{ISOWeek.GetYear local}-W{week:D2}"
let invoice (billing: IBilling) =
Workflow.define "invoice" (fun (run: ScheduledRun<string>) -> workflow {
// Keyed by the week, not by the run: a second run in the same week
// finds the invoice issued.
return!
activity "issue" (Retry.times 3) (fun _ ->
billing.Issue(invoiceNumber run, run.Input))
})
zone: stringinvoiceNumber: ScheduledRun<string> -> stringrun: ScheduledRun<string>FCQRS.Workflows.ScheduledRun`1The input of a run that a schedule starts. A workflow body cannot read its own ID, so the run gets the ID of its schedule and the due time it serves.
stringAn abbreviation for the CLI type . Basic Types
local: DateTimeSystem.TimeZoneInfoRepresents any time zone in the world.
ConvertTimeFromUtc: DateTime * TimeZoneInfo -> DateTimeConverts a Coordinated Universal Time (UTC) to the time in a specified time zone. The Coordinated Universal Time (UTC). The time zone to convert to. The property of is . is . The date and time in the destination time zone. Its property is if is ; otherwise, its property is .
Due: DateTimeThe due time the run serves, in UTC.
FindSystemTimeZoneById: string -> TimeZoneInfoReturns a object based on its identifier. The time zone identifier, which corresponds to the property. The system does not have enough memory to hold information about the time zone. The parameter is . The time zone identifier specified by was not found. This means that a time zone identifier whose name matches does not exist, or that the identifier exists but does not contain any time zone data. The process does not have the permissions required to read from the registry key that contains the time zone information. The time zone identifier was found, but the registry data is corrupted. An object whose identifier is the value of the parameter.
week: intSystem.Globalization.ISOWeekProvides static members to support the ISO week date that is part of the ISO 8601 date and time standard issued by the International Organization for Standardization (ISO).
GetWeekOfYear: DateTime -> intCalculates the ISO week number of a given Gregorian date. A date in the Gregorian calendar. A number between 1 and 53 representing the ISO week number of the given Gregorian date.
invoice: IBilling -> WorkflowDefinition<ScheduledRun<string>,bool>billing: IBillingFcqrs_550-how-to_009-run-scheduled-jobs.md_page.Jobs.IBillingFCQRS.Workflows.WorkflowDefines workflows.
define: string -> ('Input -> Workflow<'Result>) -> WorkflowDefinition<'Input,'Result>A workflow named `name` whose instances run `body` on their input.
workflow: WorkflowBuilderBuilds a workflow: `workflow { let! x = activity ... }`.
activity: string -> RetryPolicy -> (AttemptContext -> Task<'T>) -> Workflow<'T>Calls an external system. The call runs at least once: a crash after the call and before its result is stored repeats it, so pass `IdempotencyKey` to the external system. `name` is stored and must not change while instances are running.
FCQRS.Workflows.RetryCommon retry policies.
times: int -> RetryPolicyAt most `attempts` attempts, with backoff from one second to one minute.
Issue: string * string -> Task<bool>Input: 'InputThe schedule's input.
public static class Jobs
{
// The time zone of both schedules.
public const string Zone = "Europe/Oslo";
// The invoice number: the customer and the ISO week of the due time in the
// schedule's zone, such as "Ada-2026-W47".
public static string InvoiceNumber(ScheduledRun<string> run)
{
var local = TimeZoneInfo.ConvertTimeFromUtc(
run.Due, TimeZoneInfo.FindSystemTimeZoneById(Zone));
var week = ISOWeek.GetWeekOfYear(local);
return $"{run.Input}-{ISOWeek.GetYear(local)}-W{week:D2}";
}
}
public sealed class InvoiceWorkflow(IBilling billing)
: Workflow<ScheduledRun<string>, bool>("invoice")
{
// Keyed by the week, not by the run: a second run in the same week
// finds the invoice issued.
public override async WorkflowTask<bool> Run(
WorkflowContext ctx, ScheduledRun<string> run) =>
await ctx.Activity("issue", Retry.times(3), _ =>
billing.Issue(Jobs.InvoiceNumber(run), run.Input));
}
A due time that runs has one run, and a second start of a run stores nothing, so a key built from
the run would already stop a second invoice for the same due time. The week also covers two runs in
one week. Changing the rule from Monday to Wednesday after Monday's run lets Wednesday's run start,
because a change only stops the due times at or before it. With the week in the key, the billing
system finds the invoice issued. The number is computed from run.Due, the due time the run serves,
not the time it starts.
Register the workflows and the dispatcher
Shared setup
open System
open System.Collections.Concurrent
open Microsoft.Extensions.Configuration
open Microsoft.Extensions.Logging
open FCQRS.Actor
open FCQRS.FSharp
open FCQRS.Workflows
open Jobs
let logging = LoggerFactory.Create(fun _ -> ())
let configuration = ConfigurationBuilder().Build()
let connection = Fcqrs.connect DBType.Sqlite "Data Source=jobs.db"
let api = Fcqrs.actor configuration logging (Some connection) "jobs"
let copies = ConcurrentDictionary<string, string>()
let store =
{ new IBackupStore with
member _.Copy(_, _) = task { return true } }
let billing =
{ new IBilling with
member _.Issue(_, _) = task { return true } }
let waitFor (what: string) (condition: unit -> bool) = ()
SystemCollectionsConcurrentMicrosoftExtensionsConfigurationLoggingFCQRSFCQRS.ActorFCQRS.FSharpIdiomatic-F# functional facade for FCQRS. Gives F# consumers the same one-call ergonomics the C# host-builder (HostExtensions.fs) gives C#, but with F# idioms: records-of-functions for the definitions, typed handles for the results, an explicit wiring pipeline, and plain helpers for saga side effects. It is a *pure addition* that wraps only the existing primitives (IActor.InitializeActor / SagaBuilder.initSimple / Projections.startTracked / InitializeSagaStarter / CreateCommandSubscription / Actor.api) and changes nothing in the C# interop layer or the core. open FCQRS.FSharp let api = Fcqrs.actor config loggerFactory (Some (Fcqrs.connect DBType.Sqlite conn)) "Cluster" let documents = Fcqrs.aggregate api { Name="Document"; Initial=...; Decide=...; Fold=... } let slugs = Fcqrs.aggregate api { Name="Slug"; Initial=...; Decide=...; Fold=... } let publication = Fcqrs.saga api (publicationDef documents.Factory slugs.Factory) Fcqrs.wireSagaStarters api [ publication ] let subs = Fcqrs.projection api (Projection.single 0 updateReadModel) // (Projection.multi when you must control which notifications publish) // send a command and await the matching aggregate reply: let! ev = documents.Send (Fcqrs.newCid()) (Fcqrs.aggregateId id) cmd (fun e -> ...)
WorkflowsFcqrs_550-how-to_009-run-scheduled-jobs.md_page.Jobslogging: ILoggerFactoryMicrosoft.Extensions.Logging.LoggerFactoryProduces instances of classes based on the given providers.
Create: Action<ILoggingBuilder> -> ILoggerFactoryCreates new instance of configured using provided delegate. A delegate to configure the . The that was created.
configuration: IConfigurationRoot``.ctor``: unit -> unitBuild: unit -> IConfigurationRootBuilds an with keys and values from the set of providers registered in . An with keys and values from the registered providers.
connection: ConnectionFCQRS.FSharp.Fcqrsconnect: DBType -> string -> ConnectionBuild a SQLite/etc. Connection from a raw connection string of any length.
FCQRS.Actor.DBTypeRepresents the type of database connection
SqliteSQLite using Microsoft.Data.Sqlite provider
api: FCQRS.Common.IActoractor: IConfiguration -> ILoggerFactory -> Connection option -> string -> FCQRS.Common.IActorCreate the actor system from plain values (cluster name as a string).
SomeThe representation of "Value of type 'T" The input value. An option representing the value.
copies: ConcurrentDictionary<string,string>``.ctor``: unit -> unitInitializes a new instance of the class that is empty, has the default concurrency level, has the default initial capacity, and uses the default comparer for the key type.
stringAn abbreviation for the CLI type . Basic Types
store: IBackupStoreFcqrs_550-how-to_009-run-scheduled-jobs.md_page.Jobs.IBackupStore_: IBackupStoreCopy: string * string -> Threading.Tasks.Task<bool>task: TaskBuilderBuilds a task using computation expression syntax.
billing: IBillingFcqrs_550-how-to_009-run-scheduled-jobs.md_page.Jobs.IBilling_: IBillingIssue: string * string -> Threading.Tasks.Task<bool>waitFor: string -> (unit -> bool) -> unitwhat: stringcondition: unit -> boolunitThe type 'unit', which has only one value "()". This value is special and always uses the representation 'null'. Basic Types
boolAn abbreviation for the CLI type . Basic Types
let backups = Workflows.register api (backup store)
let invoicing = Workflows.register api (invoice billing)
// The dispatcher starts only the workflows in this list.
let schedules =
Schedules.register api [
ScheduledWorkflow.ofHandle backups
ScheduledWorkflow.ofHandle invoicing
]
// A schedule is an aggregate, and an aggregate stores nothing until the saga
// starters are wired. Wire them after the last registration.
Fcqrs.wireSagaStarters api []
backups: WorkflowHandle<ScheduledRun<string>,bool>FCQRS.Workflows.WorkflowsRegisters workflows with an actor system.
register: FCQRS.Common.IActor -> WorkflowDefinition<'Input,'Result> -> WorkflowHandle<'Input,'Result>Registers the workflow and returns its handle. Instances that were running when the process last stopped resume as soon as this is called.
api: FCQRS.Common.IActorbackup: IBackupStore -> WorkflowDefinition<ScheduledRun<string>,bool>store: IBackupStoreinvoicing: WorkflowHandle<ScheduledRun<string>,bool>invoice: IBilling -> WorkflowDefinition<ScheduledRun<string>,bool>billing: IBillingschedules: ScheduleHandleFCQRS.Workflows.SchedulesSchedules that start a registered workflow at each due time. A schedule is created, changed, paused, resumed and deleted while the application runs, and is stored in the journal as an aggregate named "fcqrs-schedule". The run for a due time is a workflow instance whose ID is the schedule's ID, "@", and the due time in UTC in ISO 8601 ("O") form. A run starts at least once per accepted due time, and a repeated start with its ID stores nothing.
register: FCQRS.Common.IActor -> ScheduledWorkflow list -> ScheduleHandleRegisters the schedule aggregate and starts this actor system's dispatcher, which may start the listed workflows. Register each workflow with `Workflows.register` first. Raises `InvalidOperationException` when the actor system has a dispatcher already, and `Akka.Configuration.ConfigurationException` when `akka.fcqrs.schedule-check-interval` is not positive. A schedule whose workflow is not listed starts no run, and the dispatcher logs an error at each of its due times, until a later start lists the workflow again. The schedule aggregate, like every aggregate, waits for the actor system's saga starters before it stores an event, and terminates the process when they are not wired within `akka.fcqrs.saga-start-timeout`. Wire them with `Fcqrs.wireSagaStarters` after registering every aggregate and saga, this one included. Until they are wired the dispatcher fires nothing, and every function of this module that sends a command raises `InvalidOperationException`. The dispatcher keeps its index in the table `fcqrs_schedule_index`, which it creates on first use in the journal's database and schema, with one row per schedule. A start reads the rows and the schedule events stored since, so it does not read the schedules' whole history again. The first start on a journal without the table reads that history once and fills the table. Raises `ArgumentException` when Akka's query journal and write journal differ in connection string, schema, table, or columns, because the table must live in the journal it reads.
FCQRS.Workflows.ScheduledWorkflowModuleMakes a `ScheduledWorkflow`.
ofHandle: WorkflowHandle<ScheduledRun<'Input>,'Result> -> ScheduledWorkflowA workflow registered with `Workflows.register` whose input is a `ScheduledRun`.
FCQRS.FSharp.FcqrswireSagaStarters: FCQRS.Common.IActor -> SagaHandle list -> unitWire every registered saga into one saga-starter (or the empty starter if none). Call after the aggregates + sagas are registered.
var backups = WorkflowHost.Register(actor, new BackupWorkflow(store));
var invoicing = WorkflowHost.Register(actor, new InvoiceWorkflow(billing));
// The dispatcher starts only the workflows in this list.
var schedules = ScheduleHost.Register(
actor,
ScheduleHost.Workflow(backups),
ScheduleHost.Workflow(invoicing));
// A schedule is an aggregate, and an aggregate stores nothing until the saga
// starters are wired. Wire them after the last registration.
ActorWiring.InitSagaStarterEmpty(actor);
The order matters:
Workflows.register(WorkflowHost.Registerin C#) registers each workflow and resumes the runs that were going when the process stopped.Schedules.register(ScheduleHost.Register) takes the workflows' handles, so the workflows are registered first. It registers the schedule aggregate,fcqrs-schedule, and starts the actor system's one dispatcher, which starts runs of the listed workflows only. A second call on the same actor system raisesInvalidOperationException.Fcqrs.wireSagaStarters(ActorWiring.InitSagaStarterEmpty) comes after the last aggregate or saga is registered. Every aggregate, the schedule aggregate included, waits for the saga starters before it stores an event, and terminates the process when they are not wired withinakka.fcqrs.saga-start-timeout. Until they are wired,Schedules.createraisesInvalidOperationExceptionwithout sending anything, and the dispatcher starts no run. A service without sagas passes an empty list.
A C# host built with AddFcqrs wires the saga starters when the host starts.
Run workflows on a schedule shows how to
register the schedules there.
Create the schedules
// A create with the content of the stored schedule stores nothing, so the
// program creates its schedules at every start.
// After downtime, only the latest backup matters: CatchUp.Latest.
Schedules.createLatest schedules "nightly-backup" "backup" "/srv/app/data"
(ScheduleRule.Daily(TimeOnly(2, 0), zone))
|> Async.RunSynchronously
// Every week must be invoiced: CatchUp.All runs each missed Monday.
Schedules.create schedules "weekly-invoice" "invoice" "Ada"
(ScheduleRule.Weekly([ DayOfWeek.Monday ], TimeOnly(8, 0), zone))
CatchUp.All
|> Async.RunSynchronously
FCQRS.Workflows.SchedulesSchedules that start a registered workflow at each due time. A schedule is created, changed, paused, resumed and deleted while the application runs, and is stored in the journal as an aggregate named "fcqrs-schedule". The run for a due time is a workflow instance whose ID is the schedule's ID, "@", and the due time in UTC in ISO 8601 ("O") form. A run starts at least once per accepted due time, and a repeated start with its ID stores nothing.
createLatest: ScheduleHandle -> string -> string -> 'Input -> ScheduleRule -> Async<unit>`create` with `CatchUp.Latest`, the default policy: after downtime, the schedule starts one run, for the most recent missed due time. Raises as `create` does. C#'s `CreateAsync` without a policy calls it.
schedules: ScheduleHandleFCQRS.Workflows.ScheduleRuleWhen a schedule is due, written `ScheduleRule.Daily`, `ScheduleRule.Weekly` or `ScheduleRule.Every`. A rule is stored in the schedule's journal, so its cases and fields are a persistent contract.
DailyEvery day at a local time in a time zone. The zone is a time-zone ID such as "Europe/Oslo".
``.ctor``: int * int -> unitInitializes a new instance of the structure to the specified hour and the minute. The hours (0 through 23). The minutes (0 through 59).
zone: string(|>): 'T1 -> ('T1 -> 'U) -> 'UApply a function to a value, the value being on the left, the function on the right The argument. The function. The function result. let doubleIt x = x * 2 3 |> doubleIt // Evaluates to 6
Microsoft.FSharp.Control.FSharpAsyncHolds static members for creating and manipulating asynchronous computations. See also F# Language Guide - Async Workflows. Async Programming
RunSynchronously: Async<'T> * int option * Threading.CancellationToken option -> 'TRuns the asynchronous computation and await its result. If an exception occurs in the asynchronous computation then an exception is re-raised by this function. If no cancellation token is provided then the default cancellation token is used. The computation is started on the current thread if is null, has of true, and no timeout is specified. Otherwise the computation is started by queueing a new work item in the thread pool, and the current thread is blocked awaiting the completion of the computation. The timeout parameter is given in milliseconds. A value of -1 is equivalent to . The computation to run. The amount of time in milliseconds to wait for the result of the computation before raising a . If no value is provided for timeout then a default of -1 is used to correspond to . The cancellation token to be associated with the computation. If one is not supplied, the default cancellation token is used. The result of the computation. Starting Async Computations printfn "A" let result = async { printfn "B" do! Async.Sleep(1000) printfn "C" 17 } |> Async.RunSynchronously printfn "D" Prints "A", "B" immediately, then "C", "D" in 1 second. result is set to 17.
create: ScheduleHandle -> string -> string -> 'Input -> ScheduleRule -> CatchUp -> Async<unit>Creates the schedule `id`, which starts the workflow named `workflow` with `input` at each due time of `rule`, with `catchUp` deciding which missed due times run (`createLatest` uses the default, `CatchUp.Latest`). Returns once the schedule is stored. Raises `ArgumentException` for a rule that `ScheduleRule.validate` rejects, a missing rule or policy, or an ID that is blank, contains '~', or is too long (see `MaxIdLength`). Raises `InvalidOperationException` when the saga starters are not wired, when the workflow is not registered with the dispatcher or takes another input, and when the schedule refuses: a schedule with this ID exists with a different content, or was deleted. A repeat with the same content is accepted and stores nothing; a weekly rule's days are sorted and their repeats removed before the comparison.
WeeklyOn the listed days of the week at a local time in a time zone.
System.DayOfWeekSpecifies the day of the week.
Monday: DayOfWeekIndicates Monday.
FCQRS.Workflows.CatchUpWhat a schedule does about due times that passed while nothing could start a run. The policy is stored in the schedule's journal, so its cases are a persistent contract.
AllStart one run for every missed due time, oldest first. At most the 1000 most recent are started; older ones are dropped.
// A create with the content of the stored schedule stores nothing, so the
// program creates its schedules at every start.
// After downtime, only the latest backup matters: CatchUp.Latest.
await schedules.CreateAsync("nightly-backup", "backup", "/srv/app/data",
ScheduleRule.NewDaily(new TimeOnly(2, 0), Zone));
// Every week must be invoiced: CatchUp.All runs each missed Monday.
await schedules.CreateAsync("weekly-invoice", "invoice", "Ada",
ScheduleRule.NewWeekly([DayOfWeek.Monday], new TimeOnly(8, 0), Zone),
CatchUp.All);
ScheduleRule.Daily(TimeOnly(2, 0), zone) is due every day at 02:00 local time in the zone
Europe/Oslo. ScheduleRule.Weekly([ DayOfWeek.Monday ], TimeOnly(8, 0), zone) is due on Mondays
at 08:00. The zone is a time-zone ID, and create raises ArgumentException for one the system
cannot read, before anything is sent. FCQRS computes each due time in the zone and stores it in UTC,
so the backup runs at 01:00 UTC in winter and at 00:00 UTC in summer.
02:00 is the hour in which Oslo changes its clocks. On 28 March 2027 the clocks move from 02:00 to 03:00, so 02:00 does not exist, and that night's backup runs at 03:00, the next local time that does. On 25 October 2026 the clocks move back from 03:00 to 02:00, so 02:00 occurs twice, and the backup runs once, at the first occurrence. A time outside the hour from 02:00 to 03:00 avoids both cases in this zone.
The two schedules differ in their catch-up policy, which decides what runs for the due times that
passed while the service was stopped. A missed backup is replaced by the next one, so the backup
uses Schedules.createLatest, whose policy CatchUp.Latest runs only the most recent. Every week
must be invoiced, so the invoice uses Schedules.create with CatchUp.All (in C#, the
CreateAsync overload that takes a policy), which runs each missed Monday, oldest first. After the
machine was off shows both.
A create returns once the schedule is stored. A second create with the same ID and the same content
stores nothing, so the program creates both schedules at every start. A create with the same ID and
a different content raises InvalidOperationException, and so does a create whose workflow is not
in the dispatcher's list or takes another input type.
A create is compared with the content the schedule was first created with, also after a change. So
editing the rule in the create call, for example to move the backup to 03:00, makes the next
start's create raise. Move a stored schedule with Schedules.change (ChangeAsync in C#), which
replaces its rule, input, workflow, and catch-up policy. A change to the content the schedule
already has stores nothing, so the program can keep the create as it was and call change with the
new rule at every start.
Read the next due time
// The index reads the schedules through a projection, so a schedule appears
// in it shortly after its create returns. NextDue is in UTC.
let nextRun (id: string) =
waitFor id (fun () -> (Schedules.state schedules id).IsSome)
let next = (Schedules.state schedules id).Value.NextDue.Value
TimeZoneInfo.ConvertTimeFromUtc(
next, TimeZoneInfo.FindSystemTimeZoneById zone)
let backupAt = nextRun "nightly-backup"
printfn "nightly-backup: next run at %s %s time"
(backupAt.ToString "HH:mm") zone
let invoiceAt = nextRun "weekly-invoice"
printfn "weekly-invoice: next run on %O at %s %s time"
invoiceAt.DayOfWeek (invoiceAt.ToString "HH:mm") zone
nextRun: string -> DateTimeid: stringstringAn abbreviation for the CLI type . Basic Types
waitFor: string -> (unit -> bool) -> unitIsSome: boolReturn 'true' if the option is a 'Some' value.
FCQRS.Workflows.SchedulesSchedules that start a registered workflow at each due time. A schedule is created, changed, paused, resumed and deleted while the application runs, and is stored in the journal as an aggregate named "fcqrs-schedule". The run for a due time is a workflow instance whose ID is the schedule's ID, "@", and the due time in UTC in ISO 8601 ("O") form. A run starts at least once per accepted due time, and a repeated start with its ID stores nothing.
state: ScheduleHandle -> string -> ScheduleView optionThe schedule as the dispatcher's index knows it, or nothing when the index has not read it. A deleted schedule leaves the index once no run decided before the delete waits to start. The index lags the schedule by one projection pass, so a change that just returned may not show yet.
schedules: ScheduleHandlenext: DateTimeValue: DateTimeGet the value of a 'Some' option. A NullReferenceException is raised if the option is 'None'.
Value: ScheduleViewGet the value of a 'Some' option. A NullReferenceException is raised if the option is 'None'.
NextDue: DateTime optionThe due time the schedule accepts next, or nothing while it is paused or deleted.
System.TimeZoneInfoRepresents any time zone in the world.
ConvertTimeFromUtc: DateTime * TimeZoneInfo -> DateTimeConverts a Coordinated Universal Time (UTC) to the time in a specified time zone. The Coordinated Universal Time (UTC). The time zone to convert to. The property of is . is . The date and time in the destination time zone. Its property is if is ; otherwise, its property is .
FindSystemTimeZoneById: string -> TimeZoneInfoReturns a object based on its identifier. The time zone identifier, which corresponds to the property. The system does not have enough memory to hold information about the time zone. The parameter is . The time zone identifier specified by was not found. This means that a time zone identifier whose name matches does not exist, or that the identifier exists but does not contain any time zone data. The process does not have the permissions required to read from the registry key that contains the time zone information. The time zone identifier was found, but the registry data is corrupted. An object whose identifier is the value of the parameter.
zone: stringbackupAt: DateTimeprintfn: Printf.TextWriterFormat<'T> -> 'TPrint to stdout using the given format, and add a newline. The formatter. The formatted result. See Printf.printfn (link: ) for examples.
ToString: string -> stringConverts the value of the current object to its equivalent string representation using the specified format and the formatting conventions of the current culture. A standard or custom date and time format string. The length of is 1, and it is not one of the format specifier characters defined for . -or- does not contain a valid custom format pattern. The date and time is outside the range of dates supported by the calendar used by the current culture. A string representation of value of the current object as specified by .
invoiceAt: DateTimeDayOfWeek: DayOfWeekGets the day of the week represented by this instance. An enumerated constant that indicates the day of the week of this value.
// The index reads the schedules through a projection, so a schedule appears
// in it shortly after its create returns. NextDue is in UTC.
async Task<DateTime> NextRun(string id)
{
await WaitFor(id, () => schedules.State(id) is not null);
var next = schedules.State(id)!.NextDue!.Value;
return TimeZoneInfo.ConvertTimeFromUtc(
next, TimeZoneInfo.FindSystemTimeZoneById(Zone));
}
var backupAt = await NextRun("nightly-backup");
Console.WriteLine($"nightly-backup: next run at {backupAt:HH:mm} {Zone} time");
var invoiceAt = await NextRun("weekly-invoice");
Console.WriteLine($"weekly-invoice: next run on {invoiceAt.DayOfWeek}"
+ $" at {invoiceAt:HH:mm} {Zone} time");
Schedules.state (State in C#) reads the dispatcher's index, not the schedule. The index follows
the schedules' events through a projection, so a schedule that was just created can be missing for
a moment, and the program waits for it. NextDue is the due time the schedule accepts next, in UTC,
or nothing while the schedule is paused or deleted.
Watch runs start
A nightly schedule starts nothing while the program runs for a few seconds. The program therefore also creates a demo schedule that starts the backup workflow every two seconds:
// A deleted schedule's ID cannot be used again, so each start creates a demo
// schedule with a new ID. CatchUp.All runs every due time, even one whose
// run starts late.
let demo = $"demo-{Guid.NewGuid():N}"
printfn "demo: a backup every 2 seconds"
Schedules.create schedules demo "backup" "demo-data"
(ScheduleRule.Every(TimeSpan.FromSeconds 2.0)) CatchUp.All
|> Async.RunSynchronously
let runs () =
copies.Keys |> Seq.filter (fun key -> key.Contains demo) |> Seq.length
waitFor "three demo runs" (fun () -> runs () >= 3)
// An Every rule counts from the creation.
let created = (Schedules.state schedules demo).Value.Anchor
Schedules.delete schedules demo |> Async.RunSynchronously
printfn "demo: deleted"
// A run's workflow ID is the schedule's ID, "@", and the due time in UTC.
for run in 1..3 do
let due = created.AddSeconds(2.0 * float run)
let id = $"""{demo}@{due.ToString "O"}"""
let outcome =
backups.Result id (TimeSpan.FromSeconds 10.0) |> Async.RunSynchronously
let copied =
match outcome with
| WorkflowOutcome.Finished copied -> copied
| other -> failwith $"{id} ended with {other}"
let result = if copied then "copied" else "already copied"
printfn $"demo: run {run}, {2 * run} s after creation: {result}"
// A run decided before the delete still starts. The schedule leaves the index
// once no such run is waiting.
waitFor "the demo schedule to leave the index" (fun () ->
(Schedules.state schedules demo).IsNone)
demo: stringprintfn: Printf.TextWriterFormat<'T> -> 'TPrint to stdout using the given format, and add a newline. The formatter. The formatted result. See Printf.printfn (link: ) for examples.
FCQRS.Workflows.SchedulesSchedules that start a registered workflow at each due time. A schedule is created, changed, paused, resumed and deleted while the application runs, and is stored in the journal as an aggregate named "fcqrs-schedule". The run for a due time is a workflow instance whose ID is the schedule's ID, "@", and the due time in UTC in ISO 8601 ("O") form. A run starts at least once per accepted due time, and a repeated start with its ID stores nothing.
create: ScheduleHandle -> string -> string -> 'Input -> ScheduleRule -> CatchUp -> Async<unit>Creates the schedule `id`, which starts the workflow named `workflow` with `input` at each due time of `rule`, with `catchUp` deciding which missed due times run (`createLatest` uses the default, `CatchUp.Latest`). Returns once the schedule is stored. Raises `ArgumentException` for a rule that `ScheduleRule.validate` rejects, a missing rule or policy, or an ID that is blank, contains '~', or is too long (see `MaxIdLength`). Raises `InvalidOperationException` when the saga starters are not wired, when the workflow is not registered with the dispatcher or takes another input, and when the schedule refuses: a schedule with this ID exists with a different content, or was deleted. A repeat with the same content is accepted and stores nothing; a weekly rule's days are sorted and their repeats removed before the comparison.
schedules: ScheduleHandleFCQRS.Workflows.ScheduleRuleWhen a schedule is due, written `ScheduleRule.Daily`, `ScheduleRule.Weekly` or `ScheduleRule.Every`. A rule is stored in the schedule's journal, so its cases and fields are a persistent contract.
EveryEvery interval, counted from the schedule's creation or last change.
System.TimeSpanRepresents a time interval.
FromSeconds: float -> TimeSpanReturns a that represents a specified number of seconds, where the specification is accurate to the nearest millisecond. A number of seconds, accurate to the nearest millisecond. is less than TimeSpan.MinValue or greater than TimeSpan.MaxValue. -or- is . -or- is . is equal to . An object that represents .
FCQRS.Workflows.CatchUpWhat a schedule does about due times that passed while nothing could start a run. The policy is stored in the schedule's journal, so its cases are a persistent contract.
AllStart one run for every missed due time, oldest first. At most the 1000 most recent are started; older ones are dropped.
(|>): 'T1 -> ('T1 -> 'U) -> 'UApply a function to a value, the value being on the left, the function on the right The argument. The function. The function result. let doubleIt x = x * 2 3 |> doubleIt // Evaluates to 6
Microsoft.FSharp.Control.FSharpAsyncHolds static members for creating and manipulating asynchronous computations. See also F# Language Guide - Async Workflows. Async Programming
RunSynchronously: Async<'T> * int option * Threading.CancellationToken option -> 'TRuns the asynchronous computation and await its result. If an exception occurs in the asynchronous computation then an exception is re-raised by this function. If no cancellation token is provided then the default cancellation token is used. The computation is started on the current thread if is null, has of true, and no timeout is specified. Otherwise the computation is started by queueing a new work item in the thread pool, and the current thread is blocked awaiting the completion of the computation. The timeout parameter is given in milliseconds. A value of -1 is equivalent to . The computation to run. The amount of time in milliseconds to wait for the result of the computation before raising a . If no value is provided for timeout then a default of -1 is used to correspond to . The cancellation token to be associated with the computation. If one is not supplied, the default cancellation token is used. The result of the computation. Starting Async Computations printfn "A" let result = async { printfn "B" do! Async.Sleep(1000) printfn "C" 17 } |> Async.RunSynchronously printfn "D" Prints "A", "B" immediately, then "C", "D" in 1 second. result is set to 17.
runs: unit -> intcopies: ConcurrentDictionary<string,string>Keys: Collections.Generic.ICollection<string>Gets a collection containing the keys in the . A collection of keys in the .
Microsoft.FSharp.Collections.SeqModuleContains operations for working with values of type .
filter: ('T -> bool) -> 'T seq -> 'T seqReturns a new collection containing only the elements of the collection for which the given predicate returns "true". This is a synonym for Seq.where. The returned sequence may be passed between threads safely. However, individual IEnumerator values generated from the returned sequence should not be accessed concurrently. Remember sequence is lazy, effects are delayed until it is enumerated. A function to test whether each item in the input sequence should be included in the output. The input sequence. The result sequence. Thrown when the input sequence is null. let inputs = [1; 2; 3; 4] inputs |> Seq.filter (fun elm -> elm % 2 = 0) Evaluates to a sequence yielding the same results as seq { 2; 4 }
key: stringContains: string -> boollength: 'T seq -> intReturns the length of the sequence The input sequence. The length of the sequence. Thrown when the input sequence is null. let inputs = ["a"; "b"; "c"] inputs |> Seq.length Evaluates to 3
waitFor: string -> (unit -> bool) -> unit(>=): 'T -> 'T -> boolStructural greater-than-or-equal The first parameter. The second parameter. The result of the comparison. 5 >= 1 // Evaluates to true 5 >= 5 // Evaluates to true [1; 5] >= [1; 6] // Evaluates to false
created: DateTimeAnchor: DateTimeThe creation or last change, which an `Every` rule counts from.
state: ScheduleHandle -> string -> ScheduleView optionThe schedule as the dispatcher's index knows it, or nothing when the index has not read it. A deleted schedule leaves the index once no run decided before the delete waits to start. The index lags the schedule by one projection pass, so a change that just returned may not show yet.
Value: ScheduleViewGet the value of a 'Some' option. A NullReferenceException is raised if the option is 'None'.
delete: ScheduleHandle -> string -> Async<unit>Ends the schedule. A run decided before the delete still starts. The ID cannot be used for a new schedule. Raises `InvalidOperationException` when the schedule does not exist.
run: int32(..): ^T -> ^T -> ^T seqThe standard overloaded range operator, e.g. [n..m] for lists, seq {n..m} for sequences The start value of the range. The end value of the range. The sequence spanning the range. [1..4] // Evaluates to [1; 2; 3; 4] [1.5..4.4] // Evaluates to [1.5; 2.5; 3.5] ['a'..'d'] // Evaluates to ['a'; 'b'; 'c'; 'd'] [|1..4|] // Evaluates to an array [|1; 2; 3; 4|] { 1..4 } // Evaluates to a sequence [1; 2; 3; 4])
due: DateTimeAddSeconds: float -> DateTimeReturns a new that adds the specified number of seconds to the value of this instance. A number of whole and fractional seconds. The parameter can be negative or positive. The resulting is less than DateTime.MinValue or greater than DateTime.MaxValue. An object whose value is the sum of the date and time represented by this instance and the number of seconds represented by .
(*): ^T1 -> ^T2 -> ^T3Overloaded multiplication operator The first parameter. The second parameter. The result of the operation. 8 * 6 // Evaluates to 48
float: ^T -> floatConverts the argument to 64-bit float. This is a direct conversion for all primitive numeric types. For strings, the input is converted using Double.Parse() with InvariantCulture settings. Otherwise the operation requires an appropriate static conversion method on the input type. The input value. The converted float float 'A' // evaluates to 65.0 float 0xff // evaluates to 255.0 float -10 // evaluates to -10.0
id: stringoutcome: WorkflowOutcome<bool>backups: WorkflowHandle<ScheduledRun<string>,bool>Result: string -> TimeSpan -> Async<WorkflowOutcome<'Result>>Waits up to the given time for the instance to end. The wait is in memory: the workflow continues when the caller stops waiting. Raises `TimeoutException` when the time passes, and `InvalidOperationException` when no workflow with this ID was started.
copied: boolFCQRS.Workflows.WorkflowOutcome`1How a workflow ended.
FinishedThe body returned this value.
other: WorkflowOutcome<bool>failwith: string -> 'TThrow a exception. The exception message. Never returns. let failingFunction() = failwith "Oh no" // Throws an exception true // Never reaches this failingFunction() // Throws a System.Exception
result: stringIsNone: boolReturn 'true' if the option is a 'None' value.
// A deleted schedule's ID cannot be used again, so each start creates a demo
// schedule with a new ID. CatchUp.All runs every due time, even one whose
// run starts late.
var demo = $"demo-{Guid.NewGuid():N}";
Console.WriteLine("demo: a backup every 2 seconds");
await schedules.CreateAsync(demo, "backup", "demo-data",
ScheduleRule.NewEvery(TimeSpan.FromSeconds(2)), CatchUp.All);
int Runs() => store.Copies.Keys.Count(key => key.Contains(demo));
await WaitFor("three demo runs", () => Runs() >= 3);
// An Every rule counts from the creation.
var created = schedules.State(demo)!.Anchor;
await schedules.DeleteAsync(demo);
Console.WriteLine("demo: deleted");
// A run's workflow ID is the schedule's ID, "@", and the due time in UTC.
for (var run = 1; run <= 3; run++)
{
var due = created.AddSeconds(2 * run);
var id = $"{demo}@{due:O}";
// Throws when the run faulted or was cancelled.
var copied = await backups.ResultAsync(id, TimeSpan.FromSeconds(10));
var result = copied ? "copied" : "already copied";
Console.WriteLine($"demo: run {run}, {2 * run} s after creation: {result}");
}
// A run decided before the delete still starts. The schedule leaves the index
// once no such run is waiting.
await WaitFor("the demo schedule to leave the index",
() => schedules.State(demo) is null);
Run it:
dotnet run --project samples/schedules/fsharp -c Release
dotnet run --project samples/schedules/csharp -c Release
Both programs print the same lines.
nightly-backup: next run at 02:00 Europe/Oslo time
weekly-invoice: next run on Monday at 08:00 Europe/Oslo time
demo: a backup every 2 seconds
copied demo-data
copied demo-data
copied demo-data
demo: deleted
demo: run 1, 2 s after creation: copied
demo: run 2, 4 s after creation: copied
demo: run 3, 6 s after creation: copied
demo: no run is waiting
ScheduleRule.Everycounts from the schedule's creation, theAnchorin its state, so the due times are 2, 4, and 6 seconds after it.Schedules.createtakes the catch-up policy as its last argument. The demo passesCatchUp.All, so a run that starts late does not make the dispatcher skip a due time.backups.Result(ResultAsync) reads each run's result by its ID, built from the schedule's ID and the due time.Schedules.deleteends the schedule. A run that the schedule decided before the delete still starts, and the schedule leaves the index once no such run waits. A deleted schedule's ID cannot be used for a new schedule, so the program gives its demo schedule a new ID at each start.
Run the program again. It starts on the first run's journal: both creates find the same content and store nothing, and the next due times are unchanged. It prints the same lines unless 02:00 Oslo time or Monday 08:00 passed between the two runs. Then the run for that due time starts when the program starts, and its line appears too, as After the machine was off describes.
After the machine was off
Nothing starts a run while the process is stopped. When it starts again, the dispatcher reads its index and the schedule events stored since the last stop, and starts no run before it has read both. Then the catch-up policy decides which of the due times that passed get a run:
CatchUp.Latest, whichcreateLatestuses, starts one run, for the most recent. A machine that was off for three nights makes one backup, for the last night's 02:00. The earlier nights get no backup, which is what a backup needs: the latest copy replaces them.CatchUp.All, which the invoice uses, starts one run for each, oldest first, but at most for the 1000 most recent. A machine that was off across two Mondays starts two invoice runs when it comes back. Each run'srun.Dueis its own Monday 08:00, so each gets that week's invoice number, and the billing system issues one invoice per week. WithCatchUp.Latest, the earlier week would never be invoiced.CatchUp.Nonestarts none and waits for the next due time. A run asked for up to one minute after its due time still counts as on time.
The schedule decides each run, and stores the decision, before the run starts. A crash between the decision and the start leaves a decided run, which the dispatcher starts after the restart with the same ID; a second start of a run stores nothing. A run therefore starts at least once for each due time the schedule accepts, and one workflow instance serves it.
What replaces the workflow server
The schedules, the dispatcher, the workflows, and their runs are in the service's process. What they
store is in jobs.db:
- Akka.NET's
journaltable holds the schedules' events and each run's steps and results, beside itssnapshotandtagstables. fcqrs_schedule_indexholds the dispatcher's index, one row per schedule. The projectionfcqrs-schedule-indexwrites each row in the transaction that stores its read position.fcqrs_projection_progressandfcqrs_projection_progress_lockhold that projection's read positions.
The dispatcher creates the last three tables on first use, so the database user needs the rights
that the Schedules reference lists. A start reads one row per schedule and
the schedule events stored since the last stop, not the history of every run. Between due times the
dispatcher sleeps until the earliest one. While fewer than 16 starts are running, it wakes at least
every akka.fcqrs.schedule-check-interval, one minute by default, so a changed clock delays a run
by at most that long. Configure the database moves the journal to
PostgreSQL.
The service now takes over what the server did: it must be running for anything to start. Run it as the machine runs its other services, such as a systemd unit or a Windows service, so it starts with the machine. The catch-up policy decides what runs for the time it was off.
What a schedule does not do
- A monthly rule.
ScheduleRulehasDaily,Weekly, andEvery. For a job on the first of each month, use a daily schedule whose run reads the local date ofrun.Dueand ends without work on other days. The date comes from the run's input, so the body stays deterministic. Each day's run is still a stored workflow instance. A job that can run on a fixed weekday fits a weekly rule instead. - More than one node. The dispatcher assumes it is the only one, and FCQRS runs on one node. Run one copy of the service.
- Exactly-once work. A run starts at least once for each due time the schedule accepts, and its activities run at least once: a step that was running when the process stopped runs again, with the same key. The backup store and the billing system must honour their keys.
- Precision below a second. A run starts after its due time, once the dispatcher has woken, the schedule has stored its decision, and the run's start is stored.
- A quick first start after upgrading from FCQRS.Workflows 7.6.0. A journal written by 7.6.0 has
no
fcqrs_schedule_index. The first start on 7.7.0 reads every stored schedule event once to build it, two events per run, and starts no run until it has finished. On SQLite this took about 0.5 ms per event. Later starts read only the new events. The Schedules reference has the details.
What this recipe leaves to you
- The stand-in services keep their keys in memory. Real ones must store them.
- Treat these as persistent contracts, which a refactoring must not change:
- the workflow names
backupandinvoice, which every schedule stores and which the backup key begins with, so a renamed workflow gives every run a new key; - the step names
copyandissue, which each run's journal stores; - the input type
ScheduledRun<string>and the schedule inputs, which every schedule and run stores. A decided run whose stored input no longer reads as the input type is lost: the dispatcher logs one error and the schedule goes on; - the invoice-number format and its zone
Europe/Oslo, because the billing system recognises a repeated invoice by that number: a new format issues a second invoice for a week already billed; - the schedule IDs and the run IDs built from them.
- the workflow names
- A run whose activity fails three times faults and stays faulted. The schedule goes on with the next due time. Watch for faulted runs, and decide whether a missed backup or invoice needs a person.
- A run that is still going at the next due time runs alongside the next one.
Next: Run workflows on a schedule covers pausing, resuming, and changing schedules, and what the dispatcher's index guarantees. The Schedules reference lists the exceptions each call raises, the ID limits, the dispatcher's timing, and the index table.