Skip to content

Seeking advice for creating CEs based on the new built in task CEΒ #12456

@mattfurness

Description

@mattfurness

I am after some guidance on implementing computation expressions (CEs) that build upon the new built in task computation expression(s). The code can be found here. I will offer two examples and the hurdles that I ran into trying to implement them in the way i had envisaged. I had hoped that I would be able to implement the below CEs in a way that takes advantage of the performance characteristics and stacktrace benefits of the new task CEs. I know that I can use the task CE directly to implement them, but it is my understanding that task.Run would then be called for each function in the builder.

The API

I thought it might be useful to summarize the TaskBuilder api here to make it easier to refer to, it is more complicated in reality but essentially boils down to:

member inline _.Delay(generator : unit -> TaskCode<'TOverall, 'T>) : TaskCode<'TOverall, 'T>
member inline _.Zero() : TaskCode<'TOverall, unit>
member inline _.Return (value: 'T) : TaskCode<'T, 'T>
member inline _.Combine(task1: TaskCode<'TOverall, unit>, task2: TaskCode<'TOverall, 'T>) : TaskCode<'TOverall, 'T>
member inline _.While ([<InlineIfLambda>] condition : unit -> bool, body : TaskCode<'TOverall, unit>) : TaskCode<'TOverall, unit>
member inline _.TryWith (body: TaskCode<'TOverall, 'T>, catch: exn -> TaskCode<'TOverall, 'T>) : TaskCode<'TOverall, 'T>
member inline _.TryFinally (body: TaskCode<'TOverall, 'T>, [<InlineIfLambda>] compensation : unit -> unit) : TaskCode<'TOverall, 'T>
member inline _.For (sequence : seq<'T>, body : 'T -> TaskCode<'TOverall, unit>) : TaskCode<'TOverall, unit>
static member _.BindDynamic (sm: byref<_>, task: Task<'TResult1>, continuation: ('TResult1 -> TaskCode<'TOverall, 'TResult2>)) : bool
member inline _.Bind (task: Task<'TResult1>, continuation: ('TResult1 -> TaskCode<'TOverall, 'TResult2>)) : TaskCode<'TOverall, 'TResult2>
static member _.RunDynamic(code: TaskCode<'T, 'T>) : Task<'T>
member inline _.Run(code : TaskCode<'T, 'T>) : Task<'T>

Some relevant types for TaskCode are:

type ResumableCode<'Data, 'T> = delegate of byref<ResumableStateMachine<'Data>> -> bool

[<Struct; NoComparison; NoEquality>]
type TaskStateMachineData<'T> =

    [<DefaultValue(false)>]
    val mutable Result : 'T

    [<DefaultValue(false)>]
    val mutable MethodBuilder : AsyncTaskMethodBuilder<'T>

and TaskStateMachine<'TOverall> = ResumableStateMachine<TaskStateMachineData<'TOverall>>
and TaskResumptionFunc<'TOverall> = ResumptionFunc<TaskStateMachineData<'TOverall>>
and TaskResumptionDynamicInfo<'TOverall> = ResumptionDynamicInfo<TaskStateMachineData<'TOverall>>
and TaskCode<'TOverall, 'T> = ResumableCode<TaskStateMachineData<'TOverall>, 'T>

Porting Expecto's TestTaskBuilder:

The implementation that I was attempting to port can be found here. It is essentially a simple wrapper around a Task Builder, with some Expecto specific logic for Run. I started with the exact code linked to above (but with a dependency on the built in task {...} and went from there:

  • Issue 1: Compiler error error FS3501: Invalid resumable code. Any method of function accepting or returning resumable code must be marked 'inline'

    • Resolution: Mark everything except Run as inline. It is possible inline Run as well, if name and focusState are made available publicly.
    • Question: Why is it legal to leave Run as standard member (not inline), given it takes a TaskCode as a parameter.
  • Issue 2: Compiler error error FS3511: This state machine is not statically compilable. The resumable code value(s) 'f' does not have a definition. An alternative dynamic implementation will be used, which may be slower. Consider adjusting your code to ensure this state machine is statically compilable, or else suppress this warning.

    • Resolution: It appears that calling task.Run with the TaskCode passed in always produces this warning, I could not find a way to get around it, the only way I could get it to compile was to suppress the error with #nowarn "3511"

Next I tried to annotate the TestTaskBuilder with types.

  • Issue 3: Compiler error due to using TaskCode in the type annotations. This construct is for use by compiled F# code and should not be used directly.
    • Resolution: I was able to suppress this with #nowarn "1204", but I was pretty surprised that using this type is so discouraged.

Implementing a TaskResultBuilder:

We have a TaskResultBuilder that depends on TaskBuilder.fs. I wanted to port it over to depend on the new built in TaskBuilder, it is quite similar to the one provided in FsToolkit.ErrorHandling which depends on Ply and it's intermediatry Ply<'u> type and uply CE, so I will link to it for additional context.

The TaskResult type:

type TaskResult<'TResult, 'Error> = TaskResult of Task<Result<'TResult, 'Error>>
  • Issue 1: Bind
    I had hoped to create a Bind function with the signature, similar to this
      member inline _.Bind
            (
                tr: TaskResult<'TResult1, 'Error>,
                f: 'TResult1 -> TaskCode<Result<'TOverall, 'Error>, Result<'TResult2, 'Error>>
            ) : TaskCode<Result<'TOverall, 'Error>, Result<'TResult2, 'Error>>
    However there is no obvious (to me anyway) way to create a TaskCode<Result<'TOverall, 'Error>, Result<'TResult2, 'Error>> when the result is an error:
              let binder r =
                  match r with
                  | Ok x -> f x
                  | Error e ->
              // How do we create the `TaskCode` here?
  • Issue 2: While
    I had hoped to create a While function with the signature, similar to this:
      member inline _.While 
          (
              condition : unit -> bool, 
              body : TaskCode<Result<'TOverall, 'Error>, Result<unit, 'Error>>
          ) : TaskCode<Result<'TOverall, 'Error>, Result<unit, 'Error>>
    However there is no obvious way to retrieve the result, calling body will only give you a bool back that is related to the resumable code flow:
          let mutable finished = false
          let mutable result = Ok()
    
          while not finished && condition () do
              let resultOfBody =  // How do we retrieve the `Result` from `TaskCode` here
              match resultOfBody with
              | Ok x -> x
              | Error _ as e ->
                  result <- e
                  finished <- true
    
          // How do we create the `TaskCode` to return here?

I'm not sure the best way to resolve the two issues above, but I am hoping to utilize TaskBuilder and not have to write the low level resumable code myself. I know that when creating a TaskCode you provide a function that has a TaskStateMachineData parameter, however the Result property of that type restricts the 'TOverall type parameter not the 'T type parameter

General questions:

  • Am I misunderstanding the general approach I should be taking?
  • Are there some combinators, or some higher level abstractions that I can build on that I am missing?
  • Is this approach supported at all? If not what approach is recommended?

Any advice or insight would be greatly appreciated. If there is a more appropriate place to ask/post this question let me know and I will do so.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions