Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How can I implement batching in FSharp.Data.GraphQL?

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?

like image 581
Harshal Patil Avatar asked Jun 04 '26 23:06

Harshal Patil


1 Answers

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
like image 112
sdgfsdh Avatar answered Jun 07 '26 17:06

sdgfsdh



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!