Open mikhailshilkov opened 6 years ago
Thanks for adding these suggestions. I like the idea of making Durable Functions more friendly to F#.
You mentioned this briefly, but especially where we look at things like Option<T>
, do you think it would make sense to have a separate F# extension library, like Microsoft.Azure.WebJobs.Extensions.DurableTask.FSharp which brings in those new APIs? I feel like this would be a nice clean way to introduce anything that would be specific to F#. It would have a dependency on Microsoft.Azure.WebJobs.Extensions.DurableTask and could therefore be the only package dependency that F# devs need to pull in explicitly.
@cgillum Yes, that was my thought process too
I got a chance to play with my ideas, so I have a little prototype working.
Activity is a combination of name and function (name is then used in orchestrator to call it):
let sayHello = Activity.define "E1_SayHello" (sprintf "Hello %s!")
Every good F# API has to use monads computation expressions, so I created orchestrator
CE:
let helloSequence = orchestrator {
let! hello1 = callActivity sayHello "Tokyo"
let! hello2 = callActivity sayHello "Seattle"
let! hello3 = callActivity sayHello "London"
return [hello1; hello2; hello3]
}
Basically, it takes care of the context implicitly, while providing friendly callActivity
API. Note that callActivity
is low ceremony but strongly typed: it infers types from activity definition.
The not-so-nice part is that I still need to keep Function definitions for Functions runtime to be able to generation bindings and entry points:
[<FunctionName("E1_SayHello")>]
let SayHello([<ActivityTrigger>] name) = sayHello.apply name
[<FunctionName("E1_HelloSequence")>]
let Run([<OrchestrationTrigger>] context: DurableOrchestrationContext) = helloSequence context
Any hints on how to get rid of those are very welcome. They are 100% mechanical, maybe ok for now, but would be nice to generate them behind the scenes in the end.
Opinions? Is this direction worth pursuing?
Orchestrator with optional input value and parallel activity calls:
let backup (inputDirectory: string option) = orchestrator {
let rootDirectory =
match inputDirectory with
| Some directory -> directory.Trim()
| None -> Directory.GetParent(Assembly.GetExecutingAssembly().Location).FullName
let! files = Activity.call getFileList rootDirectory
let! totalBytes = Activity.all copyFileToBlob files
return Array.sum totalBytes
}
[<FunctionName("E2_BackupSiteContent")>]
let Run([<OrchestrationTrigger>] context: DurableOrchestrationContext) =
Orchestrator.run context backup
To answer your previous question:
Opinions? Is this direction worth pursuing?
I think this is worth pursuing. I feel pretty strongly that we should be very intentional in our support of languages beyond C#, and this is a great example of how we can move towards making F# a first-class citizen of the Durable Functions family (and in some ways leading the way in terms of innovation or ease of use). I also expect it will help motivate further investment in F# overall for Azure Functions.
Phone verification workflow:
let verifyPhone (phoneNumber: string) =
orchestrator {
let! challengeCode = Activity.call generateChallenge phoneNumber
let! response = Activity.waitForEvent "SmsChallengeResponse"
return response = challengeCode
}
|> Orchestrator.timeout 90.
|> Orchestrator.retry 3
[<FunctionName("E4_SmsPhoneVerification")>]
let Run([<OrchestrationTrigger>] context: DurableOrchestrationContext) =
Orchestrator.run context verifyPhone
@mikhailshilkov Looks good! I'm trying to compare it with C# example and I have some questions:
phoneNumber
? What happens if phoneNumber
is null
?orchestrator
block will be repeated (with sending challenge code), right?@gsomix Thanks for having a look at this!
Orchestrator.run
and (similar to the original example) it will throw exception if input is not providedlet run (context: DurableOrchestrationContext) f =
let input = context.GetInput<string>()
if String.IsNullOrEmpty(input) then raise (ArgumentNullException("input", "Input is required."))
else f input context
let verifySecurityCode (challengeCode: int) =
orchestrator {
let! response = Activity.waitForEvent "SmsChallengeResponse"
return response = challengeCode
}
|> Orchestrator.retry 3
let verifyPhone (phoneNumber: string) =
orchestrator {
let! challengeCode = Activity.call generateChallenge phoneNumber
return! verifySecurityCode challengeCode
}
|> Orchestrator.timeout 90.
Hey @mikhailshilkov just wanted to check in with you on this. Are you still interested in contributing it? I would love to have it. I think it would really make F# a first-class citizen of Durable Functions and I would happily give it proper first-class treatment in our docs.
@cgillum Yes.
The problem is that I switched projects a bit, so I'm currently not using durable functions, and it's hard to find time for that. I really want to get this done though, so I guess I should just start a PR and get this going.
Any particular time frame that this would fit nicely?
@mikhailshilkov I'm keen to look at this. Have you go any code I can have a look at ? doesn't need to be prod ready, just a gist would be helpful.
@davidglassborow I'm planning to publish a version of it literally next week. Will it work for you?
Yes that's great, and happy to dog-food anything earlier if they helps
@davidglassborow Have a look here. I'm planning to incrementally add examples and API helpers for the next couple weeks. Any feedback is very welcome, especially real-world scenarios and issues!
Great, will do. I'm looking at durable functions as part of the F# advent calendar blog I'm writing, so its won't be real world usage, but I'll certainly raise any issues/thoughts during my exploration.
Haha, I was planning to present this library as part of my post. But you have 5 days of edge, mine is only on Dec 20 :)
That's fine, I'm going to try and map my previous year Computation Expression (https://blogs.conceptfirst.com/2017/12/21/answer-the-phone.html) onto Durable Functions. I'll just use the normal code, and once you've done your post I'll add a version using your stuff if it helps, definitely don't want to steal your thunder ⚡️
Feel free to structure your post as you wish. I didn't mean to restrict you from using any of my code, quite the opposite, go for it if it helps! We are not competing here, but going towards the same goal.
of course 👍
I've come to the conclusion of what I'm trying to achieve may not be possible, I'd appreciate it if you could have a quick look when you have a moment @cgillum and tell me if I've missed something - #539
@mikhailshilkov I'm come to the conclusion my use-case is not a good fit for durable functions, so won't get a chance to test your code I'm afraid. I'll certainly try it out in the future if I get the chance.
I have several random thoughts about how F# code for Durable Functions could be improved. They are all simple things, no need to re-imagine the whole framework.
The goals of this issue are a) Start discussion and gather ideas from F# folks b) See if that generally fits into Durable Functions vision c) Ideally, design a way to go forward (define F# extension library?)
If that works out, I would be willing to contribute to further design and implementation.
CallActivityAsync
Activities and Orchestrator are always in the same project, so it just feels wrong to call activity with untyped method
I would prefer to make it typed, something like
Context
We'd probably need a separate Context type to provide F#-specific methods on it. We can just convert it
but ideally it could replace the existing type in function declaration
Input Values
Inputs return nulls if input is not provided, even for F# record types which are in theory not-nullable. That's confusing. It should return
Option<T>
when trying to get Input parameters.Now:
Could be:
We can then replace the super ugly check for null in Monitor example
with a simple pattern match as above.
Now, in ideal case, input parameters should be... just input parameters:
It could apply to C# too, not sure if there was a good reason for not doing so.
Error handling
Same story about exceptions: F# doesn't like exceptions.
Every activity call could return
Result<T, E>
, so that the client code can and should handle the error case properly. Say we haveAny error handling other than covering all with try-catch is difficult. But with
Result
we could do pattern matchIt might make sense to make a separate method for that like
TryCallActivity
Currying
CallActivity
should probably be curried to simplify partial application.Now:
Could be:
Building blocks: Timeouts
It's not normal to write code like this in F#:
Instead, there should be a primitive on top, e.g.
Orchestrator.withTimeout
which would wrap the original flow and insert timeout mechanics around:Tasks can be nested, so
withTimeout
could be used inside a bigger orchestration if needed.Building blocks: Retries
Same applies to retry loops. While loops are possible in F#, and I used recursion in samples, non of those is particularly readable. Instead of doing some
we could make this work:
The building blocks could probably keep growing organically over time as the need arises.
I invite anybody with F# and Durable Functions experience to comment on this case.