dotnet / orleans

Cloud Native application framework for .NET
https://docs.microsoft.com/dotnet/orleans
MIT License
10.02k stars 2.02k forks source link

F# support - discussion #5772

Open Arshia001 opened 5 years ago

Arshia001 commented 5 years ago

Following our discussion in #38, I've been thinking of possible problems we'll run into along the way. Here's a list (I'll elaborate on each one further below):

  1. Orleans grains are defined via interfaces and classes. While F# supports OO-style code, it's not what we love F# for. We'll need a functional way of creating grains. This should be the single most important goal of this effort IMO: no OO code in grains, and as little as possible in other places.
  2. Orleans uses constructor dependency injection. No OO means no constructors, which means we need a functional way to inject dependencies.
  3. The Grain class includes some common functionality, such as timers and reminders. We need a way to make the same functionality available.
  4. Grains use fields on the grain class as temporary storage. We some place to store transient data between calls to the same grain.
  5. We need compile-time F# code generation.
  6. We need a (functional) way to actually call the grains from the client side.
  7. We need to pass functions as arguments. Higher order functions are a fundamental building block of functional programming.
  8. …?

1. Functional interface to grains

This is a rather challenging design choice. I can think of three ways to implement it, detailed below. We'd probably use some special grain class which can call into these.

Messages as DU with single handler method

This is almost exactly what Orleankka does. Each grain will be defined as a DU containing the messages to it, and a single handler function. Personally, I don't like really long functions, but the F# function keyword will be helpful here:

type HelloGrainMessages =
    | SetName of name: string
    | SayHello

let helloGrainHandler = function
    | SetName name -> myname <- name // Assuming myname is a transient field
    | SayHello -> sprintf "Hello, %s!" myname

Pros:

Cons:

Messages as record containing function signatures

Similar to Bolero's remoting feature, there will be a record type per grain. This record type will include function fields, which will then be implemented as a value:

type HelloGrain = {
    SetName: string -> unit
    SayHello: unit -> string
    }

let helloGrain = {
    SetName = fun name -> myname <- name
    SayHello = fun () -> sprintf "Hello, %s!" myname
}

Pros:

Cons:

Messages as module functions

Each grain will be a module, containing functions which will represent grain methods:

module HelloGrain
let SetName name = myname <- name
let SayHello = sprintf "Hello, %s!" myname

Pros:

Cons:

2. Dependency injection

The grains will need a way to declare the services they need. I'm guessing they can introduce a record type containing the services they require, which will be created at runtime and passed to each invocation of each function.

We would still need a type-safe way to introduce the record the framework and still use it in the functions.

3. Grain class functionality

This is rather simple. We could just create a module with the equivalent functions for "grains" to call. However, we'd need a way to pass the grain's identity to those functions. Grain identity can probably be obtained via our dependency injection mechanism, or a mechanism similar to it. For example, we could have something like this:

type RequiredStuffForGrains<'TServices> = {
    identity: GrainIdentity
    services: 'TServices
    ...
    }

4. Transient storage

This is probably simple: We could inject an ITransientData<'TData> into the grains and use that.

5. Code-gen

I haven't looked at the codegen sources yet, but if my guess is right and it reads the compiled binaries, this should be (relatively) simple to implement.

6. Calling grains

This really depends on the details of how grains are implemented, so I'll leave it out for now.

7. Higher order functions

IIRC, F# compiles each "function" to many subclasses of FSharpFunc, one for each argument. We may very well be able to just serialize the resulting objects like any other POCO.

However, careful thought must be given to this matter, because any POCO's that travel over the wire need serialization code generated for them. We could generate code for all subclasses of FSharpFunc, but I think it'll lead to an unacceptable increase in binary size, specially since F# generates so many of them.


I'd love to know what everybody thinks about all of this.

sergeybykov commented 5 years ago
  1. The Grain class includes some common functionality, such as timers and reminders. We need a way to make the same functionality available.

We've been thinking about moving away from Grain as a base class and supporting POCOs with injection of system services instead. Probably in 4.0 timeframe.

ShalokShalom commented 5 years ago

Just some question to clarify the situation to me: What is the potential benefit of Fsharp Orleans compared to other integrations, such as Akkling? I ask this so we can figure out which potential benefits can make a difference, so to create something unique and distinctive. Thanks a lot :hugs:

Arshia001 commented 5 years ago

@ShalokShalom this is meant to be a functional interface to Orleans. It'll work and feel like Orleans, but be compatible with (idiomatic) F#.

Currently, it's a pain to use Orleans from F#. It's certainly possible, but offers few of the benefits one might expect. I expect the end result to be to Orleans what Bolero is to Blazor for example: A fully functional interface which wraps all aspects of the framework and adds/changes as little functionality as possible.

Akkling is an F# interface to Akka.Net if I'm not mistaken? Then we're probably looking to do the same for Orleans.

ShalokShalom commented 5 years ago

I guess https://github.com/OrleansContrib/Orleankka wont make it?

And the comment of sergeybykov suggests that Orleans could become potentially multi threaded, correct?

I still see a complete switch over to FSharp as more beneficial then, while this is obviously just wishful thinking. :)

wanton7 commented 5 years ago

@ShalokShalom I'm quite sure @sergeybykov meant that Orleans would work pretty similarly how it works now. Except class would be a plain class without inheriting from Grain or Grain<T> and everything would be injected from constructor, like grain factory and class that's keeping the state.

ShalokShalom commented 5 years ago

Oh, I thought POCO`s are multi thread capable, sorry for the confusion.

Arshia001 commented 5 years ago

I have (only recently) seen Orleankka. Again, it's more Akka and less Orleans. As I said above, we're aiming for a functional interface that looks, feels and behaves like Orleans itself.

Arshia001 commented 5 years ago

I think we should allow F#-based grains to also be usable from C# clients. To do that, we'd definitely need to do some compile time code generation. I think module functions along with code generation will make for a generally pleasant development experience. However, if we do that, we'd have no easy way to use our grains from within other grains in the same assembly (Remember that grains themselves use other grains' interfaces from the interface binary). That is, unless the code generation runs not only on compile, but during development, in much the same way Android IDE's keep the R namespace up-to-date with current sources. I'll write some code to show how this will look, and then we can discuss it further.

For those unfamiliar with android development, you have a resources folder and put different assets inside it (bitmaps, UI markups, string resources, etc.). Each resource gets an auto-generated integer ID during build, and it's all put into a namespace named R, so you can get any resource by its ID, like so: R.drawable.my_bitmap_drawable. This generally happens at build time before code is built, but IDE's keep it up-to-date during development, so you always get intellisense for the R namespace when the resources change.

sergeybykov commented 5 years ago

@wanton7

@ShalokShalom I'm quite sure @sergeybykov meant that Orleans would work pretty similarly how it works now. Except class would be a plain class without inheriting from Grain or Grain and everything would be injected from constructor, like grain factory and class that's keeping the state.

Exactly right.

Arshia001 commented 5 years ago

OK, here's the code: Arshia001/Orleans.FS.Mockup

The code includes a fair amount of comments explaining how everything will work. Keep in mind that most of it will be generated at compile time, so please pay attention to the comments specifying which part of the code is written manually and which is not.

I think it's looking good (in an idiomatic way), but it doesn't work yet. Aside from some missing implementations I intentionally left out, there are a bunch of questions that need answers before it'll work and we can implement it:

I'd love to know what everybody thinks about the code, and will be waiting for your feedback.

Arshia001 commented 5 years ago

IIRC, F# compiles each "function" to many subclasses of FSharpFunc, one for each argument. We may very well be able to just serialize the resulting objects like any other POCO.

Yeah, that's completely possible. This code works:

let g a b c = a + b + c + 1
let a = g 1
let t = 
    Type
        .GetType(a.GetType().FullName)
        .GetConstructors(BindingFlags.NonPublic ||| BindingFlags.Instance)
        .[0]
        .Invoke([|1|]) // So I cheated by hardcoding the parameter here, but that can also be retrieved via reflection
        :?> int -> int -> int
printfn "%i" (t 2 3)

This gives me an idea. To invoke grain functions from within grain assemblies, and without relying on codegen, we can do something like this:

// In Orleans:
let invoke grainType key (f: GrainFuncInput -> Task) : Task = ... // Send f over to the hosting silo to execute against the grain, it's a lambda (an FSharpFunc) and can be serialized directly

// In grains:
invoke HelloWorkerGrain 0 (SayHello name)
Arshia001 commented 5 years ago

I've just uploaded another version of the code to the repo. I dropped modules in favor of types to enable calls to other grains within the same assembly. I've also reorganized the code a little. You'll want to look at these files, since those are the ones representing code that is to be written by hand:

I do post the code hoping to get some feedback, so if anyone has any opinions on the matter, I'd love to hear them.

Arshia001 commented 5 years ago

I was very unhappy with the need for types to contain grain functions, and also wasn't fond of the syntax for making calls to grains within the assembly, so I took a step back and started reconsidering the entire design. After much though, I came up with what was essentially the same design I had in the first version, so I decided to find a way to make module functions work. The result is a function proxy system based on F#'s quotations and custom subclasses of FSharpFunc, which I have uploaded to the repo. I also made sure the code runs this time around, so you can get it and check it all out.

Here's some sample code defining a grain (note the GrainFactory.proxyi call):

[<CLIMutable>]
type HelloGrainState = { lastHello: string }

type Services = { 
    persistentState: IPersistentState<HelloGrainState>
    transientState: ITransientState<HelloArgs.T>
}

[<GrainModuleWithIntegerKey(typeof<Services>)>]
module HelloGrain =
    let setName i name =
        i.Services.transientState.Value <- Some name
        Task.CompletedTask

    let sayHello i () = task {
        // Look here! vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv
        let sayHelloProxy = i.GrainFactory.proxyi <@ HelloWorkerGrain.sayHello @> (i.Identity.key + 42L)
        match i.Services.transientState.Value with
        | Some name ->
            let! result = sayHelloProxy name
            i.Services.persistentState.State <- { lastHello = result }
            do! i.Services.persistentState.WriteStateAsync()
            return result
        | None -> return "I have no one to say hello to :("
    }

And this is some sample client code:

        let client = host.Services.GetRequiredService<IClusterClient>()
        let hello = client.getHelloGrain 0L
        do! hello |> HelloGrain.setName (HelloArgs.create "world")
        let! result = hello |> HelloGrain.sayHello

which looks nice to me. I'll wait a few days for comments on the design, then start implementing it.

Arshia001 commented 5 years ago

@sergeybykov @ReubenBond no comments?

ray440 commented 5 years ago

i.GrainFactory.proxyi <@ HelloWorkerGrain.sayHello @>

Not a huge fan of this. Have you considered using TypeProviders? They seem ideal for making proxies.

ReubenBond commented 5 years ago

Apologies, @Arshia001 - I'm following along with interest (and I briefly mentioned this thread in Gitter here). I'm still not entirely sure what the most idiomatic F# model would be. I'm a little apprehensive about the quotations approach, though, since they don't appear very ergonomic/simple to me. I'm interested to know what others think.

Arshia001 commented 5 years ago

Not a huge fan of this. Have you considered using TypeProviders? They seem ideal for making proxies.

I did. Type proxies only take primitive types as input. To do this, we'd need to provide the entire module as input to the type provider, which will not work unfortunately.

Unless, of course, we were to declare the grains in some other way, such as a JSON, but that'd be much less ideal IMHO.

I'm a little apprehensive about the quotations approach

As am I. I'm simply out of ideas though.

The real problem is that the assembly with the functions has to compile without the codegen'ed parts. Without codegen, there is simply nothing to identify the functions with. I'm against using objects, as that would defeat the entire purpose of what we're trying to do here.

A discriminated union or record type would be the standard way to go, but they both have the very serious problem of being even less developer-friendly: DUs can't identify the return type, and records can't give names to function arguments. The quotation approach is type safe and intellisense-friendly, if a bit ugly to look at.

I haven't yet managed to find a proper solution to this problem in any of the libraries I've looked at. I'm open to all suggestions.

Arshia001 commented 5 years ago

Now, @johnberzy-bazinga suggested using [<ReflectedDefinition>] to auto-convert arguments to expressions, eliminating the need for <@ @>. Unfortunately, using it this way converts the function to an FSharpFunc and passes that as the expression in a ValueWithName, while using <@ @> passes the function as a Lambda from which we can get the MethodInfo.

If others also think <@ @> is too much noise (I know I do), there is one more way: to parse the body of the Invoke function and find out which method it's calling. This information can be cached per FSharpFunc subclass (F# generates one of those for every function call) so there won't be a runtime overhead. This will eliminate the use of quotations altogether.

Or maybe we could do it at code-gen time... A map of Type.FullName to grain functions, maybe?

Arshia001 commented 5 years ago

That's completely possible to do. This code gives the exact same result as using quotations:

open Mono.Reflection

// TODO traverse all fsharp funcs for more than 5 arguments
let getInvokeMethod f =
    f.GetType().GetMethods()
    |> Array.filter (fun m -> m.Name = "Invoke")
    |> Array.maxBy (fun m -> m.GetParameters().Length)

let getMethodFromBody (body: MethodInfo) =
    body.GetInstructions()
    |> Seq.filter (fun i -> i.OpCode = Emit.OpCodes.Call)
    |> Seq.map (fun i -> i.Operand :?> MethodInfo)
    |> Seq.head

Only you don't have to pass in a quotation any more, it works directly on the generated FSharpFuncs. With it, the code becomes:

//    1        2             3          4      5   6
grainFactory.proxyi HelloWorkerGrain.sayHello key name

This isn't any more noisy than the C# version already in use and has exactly as many parts, in just slightly different order (and it has the added benefit that one can open the grain module beforehand and not have to mention the module name):

//    1          2              3         5      4      6
GrainFactory.GetGrain<IHelloWorkerGrain>(key).sayHello(name);
johnberzy-bazinga commented 5 years ago

@Arshia001 @ReubenBond There is a language suggestion (and experimental POC) for allowing Type Providers to accept Types as Static Parameters https://github.com/fsharp/fslang-design/blob/master/RFCs/FS-1023-type-providers-generate-types-from-types.md . No movement on this lately, but maybe a use case like Orleans integration might get the ball rolling again.

johnberzy-bazinga commented 5 years ago

I believe @TobyShaw fork is farthest along for the TypePassing POC. You can take a look at some examples here: https://github.com/TobyShaw/visualfsharp/tree/6bb3e74761b9e528fde07892df2f88847b84be73/tests/fsharp/typeProviders/samples/TypePassing

Arshia001 commented 5 years ago

@johnberzy-bazinga I'd love to have that, but even if they do start working on it, it'll probably be quite some time before it's ready. We can migrate to that feature once (if) it's implemented. Meanwhile, the problem can be solved by parsing the bodies of FSharpFunc.Invoke methods at code-gen time. I'll update the sample real quick.

Arshia001 commented 5 years ago

The new sample is up. The only change to the end user experience is that we no longer need quotations:

        let sayHelloProxy = i.GrainFactory.proxyi HelloWorkerGrain.sayHello (i.Identity.key + 42L)

There's very little runtime overhead (a dictionary lookup per grain call). @ReubenBond does this look alright to you?

Arshia001 commented 4 years ago

I've added some more code to the sample. It now supports OnActivate, OnDeactivate and ReceiveReminder. I also moved some of the code from runtime to code-gen, so we no longer need any reflection work at runtime. The cache of FSharpFuncs now works with types instead of names, so that's one less .FullName per call as well.

ReubenBond commented 4 years ago

let sayHelloProxy = i.GrainFactory.proxyi HelloWorkerGrain.sayHello (i.Identity.key + 42L)

What does the + 42L do there?

Arshia001 commented 4 years ago

Absolutely nothing! It was meant as a joke...

ReubenBond commented 4 years ago

Ok, I think I understand now. The grain calls into another grain (its key + 42) to say hello periodically. The syntax seems alright. I think it matters more what F# developers think, since they will have a much stronger feel for F# idioms/style. Are we able to get others to weigh in here?

ray440 commented 4 years ago

I think it matters more what F# developers think

Kudos! It's certainly better without the <@ @> noise.

With more functions I'd be tempted to write a module like:

module HelloProxy =
    let sayHello i = i.GrainFactory.proxyi HelloWorkerGrain.sayHello i
    let sayMoo i = i.GrainFactory.proxyi HelloWorkerGrain.sayMoo i
    :

Or even a class(!) type HelloProxy(i) = ... which of course defeats the original intent. All this leads me to wonder what have these "Module Grains" gained over the original "Object Grains"?

Arshia001 commented 4 years ago

@ray440 F# does support OO, but doesn't seem to like it. I wanted to do my next project in F#, but I ran into too many problems with just about 20 lines of code, which is why I started all of this. To me, the most terrible limitation is that you can't use this inside closures, and task/async builders are closures. How are you going to write your code if you can't access the grain in its own code?

Aside from things like F# code-generation, better serializer support and higher order function support, grain modules gain over grain classes what any module gains over any class. You could just as well have asked why F# modules exist at all, and why the language wasn't made into another OO language in the first place. Of course, everybody is still free to use grain classes if they see fit.

ray440 commented 4 years ago

you can't use this inside closures,

I agree, this is super annoying!! And worthy of some rethinking.

Just to avoid confusion, it's better to say that F# is picky about accessing protected base methods rather than any this references.

member this.GetGF2() = task { return this.GrainFactory } // Not OK. GrainFactory is protected
member this.GetGRef() = task { return this.GrainReference } // OK. GrainReference is public

How are you going to write your code if you can't access the grain

I usually add a helper method in my grain:

member __.GetGF() = base.GrainFactory                 // OK helper ... but ugh
member this.Reg mkey key = task { 
    do! this.GetGF().GetGrain<ITodoManagerGrain>(mkey).RegisterAsync(key)  // OK
}

Aside from things like ... {super cool stuff}

Sure those things are nice. But I want more!! :) I'm not sure what, but I feel we can do more (maybe we can't?). As it stands there's little reason for me, as an F# dev, to switch (except the annoying base.method stuff...).

Arshia001 commented 4 years ago

Just to avoid confusion, it's better to say that F# is picky about accessing protected base methods rather than any this references.

F# is just generally picky about inheritance and everything that comes with it. There isn't even a protected keyword.

I usually add a helper method in my grain:

More boilerplate. Who doesn't like that?

I'm not sure what, but I feel we can do more

I've already written down everything that came to mind, I'd love to have more items added to that list :) F# currently feels like a second class citizen in Orleans. Let's fix that.

ray440 commented 4 years ago

I'd love to have more items added to that list

Sadly, I haven't got any (good) ideas...

Is there any chance that Orleans can make the protected members public??

Arshia001 commented 4 years ago

@ray440 adding protected to F# or making Orleans members public are both the wrong way to handle this IMO.

Orleans' design is purely object oriented, which necessitates the use of protected members.

F# is against the use of implementation inheritance (for good reasons) and has other mechanisms to handle such cases; and that's what it's all about. If I wanted OO code, I'd write C#. When I consciously choose to write F# it (probably) means I'm looking for something different, in this case functional code. So to me, it makes no sense to forcefully marry Orleans and F# by working around the limitations. Even if we did, we'd still end up with an OO codebase that's just written in F#, not truly functional code.

I actually consider this to be the genius of F#'s design: leaving just enough out to make people second guess their presumptions and look for a better way.

Arshia001 commented 4 years ago

We were missing generic grains completely, so I went ahead and added a primitive version of it in. Methods may have many generic arguments. The user may specify some of those as being part of the grain class's type arguments, the rest will be on the methods themselves:

[<Core.SystemTypes.GrainModuleWithIntegerKey([| "'T1" |])>]
module MyModule.Grains.HelloWorkerGrain
let sayHello<'T1, 'T2> (i: GrainFunctionInputI<unit>) name (t1: 'T1) (t2: 'T2) = …

Which will result in:

    type IHelloWorkerGrain<'T1> =
        inherit IGrainWithIntegerKey
        abstract SayHello: name: HelloArgs.T -> t1: 'T1 -> t2: 'T2 -> Task<string>

The downside of this approach is that the user must manually specify all type information on grain functions, effectively losing (a bit of) automatic type inference.

Arshia001 commented 4 years ago

I'm not really fond of how those generics look... Any ideas?

Arshia001 commented 4 years ago

I moved grain class generic parameters out of the GrainModule attribute and into the definition of the "grain record", which is what we were calling "services" up until now. I'm happy with how it all looks now. Any feedback?

Aside from number 7 (passing functions as arguments), everything from the list above is in place now. We just need to do a lot of code generation (functions as arguments also falls under this category since F# functions are really just objects) and we'd be done.

nkosi23 commented 1 year ago

Since Orleans 4.0 has already landed and finally introduced the IGrain interface mentioned by @sergeybykov, how well does Orleans play with F# as of today?

We are building a backend fully in F# and Orleans' Grains are the only part of the architecture that we are not sure how to best handle from F# (in terms of design patterns, best practices etc...)

dggmez commented 1 year ago

@nkosi23 it's a shame that the F# Foundation Slack doesn't show files uploaded more than 90 ago because I uploaded some images with the code to make it work. Yeah, Grains can be defined and implemented in F#. I only needed to add one small C# library project with a few lines to have the source generator working but other than that it worked flawlessly. The whole solution structure was a F# web app, F# grain interface project, F# grain implementation project and a C# project.

I'll leave the message link so if for some reason someone can get those images (maybe people with some kind of Slack subscription can see them?) maybe they can do us a favor and post them here. I don't know why I didn't think about uploading the code to some GitHub repository:

https://fsharp.slack.com/archives/C1R50TKEU/p1677487797631779?thread_ts=1677469422.100779&cid=C1R50TKEU