Skip to content

Async memos

createAsync returns an AsyncMemo<'T>. Its first read starts an async request, called a flight. The memo stays pending until the task completes. A tracked input change starts another flight on the next read.

Peek returns the last settled value without starting a flight. The compute function takes Previous<'T> and a CancellationToken, and returns Task<'T>.

createAsync is pure; createAsyncWith owns the nodes and cleanups created before its first suspending await. Their scopes follow the same rules as pure and owning memos.

Examples using tasks import the .NET types and construct a graph with a manual dispatcher:

open System
open System.Threading
open System.Threading.Tasks
open Ranvier

let newGraph () =
    new Graph ({ GraphOptions.Default with Dispatcher = Some (ManualDispatcher () :> IGraphDispatcher) })

When a completion arrives from another thread, pump the graph on its owning thread. See Threading and dispatch and the pumpUntil helper in Testing async state.

The body runs synchronously up to its first await that suspends. Only the reactive reads made in that part are tracked. An await on a task that has already completed does not suspend, so a read after it is still tracked.

let flightGraph = newGraph ()
let userId = flightGraph.Run (fun () -> createSignal 1)
let response = TaskCompletionSource<string> ()

let profile =
    flightGraph.Run (fun () ->
        createAsync (fun _ _ ->
            let id = userId.Value // tracked: read before the first await

            task {
                let! body = response.Task
                return $"user %d{id}: %s{body}"
            }))

profile.TryValue
Pending

Completing the task settles the memo:

response.SetResult "Ada"
pumpUntil flightGraph (fun () -> not (profile.Status.HasFlag Status.Pending)) (TimeSpan.FromSeconds 5.)
profile.TryValue
Ready "user 1: Ada"
The pumpUntil helper

pumpUntil is this page's helper: it calls graph.Pump () until the condition holds.

let pumpUntil (g: Graph) (cond: unit -> bool) (timeout: TimeSpan) =
    let sw = Diagnostics.Stopwatch.StartNew ()

    while not (cond ()) do
        if sw.Elapsed > timeout then
            failwithf "condition not met within %O" timeout

        g.Pump () |> ignore
        Threading.Thread.Sleep 1

Threading and dispatch explains when a pump is required.

Creating nodes in an async continuation

The same boundary applies to creation. createAsync's purity check and createAsyncWith's scope cover the body up to its first await that suspends. A node created in a continuation, onCleanup included, attaches to the owner current when the continuation runs, usually the graph's root, and lives until that owner is disposed.

Failures and cancellation during a flight

A body that throws before its first await, and a flight whose task faults, both settle the memo as Failed with the original exception. A flight whose task is cancelled by its own IO, such as an HttpClient timeout, settles as Failed with the OperationCanceledException. A cancellation of a superseded flight publishes nothing.

let failGraph = newGraph ()

let refused =
    failGraph.Run (fun () -> createAsync (fun _ _ -> failwith "no connection" : Task<int>))

let faulting = TaskCompletionSource<int> ()
let faulted = failGraph.Run (fun () -> createAsync (fun _ _ -> faulting.Task))
faulted.TryValue |> ignore
faulting.SetException (TimeoutException "timed out")
pumpUntil failGraph (fun () -> not (faulted.Status.HasFlag Status.Pending)) (TimeSpan.FromSeconds 5.)

let show (reading: Reading<'T>) =
    match reading with
    | Ready value -> sprintf "Ready %A" value
    | Pending -> "Pending"
    | Failed error -> sprintf "Failed %s: %s" (error.GetType().Name) error.Message

show refused.TryValue, show faulted.TryValue
("Failed Exception: no connection", "Failed TimeoutException: timed out")

Flight policy

GraphOptions.FlightPolicy sets what a change does while a flight is in progress:

PolicyNew flight at onceOld token cancelledSuperseded result discardedEvery result applied
CancelPrevious (default)YesYesYesNo
KeepLatestYesNoYesNo
QueueYesNoNoYes, in the order the flights started
FinishCurrentNo, one trailing flight after it settlesNoNo flight is supersededNo

CancelPrevious and KeepLatest publish the same values. They differ only in whether the superseded flight's CancellationToken is cancelled. Pass the token to the IO the flight performs, so that CancelPrevious stops the superseded IO.

Compare this queued replay with the default-policy map below. Three flights start; the newest answer waits until both older answers can be applied in order.

Starting flights and observing exceptions

CancelPrevious, KeepLatest and Queue start a new flight as soon as a changed memo is read, and decide only the fate of the flights already in progress. The memo observes every flight's exception, including a superseded flight's and one raised after the memo or its graph was disposed, so TaskScheduler.UnobservedTaskException receives none of them.

When to use FinishCurrent

FinishCurrent lets the flight in progress finish. A change during it runs no body and starts no flight, and the memo stays pending. When the flight settles, the memo runs once more against the current inputs, however many changes arrived: its value becomes the Peek value and the trailing run's previous value, and a failure is discarded. The trailing run starts at the memo's next read, so a memo read by an effect starts it as soon as the flight settles. A flight with no change during it applies as under KeepLatest. Use it for work that must not be abandoned halfway, such as a save, where the settled value must still match the latest inputs.

Compare policies with other .NET libraries

The same policies under the names other .NET libraries use. A name appears only where its behaviour matches exactly:

RanvierR3 AwaitOperationSignalsDotnet ConcurrentChangeStrategy
CancelPreviousSwitchCancelCurrent
KeepLatestnone (Switch without the cancellation)none
QueueSequentialParallelnone
FinishCurrentnonenone

FinishCurrent is closest to SignalsDotnet's ScheduleNext and R3's ThrottleFirstLast. Both publish the finished run's result; FinishCurrent keeps the memo pending until the trailing run settles, so a published value always belongs to the current inputs.

Ranvier does not implement these policies:

PolicyR3 AwaitOperationSignalsDotnet ConcurrentChangeStrategy
Run one flight at a time, queue every changeSequentialnone
Run once more after the current run and publish both resultsnoneScheduleNext
Ignore changes while a flight runsDropnone
Run every flight, apply results in completion orderParallelnone
Run the first and the last changeThrottleFirstLastnone

CommunityToolkit's AsyncRelayCommand has no async-memo counterpart. AllowConcurrentExecutions is a gate on CanExecute for a command with no result: false reports the command as not executable while it runs, and true lets executions overlap. An async memo has no CanExecute: every change starts a new flight, or under FinishCurrent a trailing one. For commands, C# has ReactiveCommand, whose CommandPolicy.Disable matches AllowConcurrentExecutions = false; see C#. Debounce and throttle are not implemented either, as a policy or as a combinator.

Cancellation costs

Cancellation costs one CancellationTokenSource per flight under CancelPrevious: each launch cancels and disposes the superseded flight's source and allocates the next. Under KeepLatest and Queue, overlapping flights share one source. Under FinishCurrent flights never overlap, and each flight allocates one source, disposed without being cancelled when the flight settles. A change during a flight allocates nothing. It is disposed, without being cancelled, once every flight that holds it has settled, and the next flight allocates another; disposing the memo cancels it. A body that ignores its token pays that allocation and a Cancel with no registered callbacks. A body that registers on the token, as HttpClient does, also pays for the callbacks the Cancel runs. The token belongs to the async memo alone: signals, memos and effects carry none, and their reads take no token.

Token registrations and lifetime

A registration on the token lives as long as its source. use _ = token.Register ..., and every API that takes the token and completes (Task.Delay, HttpClient, SemaphoreSlim.WaitAsync, cancellableTask binds), releases its registration when the operation ends. A registration left undisposed is released with the source: under CancelPrevious at the next launch, under FinishCurrent when its flight settles, under KeepLatest and Queue once the flights sharing the source have all settled, so a stream of overlapping flights keeps each one's registration until the stream goes quiet. A flight that completes only on cancellation stays in progress under KeepLatest and Queue until the memo is disposed, together with everything its task and registrations hold; use CancelPrevious for such bodies. Work that outlives its flight and keeps the token sees a disposed source once the source is released: it is not cancelled when the memo is disposed, and token.WaitHandle throws ObjectDisposedException. FlightPolicyBenchmarks in the suspension bench compares a launch under CancelPrevious with one under KeepLatest, and FlightBenchmarks compares every policy, with and without changes during a flight.

Try the default policy in the map. Desk keeps each request pending until you answer it:

  • Press Next user twice to supersede a flight.
  • Press Answer to settle the newest flight, or Fail to fail it.
  • Use the timeline to step through the events.
Reading queued outcomes while later flights run

Under Queue each outcome is readable as soon as it is applied, while later flights are still in progress: a new run makes the memo pending until the next outcome is applied. A body that throws before its first await fails in its turn, after the flights started before it. While the newest run waits on a pending source, an earlier flight's value becomes the Peek value and the memo stays pending.

The previous value

The body's first argument is a Previous<'T>: a handle on the value the memo last published. Its one member, Settled, is a Task<'T voption> that completes with ValueNone before the first value. As on a memo, a run that suspends on a pending source or fails leaves the previous value unchanged.

Previous values under each flight policy

Read every input, then await previous.Settled. Under CancelPrevious, KeepLatest and FinishCurrent, Settled is complete when the body runs. Under the first two, only the newest flight's result becomes a previous value; under FinishCurrent, a trailing run receives the value of the flight it waited for, or the value before it when that flight failed. Under Queue, Settled completes when the flight started before this one is applied, so the flights fold in start order. An await on it suspends, and reads after it are untracked. If the earlier flight fails or is dropped, Settled returns the value published before it. Disposing the memo completes a pending Settled with the value last published.

Cost of reading Settled

Settled creates its task when first read: one completed task, shared by every read until the memo next publishes, or under Queue, one pending task for a flight that waits on an earlier one. The async scenarios in the counter bench run within 0.1 % of their earlier instruction counts, with the same allocation.

Each page below appends to the list the previous flight produced. The second page answers first, and the second flight still waits for the first:

Test your understanding

The second page completes first. Which lists does the effect see, and in what order?

let queueGraph =
    new Graph (
        { GraphOptions.Default with
            Dispatcher = Some (ManualDispatcher () :> IGraphDispatcher)
            FlightPolicy = FlightPolicy.Queue }
    )

let page = queueGraph.Run (fun () -> createSignal 1)
let replies = Array.init 2 (fun _ -> TaskCompletionSource<string list> ())

let feed =
    queueGraph.Run (fun () ->
        createAsync (fun previous _ ->
            let n = page.Value // tracked: read before the first await

            task {
                let! items = replies[n - 1].Task
                let! earlier = previous.Settled
                return ValueOption.defaultValue [] earlier @ items
            }))

let pages = ResizeArray<string list> ()
queueGraph.Run (fun () -> createEffect (fun () -> pages.Add feed.Value))

page.Value <- 2
replies[1].SetResult [ "c"; "d" ]
replies[0].SetResult [ "a"; "b" ]
pumpUntil queueGraph (fun () -> pages.Count = 2) (TimeSpan.FromSeconds 5.)
List.ofSeq pages
Answer
[["a"; "b"]; ["a"; "b"; "c"; "d"]]

Bodies written with cancellableTask

On .NET, the cancellableTask builder from IcedTasks builds a CancellationToken -> Task<'T>: write the body as fun previous -> cancellableTask { ... }, or apply it to the token as below. Its let! and do! pass the flight's token to any CancellationToken -> Task they bind, and each bind throws once the token is cancelled. Under CancelPrevious, a superseded flight stops at its next bind and settles as cancelled, so it publishes nothing.

open IcedTasks

let http = new Net.Http.HttpClient (BaseAddress = Uri "https://example.com")

let fetchProfile (id: int) (token: CancellationToken) : Task<string> =
    http.GetStringAsync ($"/users/%d{id}", token)

let profile =
    createAsync (fun _ token ->
        (cancellableTask {
            let id = userId.Value // tracked: read before the first bind that suspends
            let! body = fetchProfile id
            return $"user %d{id}: %s{body}"
        }) token)

The tracking and purity rules of the body are unchanged: the builder runs synchronously up to its first bind that suspends. IcedTasks targets .NET only; a body shared with Fable stays a task.

Disposal while pending

A disposed async memo reads as Failed with an ObjectDisposedException. Its readers are woken once to see the failure.

Edit this page