orleans icon indicating copy to clipboard operation
orleans copied to clipboard

F# support - discussion

Open Arshia001 opened this issue 6 years ago • 42 comments

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:

  • Function arguments can be named.
  • Very easy to split into "interface" and "implementation", just put the DU in the interface binary and the handler in the implementation binary.
  • If we need to pass additional parameters to the "grain methods", we'll only need to specify them once per grain.

Cons:

  • No return types, so can't be fully type-safe. This is a very important drawback.
  • The async part has to be hidden and handled by the framework, so optimizations such as return WriteStateAsync(); are impossible.

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:

  • Also very easy to split into interface and implementation. The type goes in the interface binary, the value goes in the implementation binary.
  • Fully type-safe, including return types.

Cons:

  • No argument names in the type, meaning clients don't get good intellisense.
  • The syntax doesn't feel... natural to me. I'm used to let f x = ... over fun x -> ...
  • It's unclear how we can pass additional parameters to the functions. They certainly can't be in the function arguments, since those are visible to clients.

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:

  • This is as idiomatic as it gets. 'Nuff said.

Cons:

  • I don't know how (or indeed, if) such code can be split into interface and implementation binaries. It may be possible to codegen the entire client interface to produce a mock copy of the module, but we'd need to compile it across binaries, which also means we'd need to have a mapping of implementation to interface binaries; not to mention making the interface dependent on the implementation. If it gets to that, we may as well just leave the interface out of the source and codegen the entire thing for clients to use.
  • Also unclear how we can pass additional parameters to the functions.

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.

Arshia001 avatar Jul 20 '19 17:07 Arshia001

  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.

sergeybykov avatar Jul 20 '19 23:07 sergeybykov

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:

ShalokShalom avatar Jul 21 '19 10:07 ShalokShalom

@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.

Arshia001 avatar Jul 21 '19 11:07 Arshia001

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. :)

ShalokShalom avatar Jul 21 '19 11:07 ShalokShalom

@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.

wanton7 avatar Jul 21 '19 12:07 wanton7

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

ShalokShalom avatar Jul 21 '19 13:07 ShalokShalom

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 avatar Jul 21 '19 20:07 Arshia001

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.

Arshia001 avatar Jul 22 '19 06:07 Arshia001

@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<T> and everything would be injected from constructor, like grain factory and class that's keeping the state.

Exactly right.

sergeybykov avatar Jul 22 '19 20:07 sergeybykov

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:

  • How will grains reference each other? The interfaces binary can't be there when we start building the grains binary. I'm currently working (thinking!) on this.
  • How do we support generic grains? I have a few unrefined ideas I need to evaluate.
  • How do we support function arguments? This is a very important matter. Functional programming without higher order functions is a rather pointless thing to have, so we must support passing functions as arguments across silo boundaries. I'll add this to the list of questions above.

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

Arshia001 avatar Jul 25 '19 12:07 Arshia001

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 avatar Jul 25 '19 16:07 Arshia001

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:

  • Common/HelloArgs.fs
  • Grains/HelloGrain.fs
  • Grains/HelloWorkerGrain.fs
  • Mockup/Program.fs

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 avatar Jul 28 '19 18:07 Arshia001

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 avatar Aug 16 '19 08:08 Arshia001

@sergeybykov @ReubenBond no comments?

Arshia001 avatar Aug 26 '19 11:08 Arshia001

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

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

ray440 avatar Aug 27 '19 19:08 ray440

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.

ReubenBond avatar Aug 27 '19 20:08 ReubenBond

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 avatar Aug 28 '19 09:08 Arshia001

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 avatar Aug 28 '19 11:08 Arshia001

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);

Arshia001 avatar Aug 28 '19 13:08 Arshia001

@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 avatar Aug 28 '19 21:08 johnberzy-bazinga

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

johnberzy-bazinga avatar Aug 28 '19 22:08 johnberzy-bazinga

@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 avatar Aug 29 '19 11:08 Arshia001

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 avatar Aug 29 '19 12:08 Arshia001

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.

Arshia001 avatar Sep 01 '19 17:09 Arshia001

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

What does the + 42L do there?

ReubenBond avatar Sep 03 '19 19:09 ReubenBond

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

Arshia001 avatar Sep 03 '19 20:09 Arshia001

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?

ReubenBond avatar Sep 03 '19 20:09 ReubenBond

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"?

ray440 avatar Sep 03 '19 21:09 ray440

@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.

Arshia001 avatar Sep 04 '19 02:09 Arshia001

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...).

ray440 avatar Sep 04 '19 19:09 ray440