Last active
December 22, 2020 18:24
-
-
Save bessey/62e1ddd30bc3d2467cf196ae9e44d412 to your computer and use it in GitHub Desktop.
Proposal for alternative Fiber based design for GraphQL Ruby batched data loading
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# The general idea is that similarly to lazy evaluation, the interpreter executes each resolver from inside a Fiber. | |
# Once we have instantiated a Fiber for every resolver reachable in this pass, we loop over the Fibers, yielding till | |
# they are dead (the resolver has returned). Resolvers that are not using the Fiber based Loader API would simply | |
# return there value on the first pass. | |
# Complete pseudo-cpde, I don't know GraphQL Ruby internals well | |
def main_graphql_ruby_interpreter_loop(reachable_fields) | |
unresolved_fields = reachable_fields.reduce({}) do |acc, field| | |
# Creating one Fiber per field is probably too expensive, and we'd want to re-use them across loops or even across | |
# different fields in the same loop. A topic for another time. | |
fiber = Fiber.new { field.resolve } | |
# Associate the field to the fiber so we can later attach the value to the right field | |
acc[field] = fiber | |
end | |
while !unresolved_fields.empty? do | |
unresolved_fields.each { |field, fiber| field.value = fiber.resume } | |
unresolved_fields = unresolved_fields.select { |field, fiber| fiber.alive? } | |
end | |
# Now every field has returned a concrete value, we can return those values, and be called again with the previously | |
# unreachable leaf nodes. Rinse and repeat until there's no fields left to call | |
reachable_fields | |
end | |
class MyQueryType < GraphQL::BaseObject | |
field :user, UserType, null: true | |
# No data-loading example for reference | |
def user(id:) | |
User.find(id) | |
end | |
# Fibre based data-loading | |
def user(id:) | |
loader = context.loader # Query wide LoaderManager instance | |
user = loader.find(User, id) | |
# At this point we have a real object, not a Promise, or any other lazy proxy. The loading complexity is completely | |
# hidden from the resolver, unlike with Shopify/graphql-batch | |
if user.deactivated? | |
# And loaders can be chained unlike with exAspArk/batch-loader, although I think similarly to React hooks, if loader | |
# calls / call order is non deterministic, it will reduce the effectiveness of batching | |
loader.find(User, user.active_user_id) | |
else | |
user | |
end | |
end | |
end | |
class LoaderManager | |
def initialize | |
@store = LoaderStore.new | |
end | |
def find(scope, id) | |
loader = store.get(scope) | |
loader.enqueue(id) | |
# Where the magic happens, yield back to the interpreter so it may queue other IDs before we try and load data | |
Fiber.yield | |
loader.find(id) | |
end | |
end | |
class LoaderStore | |
def initialize | |
@cache = {} | |
end | |
def get(scope) | |
@cache[scope] ||= Loader.new(scope) | |
end | |
end | |
class Loader | |
def initialize(scope) | |
@scope = scope | |
@ids = [] | |
@results = nil | |
end | |
def enqueue(id) | |
@ids << id | |
end | |
def find(id) | |
populate_results_if_missing | |
@results.find { |record| record.id == id } | |
end | |
private | |
def populate_results_if_missing | |
return if @results | |
@results = scope.where(id: ids) | |
end | |
end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment