Comment construire un cache d'API sans se prendre la tête grâce aux surrogate keys ?

Romain Commandé

Disclaimer

Architecture

Content

API

API

API

API

FRONT API

FRONT APP

Text

Du cache ?

SERVEUR WEB

Résultat

Internet

URL

CACHE

Du cache sur une API ?

Pas aussi simple !

On expose de la donnée qui doit rester cohérente

Exemple

Exemple

Comment invalider correctement le cache ?

À la main

for item in models:
   for subitem in item.subitems:
      for subsubitem in subitem.subitems:
          invalid_cache(subsubitem)
      for subsubitem in subitem.other_subitems:
          invalid_cache(subsubitem)
      invalid_cache(subitem)
   invalid_cache(item)

😭

Se brancher sur les hooks et suivre les relations de l'ORM

 

🤔

?

Merci les surrogate keys !

Surrogate key

  • Clé artificielle
  • Contient l'ensemble des ressources chargées par une URI
  • Ajoutée dans les headers de la réponse de l'API
"Site-1 Category-1 Category-2 Category-3"

Hook SQLAlchemy

Un middleware

def surrogate_keys_tween(request):
    request.surrogate_keys = set()
    response = handler(request)
    response.headers["Surrogate-Key"] = " ".join(request.surrogate_keys)
    return response
from pyramid.threadlocal import get_current_request
from sqlalchemy import event


@event.listens_for(SomeSessionOrFactory, 'loaded_as_persistent')
def loaded_as_persistent(session, instance):
    key = f"{instance.__class__.__name__}-{instance.id}"
    get_current_request().surrogate_keys.add(key)

Les besoins côté code

Côté cache

  • Fonctionnement classique
  • Partitionner le cache en 2 parties
  • Stocker également les associations surrogate key <=> uri

Alimentation

Key Value
/site [{id: 1, ...}, {id: 2, ...}, ...]
Key Value
Site-1 ["/site"]
Site-2 ["/site"]
Surrogate-Key: Site-1 Site-2

/site

Alimentation

Key Value
/site [{id: 1, ...}, {id: 2, ...}, ...]
/site/1 {id: 1, ...}
Site-1 ["/site", "/site/1"]
Site-2 ["/site"]
Category-1 ["/site/1"]
Category-2 ["/site/1"]
Category-3 ["/site/1"]
Surrogate-Key: Site-1 Category-1 Category-2 Category-3

/site/1

Invalidation

Key Value
/site/ [{id: 1, ...}, {id: 2, ...}, ...]
/site/1 {id: 1, ...}
Site-1 ["/site", "/site/1"]
Site-2 ["/site"]
Category-1 ["/site/1"]
Category-2 ["/site/1"]
Category-3 ["/site/1"]

surrogate key : Site-1

import itertools

from monprojet.cache import redis_client, redis_paginator, scan_iter, sscan_iter


def store_response(path, surrogate_keys, response):
    pipe = redis_client.pipeline()
    for surrogate_key in surrogate_keys:
        pipe.sadd(f"associations:{surrogate_key}", path)
    pipe.set(f"responses:{path}", pickle.dumps(response))
    pipe.execute()


def invalid(surrogate_keys_str):
    surrogate_keys = surrogate_keys_str.split(" ")
    pipe = redis_client.pipeline()
    association_keys = [
        association_key.decode("utf-8")
        for surrogate_key in surrogate_keys
        for association_key in scan_iter(0, match=f"associations:{surrogate_key}")
    ]
    response_keys = (
        "responses:{}".format(key.decode("utf-8"))
        for asso in association_keys
        for key in sscan_iter(asso, 0)
    )
    for key in set(itertools.chain(association_keys, response_keys)):
        pipe.delete(key)
    pipe.execute()

Comment détecte-t-on les modifications de modèle ?

SQLAlchemy !

from sqlalalchemy import event


@db.event.listens_for(SomeSessionOrFactory, "after_begin")
def handle_after_begin(session, transaction, connection):
    session.surrogate_keys = set()

@db.event.listens_for(SomeSessionOrFactory, "before_flush")
def handle_before_flush(session, flush_context, instances):
    session.surrogate_keys |= get_surrogate_keys_for_modified_objects(session)

@db.event.listens_for(SomeSessionOrFactory, "before_commit")
def handle_before_commit(session):
    session.surrogate_keys |= get_surrogate_keys_for_modified_objects(session)

@db.event.listens_for(SomeSessionOrFactory, "after_commit")
def handle_after_commit(session):
    invalid(" ".join(sorted(session.surrogate_keys)))
    
def get_surrogate_keys_for_modified_objects(session):
    return (
        set(f"{instance.__class__.__name__}-{instance.id}" for instance in session.new)
        | set(f"{instance.__class__.__name__}-{instance.id}" for instance in session.dirty)
        | set(f"{instance.__class__.__name__}-{instance.id}" for instance in session.deleted)
    )

Ça fonctionne ...

  • C'était simple à mettre en place malgré la complexité du problème
  • Le cache semble s'invalider correctement
  • Fonctionne sur un graph de données simple ou complexe
  • On peut se passer de politique d'expiration des clés

... dans certaines limites

  • Si on fait une modification directement en base
    • Utiliser l'API
    • python-mysql-replication
  • surrogate key == 1024 bytes max
  • J'ai identifié un cas qui ne fonctionne pas en préparant cette présentation :
    • Pas d'invalidation des collections lors de la création d'un nouvel élément
    • N'existait pas dans la front-api chez Ooreka
    • Solvable (sûrement) facilement (car pas testé)

@rcommande

Made with Slides.com