aseigo
Had a little task today, and the quick solution felt a bit like a stupid pet trick … thought I’d share it here as something a little different from the continual stream of questions ![]()
The task was this: we needed all the pair-wise combinations from a set of entries, and these need to (for performance reasons) be batched up. So we have data like this:
[1..10_000]
… which gets turned into batches like this using Enum.chunk_every/2:
[[1..500], [501..1000], [1001..1500], .. etc]
We then want to do some computation with the next value (e.g. 1) against the rest of the values in its batch (e.g. [2..500]) and then subsequently against each further batch (e.g. [501..1000], then [1001..1500], etc). Pair-wise combinations. Fun.
The computation is done async and the batches are generated on request by a GenServerfor consumption by workers. So we need to keep track of where we are in the batching, so we need to keep state. Yuck! State! amiright?
Instead of keeping the chunked data around in a state term, I instead opted to keep a function there instead which captured that data:
batches = Enum.chunk_by(sequence, batch_size)
state = fn -> next_batch(batches) end
It is then used like this from a message handler in the GenServer:
def next_job(state) do
case state.() do
{:done, _} = done -> done
{{subject, batch}, next} -> {create_job(subject, batch), next}
end
end
What is that next_batch call in initial state term, you ask? (Ok, you probably didn’t .. but, then again, maybe you did since you have read this far!)
defp next_batch([[current| rest] | batches]) do
next_batch(current, rest, batches, [])
end
defp next_batch(current, [], [], []) do
done_tuple()
end
defp next_batch(_current, [], [], [[next | rest] | batches]) do
{{next, rest}, fn -> next_batch(next, rest, batches, []) end}
end
defp next_batch(_current, [next|rest], [], acc) do
{{next, rest}, fn -> next_batch(next, rest, acc, []) end}
end
defp next_batch(current, rest, [next_batch | batches], acc) do
{{current, next_batch},
fn -> next_batch(current, rest, batches, [next_batch | acc]) end}
end
defp done_tuple(), do: {:done, fn -> done_tuple() end}
Generators! Ignoring the moderately ugly function headers, the useful bit is that the functions return a tuple containing the result of the calculation (the next batch) as well as an anonymous function that contains the next call to next_pair_job that can be used to get the next job … this allows the “detail” of how next_batch/4 works to be entirely opaque to the calling code.
It calls the function (which is its state!) until it gets a :done tuple. It doesn’t matter how many times it is called as the :done tuple has a function which, when called, itself returns the same :done tuple. As this is used in a distributed application where we can not know the order or number of calls in advance, that’s a necessary attribute to have, and the above manages that elegantly. ![]()
It was just a nice way to encapsulate the actual iteration through the batches so it could be “hidden” from the GenServer using it without cluttering up its own message handlers. Performance was just fine, so the code cleanliness this approach offered was considered to offset the overhead.
During code review it came up as an out-of-the-ordinary approach (though certainly not novel), so thought I’d share it here. Yay, stupid pet tricks! ![]()
Trending in Discussions
Other Trending Topics
Categories:
Sub Categories:
Forums
Popular Tags
- #ecto
- #liveview
- #troubleshooting
- #learning-elixir
- #library
- #deployment
- #erlang
- #testing
- #genserver
- #mix
- #absinthe
- #remote-other
- #otp
- #plug
- #how-to-question
- #macros
- #postgres
- #elixirconf
- #channels
- #exunit
- #discussion
- #code-sync
- #podcasts
- #javascript
- #onsite
- #dialyzer
- #docker
- #authentication
- #umbrella
- #full-time-contract
- #podcasts-by-brainlid
- #ecto-query
- #elixirconf-us
- #ai
- #blog-post
- #elixir-ls
- #phoenix_html
- #iex
- #graphql
- #genstage
- #websockets
- #supervisor
- #advent-of-code
- #distillery
- #processes
- #api
- #forms
- #hex
- #security
- #metaprogramming










Showing Posts 1 to 10- Show Best Posts
- Show All (oldest first)
- Show All (newest first)
peerreynders
It seems the real culprit for containing state isn’t mentioned: closure.
Returning state via a function closure, for example, is at the core of implementing trampolines in JavaScript in order to implement stackless recursion.
And then there is this (2003):
michalmuskala
It’s not entirely true in Elixir because of one thing - you can’t mutate data in the closure. It’s entirely true in runtimes with mutable data.
michalmuskala
There’s one disadvantage of the described pattern - the state is opaque. This will become problematic in some debugging situations - the function is basically opaque and you can’t “look into” it with default logging to figure out what’s going on (unless you use some tricks like
:erlang.fun_info(fun, :env)to get the data bound in the closure).I’d propose a slightly different, but I believe similarly convenient mechanism of a “continuation token”:
This provides similar benefits of encapsulating the continuation state, but makes it more debuggable. Similar approach is used in things like
:ets.select/1for batched traversal of the ets table.aseigo
Yes, that also works and indeed has advantages in terms of transparency.
In this particular case: the generator functions are unit tested and pure, so the only things needed in tracing are the created jobs (which are done with the return values of the state function, but which are not the return values of the state function) and the initial state which is known and visible when the state is initialized.
So while it is opaque, in this case it is in an entirely benign way. A benefit is that one does not need to know what function is being called at all → it could be calling any function in any module, and the generator itself could live elsewhere as well … and if what it calls (and in what order) changes, this is also opaque to the caller. Having to know that the token “belongs with”
Batcher.next/1is not quite as cute, and it makes it harder to move the closure to an entirely other handler at runtime, since each handler needs to know what function that token “goes with”.Still, there are indeed times that is not useful / worth it / possible without contortion, and then it is much better to pass the data about.
peerreynders
The point is that a closure can serve as a container of related data, a role in which objects are typically used and that to some degree one can be used to emulate the other - which you promptly exploit by proposing to replace the closure with a token.
Java for the longest time was using inner classes as surrogate closures and the command pattern emulates a closure.
aseigo
indeed … with generators being one possible specialization of closures: when they are used to control iteration. (Not to suggest all generators are closures, as we know from e.g. comprehensions ..)
Qqwy
I actually think that we can do some Y-combinator-like trickery here to create a closure that, when called, returns new versions of itself that include updated data.
Not that that would be extremely useful, probably, since it would indeed mostly make introspection difficult.
@aseigo Thanks for sharing this technique though! I am wondering currently if it is possible to use some more higher-level stream combinators instead of writing out the recursion of `next_batch manually; this might improve readability. I’ll give it a try.
aseigo
The category is “stupid pet tricks” … they don’t need to be useful, only fun
Should be possible … would be interested in seeing what you come up with if you toy with it a bit!
Qqwy
Please tell me if this code does not follow the requirements to the mapping of data, but here it is:
The output is a list of lists of
:oks, which you might improve upon by gathering errors that might have occured in the creation tasks and combining them, if required.aseigo
While that probably works (didn’t run it to confirm, but I trust you
), it has a serious shortcoming given that this is a combinatorial algorithm: it scales super poorly. An average request from our workload generates ~100,000,000 pairs, which when batched up create some 200k batch jobs (which itself is chunking size that is an optimization over space and compute time).
We don’t want them all created up front as it would mean holding all the jobs for every request in memory at the same time and means we can’t start any jobs until they are all created in the first place. That ends up being non-trivial, especially once we start adding parallel requests into the mix.
But in the realm of “can it be done”, you’ve shown it can