docs: write up some engine docs

This commit is contained in:
Zach Daniel 2020-09-25 00:36:50 -04:00
parent 12891c3f38
commit 657dd4bfc2
No known key found for this signature in database
GPG key ID: C377365383138D4B
5 changed files with 104 additions and 7 deletions

View file

@ -15,7 +15,7 @@ end
See the documentation for the aggregates section in `Ash.Resource.Dsl.aggregates/1` for more information. See the documentation for the aggregates section in `Ash.Resource.Dsl.aggregates/1` for more information.
The aggregates declared on a resource allow for declaring a set of named aggregates that can be used by extensions. The aggregates declared on a resource allow for declaring a set of named aggregates that can be used by extensions.
They can also be loaded in the query using `Ash.Query.load/2`, or after the fact using `c:Ash.Api.load/2`. Aggregates declared on the resource will be keys in the resource's struct. They can also be loaded in the query using `Ash.Query.load/2`, or after the fact using `c:Ash.Api.load/3`. Aggregates declared on the resource will be keys in the resource's struct.
## Custom aggregates in the query ## Custom aggregates in the query

View file

@ -44,7 +44,7 @@ end
See the documentation for the calculations section in `Ash.Resource.Dsl.calculations/1` for more information. See the documentation for the calculations section in `Ash.Resource.Dsl.calculations/1` for more information.
The calculations declared on a resource allow for declaring a set of named calculations that can be used by extensions. The calculations declared on a resource allow for declaring a set of named calculations that can be used by extensions.
They can also be loaded in the query using `Ash.Query.load/2`, or after the fact using `c:Ash.Api.load/2`. Calculations declared on the resource will be keys in the resource's struct. They can also be loaded in the query using `Ash.Query.load/2`, or after the fact using `c:Ash.Api.load/3`. Calculations declared on the resource will be keys in the resource's struct.
## Custom calculations in the query ## Custom calculations in the query

View file

@ -1,5 +1,38 @@
defmodule Ash.Engine do defmodule Ash.Engine do
@moduledoc false @moduledoc """
The Ash engine handles the parallelization/running of requests to Ash.
Much of the complexity of this doesn't come into play for simple requests.
The way it works is that it accepts a list of `Ash.Engine.Request` structs.
Some of values on those structs will be instances of `Ash.Engine.Request.UnresolvedField`.
These unresolved fields can express a dependence on the field values from other requests.
This allows the engine to wait on executing some code until it has its required inputs,
or if all of its dependencies are met, it can execute it immediately. The engine's job is
to resolve its unresolved fields in the proper order, potentially in parallel.
It also has knowledge baked in about certain special fields, like `data` which is the
field we are ultimately trying to resolve, and `query` which is the field that drives authorization
for read requests. Authorization is done on a *per engine request* basis.
As the complexity of a system grows, it becomes very difficult to write code that
is both imperative and performant. This is especially true of a framework that is
designed to be configurable. What exactly is done, as well as the order it is done in,
and wether or not is can be parallelized, varies wildly based on factors like how
the resources are configured and what capabilities the datalayer has. By implementing
a generic "parallel engine", we can let the engine solve for the optimization. We simply
have to express the various operations that must happen, and what other pieces of data
they need in order to happen, and the engine handles the rest.
Eventually, this module may (potentially) be used more explicitly, as a way to construct
"sagas" or "multis" which represent a series of resource actions with linked up inputs.
If each of those resource actions can be broken into its component requests, and the full
set of requests can be processed, we can compose large series' of resource actions without
having to figure out the most optimal way to do it. They will be done as fast as possible.
But we have a long way to go before we get there.
Check out the docs for `Ash.Engine.Request` for some more information. This is a private
interface at the moment, though, so this documentation is just here to explain how it works
it is not intended to give you enough information to use the engine directly.
"""
defstruct [ defstruct [
:api, :api,
:requests, :requests,

View file

@ -1,14 +1,22 @@
defmodule Ash.Engine.Request do defmodule Ash.Engine.Request do
@moduledoc false @moduledoc """
Represents an individual request to be processed by the engine.
See `new/1` for more information
"""
alias Ash.Error.Forbidden.MustPassStrictCheck alias Ash.Error.Forbidden.MustPassStrictCheck
alias Ash.Error.Framework.AssumptionFailed alias Ash.Error.Framework.AssumptionFailed
alias Ash.Error.Invalid.{DuplicatedPath, ImpossiblePath} alias Ash.Error.Invalid.{DuplicatedPath, ImpossiblePath}
defmodule UnresolvedField do defmodule UnresolvedField do
@moduledoc false @moduledoc """
Represents an unresolved field to be resolved by the engine
"""
defstruct [:resolver, deps: [], data?: false] defstruct [:resolver, deps: [], data?: false]
@type t :: %__MODULE__{}
def new(dependencies, func) do def new(dependencies, func) do
%__MODULE__{ %__MODULE__{
resolver: func, resolver: func,
@ -69,10 +77,63 @@ defmodule Ash.Engine.Request do
alias Ash.Authorizer alias Ash.Authorizer
@doc """
Create an unresolved field.
Can have dependencies, which is a list of atoms. All elements
before the last comprise the path of a request that is also
being processed, like `[:data]`, and the last element is the
key of that request that is required. Make sure to pass a
list of lists of atoms. The second argument is a map, which
contains all the values you requested, at the same path
that they were requested.
For example:
resolve([[:data, :query], [:data, :data]], fn %{data: %{query: query, data: data}} ->
data # This is the data field of the [:data] request
query # This is the query field of the [:data] request
end)
"""
@spec resolve([[atom]], (map -> {:ok, term} | {:error, term})) :: UnresolvedField.t()
def resolve(dependencies \\ [], func) do def resolve(dependencies \\ [], func) do
UnresolvedField.new(dependencies, func) UnresolvedField.new(dependencies, func)
end end
@doc """
Creates a new request.
The field values may be explicit values, or they may be
instances of `UnresolvedField`.
When other requests depend on a value from this request, they will
not be sent unless this request has completed its authorization (or this
request has been configured not to do authorization). This allows requests
to depend on eachother without those requests happening just before a request
fails with a forbidden error. These fields are `data`, `query`, `changeset`
and `authorized?`.
A field may not be resolved if the data of a request has been resolved and
no other requests depend on that field.
Options:
* query - The query to be used to fetch data. Used to authorize reads.
* data - The ultimate goal of a request is to compute the data
* resource - The primary resource of the request. Used for openeing transactions on creates/updates/destroys
* changeset - Any changes to be made to the resource. Used to authorize writes.
* path - The path of the request. This serves as a unique id, and is the way that other requests can refer to this one
* action_type - The action_type of the request
* action - The action being performed on the data
* async? - Whether or not the request *can* be asynchronous, defaults to `true`.
* api - The api module being called
* name - A human readable name for the request, used when logging/in errors
* strict_check_only? - If true, authorization will not be allowed to proceed to a runtime check (so it cannot run db queries unless authorization is assured)
* actor - The actor performing the action, used for authorization
* authorize? - Wether or not to perform authorization (defaults to true)
* verbose? - print informational logs (warning, this will be a whole lot of logs)
* write_to_data? - If set to false, this value is not returned from the initial call to the engine
"""
def new(opts) do def new(opts) do
query = query =
case opts[:query] do case opts[:query] do
@ -596,8 +657,8 @@ defmodule Ash.Engine.Request do
defp try_resolve_local(request, field, internal?) do defp try_resolve_local(request, field, internal?) do
authorized? = Enum.all?(Map.values(request.authorizer_state), &(&1 == :authorized)) authorized? = Enum.all?(Map.values(request.authorizer_state), &(&1 == :authorized))
# Don't fetch honor requests for dat until the request is authorized # Don't fetch honor requests for data until the request is authorized
if field in [:data, :query, :changeset] and not authorized? and not internal? do if field in [:data, :query, :changeset, :authorized?] and not authorized? and not internal? do
try_resolve_dependencies_of(request, field, internal?) try_resolve_dependencies_of(request, field, internal?)
else else
case Map.get(request, field) do case Map.get(request, field) do

View file

@ -83,6 +83,9 @@ defmodule Ash.MixProject do
filter: ~r/Ash.Filter/, filter: ~r/Ash.Filter/,
"resource introspection": ~r/Ash.Resource/, "resource introspection": ~r/Ash.Resource/,
"api introspection": ~r/Ash.Api/, "api introspection": ~r/Ash.Api/,
engine: [
~r/Ash.Engine/
],
miscellaneous: [ miscellaneous: [
Ash.NotLoaded, Ash.NotLoaded,
Ash.Error.Stacktrace, Ash.Error.Stacktrace,