Repository: internetarchive/openlibrary

Issue:
Add cacheing to /collections pages
### Problem

Our curated collections pages take way too long to load.

#### Evidence / Screenshot

#### Relevant URL(s)
https://openlibrary.org/collections


### Reproducing the bug

1. Go to any `/collections` page


* Expected behavior: The page loads quickly.
* Actual behavior: You wait.  Then you wait some more.  Finally, the page loads.


### Context

- Browser (Chrome, Safari, Firefox, etc): All
- OS (Windows, Mac, etc): All
- Logged in (Y/N): N
- Environment (prod, dev, local): prod


### Notes from this Issue's Lead

#### Proposal & constraints
<!-- What is the proposed solution / implementation? Is there a precedent of this approach succeeding elsewhere? -->
Add caching for `/collections` pages.

#### Related files
<!-- Files related to this issue; this is super useful for new contributors who might want to help! If you're not sure, leave this blank; a maintainer will add them. -->

#### Stakeholders
<!-- @ tag stakeholders of this bug -->



Discussion:

Resolving patch:
diff --git a/openlibrary/plugins/openlibrary/code.py b/openlibrary/plugins/openlibrary/code.py
--- a/openlibrary/plugins/openlibrary/code.py
+++ b/openlibrary/plugins/openlibrary/code.py
@@ -47,6 +47,7 @@
 delegate.app.add_processor(processors.ProfileProcessor())
 delegate.app.add_processor(processors.CORSProcessor(cors_prefixes={'/api/'}))
 delegate.app.add_processor(processors.PreferenceProcessor())
+delegate.app.add_processor(processors.CacheablePathsProcessor())
 
 try:
     from infogami.plugins.api import code as api
diff --git a/openlibrary/plugins/openlibrary/processors.py b/openlibrary/plugins/openlibrary/processors.py
--- a/openlibrary/plugins/openlibrary/processors.py
+++ b/openlibrary/plugins/openlibrary/processors.py
@@ -5,7 +5,10 @@
 import web
 
 from openlibrary.accounts import get_current_user
+from openlibrary.core import cache
 from openlibrary.core.processors import ReadableUrlProcessor
+from openlibrary.plugins.openlibrary.home import caching_prethread
+from openlibrary.utils import dateutil
 
 from openlibrary.core import helpers as h
 
@@ -109,6 +112,123 @@ def __call__(self, handler):
         return handler()
 
 
+class CacheablePathsProcessor:
+    """Processor for caching Infogami pages.
+
+    This processor determines if the current path matches any of the
+    patterns in the `paths_and_expiries` dictionary, and if so, it
+    attempts to return the page from cache.  If no cached page is found,
+    it will fetch the page using the usual handler.
+    """
+
+    # Maps paths of cacheable pages to the pages' expiry times
+    paths_and_expiries: dict[str, int] = {
+        '/collections': 10 * dateutil.MINUTE_SECS,
+        '/collections/([^/]+)': 5 * dateutil.MINUTE_SECS,
+    }
+
+    def __call__(self, handler):
+        def get_page() -> dict:
+            """Fetches the page using the usual handler.
+            In order to cache the page, it is returned as a dictionary.
+            """
+            _page = handler()
+            return dict(_page)
+
+        def get_cached_page(path: str) -> dict:
+            """Returns the page located at the given path.
+            Attempts to return a cached page, if one exists. The full
+            path is used as the cache key for the page.
+            """
+            mc = cache.memcache_memoize(
+                get_page,
+                path,
+                self.paths_and_expiries[match],
+                prethread=caching_prethread(),
+            )
+            _page = mc()
+
+            if not _page:
+                mc(_cache='delete')
+
+            return _page
+
+        def create_base_cache_key(_modes: list[str]) -> str:
+            """
+            Returns a cache key for a cacheable path.
+
+            Function constructs a key based on the path and language
+            code, like the following key for the Spanish collections page:
+            /collections.es
+
+            Any entries in the given _modes list will be prepended
+            to the key.  The entries will be pipe-delimited, and
+            inside of square brackets.  For example:
+
+            [pd|sfw]/collections.es
+
+            ...is the key for the Spanish language, print-disabled, safe
+            for work `/collections` page.  If there are no entries in the
+            _modes list, no square brackets will be included in the key.
+            """
+            key = f'{web.ctx.path}.{web.ctx.lang}'
+            if _modes:
+                key = f'[{"|".join(_modes)}]{key}'
+
+            return key
+
+        if web.ctx.method != 'GET':
+            # Don't cache non-GET requests
+            return handler()
+
+        if web.ctx.encoding:
+            # Don't cache `json`, `yml`, etc.
+            return handler()
+
+        # Determine if current path matches a cacheable path pattern
+        match = None
+        for pattern in self.paths_and_expiries:
+            if re.match(pattern, web.ctx.path):
+                match = pattern
+                break
+
+        if not match:
+            # No match found, continue processing as per usual
+            return handler()
+
+        i = web.input()
+        if 'debug' in i:
+            # Avoid caching error pages
+            return handler()
+
+        if 'm' in i:
+            if i['m'] != 'view':
+                # Avoid caching non-view modes
+                return handler()
+            # Prevent redundant cache entries when `m=view` is passed
+            del i['m']
+
+        modes = []
+        cookies = web.cookies()
+        if cookies.get('pd', ''):
+            modes.append('pd')
+
+        # Uncomment the following to enable safe-for-work page caching:
+
+        # if cookies.get('sfw', ''):
+        #     modes.append('sfw')
+
+        cache_key = create_base_cache_key(modes)
+        if len(i):
+            # Construct query string in alphabetical order
+            query_str = '&'.join([f'{k}={v}' for k, v in sorted(i.items())])
+            cache_key += f'?{query_str}'
+
+        page = get_cached_page(cache_key)
+
+        return web.template.TemplateResult(page)
+
+
 if __name__ == "__main__":
     import doctest
 


Test patch: