I am building a GraphQL server using F# with .NET Core. To implement batching and address N+1 select, I am building a data loader. The Facebook's dataloader uses Node.js event loop tick to collect and dispatch batched requests.
However, such mechanism is not available in .NET Core. I know I can implement run/dispatch method from the data loader instance which can be called manually. But, that is something very hard to do from within the resolvers which execute independently. So I need some auto dispatching mechanism to run the batched requests.
Any suggestions on how to achieve it?
This is a great question, which I recently hit myself.
There are a few "data-loader" type libraries for F# and .NET, however if you are also using FSharp.Data.GraphQL then there are fewer solutions that integrate well.
Note that the "Haxl" approach will not work (easily) with FSharp.Data.GraphQL. This is because the Haxl types must be integrated into GraphQL query models, but FSharp.Data.GraphQL only understands sync and async.
The most suitable implementation that I could find is in FSharp.Core.Extensions. This is fairly new library, but it's high quality and Apache 2.0 licensed.
I'm sure there are many ways it can be integrated it into FSharp.Data.GraphQL, however my preferred approach was to put the data-loaders into the root value of the schema. This allows all GraphQL resolvers down the tree to access it.
I think the best way to explain it is to show an example.
Here we have a domain of "People" who can have zero or more "followers", who are also "People". Each person has a globally unique ID. There is significant overlap in the followers between people, so a naive solution may re-fetch the same data repeatedly. Our database layer can fetch many person records in one query, so we would like to leverage that where possible.
You can paste this code into an .fsx file and run it. The dependencies are fetched by Paket.
paket.dependencies
generate_load_scripts: true
source https://www.nuget.org/api/v2
source https://api.nuget.org/v3/index.json
storage: none
framework: net5.0, netstandard2.1
nuget FSharp.Core 5.0.0
nuget FSharp.Data.GraphQL.Server 1.0.7
github Horusiath/fsharp.core.extensions:0ff5753bb6f232e0ef3c446ddcc72345b74174ca
DataLoader.fsx
#load ".paket/load/net50/FSharp.Data.GraphQL.Server.fsx"
#load "paket-files/Horusiath/fsharp.core.extensions/src/FSharp.Core.Extensions/Prolog.fs"
#load "paket-files/Horusiath/fsharp.core.extensions/src/FSharp.Core.Extensions/AsyncExtensions.fs"
type Person =
{
ID : string
Name : string
}
// Mocks a real database access layer
module DB =
// Used to avoid interleaving of printfn calls during async execution
let private logger = MailboxProcessor.Start (fun inbox -> async {
while true do
let! message = inbox.Receive()
printfn "DB: %s" message
})
let private log x =
logger.Post(x)
// Our data-set
let private people =
[
{ ID = "alice"; Name = "Alice" }, [ "bob"; "charlie"; "david"; "fred" ]
{ ID = "bob"; Name = "Bob" }, [ "charlie"; "david"; "emily" ]
{ ID = "charlie"; Name = "Charlie" }, [ "david" ]
{ ID = "david"; Name = "David" }, [ "emily"; "fred" ]
{ ID = "emily"; Name = "Emily" }, [ "fred" ]
{ ID = "fred"; Name = "Fred" }, []
]
|> Seq.map (fun (p, fs) -> p.ID, (p, fs))
|> Map.ofSeq
let fetchPerson id =
async {
log $"fetchPerson {id}"
match people |> Map.find id with
| (x, _) -> return x
}
let fetchPersonBatch ids =
async {
let idsString = String.concat "; " ids
log $"fetchPersonBatch [ {idsString} ]"
return
people
|> Map.filter (fun k _ -> Set.contains k ids)
|> Map.toSeq
|> Seq.map (snd >> fst)
|> Seq.toList
}
let fetchFollowers id =
async {
log $"fetchFollowers {id}"
match people |> Map.tryFind id with
| Some (_, followerIDs) -> return followerIDs
| _ -> return []
}
// GraphQL type definitions
open FSharp.Core
open FSharp.Data.GraphQL
open FSharp.Data.GraphQL.Types
#nowarn "40"
[<NoComparison>]
type Root =
{
FetchPerson : string -> Async<Person>
FetchFollowers : string -> Async<string list>
}
let rec personType =
Define.Object(
"Person",
fun () -> [
Define.Field("id", ID, fun ctx p -> p.ID)
Define.Field("name", String, fun ctx p -> p.Name)
Define.AsyncField("followers", ListOf personType, fun ctx p -> async {
let root = ctx.Context.RootValue :?> Root
let! followerIDs = root.FetchFollowers p.ID
let! followers =
followerIDs
|> List.map root.FetchPerson
|> Async.Parallel
return Seq.toList followers
})
])
let queryRoot = Define.Object("Query", [
Define.AsyncField(
"person",
personType,
"Fetches a person by ID",
[
Define.Input("id", ID)
],
fun ctx root -> async {
let id = ctx.Arg("id")
return! root.FetchPerson id
})
])
// Construct the schema once to cache it
let schema = Schema(queryRoot)
// Run an example query...
// Here we fetch the followers of the followers of the followers of `alice`
// This query offers many optimization opportunities to the data-loader
let query = """
query Example {
person(id: "alice") {
id
name
followers {
id
name
followers {
id
name
followers {
id
name
}
}
}
}
}
"""
let executor = Executor(schema)
async {
// Construct a data-loader for fetch person requests
let fetchPersonBatchFn (requests : Set<string>) =
async {
let! people =
requests
|> DB.fetchPersonBatch
let responses =
Seq.zip requests people
|> Map.ofSeq
return responses
}
let fetchPersonContext = DataLoader.context ()
let fetchPersonLoader = DataLoader.create fetchPersonContext fetchPersonBatchFn
// Construct a data-loader for fetch follower requests
let fetchFollowersBatchFn (requests : Set<string>) =
async {
let! responses =
requests
|> Seq.map (fun id ->
async {
let! followerIDs = DB.fetchFollowers id
return id, followerIDs
})
|> Async.Parallel
return Map.ofSeq responses
}
let fetchFollowersContext = DataLoader.context ()
let fetchFollowersLoader =
DataLoader.create fetchFollowersContext fetchFollowersBatchFn
let root =
{
FetchPerson = fun id -> fetchPersonLoader.GetAsync(id)
FetchFollowers = fun id -> fetchFollowersLoader.GetAsync(id)
}
// Uncomment this to see how sub-optimal the query is without the data-loader
// let root =
// {
// FetchPerson = DB.fetchPerson
// FetchFollowers = DB.fetchFollowers
// }
// See https://bartoszsypytkowski.com/data-loaders/
do! Async.SwitchToContext fetchPersonContext
do! Async.SwitchToContext fetchFollowersContext
// Execute the query
let! response = executor.AsyncExecute(query, root)
printfn "%A" response
}
|> Async.RunSynchronously
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With