Skip to content

Commit 7a6fce2

Browse files
authored
Backend caching of requests (#644)
* Update pre-commit * Add caching to most GET endpoints * Undo accidental change * Do not cache if no db timestamp * Fix cache condition, refactor caching * Add missing future import * Properly close the db in the transaction task
1 parent 55353e9 commit 7a6fce2

File tree

15 files changed

+202
-103
lines changed

15 files changed

+202
-103
lines changed

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ repos:
1212
rev: v1.15.0
1313
hooks:
1414
- id: mypy
15-
args: [--ignore-missing-imports, --no-strict-optional]
15+
args: [--install-types, --non-interactive, --ignore-missing-imports]
1616
additional_dependencies:
1717
- types-setuptools
1818
- types-PyYAML

gramps_webapi/api/__init__.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626

2727
from ..const import API_PREFIX
2828
from .auth import jwt_required
29-
from .cache import thumbnail_cache
29+
from .cache import thumbnail_cache_decorator
3030
from .media import get_media_handler
3131
from .resources.base import Resource
3232
from .resources.bookmarks import (
@@ -37,7 +37,7 @@
3737
from .resources.chat import ChatResource
3838
from .resources.citations import CitationResource, CitationsResource
3939
from .resources.config import ConfigResource, ConfigsResource
40-
from .resources.dna import PersonDnaMatchesResource, DnaMatchParserResource
40+
from .resources.dna import DnaMatchParserResource, PersonDnaMatchesResource
4141
from .resources.events import EventResource, EventSpanResource, EventsResource
4242
from .resources.export_media import MediaArchiveFileResource, MediaArchiveResource
4343
from .resources.exporters import (
@@ -120,7 +120,7 @@
120120
UsersResource,
121121
UserTriggerResetPasswordResource,
122122
)
123-
from .util import get_db_handle, get_tree_from_jwt, make_cache_key_thumbnails, use_args
123+
from .util import get_db_handle, get_tree_from_jwt, use_args
124124

125125
api_blueprint = Blueprint("api", __name__, url_prefix=API_PREFIX)
126126

@@ -407,7 +407,7 @@ def register_endpt(resource: Type[Resource], url: str, name: str):
407407
},
408408
location="query",
409409
)
410-
@thumbnail_cache.cached(make_cache_key=make_cache_key_thumbnails)
410+
@thumbnail_cache_decorator
411411
def get_thumbnail(args, handle, size):
412412
"""Get a file's thumbnail."""
413413
tree = get_tree_from_jwt()
@@ -429,7 +429,7 @@ def get_thumbnail(args, handle, size):
429429
},
430430
location="query",
431431
)
432-
@thumbnail_cache.cached(make_cache_key=make_cache_key_thumbnails)
432+
@thumbnail_cache_decorator
433433
def get_cropped(args, handle: str, x1: int, y1: int, x2: int, y2: int):
434434
"""Get the thumbnail of a cropped file."""
435435
tree = get_tree_from_jwt()
@@ -451,7 +451,7 @@ def get_cropped(args, handle: str, x1: int, y1: int, x2: int, y2: int):
451451
},
452452
location="query",
453453
)
454-
@thumbnail_cache.cached(make_cache_key=make_cache_key_thumbnails)
454+
@thumbnail_cache_decorator
455455
def get_thumbnail_cropped(
456456
args, handle: str, x1: int, y1: int, x2: int, y2: int, size: int
457457
):

gramps_webapi/api/cache.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,109 @@
11
"""Caching functions."""
22

3+
from __future__ import annotations
4+
5+
import hashlib
6+
import os
7+
8+
from flask import request
39
from flask_caching import Cache
10+
from gramps.gen.errors import HandleError
11+
12+
from gramps_webapi.api.auth import has_permissions
13+
from gramps_webapi.api.util import (
14+
abort_with_message,
15+
get_db_handle,
16+
get_db_manager,
17+
get_tree_from_jwt,
18+
get_tree_from_jwt_or_fail,
19+
)
20+
from gramps_webapi.auth.const import PERM_VIEW_PRIVATE
421

522
thumbnail_cache = Cache()
23+
request_cache = Cache()
24+
25+
26+
def get_db_last_change_timestamp(tree_id: str) -> int | float | None:
27+
"""Get the last change timestamp of the database.
28+
29+
We do this by looking at the modification time of the meta db file.
30+
If the file does not exist, returns None.
31+
"""
32+
if not tree_id:
33+
raise ValueError("Tree ID must not be empty")
34+
dbmgr = get_db_manager(tree_id)
35+
meta_path = os.path.join(dbmgr.dbdir, dbmgr.dirname, "meta_data.db")
36+
try:
37+
return os.path.getmtime(meta_path)
38+
except FileNotFoundError:
39+
return None
40+
41+
42+
def _hash_request_args() -> str:
43+
"""Hash the request arguments for use in cache keys."""
44+
query_args = list((k, v) for (k, v) in request.args.items(multi=True) if k != "jwt")
45+
args_as_sorted_tuple = tuple(sorted(query_args))
46+
args_as_bytes = str(args_as_sorted_tuple).encode()
47+
arg_hash = hashlib.md5(args_as_bytes)
48+
return str(arg_hash.hexdigest())
49+
50+
51+
def make_cache_key_thumbnails(*args, **kwargs):
52+
"""Make a cache key for thumbnails."""
53+
# hash query args except jwt
54+
arg_hash = _hash_request_args()
55+
56+
# get media checksum
57+
handle = kwargs["handle"]
58+
tree = get_tree_from_jwt()
59+
db_handle = get_db_handle()
60+
try:
61+
obj = db_handle.get_media_from_handle(handle)
62+
except HandleError:
63+
abort_with_message(404, f"Handle {handle} not found")
64+
# checksum in the DB
65+
checksum = obj.checksum
66+
67+
dbmgr = get_db_manager(tree)
68+
69+
cache_key = checksum + request.path + arg_hash + dbmgr.dirname
70+
71+
return cache_key
72+
73+
74+
def make_cache_key_request(*args, **kwargs):
75+
"""Make a cache key for a base request."""
76+
# hash query args except jwt
77+
arg_hash = _hash_request_args()
78+
79+
# the request result will depend on whether the user can view private records
80+
# this will be "1" if the user can view private records, "0" otherwise
81+
permission_hash = str(int(has_permissions({PERM_VIEW_PRIVATE})))
82+
83+
tree_id = get_tree_from_jwt_or_fail()
84+
db_timestamp = get_db_last_change_timestamp(tree_id)
85+
if db_timestamp is None:
86+
raise ValueError("Database last change timestamp is None")
87+
88+
cache_key = tree_id + str(db_timestamp) + request.path + arg_hash + permission_hash
89+
90+
return cache_key
91+
92+
93+
def skip_cache_condition_request(*args, **kwargs) -> bool:
94+
"""Condition to skip caching for a request."""
95+
# skip caching if the user is not authorized to view private records
96+
tree_id = get_tree_from_jwt_or_fail()
97+
db_timestamp = get_db_last_change_timestamp(tree_id)
98+
# if the database timestamp is None, we cannot determine if the db
99+
# was changed, so we skip caching!
100+
should_skip = db_timestamp is None
101+
return should_skip
102+
103+
104+
request_cache_decorator = request_cache.cached(
105+
make_cache_key=make_cache_key_request, unless=skip_cache_condition_request
106+
)
107+
thumbnail_cache_decorator = thumbnail_cache.cached(
108+
make_cache_key=make_cache_key_thumbnails
109+
)

gramps_webapi/api/image.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
from pdf2image import convert_from_path
3131
from PIL import Image, ImageOps
3232
from PIL.Image import Image as ImageType
33-
from pkg_resources import resource_filename
33+
from pkg_resources import resource_filename # type: ignore[import-untyped]
3434

3535
from gramps_webapi.const import MIME_PDF
3636
from gramps_webapi.types import FilenameOrPath

gramps_webapi/api/llm/__init__.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from __future__ import annotations
44

55
from flask import current_app
6-
from openai import OpenAI, RateLimitError, APIError
6+
from openai import APIError, OpenAI, RateLimitError
77

88
from ..search import get_semantic_search_indexer
99
from ..util import abort_with_message, get_logger
@@ -48,8 +48,8 @@ def answer_prompt(prompt: str, system_prompt: str, config: dict | None = None) -
4848
}
4949
)
5050

51-
client = get_client(config=config)
52-
model = config.get("LLM_MODEL")
51+
client = get_client(config=config) # type: ignore
52+
model = config.get("LLM_MODEL") # type: ignore
5353
assert model is not None, "No LLM model specified" # mypy; shouldn't happen
5454

5555
try:

gramps_webapi/api/resources/base.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
#
22
# Gramps Web API - A RESTful API for the Gramps genealogy program
33
#
4-
# Copyright (C) 2020-2024 David Straub
4+
# Copyright (C) 2020-2025 David Straub
55
#
66
# This program is free software; you can redistribute it and/or modify
77
# it under the terms of the GNU Affero General Public License as published by
@@ -30,19 +30,17 @@
3030
from gramps.gen.db.base import DbReadBase
3131
from gramps.gen.errors import HandleError
3232
from gramps.gen.lib.primaryobj import BasicPrimaryObject as GrampsObject
33-
34-
# from gramps.gen.lib.serialize import from_json
3533
from gramps.gen.utils.grampslocale import GrampsLocale
3634
from pyparsing.exceptions import ParseBaseException
3735
from webargs import fields, validate
3836

39-
from gramps_webapi.api.search.indexer import SemanticSearchIndexer
4037
from gramps_webapi.types import ResponseReturnValue
4138

4239
from ...auth.const import PERM_ADD_OBJ, PERM_DEL_OBJ, PERM_EDIT_OBJ
4340
from ...const import GRAMPS_OBJECT_PLURAL
4441
from ..auth import require_permissions
45-
from ..search import SearchIndexer, get_search_indexer, get_semantic_search_indexer
42+
from ..cache import request_cache_decorator
43+
from ..search import SearchIndexer, get_search_indexer
4644
from ..tasks import run_task, update_search_indices_from_transaction
4745
from ..util import (
4846
check_quota_people,
@@ -232,6 +230,7 @@ class GrampsObjectResource(GrampsObjectResourceHelper, Resource):
232230
},
233231
location="query",
234232
)
233+
@request_cache_decorator
235234
def get(self, args: dict, handle: str) -> ResponseReturnValue:
236235
"""Get the object."""
237236
try:
@@ -377,6 +376,7 @@ class GrampsObjectsResource(GrampsObjectResourceHelper, Resource):
377376
},
378377
location="query",
379378
)
379+
@request_cache_decorator
380380
def get(self, args: dict) -> ResponseReturnValue:
381381
"""Get all objects."""
382382
locale = get_locale_for_language(args["locale"], default=True)

gramps_webapi/api/resources/dna.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
from gramps_webapi.types import Handle, MatchSegment, ResponseReturnValue
4040

4141
from ...types import Handle
42+
from ..cache import request_cache_decorator
4243
from ..util import get_db_handle, get_locale_for_language, use_args
4344
from . import ProtectedResource
4445
from .util import get_person_profile_for_handle
@@ -60,6 +61,7 @@ class PersonDnaMatchesResource(ProtectedResource):
6061
},
6162
location="query",
6263
)
64+
@request_cache_decorator
6365
def get(self, args: dict, handle: str):
6466
"""Get the DNA match data."""
6567
db_handle = CachePeopleFamiliesProxy(get_db_handle())

gramps_webapi/api/resources/face_detection.py

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,20 +25,16 @@
2525
from flask import Response, abort
2626
from gramps.gen.errors import HandleError
2727

28-
from ..cache import thumbnail_cache
28+
from ..cache import request_cache_decorator
2929
from ..media import get_media_handler
30-
from ..util import (
31-
get_db_handle,
32-
get_tree_from_jwt,
33-
make_cache_key_thumbnails,
34-
)
30+
from ..util import get_db_handle, get_tree_from_jwt
3531
from . import ProtectedResource
3632

3733

3834
class MediaFaceDetectionResource(ProtectedResource):
3935
"""Resource for face detection in media files."""
4036

41-
@thumbnail_cache.cached(make_cache_key=make_cache_key_thumbnails)
37+
@request_cache_decorator
4238
def get(self, handle) -> Response:
4339
"""Get detected face regions."""
4440
db_handle = get_db_handle()

gramps_webapi/api/resources/filters.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ def build_filter(filter_parms: Dict, namespace: str) -> GenericFilter:
152152
filter_regex = False
153153
if "regex" in filter_rule:
154154
filter_regex = filter_rule["regex"]
155+
assert rule_instance is not None # for mypy
155156
filter_object.add_rule(rule_instance(filter_args, use_regex=filter_regex))
156157
return filter_object
157158

gramps_webapi/api/resources/relations.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@
3030
from gramps_webapi.api.people_families_cache import CachePeopleFamiliesProxy
3131

3232
from ...types import Handle
33-
from ..util import get_db_handle, get_locale_for_language, use_args, abort_with_message
33+
from ..cache import request_cache_decorator
34+
from ..util import abort_with_message, get_db_handle, get_locale_for_language, use_args
3435
from . import ProtectedResource
3536
from .emit import GrampsJSONEncoder
3637
from .util import get_one_relationship
@@ -48,6 +49,7 @@ class RelationResource(ProtectedResource, GrampsJSONEncoder):
4849
},
4950
location="query",
5051
)
52+
@request_cache_decorator
5153
def get(self, args: Dict, handle1: Handle, handle2: Handle) -> Response:
5254
"""Get the most direct relationship between two people."""
5355
db_handle = CachePeopleFamiliesProxy(get_db_handle())
@@ -93,6 +95,7 @@ class RelationsResource(ProtectedResource, GrampsJSONEncoder):
9395
},
9496
location="query",
9597
)
98+
@request_cache_decorator
9699
def get(self, args: Dict, handle1: Handle, handle2: Handle) -> Response:
97100
"""Get all possible relationships between two people."""
98101
db_handle = CachePeopleFamiliesProxy(get_db_handle())

0 commit comments

Comments
 (0)