MarthinL
Background: In response to the l unrelated question about TreeView in LiveView a lively yet off-topic debate about the pros and cons of using streams was in progress. This is my attempt to give the highly relavent streams discussion a more appropriate home and share my thoughts on that as a point of departure.
Summary: Amidst general consensus about the virtues of LiveView @garrison warned against nested Streams on the premise that Streams negate the declarative (later re-labelled to React-like) heart of LiveView which forced him to turn to imperative (later relabelled to jQuery-like) code to handle the intractible amount of boundary conditions required to accomodate cascading changes.
Context: For the purpose of this discussion let’s abstract LiveView as: a declarative mapping between structured server data and HTML where event handling is (by default) rigged to relay to server. When (in response to a relayed event or a change to an active subscription) a change in the data is detected the changed part of the data is sent to each affected client (session) which calculates the impact on the DOM (based on definitions extracted from the declarations) which are then passed onto pre-written JS code in each client to patch the DOM.
In that context, Streams in their native use-case are (often partial / paginated) lists of Ecto Schema structs for which LiveView (server-side) is able to determine how additions, deletions and changes to individual structs should impact the DOM.
Workload: For simple lists of structs sourced straight from an Ecto query with limits and offsets it is straight forward to determine the changes in order to send only the changes to the client. The workload on the server and clients are of complexity O(n) where n is the query window size rather than the total number of records on file.
Enter the Dragon: When the underlying data changes from “a (section of a) long list of small, independent structs” to “a short list of very large (deeply nested and/or recursive) structs” the change detection and handling algorithms are bound to see a change to any descendent structure as a change to the parent, and as such most of the root structures in the stream appear to have changed requiring them to be sent down to processing to be dealt with. That’s (clearly) not a desirable outcome.
The real issue: For complex structures (in the sense of having many associations preloaded for the presentation layer, generally referred to as nested structures) the current change detection rules are justified. The issue arise when dealing with recursive structures, i.e. where the same constellation of associated schemas re-occur in the data an arbitrary number of levels deep. This way it can easily happen that the entire contents of a database rolls up into a single root structure. Put that root structure in a stream and every client gets sent the whole database every time someone sneezes.
A derivative problem: In the thread leading up to this a somewhat heated debate arose from blaming Streams for forcing users to write an impossible amount of special case code to counteract its intrinsic change detection and handling logic. It’s my personaly impression that the member holding Streams responsible for that appear to have been attempting to write those intervention and special cases either on the client itself or in some other way at an inappropriate level of abstraction. I might be wrong about that but even if I’m not I confess to having great empathy with the struggles related to streaming recursive content. I just don’t want this discussion from getting distracted by that particular (potentially misguided) set of challenges.
What to discuss: I believe there is a valid and relevant discussion to be had about different approaches for managing the LiveView presentations of indefinitely recursive data. The need to mitigte against runaway recursion is obvious, but once we’ve gained control over that, the objective is to enable LiveView and Streams to detect and address changes at the level of recursion where they happen and nowhere else.
Why? My application’s data is modelled as indefinitely recursive data, actually several aspects of it follow their own independent indefinitely recursive structure, so there is nothing hypothetic or theoretical about this for me. It’s a real and pressing issue.
My current approach: As such, I’ve have to look into ways to mitigate against false positives in terms of LiveView chance detection with or without streams. I can summarise my current approach as streaming MapSets rather than native Ecto schema structs. It works well where I’ve implemented it for a subset of data but it’s not yet suitable as a general pattern to apply to my primary data where the consequences of getting it wrong are far more grim.
Some Ideas: As a general principle (MapSet does something similar but it might require something custom) I see a useful but under-utilised correlation between a tree or even graph of related records and a stream of records. An array of related nodes seems to be an established way to represent of a tree or graph on file or in memory. We already know that Elixir’s clostest approximation of arrays, List, is really a (double?) linked list in memory. By implication we could derive a robust mapping between a recursively associated schema and a linked list traversing its nodes in depth-first order.
The case, where each node equates to a single struct with an identifiable parent_id pointing at the parent node, is trivial to specify and implement, but that’s not my reality.
The recursion in my data is technically indirect recursion, i.e. the relationship between two structs of the same schema is through a struct of another schema. It might be slightly more challenging to specify to some implementation code what should consitute one level of recursion but based on my own experiences it’s fairly easily achieved using preload semantics. Basically you can express the definition of an arbitrarily complex recursive node structure as a preload specification which references the same schema at its base and the deepest level of on of the preload chains.
With reference to Gall’s Law (which featured in the original debate) I have dabbled enough with procedural implementation along these lines to be confident about the feasibility of achieve a declarative implementation.
But then: Without a reliablly paginatable data source begind it, the Stream value proposition is severely limited. While it is possible to preload the data to an arbitrary (yet controlled) depth first and then apply this tree-list mapping algorithm to extract the data for the stream, there is another option too. I personally had zero success trying to get recursive_ctes and with_cte working in Ecto. But the PostgreSQL query construct it is based on (similar constructs exists in other databases I’ve worked with slightly different terminology and semantics) returns a one-dimensional recordset which corresponds directly with the list representation of the recursive data we’re looking to not only preload but stream as a list of independent nodes. I see an opportunity in a possible declaritive implementation of recursive streams to generate the requisite recursive cte at (or closer to) the Ecto level, load the result into the list-representation first and then run the mapping algorithm to patch the associations in the tree/graph view to point to the same nodes.
Objective: There’s still a lot to consider, but I’m excited by the prospect of doing this eloquently enough to make it useful without needing any changes to the Ecto schemas and context app code involved. All existing code should be able to function as they do right now and there should be a “new way” of doing things in future. The only impact should be that it should become possible to specify that a stream contains recursive data with a node structure given as a preload expression, and be assured that change detection and propagation will be as efficient as they are for non-recursive data.
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
- #metaprogramming
- #hex
- #security










Showing Posts 21 to 30- Show Best Posts
- Show All (oldest first)
- Show All (newest first)
garrison
The
update/2clause that comment is placed before pattern matches on the currentsocketto determine whether theentryis already set, and if so it avoids streaming the children. In effect, it ensures that the children of a parent are only ever rendered the first time the tree is rendered (via the secondupdate/2clause, which is only reached whenentryis not yet set), and after that they have to be updated individually by asend_update.In fact, as I read more closely, I don’t believe it’s possible to update the structure of the tree at all in this example - but of course it is only an example.
MarthinL
Ah, now I notice it. Cool, thanks.
In my rant about handling structure separately i conclude not having access to the structure via the LiveComponent for each node is probably a good thing. TL/DR: I’m given to the idea of splitting the problem to separate concerns - one dealing exclusively with structure and the other with the contents of each node.
PS. I did better catching the gist of the gist once I realised I should regard TreeComponent as the NodeComponent because it doesn’t operate on the tree, only on a Node.
garrison
Note that this is what I described in one of my more recent replies (about the maps), and I found that approach to be helpful when dealing with updates that come in (via PubSub in my case) which touch a given record. The reason I took that path is that, otherwise, I would have to write recursive functions to “patch” the tree and I prefer to just use indirection (especially since I have very complex functionality which lends itself to recomputing the final tree as I explained).
However, what I meant with regards to the example was simply that, because the
childrenlist is never re-rendered, the structure of the tree can never change: i.e. you could never re-order a set of nodes, or move a node elsewhere. But it is just an example.Ah, but if you were a philosopher you would recognize that a Node is nothing more than a smaller Tree
MarthinL
I didn’t notice it to be the same, and still don’t to be honest. It’s similar, related or even converging, but you described (the map) as a way to keep the nodes in a flat structure but if I understood correctly each node would still contain its own child list, whether that’s in effect a copy or a list of references by map key, the structure is still within the data. I’m proposing taking that just one step further and moving the knowledge about each node’s children out of the flattened data. Practically this means the association representing the list of children of a node reverted to :not_loaded.
The structure exlusively lives in a different calculated value and passed to the appropriate LiveView component as an assign, and a very lightweight one at that since the structure data fits snugly into a list of either integers or 2-tuples with an integer and recursive list. Once the structure is in that form and completely independent from the data/HTML associated with each id in the tree, any updates to it can be succintly reduced to a series of primitives which becomes the basis for updating the rendered content correctly in any situation from loading a different root which replaces all the content to adding or moving child nodes to updating a parent without touching any children to moving any portion of the tree from one parent to another. Those are all things we know how to do with trees and therefore with simple nested lists with a small enough memory footprint so we can make and compare several interim copies of it if we need to at very low cost. Plus you only need to do it once, ever. None of what you implement in structure manipulation gets affected in any way when there are changes to the content schemas.
Really, O(1)? I’d buy O(log n), but for the map code to find the key it has to, one way or another, search through the keys. My understanding is that the keys are kept in a balanced tree of sorts to minimise the number of comparisons it needs to make on average to find a key, but unavoidably as the number of keys grow access time must eventually get slower. I was also lead to believe that maps, sets and mapsets all use the exact same mechanism anyway, so I think your assumption that, being sets, mapsets would unavoidably be slower might be a little off or even false.
The way lists live in memory means all access has to be from the head walking recursively into each tail. That fundamental recursion is reduced to mere iteration through the magic of (originally Erlang’s) tail recursion detection and optimisation. The array-like semantics of lists is purely an illusion. It does come with substantial benefits, not the least of which is that a list may contain not only variable size elements but also elements of different types. Most of the list versions of Enum has been operating at maximum optimisation levels for a long time resulting in really good performance, but it will never be actually possible to access it like an array by calculating an element offset from an index.
Several of the underlying Erlang data structures including sets and gb_trees if I remember correctly, use internally defined and managed memory constructs which are opague to the user. That allows them to forfeit variable length elements and store data in fixed element size arrays with actual array access performance, i.e. calculating the address of an element as a starting point plus an index times the element size. This resulted in some highly efficient techniques from the world of advanced data structures and algorithms becoming available in Erlang and through that also to Elixir where they ended up being put to use to implement maps.
Erlang didn’t have maps (it had records, with metadata only at compile-time, not runtime, which wasn’t great) until Elixir formulated and implemented the concept which ultimately made its way bak to Erlang. The core of Elixir is still written in Erlang and I believe the data structures used to create Map wasn’t custom written exclusively for Elixir’s Map type. Map either used pre-existing Erlang library functions or what was done to make Map run faster was not exclusively for Map but helped improve efficiency for many other structures as well.
Disclaimer: It’s all open source but I’ve never been directly involved in creating or maintaing any of the Erlang, OTP, Elixir, Phoenix, Ecto or LiveView code nor have I made any sort of habit of trying to understand the underlying code. I’ve merely been professionally aware of Erlang from before it was open-sourced, evangelised many of my peers into taking a look at it who built their entire careers and businesses around it since, written a few small systems in Erlang and planned to write my life’s work’s proper server in Erlang until I discovered how far I can get how fast using Elixir and Phoenix instead. Which is a long way of saying I don’t know these things for fact because I lived it, but through keen observation over a long time, albeit usually at a fairly abstract level only. I did recently go read a bit of the Elixir code for clues as to how I might implement this unicorn dual-natured structure I thought I needed. Only after that (and the confirmation that Elixir lists are directly based on Erlang’s lists) did i truly realise that what is called a linked list is really a completely unbalanced tree turned on its side. That’s when all the pieces of the picture fell into place for me and I was able to see that all the “pointers” I thought I’d have to implement and manage in keeping with both Elixir and Erlang’s immutable data principles are already present in the list construct. It is absolutely perfect for the job and I couldn’t have hoped for a more optimal set of tools to manipulate them with already tested to the fullest possible extent.
It is clear as day to me that nested lists of primary key values represents the structural part of a tree so accurately and effeciently that there is not a shred of doubt in my mind that it’s the ideal choice of how to keep the strucure of any portion of the data in memory.
Yeah, or because I am one I differentiate between a Node and a Tree.
garrison
Well no, not exactly. Associative arrays in most programming languages are built on hash tables, which are
O(1)if there are no hash collisions. Which is a big if, but people usually call themO(1)even though I think it’s technicallyOmega(1)for that reason. Note that the trade-off versus a tree here is that hash tables are not order-preserving.Erlang maps are actually technically hash array-mapped tries which are a more exotic hybrid data structure. I’m not sure the exact performance characteristics but you can investigate further if you are interested
But sets have no method to retrieve an object by key at all - you would have to brute-force iterate through the whole thing.
The use-case I was describing was to store the tree in one data structure (like you said) and then join it with the associated records by looking them up in a map. You would not use a set for this.
The fact that sets internally use maps is an implementation detail.
garrison
Not quite. Let me try to demonstrate what I did but in the context of the (simpler) files/folders example.
There are three database tables with corresponding Ecto schemas:
nodes,files, andfolders. Thenodesstore the structure of the tree and contain foreign keys pointing tofilesandfolders. Thefilesandfolderstables then store metadata, like names and so on.This tree only has two nodes, but you could imagine an arbitrary filesystem structure. Now, you could write a recursive query to load the tree from the database. You then might join every
Nodewith its associatedFolderorFileto get the whole tree.The annoying bit, though, is that now if the user updates a folder (a rename, say) then the new version will come in over PubSub. And you have to recursively walk the tree, find that Folder node, and update it. This is valid, but there is another approach: use indirection.
Instead, you could avoid joining the files and folders to the nodes in the query, and instead load them separately. Then you can store them in maps (
files = %{file.id => file}and so on) and join them at runtime by looking up theFilefor afile_idon aNode.Now you can simply update the
foldersmap when a newFoldercomes in over PubSub, and then it will appear next render. I think this is in the same vein as what you are talking about, but correct me if I’m wrong.There is one problem with this approach: LiveView is not smart enough to diff the arbitrary
folderslookups, so any change tofolderswould re-render the entire tree and send the whole thing down the wire. I avoid that problem by materializing the tree myself into a simplified representation which I then pass to the LiveComponents. So the “simplified tree” comes out looking like:This might sound like a lot of extra complexity, but my use-case is very complicated because the UI is highly interactive and has a lot of moving parts. I iterated several times before arriving at this design, which I found to be radically simpler and more performant. Your situation may vary of course.
MarthinL
I think we’ve misunderstood each other on this. Map for the flattened content is spot on and I was discussing how one can store the structure as nested lists. I believe you made reference to using maps in the context of the streams discussion before I decided to physically split the structure and content. But it’s no issue, we’re in agreement that the bulky node data can live in maps if you’re going to keep them in memory, or loaded from database if you need to save on memory and if you need it, they can go into streams as well. As long as you can “surgically” remote control the DOM, i.e. to change the children shown content of container or not depending on how the structure changes, it should work well.
I haven’t yet figured out if it really will require a LiveComponent per node as per the example or not or what the overheads for that is like, but I’ll get there.
garrison
If you want to minimize diffs over the wire you will probably want one LiveComponent per node as discussed previously. The overhead for LiveComponents is not very large, they are really just a vehicle for maintaining the diffs and that overhead is obviously unavoidable. Importantly LiveComponents live in the same process as the parent LiveView so when you pass things to them they will share that memory (no copying).
MarthinL
Yes, that is a LiveView challenge that to date we’ve only seen one viable solution for. I’m hoping more will come to light.
Our approaches are converging. I’ve taken the simplification of the tree view a whole lot further until it genuinely only contains the IDs by which to find the actual content from whereever they are, but it’s the same principle. You’re storing the simplified tree still in nested maps, I’m using essentially nested lists or lists of either integers (meaning it’s a leaf node) or tuples {id, child_id_list} meaning it’s a branch or node with children.
For completeness, I’m actually storing the structure in two parts. Both are list based, but the semantics are slightly different. The first is a straight list of ids representing the path to the node that forms the “root” of the tree data being displayed, like breadcrumbs. All the nodes in that list (must have) children (in order to be part of the path) but I’m not storing any detail about how many other siblings each node miight have or such as that’s not relevant data. The second part is the recursive structure of the part of the data for which HTML has been generated and sent to the client that’s been extracted from the nested associations before they’re flattened (with the recursive association reset to not_loaded).
MarthinL
The jury is still out (in my case) about that (both the diffs part and the unavoidability of the overhead). I don’t have sufficient command over all the tools at my disposal just yet. It is obviously in my interest to keep the load per user session to a minimum, but there’s still too many variables and at some point it will have a run-in with the law of diminishing returns.