Git Product home page Git Product logo

Comments (18)

Martinsos avatar Martinsos commented on May 10, 2024 2

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:

  1. Directly -> action directly invalidates/updates cache of specific query/queries. This could work via react-query but as explained above, it does not work well enough at the moment. We could improve this by either making query cache key part of public Wasp API or by exposing functions for invalidating/updating the cache directly, together with the query function itself (but keep in mind we don't yet have caching when using the query function directly, this is also something we will want to add in this case).
  2. Via Resources -> Each operation (query/action) defines which resources it works with, where resources are arbitrary labels. When action using resource R is done, it means all queries using R need to be refetched (their cache is invalidated). This could be developed a lot, adding more advanced rules like layered resources (Task:id:42) and automatic inference of which resources does query/action depend on (if query Q1 uses query Q2 which uses resource R, then query Q1 also uses resource R). This sounds like the most practical approach, since it gives a lot of flexibility and power, while not being very hard to use. This all makes sense for invalidating, but what about updating? Well, we could use some kind of event system for updates -> actions sends event, and then queries can listen for that event and possibly decide to update themselves based on it, and not just invalidate. Actually events are more generic then resources then, they are the "core" concept actually, resources are just nicer specialization.
  3. Entities as Resources -> We could have Entity as special case of Resource, conceptually: query and action define which entities they use, and then we treat those entities as resources -> if action using entity E is executed, queries using entity E are invalidated. This approach is inferior to Resources approach, as it works only for Entities and is not very flexible (what if I want query to use Entity but not to treat it as a Resource regarding caching, because it is more performant for me to handle the cache directly/manually?), but it is very natural for the Wasper -> they don't even have to think about caching, they just use Entities and the rest happens on its own. To make this more effective, it should be so that this is the only way to use Entities in operations -> meaning that it should not be easy/possible to import Entities in operations, instead they should be injected into operations by Wasp based on which Entities is operation using.

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.
Approach 2 is great because it handles a lot of stuff automatically, decouples actions from queries regarding caching, and is easy to use. In theory it could do all that approach 1 can, but it might become impractical if we try to cover direct updates/invalidation of single queries, so it is probably best to combine it with approach 1.
Approach 3 is very natural to use and you don't even know that you are using it, but alone it is not enough to cover all the use cases. It is more of a "happy use case" that is cool when it works, but is not really a complete solution.

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.
Or, we could skip approach 3 and go directly for approaches 2 and 1.

Additional rant:
For all the approaches, we should keep in mind that right now cache is used only when using useQuery hook. If we want it to work also when using the queries directly, we need to add support for this. But in which case is this needed? Welll, it can be situation in which we are responding to query but not via hook, but instead via callback (react-query has support for that). Or, it can be situation in which we are calling it directly, just as normal function (and it still uses cache, or not if we tell it to force fetch).

from wasp.

Martinsos avatar Martinsos commented on May 10, 2024 2

This makes a lot of sense to me!

  • onMutate happens when exactly -> right before the mutation/action is called? Why don't we just put this code at the start of the action then? I know this is a silly question and there is a reason, I just can't remember it.
  • Aha so they first cancel the existing queries (ok, because they don't want them to happen a bit after this and modify state -> we assume we know what state should be and any existing queries are obsolete). Makes sense to me.
  • Providing access to queryClient -> ok, we could inject it like that, that sounds good. Or we can provide some kind of abstraction over it if we figure out that there is only a closed set of operations we need to do on it.
  • Referencing queries in code -> yeah, you are right, we need that. One way is to import it from Wasp, via smth like `import getListsAndCards from "@wasp/queries/getListsAndCards" -> something in this direction. That way we get the id that Wasp generated for this query, without knowing its exact value.
  • the relationship -> ok, so that query still depends on Card, but there is a case where when this specific action is called, that automatic dependcy is canceled and we set the state manually. That might not be so bad -> we have override in this specific use case. It doesn't mean that it is false that specific query depends on specific entity, it just means there is an ability to override it sometimes. It can be cumbersome to track this stuff though -> what are all the pieces of code that are affecting that query? That becomes hard to track. We could, in Wasp-lang, declare which queries are affected by specific onMutate and then enable that onMutate to modify only those queries, that could be a way to obtain that information in wasp-lang -> in that case we wouldn't import query id via JS but would inject it into the function, similar like we do with entities. Information wouldn't be centralized, but it would at least be completely captured in Wasp in declarative manner. So maybe you could ask Wasp what are all the places that affect that query and it could list them, that is something. Or we could actually centralize it all, so that just looking at that query tells you about all the places that modify it -> instead of declaring next to onMutate of specific action that there is a query it affects, we could instead declare in the definition of that query that there is an action that might modify it -> so we just move that declaration into the query declaration instead of action declaration. Then you can't see for an action all the queries it modifies (although you could ask Wasp), but you can see for a query what are all the actions that modify it.

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.

from wasp.

Martinsos avatar Martinsos commented on May 10, 2024 1

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.
Invalidation/updating ensures that correct Wasp queries are triggered once Wasp action is successfully performed (once server responded!).
Optimistic updates are what happens in the meantime, while waiting for the response from the server.

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.
On the other hand, when doing optimistic updates, we don't want to invalidate queries, instead, we want to update them -> so we are going a step further, we are not saying they have incorrect data, we are supplying them with new data, but manually.
So in that sense the two concepts are similar, but they are still different.

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.

from wasp.

Martinsos avatar Martinsos commented on May 10, 2024 1

@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.

from wasp.

Martinsos avatar Martinsos commented on May 10, 2024 1

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.

from wasp.

faassen avatar faassen commented on May 10, 2024 1

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.

  • Each page would always reload its state.
  • For collection / data table views, operations that affect the state would reload it explicitly, triggered from the UI, similar to how pagination does it.
  • For pages with forms draft state is loaded and the form is generated from it. Draft state is client side so can be manipulated freely until a save takes place. This would either trust optimistic updates, or navigate to another page.

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.

from wasp.

Immortalin avatar Immortalin commented on May 10, 2024

This might be useful.

from wasp.

Martinsos avatar Martinsos commented on May 10, 2024

This might be useful.

Thanks for sharing this!
I knew about SWR but never used it. I just read https://blog.logrocket.com/caching-clash-useswr-vs-react-query/ and well, it might make sense to consider using SWR at some point instead of react-query, especially due to the ability to quickly perform local update while waiting for the response. We will certainly have to give it some more thought though!

from wasp.

matijaSos avatar matijaSos commented on May 10, 2024

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):

  1. explicitly managing (global) state on the client
  2. having a local/client version of the Prisma database (like mini-mongo in Meteor)
  3. allow developer to explicitly mutate the query's response locally, something like https://swr.vercel.app/docs/mutation#bound-mutate

Explicitly managing state on the client

This 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 locally

Also 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

  • learn more about 3), mutating query response locally. Does react-query also support it?

from wasp.

matijaSos avatar matijaSos commented on May 10, 2024

All makes sense @Martinsos! I've just been investigating what can be done with react-query, here is what I learned in short:

In react-query, when defining a mutation, a developer has an opportunity to provide a middleware logic via onMutate(mutationArgs) (there are also a few others) that is invoked immediately when a mutation is called and then in there access a specific query's cache and update optimistically its result (e.g. add a new todo card to the list of cards that was previously returned by a query).

Here is an example of how is mutation defined in react-query (no optimistic stuff here), just to give you an idea of the syntax: https://react-query.tanstack.com/guides/mutations

And here is an example of how dev can additionally expand mutation definition with onMutate() as mentioned: https://react-query.tanstack.com/guides/optimistic-updates#updating-a-list-of-todos-when-adding-a-new-todo

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 react-query internals, or is there a way to avoid it? Is the interface for accessing the query's cache going to be available in a DSL (e.g. a js function in main.wasp), or purely in js?

Another thing I am looking into is how we could right now implement optimistic UI updates by using explicit state.

Next steps:

  • come up with ideas for an interface in Wasp to access react-query's onMutate() functionality
  • see how we can implement optimistic UI updates (in Waspello example) via explicit state

from wasp.

Martinsos avatar Martinsos commented on May 10, 2024

All makes sense @Martinsos! I've just been investigating what can be done with react-query, here is what I learned in short:

In react-query, when defining a mutation, a developer has an opportunity to provide a middleware logic via onMutate(mutationArgs) (there are also a few others) that is invoked immediately when a mutation is called and then in there access a specific query's cache and add update optimistically its result (e.g. add a todo card to the list cards that was previously returned by a query).

Here is an example of how is a mutation defined in react-query (no optimistic stuff here), just to give you an idea of the syntax: https://react-query.tanstack.com/guides/mutations

And here is an example of how dev can additionally expand mutation definition with onMutate() as mentioned: https://react-query.tanstack.com/guides/optimistic-updates#updating-a-list-of-todos-when-adding-a-new-todo

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 react-query internals, or is there a way to avoid it? Is the interface for accessing the query's cache going to be available in a DSL (e.g. a js function in main.wasp), or purely in js?

Another thing I am looking into is how we could right now implement optimistic UI updates by using explicit state.

Next steps:

  • come up with ideas for an interface in Wasp to access react-query's onMutate() functionality
  • see how we can implement optimistic UI updates (in Waspello example) via explicit state

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"?

from wasp.

matijaSos avatar matijaSos commented on May 10, 2024

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. updateCard(), getListsAndCards will get invalidated and refetch itself. But with this, we also get the delay which is the problem we want to solve with optimistic UI updates.

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 react-query) and if we want to change the UI we have to manually update the local state listsAndCards..

Specifically in this case - in the place where the action is invoked (e.g. in the event handler function onCardMoved()), besides invoking the action updateCard(), we also have to manually update the local state listsAndCards. It would look sth like this:

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 matija-test-ouiu-no-useQuery. We could do it in a similar way via e.g. Redux as well probably (maybe there are only a few small things in Wasp missing to support that, but shouldn't be far away), but then we also get all the boilerplate that comes with it.

In my current understanding, there are two main ways to achieve optimistic UI update functionality:

  • via explicit state (local or e.g. Redux) - no reactivity, each action/mutation has to also provide logic for updating that explicit state
  • via hooking into onMutate interface by react-query - we keep reactivity, but each action/mutation has to provide the logic for updating cache (previous result) of all the queries for which we want to have the optimistic UI update functionality.

Next steps

  • investigate interfaces that we could provide towards react-query's onMutate() via Wasp.

from wasp.

matijaSos avatar matijaSos commented on May 10, 2024

If we start with the most simplistic approach, just trying to provide the most direct interface to react-query's onMutate(), I figured it would look something like this in Wasp:

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:

  • we need to provide access to queryClient (e.g. inject it in the function as above)
  • we need to make it possible for devs to reference queries in the js code
  • and maybe the biggest one: we need to make sense of how declarative stuff (e.g. declaring that query getListsAndCards depends on Card entity) works together with imperative stuff (in the example above we override this dependency since we go with an optimistic update, so things aren't really consistent anymore, we cannot rely anymore on our declarations).

Next step

I'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.

from wasp.

matijaSos avatar matijaSos commented on May 10, 2024

@Martinsos good question about the onMutate() - I check the docs and turns out it runs before the mutation function is executed. Although, theoretically, we could update query cache either before or after the mutation, even do it all in the same function.
From what I've read it seems that part of the reason on why this is done in onMutate() is because its result feeds into other handlers, such as onError etc -> so I guess by using it we stay compliant with how the data/error flow was imagined in react-query. Plus maybe we get some extra benefits I'm not currently aware at.

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.

from wasp.

faassen avatar faassen commented on May 10, 2024

Cache invalidation

So 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:

  • the result of the contents of queries and actions - this seems to be out of reach for us at the moment as it's within user code.

  • the result of explicit information supplied by the programmer

    • either by explicit invalidation written in application code
    • or by declared relationships between query and action

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 updates

The 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:

  • primary state editing: the developer wants to modify state and have it immediately reflected in the UI

  • derived state updates: by editing the primary state some derived state also can be updated immediately (instead doing a cache invalidation and a server round-trip to recreate it).

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 get query depends on any entities contained and referenced, and the save query only touches the base item and any contained (but not references).

from wasp.

Martinsos avatar Martinsos commented on May 10, 2024

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!

from wasp.

Martinsos avatar Martinsos commented on May 10, 2024

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:

Use cases for manual cache updates include:

  • Providing immediate feedback to the user when a mutation is attempted
  • After a mutation, updating a single item in a large list of items that is already cached, rather than re-fetching the whole list
  • Debouncing a large number of mutations with immediate feedback as though they are being applied, followed by a single request sent to the server to update the debounced attempts

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 .

from wasp.

Martinsos avatar Martinsos commented on May 10, 2024

caches aren't cleared when the user navigates to a new page

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".
And one answer, as you presented, is using Item/Entity that knows how to save itself to the server. Although this goes beyond world of Operations and enters the realm of Items/Entities.

from wasp.

Related Issues (20)

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.