List API Caching
Every resource list in the web console, e.g. the LLMClient, Secret or Guardrail list, is a React
app that asks its Django list api for one page of resources at a time (see List Pagination, Search and Sorting).
Each list api is a subclass of SmarterListApiView, which
declares the resource’s model and serializer, the columns that sort it, and the relations that
its serializer reads. The base class does the rest: it filters the resources by ownership, joins
their owners and prefetches their tags and the declared relations, so that a page costs a few
queries however many resources it holds, and it caches the page.
Two caches, one request
A list is cached twice: in the browser, by the React list, and on the server, by its list api. When a user opens a list, pages it, sorts it or comes back to it:
The React list paints at once, from the browser’s cache, if it holds that page, with a small Refreshing spinner beside the view toggle. Otherwise it shows its loading skeleton.
It asks the list api for the page anyway (stale-while-revalidate), and replaces what it painted with the response, which it writes back to the browser’s cache.
The list api answers from Redis, if it holds the page for that user, and builds it from the database otherwise, in a few queries, then caches it.
So a list that the user has seen before paints without waiting for the network, and the request that refreshes it is usually a cache hit too.
The browser cache
The React side is shared by every list, in the @smarter/common package
(smarter/react/packages/smarter-common):
src/lib/cache.tsxstores pages in the browser’slocalStorage, which outlives the browser tab, so a new tab or a return visit paints at once.makeCacheKey()names a page by the list api’s url, the tab (ownedorshared), and the page’s number, ordering and filter. Searches are never cached. Each entry holds the page’s objects and itspagination, so the pagination controls paint with it.sessionScope()adds a hash of the session’s CSRF cookie to every key. Django rotates the CSRF token at every login, so a page cached for one user is never shown to the next user of the same browser, and the token itself is never stored.readCache()andwriteCache()treat a private window, blocked site data or a full quota as a cache miss, never an error.pruneCache()runs on each write: it removes entries older than a week, and keeps only the most recent hundred.TabbedListView(src/components/TabbedListView), the tabs, toolbar and pagination of every list, reads the cache whenever a tab’s page, ordering or filter changes, and marks the tabisRefreshingwhile it shows a cached page. Its Refresh button asks the api for a fresh page with?invalidate_cache=true, which also makes the user’s cached pages on the server stale.
The server cache
The list api caches each page’s whole response in Redis, with
cache_results(), in
cached_listview_page(). Its key is everything that can change the
page: the user, the ownership tab, the page, page size, search, ordering and filter, and the
generations described below. A page that is in the cache costs no query beyond the request’s
session. On a miss, build_page() builds it with
the relations that the list api declared, and its can_delete column asks once per page which
rows the user owns, rather than once per row.
Staying correct
A cached page is never stale for long, because its cache key includes three generations, values
that change whenever they are invalidated (listview_cache_generation()):
the list’s generation, which changes whenever a resource that the list shows is saved or deleted. Each app connects its list to the models it shows, in its
receivers.py, withconnect_listview_cache(): the resource, the rows that its serializer nests, and the rows that decide whether it can be deleted, e.g. an LLMClient’s plugins. Becausereceivers.pyis imported by the app’sAppConfig.ready(), the Celery workers invalidate lists too;the user’s generation of the list, which changes when the user clicks the list’s Refresh button, which sends
?invalidate_cache=true;the generation of every list, which changes when a user profile, an account or a resource’s tags change, since those can alter what any list shows.
Invalidating a generation is a single cache delete, however many users and pages the list has,
and waits for the database transaction to commit, so that a request can’t cache the old rows under
the new generation. A change made with QuerySet.update() sends no signal, so code that updates
a column that a list shows calls invalidate_listview_cache()
afterwards, as the custom domain verification status and the MCPClient connection status do.
Pages expire after SMARTER_LISTVIEW_CACHE_EXPIRATION seconds, an hour by default, which bounds
how long any other such change can go unnoticed.
Adding a list
# apps/widget/views/listview/api.py
class WidgetListApiView(SmarterListApiView):
model = Widget
serializer_class = WidgetSerializer
listview_cache_kind = "Widget"
sort_fields = SORT_FIELDS
select_related = ("provider",)
prefetch_related = ("widgetpart_set__part",)
# apps/widget/receivers.py
connect_listview_cache("Widget", "widget.Widget", "widget.WidgetPart", "provider.Provider")
A serializer method that reads a reverse relation uses
related_rows(), which reads the prefetched rows on a list
page, and queries them elsewhere.
Cached pages for the list apis of the React list views in the Smarter web console.
Every resource list, e.g. the LLMClient or Secret list, asks its Django list api for one page of
its resources at a time (see smarter.lib.django.pagination). SmarterListApiView is
the base class of those apis. It builds the page’s whole response once, and caches it in Redis
with cache_results(), per user and per page, so that the next request for
the same page costs a few cache reads instead of a query and a serializer per resource.
The cached pages stay correct because their cache key includes three generations, each a value
from listview_cache_generation() that changes when it is invalidated:
the list’s generation, e.g.
LLMClient’s, which changes whenever a resource that the list shows is saved or deleted (seeconnect_listview_cache());the user’s generation of the list, which changes when the user asks for a fresh list, with the list api’s
invalidate_cache=true;the generation of every list,
LISTVIEW_CACHE_ALL, which changes whenever a change can alter what any list shows, such as a user’s profile, an account or a resource’s tags.
A new generation leaves the previous pages unread, until they expire after
smarter_settings.listview_cache_expiration seconds. So invalidating a list is a single cache
delete, however many users and pages it has.
# an app's receivers.py, which its AppConfig.ready() imports, so that the Celery workers
# invalidate the list too
connect_listview_cache("Guardrail", "guardrail.Guardrail", "llmclient.LLMClientGuardrails")
# the app's views/listview/api.py
class GuardrailListApiView(SmarterListApiView):
model = Guardrail
serializer_class = GuardrailSerializer
listview_cache_kind = "Guardrail"
- smarter.lib.django.listview.LISTVIEW_CACHE_ALL = '*'
invalidating it makes the cached pages of every list stale.
- Type:
The generation of every list
- class smarter.lib.django.listview.ListviewPageRequest(user)[source]
Bases:
HttpRequestThe request that a cached list page is serialized with, which no http request is.
A cached page is shared by every request of the user, so it is built without one. Its serializer still needs a user, since
can_deletedepends on it, and a host and scheme, since a file field, e.g. a Provider’slogo, is made an absolute url withrequest.build_absolute_uri(). Both are the platform’s,smarter_settings.environment_url.- Parameters:
user (
User) – The user whose page it is.
- class smarter.lib.django.listview.SmarterListApiView(**kwargs)[source]
Bases:
SmarterAuthenticatedNeverCachedWebViewThe base class of the list apis of the web console’s React list views.
A list api returns one page of the resources that the authenticated user owns, that are shared with them, or both, as
{"user": ..., "admin": ..., "objects": [...], "pagination": {...}}. The page is cached per user (seecached_listview_page()), and?invalidate_cache=truemakes the user’s cached pages of the list stale first, for the list’s Refresh button.A subclass sets
model,serializer_classandlistview_cache_kind, and names the relations that its serializer reads inselect_relatedandprefetch_related, so that a page costs a few queries, however many resources it has. The resource’s owner and tags, when it has them, are always fetched with it.- classmethod build_page(user_profile, ownership_filter, page=None, page_size=None, search='', ordering='', list_filter='')[source]
Build one page of the list api’s response, from the database.
The serializer gets a
ListviewPageRequest, whose user is the user profile’s. The result is passed through JSON, so that the cache stores plain values, not the serializer’sReturnDict, which refers to the serializer.- Parameters:
- Return type:
- Returns:
{"user": ..., "admin": ..., "objects": [...], "pagination": {...}}.
- property formatted_class_name: str
Returns a formatted string of the class name for logging purposes.
- get(request, *args, **kwargs)[source]
Return the same json as a POST, so that the list api can be browsed.
- Return type:
- classmethod get_queryset(user, ownership_filter)[source]
Return the resources of the list that the user may see, with the relations that the serializer reads.
- listview_cache_kind: ClassVar[str]
The list’s name in the cache, as
connect_listview_cache()invalidates it, e.g.LLMClient.
- model: ClassVar[type[Model]]
The resource, whose manager
objectsis anOwnershipManager, e.g. a MetaDataWithOwnershipModel.
- post(request, *args, **kwargs)[source]
Return one page of the list’s resources, which the user owns, are shared with them, or both.
- Parameters:
request (
HttpRequest) – The request. Its query parameters arepage,page_size,search,ordering,filterandinvalidate_cache, all optional.kwargs –
ownership_filter, from the url:owned,sharedorall(the default).
- Return type:
- Returns:
The page, or a 400 response for an unknown ownership filter.
The reverse and many-to-many relations that the serializer reads, besides the tags.
The forward relations that the serializer reads, besides the owner’s.
- sort_fields: ClassVar[Mapping[str, str]] = mappingproxy({'name': 'name', 'description': 'description', 'createdAt': 'created_at', 'updatedAt': 'updated_at'})
The columns that the list may be sorted by.
See
paginate_queryset().
- classmethod view_path()[source]
Return the class’s dotted path, by which
cached_listview_page()imports it.- Return type:
- smarter.lib.django.listview.cached_listview_page(view, kind_generation, user_generation, all_generation, user_profile_id, ownership_filter, page, page_size, search, ordering, list_filter)[source]
Return one page of a list api’s response, from the cache when it holds it.
Its parameters are the cache key: everything that can change the page. The generations are not read, but they make the page stale when the list changes (see
listview_cache_generation()).- Parameters:
view (
str) – The list api’s class, as a dotted path, whoseSmarterListApiView.build_page()builds the page.kind_generation (
int) – The list’s generation.user_generation (
int) – The user’s generation of the list.all_generation (
int) – The generation of every list.user_profile_id (
int) – The user profile whose page it is.ownership_filter (
str) –owned,sharedorall.page (
str|None) – Thepagequery parameter, as it arrived.page_size (
str|None) – Thepage_sizequery parameter, as it arrived.search (
str) – Thesearchquery parameter.ordering (
str) – Theorderingquery parameter.list_filter (
str) – Thefilterquery parameter.
- Return type:
- Returns:
The list api’s response, as JSON-compatible values.
- smarter.lib.django.listview.connect_listview_cache(kind, *model_labels)[source]
Invalidate a list’s cached pages whenever one of these models is saved or deleted.
Call it from an app’s
receivers.py, which itsAppConfig.ready()imports, so that every process connects it, including the Celery workers, which save resources too. List every model whose rows the list’s serializer shows: the resource, and the related models that it nests, e.g. an LLMClient’s plugins. A many-to-many model is invalidated bym2m_changedtoo, sinceadd()andremove()save no row throughsave().Each model’s proxy models and multi-table children are connected too, since they send their signals as themselves. A change made with
QuerySet.update()sends no signal. Callinvalidate_listview_cache()after it, if the list shows what it changes.- Parameters:
kind (
str) – The list, e.g.LLMClient, orLISTVIEW_CACHE_ALLfor every list.model_labels (
str) – The models, asapp_label.ModelName, e.g.llmclient.LLMClientPlugin.
- Return type:
- smarter.lib.django.listview.invalidate_listview_cache(kind, user_profile_id=None)[source]
Make the cached pages of a list stale, once the current transaction commits.
Waiting for the commit keeps a request that reads the database before the change commits from caching the old rows under the new generation. Outside a transaction, it happens at once.
- Parameters:
kind (
str) – The list, e.g.LLMClient, orLISTVIEW_CACHE_ALLfor every list.user_profile_id (
int|None) – The user profile whose pages to make stale, or None for every user’s.
- Return type:
- smarter.lib.django.listview.listview_cache_generation(kind, user_profile_id=None)[source]
Return the current generation of a list’s cached pages.
The generation is any value that changes when it is invalidated: the time of the first call after the last invalidation, in nanoseconds. It is part of the cache key of every cached page, so invalidating it makes all of them stale at once.
- Parameters:
kind (
str) – The list, e.g.LLMClient, orLISTVIEW_CACHE_ALLfor every list.user_profile_id (
int|None) – The user profile whose pages of the list it is, or None for every user’s.
- Return type:
- Returns:
The generation.