-
-
Notifications
You must be signed in to change notification settings - Fork 1.3k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Query invalidation/updating #63
Comments
Problem: we have queries. The data they returned at some moment can become stale -> we need to know when to refetch it. What we actually need to do is to invalidate them (their last returned/cached data) -> that means they have to be refetched once they are needed again. Optimization is to not only invalidate them, but to immediately update their value, so they don't have to refetch -> sometimes whoever invalidated them has this knowledge and can prevent redundant refetching. Right now we are using react-query pretty transparently to implement queries and actions. react-query offers way to refetch/invalidate specific query either by referencing it via key or by using methods returned by it when used via useQuery hook. Via key is currently not a friendly option for Wasper because cache key is not part of public Wasp API (but we could make it part of it, it is there). Using methods returned by useQuery works for Wasper, but with that we can affect query only if we are next to the place where it is used via useQuery hook, which will often not be true. There are 3 main ways of invalidating/updating cache:
While approach 1 is in theory enough to handle all the cases, in practice it is not easy enough to use, it introduces coupling between queries and actions, resulting with over-coupled code. I am thinking that we should start with approach 3, since it is very attractive and also demonstrates how parts of Wasp work together. Later, we would also ideally implement approaches 2 and 1 to supplement it and offer full solution. Additional rant: |
This might be useful. |
Thanks for sharing this! |
I did some initial reading on optimistic UI updates (updating what query returns without refetching it), so here are some first thoughts. It seems to me there are three main ways to achieve optimistic UI updating (some with support from Wasp, some without):
Explicitly managing state on the clientThis would require pretty much no work from our side, the developer would introduce an explicit state when in need of an optimistic UI update. E.g. they could use Redux or even something simpler if they don't need the state to be global. The query wouldn't be piped directly into the view; instead, there would be an explicit in-memory state between the query and the view. Having a local/client version of the Prisma database (like mini-mongo in Meteor)This might be the approach that would provide the best DX - developer wouldn't need to care about anything, they would just write their queries and actions that would execute against the "client" version of Prisma which would then sync with the "real" db in the background. This is definitely the most complicated approach to implement and there is a lot more to explain and understand, but we can also probably learn a lot from Meteor that did it via mini-mongo. Allow the developer to explicitly mutate the query's response locallyAlso not super familiar with this yet, but saw it in SWR and it looked interesting: https://swr.vercel.app/docs/mutation#bound-mutate. There are probably different variations on this, as @Martinsos explained above - queries could subscribe to events emitted by actions and have strategies on how to react. The potential downside/complexity here is implementing a strategy for each / a lot of queries, which would add a lot of extra logic. But this still needs to be refined further with concrete examples to develop a better feeling about it. Next steps
|
Very nice analysis @matijaSos ! For (1), explicit state, you mean they would not even use the Wasp operations, right? Because if they are using Wasp operations, then they are getting state from queries, and not from some explicit state. I am guessing they could define some explicit state maybe, e.g. using ReactContext or Redux, and then in React components they would listen for both queries and for redux and react to both maybe, not really sure actually, sounds tricky, I would have to give it a try to see if this makes any sense what I am writing. But what I am saying is that if they are doing it all on their own, fine, and if not, if they are doing Wasp actions, then I am not sure how they can manually do anything to do optimistic updates -> if they could I suspect it would be complicated/ugly. Btw. we should separate query invalidation/updating from optimistic updates. These concepts are somewhat intermixing though -> for example when doing query invalidation, we will probably want to allow devs to define their own, manual invalidations -> sometimes that is the easiest way to ensure that correct queries are invalidated by a certain action, instead of letting Wasp trying to figure it out. GraphQL also offers this for cases where it can't figure it out on its own. React query has support for this, if I am correct. It might make sense btw to move this discussion to another issue, just to keep it separate from query invalidation/updating. I am not sure though what "updating" means here, although I wrote this issue some time ago :D, maybe I was referring to optimistic updates? Hm. As for local version of Prisma, sounds like ultimate solution but also like a very big undertaking, as you said. As for the approach (3) -> yeah, I think that would be the best for the start probably. And ok, now that I am writing this, it is certainly connected with the manual query invalidation, to some extent. So, the point is that devs could manually describe how are certain queries to be updated when a specific action is performed. And as you said, that could mean that dev ends up with a lot of boilerplate. But maybe that is a good way to go, as it provides a lot of flexibility, and then when we see how it is used and what the boilerplate looks like, we will be smarter regarding how to make it go away. Ok sorry for this train thought dump, TDLR would be: Makes sense to me, I don't understand (1) completely, (3) makes the most sense as next step, and I think it might make sense to try to elaborate a bit more on all three options, get a bit deeper on all of them, and then it will be easier to choose one of them to continue with (probably (3)). As to your question for mutating query response locally, I am pretty sure react-query does support it. |
All makes sense @Martinsos! I've just been investigating what can be done with In Here is an example of how is mutation defined in And here is an example of how dev can additionally expand mutation definition with The next step in this direction would be to think of what kind of interface to provide for Wasp devs to access this functionality. Are we going to expose more of Another thing I am looking into is how we could right now implement optimistic UI updates by using explicit state. Next steps:
|
Awesome, sounds like a good direction and all those questions make sense to me! If we allow users to access onMutate functionality, that way we can enable them to do optimistic updates, that is for sure. Does that also enable them to invalidate certain queries manually? That is another feature which is actually the main topic of this GH issue and is very close to optimistic updates. I guess optimistic updates need to both set value for a query and invalidate it, right? So it gets updated when the mutation is done? But we do want to allow users to invalidate stuff manually, in case Wasp's automatic invalidation is not granular enough, even if they don't want to optimistic update. We don't have to solve this now, I see these as two separate issues, but on the other hand they also seem to be intertwined to some degree. What is your thought on these, how to do you see their relation? I still don't really understand what you mean by "explicit state"? |
Re explicit local state - this is how things currently work (no explicit state): const MainPage = ({ user }) => {
// react-query ensures reactivity.
const { data: listsAndCards, isFetchingListsAndCards, errorListsAndCards } = useQuery(getListsAndCards)
return (
<Board data={listsAndCards} />
)
}
const onCardMoved = () => {
// ... code figuring out which card was moved, and where.
try {
// This action will invalidate getListsAndCard which will then automatically refetch itself.
await updateCard({ cardId: movedCardId, data: { pos: newPos, listId: destListId } })
} catch (err) {
window.alert('Error while updating card position: ' + err.message)
}
} When the action is invoked, e.g. If we wanted to implement optimistic UI updates via explicit state, we'd do it like this: const MainPage = ({ user }) => {
// Explicit local state.
const [listsAndCards, setListsAndCards] = useState([])
useEffect(() => {
const fetchListsAndCards = async () => {
const result = await getListsAndCards() // We call the query, but get no reactivity.
setListsAndCards(result)
}
fetchListsAndCards().catch(console.error)
// NOTE(matija): empty array ensures that useEffect() will be called only once,
// when the component is loaded for the first time.
}, [])
return (
<Board data={listsAndCards} />
)
} This way we have no reactivity anymore (since the query is not executed via Specifically in this case - in the place where the action is invoked (e.g. in the event handler function const onCardMoved = (setListsAndCards) => {
// ... code figuring out which card was moved, and where.
try {
// OPTIMISTIC UI UPDATE - we must not mutate prevListsAndCards so we have to do all the spreading below.
setListsAndCards(prevListsAndCards => {
const unChangedListsAndCards =
prevListsAndCards.filter(l => l.id != sourceListId && l.id != destListId)
const prevSourceList = prevListsAndCards.filter(l => l.id == sourceListId)[0]
const prevMovedCard = prevSourceList.cards.filter(c => c.id == movedCardId)[0]
// Add card to the target list.
const prevDestList = prevListsAndCards.filter(l => l.id == destListId)[0]
const newDestList = {
...prevDestList,
cards: [
// In case this is also a source list, remove the card.
...prevDestList.cards.filter(c => c.id != movedCardId),
{...prevMovedCard, pos: newPos, listId: destListId}
]
}
// Remove card from the source list.
const newSourceList = {
...prevSourceList,
cards: prevSourceList.cards.filter(c => c.id != movedCardId)
}
if (sourceListId === destListId) {
return [...unChangedListsAndCards, newDestList]
} else {
return [...unChangedListsAndCards, newSourceList, newDestList]
}
})
// ACTION - api request.
await updateCard({ cardId: movedCardId, data: { pos: newPos, listId: destListId } })
} catch (err) {
window.alert('Error while updating card position: ' + err.message)
}
} This is how we can do it right now in Wasp, and that would work! I implemented it in branch In my current understanding, there are two main ways to achieve optimistic UI update functionality:
Next steps
|
@matijaSos makes sense now! One thing to mention is that if you are using local state approach, the action has to happen in the same react component, or possibly a child, in order for it to have access to the local state. If it is some other part of the web app, then you need Redux or react-query or some other solution. |
If we start with the most simplistic approach, just trying to provide the most direct interface to main.wasp: action updateCard {
fn: import { updateCard } from "@ext/actions.js",
entities: [Card]
// maybe some better name than onMutate, e.g. onInvoked or onActionWasCalled ?
// Since react-query's onMutate() is called before the mutation, we could simply call it beforeAction?
onMutate: import { onUpdateCard } from "@ext/actions.js"
// This function would need to:
// - access cache (last fetched result) of getListsAndCards and mutate it accordingly
// -> dev needs to be able to reference certain query (getListsAndCards) in js code.
} ext/actions.js: const export onUpdateCard = async ({cardId, data}, queryClient) => {
// Cancel any outgoing refetches (so they don't overwrite our optimistic update) - from react-query example
//
// NOTE(matija): can we automate this? On the other hand, dev needs to somehow specify that getListsAndCards query
// musn't be auto-refetched (because of entities: [Card] in action definition above), but I am not sure if this is the right
// place to do it?
//
// Another thing is we don't have a way to reference queries in js code. We could auto-generate their names, or let users
// define the id in the query definition?
await queryClient.cancelQueries('getListsAndCards')
// Snapshot of the previous value
const previousListsAndCards = queryClient.getQueryData('getListsAndCards')
// Optimistically update to the new value
queryClient.setQueryData('getListsAndCards', old => {/* opt. update logic */})
// Return a context object with the snapshotted value (it gets forwarded to onError handler in case sth goes wrong here,
// so we can restore things back to the original state.
return { previousListsAndCards }
} So it would look something like this! The main problems/questions that I see:
Next stepI'd say it's investigating further about the last point - how to make sure that declarative and imperative worlds make sense together in this case, so we preserve the truthfulness of our Wasp declarations. |
This makes a lot of sense to me!
What about just invalidating certain query, what if we wanted to do that? That means it would keep its current state, but it would know it is stale and would therefore refresh as soon as it can. I guess that would just be a bit different call to anoher method of queryClient right? It is what we do with our automatic system, but I wonder if you could do it directly here manually if needed -> I think you could. There is also this analysis of mine, at the beginning of this issue: #63 (comment) . How does that play with what you are looking at now regarding onMutate? If we develop automatic invalidation in one of the directions I explained here, does that possibly mean that in onMutate we don't need all the manual control that queryClient gives, but maybe just a subset of it, because a lot of stuff can be done via automatic invalidation and its label system? What subset could it be, and does that maybe allow us to be more declarative / smarter? One thing I also suggest there is adding this layer of "events", so that actions wouldn't directly update state of queries or invalidate them -> instead they would send "events" that suggest that certain query should be updated or invalidated. Then, that query has its own piece of logic where it can decide to which events it responds and how. This is pretty abstract, but it decouples queries from actions, because event doesn't have to be "I want to update query Q1 to this state", instead it can be "I just deleted resource R" and then query Q1 is the one that has logic which listens of events deleting resource R and reacts to it. Suddenly query doesn't know about action and vice versa -> instead they both just care about that common resource. And this is how we actually think -> when you made that action in that code example update that query, you did that because you know that query cares about that specific Card. So it is all about the Card really. So why not capture that in the code? This actually results with semantically richer code, that explicitly captures the relationship between that action and query, while also decoupling them. Now you could have multiple actions doing deletion on Card and query doesn't need to care that there are multiple of them. So we don't get N to N combinations of relationships between queries and actions. I pushed this in somewhat more complex direction now, but I think it is worth exploring it right now because it is all connected and even if we decide to go with simpler approach for now, it would be good to understand upfront how it plays with the longer game, and if it is step in the long term direction. |
Btw this approach with events that I described -> I wonder if we will end up with something similar to Redux if we go in that direction. Not sure if that is what we want since we wanted something less boilerplatish. Just something to be cautious of. |
I won't pretend I have absorbed this whole discussion fully, but here goes... What strikes me is that the discussion is focused on effects that are immediately sent to the server, affecting the database. Besides these in UIs we have deferred effects (draft state) and ephemeral client side effects. Ephemeral effects we may forget about for now because they are not persisted and temporary, which hopefully means component state with useState is sufficient. What also strikes me is that I had little in the way of stale cache issues in some big applications I built because the entire issue was mostly sidestepped or made explicit on the client.
So when I read the discussion focusing on cache invalidation it made me wonder how far a coarse grained "fetch the data again for this page" can take us. (NextJS does this with getServerSideProps) Perhaps more concrete application use cases are in order to explore this better? The notion of manipulating the query result directly is an interesting one. If we defined the idea of a payload (call it a resource or aggregate) that is symmetrical between query and update suddenly we gain a lot of properties we may be able to exploit. But I am not yet clear how this ties into the cache invalidation topic. |
@Martinsos good question about the An interesting idea re entity-related events that queries can listen to! It would definitely be a big benefit to avoid n-to-n situation where each action has to update all the queries that are affected by it. @faassen good points! I agree, optimistic/instant UI updates are often not that common/critical. It is just that this was the road where Waspello took me so I decided to explore that direction and see where we come :). We should still be able to do it in Wasp somehow - current way of doing it all via local state is quite impractical and we lose all the benefits of query/action system. But also agree on exploring/defining use cases and identifying all the avenues, and also noting how commmon/important each one of them is - this is a wide issue so as we progress and get more clarity I believe we'll be able to split it into several sub-issues. |
Cache invalidationSo invalidation is about tracking state used by queries and actions so that when an action affects a query result, we can invalidate (and possibly immediately reload) the query that produced it. The simplest form of invalidation that would work is to invalidate all queries as soon as any action takes place. But this is inefficient - so we want to be more precise. This precision can be:
Explicit invalidation creates a degree of coupling - the invalidating application code needs to somehow have a handle on queries to invalidate. Looking at it from a UI perspective, the user needs to touch another part of the page. Or, if caches aren't cleared when the user navigates to a new page (are they?), anywhere in the application. If we were to automatically clear all query caches upon navigation at least the problem becomes more localized, and the coupling is reduced. The user does not need to invalidate some query in an collection page if they change some data in an item page. So we go to the alternative, let the developer declare some relationship between actions and query. Currently that is entities. If an action touches an entity, that means all queries that use that entity are invalidated. The user is responsible for declaring all entities that a query needs (even if it's implicit because a relationship is navigated), and all entities that an action touches. This adds more precision. Is this precision too low, and why so? Or is it too easy for a developer to make mistakes? Please let me know whether I'm missing something too, as I am not sure I understand the entire discussion Optimistic updatesThe other related topic is optimistic updates. Since queries produce a local cache on the client, the discussion on optimistic updates then goes into directly modifying this cache along with issuing the equivalent action. The problem with this approach is that the developer has to remember to do both, even though they're expressed very differently. And what then happens to automatic invalidation? Would you turn it off? There are two use cases I can think of for optimistic updates:
So what if you turn this around and let the primary state be the truth? Instead of optimistic updates, you'd modify the primary state on the client - the primary state and any derived state changes as a result of that. Besides this, you also automatically update the server. This can be done immediately as the state changes (a "live item") or as a result of an explicit save action by the user. This can be combined with entity based cache invalidation. As soon as you save an entity, any queries that use that entity have their cache invalidated. Or in case of a live item, the cache is invalidated as soon as you touch the entity - this would have a problem as the query producing the item would rerun right away, which isn't what we want, so this needs to be disabled temporarily somehow for writable items (but not for read-only items). If you have an "item" abstraction where you have a symmetrical get and update, where the user writes get and update by hand, the user needs to declare explicitly how the get query and update action depend on entities. But if you have an automated item with knowledge about containment and relationships, the |
I just learned that Redux Toolkit (RTK) uses the mechanism extremely similar to what I have been describing above as smart invalidation via resources/labels! They call it "cache tags": https://redux-toolkit.js.org/rtk-query/usage/automated-refetching. It would be smart to take a look at how they did it once/if we decide to go in this direction. Also, this a +1 data point for this direction! |
RTK also has this section on manual cache updating/invalidation: https://redux-toolkit.js.org/rtk-query/usage/manual-cache-updates . They have a nice explanation of use cases for manual cache updates:
Specifically, here is their take on optimistic updates: https://redux-toolkit.js.org/rtk-query/usage/manual-cache-updates#optimistic-updates -> basically on start of query/mutation you manually update some other query. They also introduce the concept of pessimistic updates, which is basically just manual query updating but after the mutation resolved. Again it is done by manually updating the query cache when mutation resolves. https://redux-toolkit.js.org/rtk-query/usage/manual-cache-updates#pessimistic-updates . |
That is a pretty good summary of it! Reading about RTK, it seems that having an invalidation based on resources/labels could already cover most of practical cases. Therefore it seems that with medium effort we can already get a lot of value there. As for client state becoming source of truth in optimistic UI updates -> makes sense, although I guess one way to look at it is to say that client state is cache state in this case, and therefore that is exactly what we do when we update that cache. The thing is, we do not have this translation from "update I just did to cache" to "update I need to do on server", we can't derive that easily. Maybe we could if we introduced some kind of mechanism to describe the update, enabling it to then be applied on multiple sides. Comes down to the question "how can we avoid duplication of update logic between optimistic UI update and server update". |
Hey, how are you doing? what about this issue? |
@CabralArthur It's a loaded issue so it's difficult to say without knowing your needs? What's your use case and what would you like to see? |
This comment reflects current state of the issue. Check comments below for discussion.
There are three stages, and we plan to implement them in order, from basic towards most advanced:
The text was updated successfully, but these errors were encountered: