Skip to content

Cache Redis des prix externes ​

Date : 2026-10-01. Backend : PR #419, issue #418.

A quoi sert Redis ? ​

Redis est un dictionnaire rapide en memoire : une cle identifie exactement ce que l'on cherche et une valeur contient le resultat. Une duree de vie (TTL) fait disparaitre automatiquement une entree trop ancienne.

Pour Snag, il contient seulement des prix externes deja obtenus. Deux utilisateurs consultant la meme carte et le meme marche reutilisent le meme prix, au lieu de repayer une requete fournisseur. Ce n'est ni la base des comptes ni celle des collections, et ce n'est pas une nouvelle source de prix.

Bloc, extension, carte : nos identifiants ​

La base actuelle contient bien cette hierarchie, verifiee sur le VPS :

ElementChampsExemple
Blocsets.serie_id, sets.serie_namesv, Ecarlate et Violet
Extensionsets.id, sets.namesv02, Evolutions a Paldea
Cartecards.id, cards.set_id, cards.local_idsv02-062, sv02, 062

L'exemple est bien Pikachu 062/193 : 193 cartes officielles et 279 cartes au total en comptant les cartes secretes. Le numero affiche n'est pas un identifiant global : plusieurs extensions possedent une carte 062. Le nom Pikachu ne l'est pas non plus. On utilise le cards.id reel, sans le reconstruire depuis un nom traduit ni supposer que tous les jeux suivent le format Pokemon.

L'identifiant Cardmarket reste une correspondance externe (cards.data.pricing.cardmarket.idProduct dans l'adaptateur actuel). Il permet d'interroger le fournisseur, mais ne remplace pas notre identifiant interne. Les variantes physiques peuvent avoir des identifiants/mappings distincts ; langue, etat et finition doivent toujours qualifier le prix.

Contrat du cache, independant du fournisseur ​

text
snag:price:v1:<policy>:<tcg>:<cardId>:<language>:<condition>:<variant>:<currency>:<grader>:<grade>:<qualifier>

Exemple pour une carte non gradee :

text
snag:price:v1:exact-nm-offer-v1:pokemon:sv02-062:fr:nm:regular:EUR:::

Les trois derniers champs vides correspondent a societe, note et qualificatif. Chaque composant est encode avec encodeURIComponent pour eviter les collisions. policy versionne la signification du prix ; changer de methode de calcul necessite une nouvelle version, pas le melange de cotes incompatibles. Les noms ReefAPI/Cardmarket n'entrent pas dans la cle.

La valeur est un JSON normalise :

json
{
  "amount": 0.25,
  "currency": "EUR",
  "source": "fournisseur-exemple",
  "updatedAt": "2026-10-01T12:00:00.000Z",
  "reference": "correspondance-externe-et-finition"
}

Ce montant illustre le format : ce n'est pas une cote de Pikachu. source permet de conserver la provenance. reference permet a l'adaptateur de rejeter un prix si sa correspondance externe a change. Un prochain fournisseur implementera le meme contrat PriceProvider, en validant ses propres mappings ; le moteur et les cles n'ont pas a devenir specifiques a ce fournisseur.

Parcours d'une lecture ​

Separation du service et des fournisseurs ​

La PR #419 contient egalement le raffinement demande apres l'installation :

text
Route HTTP
  -> PriceService.getPrice(request)
       -> PriceCache : Redis
       -> repository du snapshot : PostgreSQL, si prix encore frais
       -> PriceProvider : adaptateur d'une source externe, si cache/snapshot manquants
       -> persistance puis alimentation de Redis

Les routes /cards/:id/live-nm-price et le rafraichissement explicite des batches ne connaissent plus ReefAPI ni Redis. Elles ne font que demander un prix au service interne. index.ts assemble les composants par injection :

  • priceService.ts : selection par couverture des marches, lectures de cache, validation, regroupement des appels simultanes et orchestration de persistence.
  • priceContracts.ts : modeles du prix, identite du marche, regle de fraicheur et ports PriceProvider, PriceSnapshotStore, PriceCache ; aucune dependance technique.
  • priceCache.ts : transport Redis, cles, JSON normalise, expiration et repli.
  • reefapiPrice.ts : adaptateur HTTP ReefAPI, mapping et normalisation des offres.
  • reefapiPriceStore.ts : repository SQL du snapshot historique uniquement.

Un adaptateur implemente PriceProvider.resolve(request), une operation pure qui declare si le marche est supporte et fournit une reference de mapping, des informations de provenance et fetchQuote(), sans operations de stockage. PriceSnapshotStore est injecte separement dans le service. Le premier adaptateur compatible dans la liste configuree est utilise ; il n'y a pas de selection par if provider == ni de fallback automatique melangeant plusieurs methodes de prix.

Ajouter une autre API ou une collecte web autorisee consiste a implementer ce contrat et l'enregistrer avec un depot de snapshots compatible dans index.ts, sans modifier les routes ni la logique Redis. Son resultat est quoted, not-found ou unavailable. Une erreur du fournisseur ne doit pas etre mise en cache comme une absence certaine. Le service ne contient ni URL fournisseur, ni identifiant specifique, ni appel HTTP, ni import du module Redis ou d'un repository SQL. Les metadonnees de mapping et eventuels champs additionnels de la reponse externe ne sont pas exposes dans le prix public ; Redis ne conserve que les cinq champs normalises documentes plus haut.

Les tests utilisent un second adaptateur injecte pour prouver qu'un prix issu d'une autre source suit exactement le meme cache. Il s'agit d'une fixture de test, pas d'un nouveau fournisseur actif. Le cache local est plafonne a 5000 entrees ; la coalescence reste par processus, pas un verrou distribue.

Etat de livraison : cette separation est ajoutee a la PR ouverte. Le VPS reste sur la version initiale a38f14f deployee plus bas ; aucun nouveau redemarrage/deploiement n'est realise pour ce raffinement sans autorisation.

Frontieres metier et base de donnees ​

Un bounded context delimite un modele et ses regles metier ; ce n'est pas une base de donnees ni obligatoirement un microservice. La carte logique de Snag pour ce parcours est la suivante, suivant la distinction modele/frontiere de Martin Fowler :

ContexteResponsabiliteEchange avec la valorisation
CatalogueIdentites canoniques, blocs, extensions, cartes, variantesIdentifiant et metadonnees necessaires, pas de client fournisseur
CollectionPossessions, quantites, achats, classeurs, utilisateurConsomme une cote ; le cache de prix ne contient aucune possession
ValorisationMarche exact, observations, fraicheur et resultat de prixRetourne montant, devise, source et date

Cette carte est une frontiere logique, pas l'annonce d'une extraction de tous ces modules dans cette PR. Le changement porte sur le cas d'usage de prix externe et ses dependances. Les autres routes et les lectures SQL historiques M1 ne sont pas refondues. Aucun nouveau schema SQL, nouvelle base ou nouveau microservice n'est ajoute.

PostgreSQL peut rester une seule instance. Catalogue et collections y conservent leurs donnees durables ; le depot de valorisation conserve les observations de prix. Redis est seulement un adaptateur du port de cache dans le contexte Valorisation. Il ne definit ni l'identite de la carte ni les regles metier.

mermaid
flowchart TB
  C["Catalogue : identite carte et metadonnees"] -->|"DTO / cards.id"| S
  U["Collection : possessions et portefeuille"] -.->|"consomme une cote"| S
  subgraph Pricing["Valorisation : parcours de prix externe"]
    S["PriceService : orchestration"] --> D["Contrats et regles de prix"]
    S --> PC["Port PriceCache"]
    S --> PS["Port PriceSnapshotStore"]
    S --> PP["Port PriceProvider"]
  end
  RC["Adaptateur Redis"] -.->|"implemente"| PC
  SQL["Repository SQL de snapshots"] -.->|"implemente"| PS
  HTTP["Adaptateur ReefAPI / autre source"] -.->|"implemente"| PP
  RC --> R[(Redis)]
  SQL --> PG[(PostgreSQL)]
  HTTP --> EXT["API externe / collecte web autorisee"]

L'adaptateur fournisseur traduit les metadonnees du catalogue et la reponse externe vers le contrat de valorisation : les formats ReefAPI ne deviennent pas le modele interne du service. Le repository PostgreSQL actuel adapte le port generique a reefapi_offer_cache. Cette table reste specifique au fournisseur historique pour ne pas casser les lecteurs M1. Un autre fournisseur devra avoir une persistance compatible ; la PR ne pretend pas avoir deja une table d'observations multifournisseur generalisee. Les marches/mappings incompatibles sont refuses par ce repository sans ecriture SQL, plutot que sauvegardes dans la mauvaise table.

Principes SOLID appliques a ce perimetre ​

PrincipeApplication verifiable
S - responsabilite uniqueService : orchestration ; fournisseur : HTTP/mapping ; repository : SQL ; cache : Redis
O - ouvert/fermeAjouter une source et son depot via l'assemblage, sans dispatch de noms dans le service
L - substitutionUn adaptateur alternatif de test respecte les memes resultats quoted / not-found / unavailable et reutilise le cache
I - segregation des interfacesLe fournisseur ne doit pas implementer la persistence ; cache et snapshots ont des ports distincts
D - inversion des dependancesContrats neutres sans import ; service dependant de ces contrats ; implementations branchees dans index.ts

Il ne s'agit pas d'une certification SOLID de tout le backend. Les tests couvrent les dependances de ce parcours et son comportement observable, y compris les erreurs de cache, d'acquisition et d'ecriture durable. Les principes de separation, de contrats explicites et d'inversion de dependances sont decrits dans la reference d'architecture Microsoft.

Ordre des lectures ​

  1. Le petit cache existant du processus API evite les lectures repetees.
  2. Redis partage les prix entre processus et redemarrages de l'API.
  3. En cas de manque, PostgreSQL est consulte (reefapi_offer_cache actuellement).
  4. Si aucune observation recente n'existe, l'adaptateur appelle le fournisseur.
  5. Le prix valide est conserve dans PostgreSQL puis dans Redis. Une ecriture durable echouee ne peuple pas Redis. Un snapshot PostgreSQL recent peut rechauffer Redis sans requete fournisseur.

Les prix positifs expirent au plus tard 72 heures apres leur observation. Relire un prix vieux de 48 heures ne lui donne que 24 heures de TTL restant. Les valeurs nulles, zero, invalides, futures ou perimees ne sont pas partagees. Les absences d'offre restent dans le cache local existant pendant 5 minutes. Les erreurs reseau ne deviennent pas une fausse cote.

La premiere integration couvre exactement les offres Pokemon FR / NM / EUR pour les finitions regular/holo identifiables par l'adaptateur ReefAPI. Les prix reverse, japonais, One Piece et grades ne sont pas inventes. Le contrat reserve societe/note/qualificatif pour une future integration gradee. Les coefficients M1 par etat et les requetes batch durables ne changent pas. Les batches ordinaires restent non bloquants ; seuls les batches explicitement live=1 et authentifies rafraichissent les offres.

La coalescence des appels concurrents existe dans un processus API. Il n'y a pas encore de verrou distribue : ajouter plusieurs replicas pourra occasionner des appels simultanes au fournisseur lors d'un manque commun.

Configuration et securite ​

ReglageValeur initiale
Image Redisredis:8.4.7-alpine
Service / conteneur VPSredis / skns-redis-1
Reseau Dockerskns_price-cache, interne, API + Redis uniquement
Port publie sur l'hoteaucun
Memoire conteneur / seuil eviction256 MiB / 192 MiB
Politique evictionallkeys-lru
Persistence RDB / AOFdesactivee : cache jetable
Stockage comptes, collections, photos, secretsaucun
Attente commande / connexion150 ms / 500 ms

REDIS_URL absent : Redis desactive hors Docker, service prive par defaut en Compose. REDIS_URL= explicitement vide : desactive egalement en Compose. L'API demarre meme si Redis est indisponible. Pas de file hors ligne, maximum 128 commandes en attente, reconnexion progressive jusqu'a 10 secondes ; les erreurs de connexion sont journalisees au plus une fois par minute sans secrets.

Redis ne demande pas de mot de passe dans ce reseau Docker isole. Cela suppose que l'hote et les utilisateurs ayant acces a Docker soient de confiance. Ne pas ouvrir 6379, ni raccorder Redis a un reseau partage avec des services non fiables. Pour un cache distant, prevoir ACL, authentification et TLS (rediss://). Redis ne stocke pas de sauvegarde disque : apres son redemarrage, PostgreSQL repeuple le cache. Les sauvegardes PostgreSQL restent indispensables.

Exploitation courante ​

Depuis le checkout deploye, utiliser les fichiers Compose correspondant effectivement au conteneur, pas un vieux chemin trouve dans une commande :

bash
docker inspect skns-api-1 --format '{{json .Config.Labels}}'
docker exec skns-redis-1 redis-cli ping
docker inspect skns-redis-1 --format '{{.State.Health.Status}} {{json .HostConfig.PortBindings}}'
docker exec skns-redis-1 redis-cli info memory
docker exec skns-redis-1 redis-cli info stats
docker exec skns-redis-1 redis-cli --scan --pattern 'snag:price:v1:*'

Pour une cle precise :

bash
docker exec skns-redis-1 redis-cli ttl 'CLE_EXACTE'
docker exec skns-redis-1 redis-cli get 'CLE_EXACTE'

TTL positif : secondes restantes ; -2 : entree absente ; -1 : absence d'expiration, anomalie pour nos prix. Utiliser SCAN, pas KEYS *, sur une base qui grossit. Ne pas executer FLUSHALL pour corriger une seule carte. Une invalidation ponctuelle se fait avec DEL sur la cle exacte apres autorisation. Les compteurs Redis incluent aussi les probes : ils ne sont pas des quotas ReefAPI.

Pour un nouveau deploiement standard, apres validation de la PR et sauvegarde :

bash
docker compose --env-file .env -f docker/compose.yml -f docker/compose.server.yml config --quiet
docker compose --env-file .env -f docker/compose.yml -f docker/compose.server.yml build api
docker compose --env-file .env -f docker/compose.yml -f docker/compose.server.yml up -d --no-deps redis api
curl -f http://127.0.0.1:18080/readyz

Ne pas lancer un up --build global : il pourrait redemarrer/reconstruire les scanners. Ne pas appliquer de migration pour ce cache : aucune n'a ete ajoutee.

Installation realisee sur le VPS ​

Le 2026-10-01, seule l'API a ete basculee et Redis ajoute. PostgreSQL, scheduler, ingress, Nginx et scanners ont conserve leurs conteneurs en cours. Le checkout historique reste sur staging, commit 4d91f33d, avec son override serveur non suivi preserve. L'API etait auparavant lancee depuis des chemins /repo temporaires dont les sources Compose n'existaient plus. Les montages et variables effectifs ont ete captures et preserves plutot que reconstruits approximativement depuis .env.

La version du cache a38f14f est deployee depuis une archive de la PR #419, encore ouverte a la date de cette verification : elle n'a pas ete fusionnee automatiquement sur staging/main.

  • Sources : /srv/snag/releases/redis-price-cache-a38f14f.
  • Image API : skns-api:redis-a38f14f.
  • Backup prive : /srv/snag/deployments/redis-price-cache-a38f14f, mode 0700.
  • Snapshot, fichiers d'environnement et overrides : mode 0600 ; ils peuvent contenir des secrets et ne doivent jamais etre colles dans une issue/un commit.
  • Image precedente conservee : skns-api:pre-redis-a38f14f.
  • API de verification arretee apres validation : snag-redis-api-canary-a38f14f.

Pour les operations sur ce deploiement precis, definir :

bash
ROOT=/srv/snag/app/tcg-collector-backend
RELEASE=/srv/snag/releases/redis-price-cache-a38f14f
DEPLOY=/srv/snag/deployments/redis-price-cache-a38f14f
dc() {
  docker compose --env-file "$ROOT/.env" \
    -f "$RELEASE/docker/compose.yml" -f "$ROOT/docker/compose.server.yml" \
    -f "$DEPLOY/runtime.override.json" "$@"
}
dc ps api redis

Avant un prochain deploy, reconcilier ces chemins et la version Git avec la PR fusionnee. Le backup runtime preserve les variables et montages du jour, mais ne doit pas ecraser de futurs changements de configuration sans comparaison.

Retour arriere ​

Apres autorisation et en utilisant les variables ci-dessus :

bash
docker compose --env-file "$ROOT/.env" \
  -f "$RELEASE/docker/compose.yml" -f "$ROOT/docker/compose.server.yml" \
  -f "$DEPLOY/rollback.override.json" up -d --no-deps --no-build api
curl -f http://127.0.0.1:18080/readyz

Cela restaure l'image API precedente et ses montages, sans toucher a PostgreSQL ni au scan. Redis peut rester inutilise ; son arret eventuel est une operation distincte. Ne pas supprimer les backups avant la validation du prochain deploy.

Verifications ​

  • API initiale deployee : 996 tests unitaires passes et controle TypeScript/JS passe.
  • Raffinement du service interne et de ses ports : 1008 tests API passes, controle TypeScript/JS passe ; tests de separation des routes/adaptateurs, d'un autre fournisseur, de coalescence, d'expiration, de depots injectes, de mappings SQL incompatibles et d'erreurs. CI du commit 649fee9 : API e2e, API/ingest unit et typechecks, integration ingest et probe passes.
  • Ingest : 527 tests passes.
  • CI : API e2e, integration ingest, probe et controles unitaires passes.
  • CI globale Compose : bloquee par le conflit preexistant suivi dans #421, numpy==2.5.3 / ultralytics==8.2.60 (numpy<2) dans les scanners ; hors de cette modification. Les images de scan existantes n'ont pas ete reconstruites.
  • Redis reel : PONG, healthy, aucun port hote, reseau interne, memoire 256 MiB, seuil 192 MiB / LRU ; JSON invalide et autres langues/finishes refusent le hit.
  • Un prix test vieux de 48h a obtenu 86400 secondes de TTL puis a ete supprime.
  • Un vrai prix durable de sv10.5w-003 a rechauffe Redis ; apres bascule de l'API, une relecture a fait passer keyspace_hits de 4 a 5 sans changer le timestamp du prix. Aucun appel fournisseur n'etait necessaire pour cette verification.
  • Un autre processus, avec les fonctions PostgreSQL et fournisseur remplacees par des erreurs explicites, a recupere ce meme prix depuis Redis : 0 appel PostgreSQL, 0 appel fournisseur, prouvant le hit inter-processus.
  • /readyz prive et public repondent {"ok":true} ; redemarrages API/Redis : 0.

References officielles : client Node et connexions, configuration du client, image officielle.