@@ -132,6 +132,157 @@ descriptives (`mime_type`, `folder_url`, `file_name`, `width`, `height`).
132132
133133---
134134
135+ ## ADR-023 — Éclatement de l'agrégat de commande en composants ECS 1:1
136+
137+ ** Statut** : Adopté
138+
139+ ### Contexte
140+
141+ Un système de commande réel nécessite des données de natures radicalement
142+ différentes : statut d'exécution (hot, consulté à chaque affichage), données
143+ financières (warm, consultées à la confirmation et à la facturation), informations
144+ de livraison (cold, consultées après expédition). Les stocker dans une table
145+ unique pollue systématiquement le cache CPU avec des données froides lors de
146+ chaque accès chaud.
147+
148+ ### Décision
149+
150+ L'entité commande est décomposée en quatre composants denses, reliés par la
151+ même clé primaire (` transaction_id ` ) :
152+
153+ | Composant | Sémantique schema.org | Fréquence d'accès |
154+ | --------------------------- | --------------------------------- | ----------------- |
155+ | ` transaction_core ` | ` Order ` | Très haute |
156+ | ` transaction_price ` | ` PriceSpecification ` | Haute |
157+ | ` transaction_payment ` | ` PaymentChargeSpecification ` | Moyenne |
158+ | ` transaction_delivery ` | ` ParcelDelivery ` | Basse |
159+ | ` transaction_item ` | ` OrderItem ` (N lignes) | Haute |
160+
161+ Chaque composant est une relation 1:1 stricte (PK = FK vers ` transaction_core ` ).
162+
163+ ### Justification — cache CPU et densité
164+
165+ L'argument "éviter une jointure" en faveur d'une fat table repose sur un coût
166+ mal évalué. Une jointure sur clé primaire entière (` INT4 ` ) est un déréférencement
167+ mémoire trivial sur une ligne déjà en cache — coût de l'ordre de quelques
168+ nanosecondes. La pollution de cache par des colonnes froides a un coût
169+ structurellement récurrent :
170+
171+ - ` tracking_number VARCHAR(255) ` dans ` transaction_core ` gonfle chaque tuple
172+ d'au moins 4 bytes d'en-tête varlena, plus le contenu. Sur 500 000 commandes,
173+ un champ non nul de 20 chars ajoute ~ 12 Mo de bruit sur le heap du composant core.
174+ - Ce bruit réduit mécaniquement le nombre de tuples par page cache, augmentant
175+ les shared_blks_hit nécessaires pour chauffer le working set.
176+
177+ Avec la décomposition, ` transaction_core ` tient en ** 32 bytes/tuple** (~ 258
178+ tuples/page). L'affichage du statut de toutes les commandes d'un client ne charge
179+ jamais le transporteur ni le numéro de facture.
180+
181+ ### Arbitrages typologiques (DOD)
182+
183+ ** ` currency_code SMALLINT ` ** (2 bytes, pass-by-value) plutôt que ` CHAR(3) `
184+ (varlena avec padding) ou ` VARCHAR(3) ` (varlena). Le code ISO 4217 numérique
185+ (978 = EUR, 840 = USD, 826 = GBP) couvre l'ensemble des devises actives.
186+ Le mapping vers le code alphabétique est délégué à la couche applicative — c'est
187+ une simple table de lookup statique, jamais un accès base de données.
188+
189+ ** ` tax_rate_bp INT4 ` ** (4 bytes, pass-by-value) plutôt que ` NUMERIC(5,2) `
190+ (varlena, arithmétique logicielle). Le taux est stocké en ** basis points**
191+ (1 bp = 0,01%). Exemples : 2000 = 20,00% · 550 = 5,50%. L'arithmétique entière
192+ ALU native remplace l'émulation NUMERIC : ` (amount_cents * tax_rate_bp) / 10000 `
193+ reste dans les registres CPU.
194+
195+ ** Tous les montants en ` INT8 ` centimes** (ADR-022) : ` shipping_cents ` ,
196+ ` discount_cents ` , ` tax_cents ` suivent la même règle que ` price_cents ` . Pas de
197+ NUMERIC dans le hot path financier.
198+
199+ ### Procédure ` create_transaction `
200+
201+ La procédure crée atomiquement les quatre composants : ` transaction_core ` +
202+ ` transaction_price ` (devise + montants à zéro) + ` transaction_payment ` (statut 0)
203+ + ` transaction_delivery ` (statut 0). ** Il n'existe jamais de transaction_core sans
204+ ses trois composants** — l'invariant est garanti par l'atomicité de la transaction.
205+ Les composants sont mis à jour indépendamment au fil du cycle de vie de la commande.
206+
207+ ### Relation avec les autres ADR
208+
209+ - ADR-005 (fragmentation ECS) : même pattern appliqué au domaine Commerce.
210+ - ADR-022 (INT8 centimes) : invariant étendu à tous les montants de ` transaction_price ` .
211+ - ADR-020 (SECURITY DEFINER) : ` create_transaction ` est la seule procédure
212+ d'écriture autorisée pour ` marius_user ` sur ` transaction_core ` et ses composants.
213+
214+ ---
215+
216+ ## ADR-022 — Optimisations CPU/mémoire : entiers natifs, VARCHAR, padding documenté, pushdown
217+
218+ ** Statut** : Adopté
219+
220+ ### Contexte
221+
222+ Audit DOD ciblé sur les composants chauds (` commerce.product_core ` ,
223+ ` commerce.transaction_item ` , ` org.org_legal ` , ` identity.auth ` ,
224+ ` commerce.v_transaction ` ). Quatre ajustements de topologie physique.
225+
226+ ### 1. NUMERIC → INT8 centimes (commerce)
227+
228+ Voir ADR-014 pour le raisonnement complet. Résumé :
229+
230+ - ` NUMERIC ` est varlena → padding d'alignement, tuple deforming indirect, arithmétique logicielle.
231+ - ` INT8 ` est pass-by-value, aligné sur 8 bytes, arithmétique ALU native.
232+ - Gain de densité : ×2 sur ` product_core ` (24 B/tuple), ×2,2 sur ` transaction_item ` (20 B/tuple).
233+ - Convention de nommage : suffixe ` _cents ` visible dans les noms de colonnes et dans les alias de vues.
234+
235+ ### 2. ` CHAR(n) ` → ` VARCHAR(n) ` (org.org_legal, commerce.product_identity)
236+
237+ ` CHAR(n) ` dans PostgreSQL est varlena comme ` VARCHAR(n) ` . La seule différence est
238+ le padding espace sur écriture et le stripping sur lecture : ** surcoût CPU sans
239+ contrepartie** . Les contraintes CHECK avec regex garantissent la longueur exacte et
240+ la conservation des zéros initiaux — ` VARCHAR(n) ` est strictement suffisant.
241+
242+ Correction étendue à ` isbn_ean VARCHAR(13) ` dans ` commerce.product_identity ` pour
243+ la même raison (était ` CHAR(13) ` ).
244+
245+ ### 3. Slot de padding libre dans ` identity.auth ` — documentation
246+
247+ Séquence de types en fin de tuple fixe de ` identity.auth ` :
248+ ```
249+ role_id SMALLINT (2 B, offset 28)
250+ is_banned BOOLEAN (1 B, offset 30)
251+ [padding] — (1 B, offset 31) ← slot libre
252+ password_hash varlena (offset 32, alignement 4 B)
253+ ```
254+
255+ Ce byte de padding est structurellement inévitable : la varlena ` password_hash `
256+ requiert un alignement sur 4 bytes, et la séquence SMALLINT + BOOLEAN consomme
257+ 3 bytes. Le slot à l'offset 31 est documenté comme ** emplacement réservé pour un
258+ prochain BOOLEAN** (ex : ` is_email_verified ` ) sans coût marginal.
259+
260+ ** Pourquoi pas le type interne ` "char" ` ** : c'est un type système sans opérateurs
261+ de domaine ni coercition standard. Il rend le schéma opaque pour tout DBA et
262+ incompatible avec les outils standards. Pour les états fermés futurs, ` SMALLINT `
263+ avec ` CHECK (status IN (...)) ` reste le bon choix.
264+
265+ ### 4. Pushdown garanti sur ` commerce.v_transaction ` — documentation
266+
267+ PostgreSQL inline les vues avant planification : la vue n'est pas une barrière
268+ d'optimisation. ` WHERE "identifier" = :id ` appliqué sur ` v_transaction ` se réécrit
269+ en ` WHERE t.id = :id ` par le query rewriter avant que le planner n'intervienne.
270+ ` t.id ` figure dans le GROUP BY — le prédicat est poussé avant ` json_agg() ` , l'index
271+ PK est utilisé, l'agrégation porte sur les lignes de la commande concernée uniquement.
272+
273+ ** Vérification recommandée** :
274+ ``` sql
275+ EXPLAIN (ANALYZE, BUFFERS)
276+ SELECT * FROM commerce .v_transaction WHERE " identifier" = 1 ;
277+ -- Attendu : Index Scan sur commerce.transaction (PK), pas de Seq Scan.
278+ ```
279+
280+ ** Invariant d'usage** : ` v_transaction ` ne doit jamais être appelée sans filtre.
281+ Un ` SELECT * ` sans ` WHERE ` agrège toutes les lignes de toutes les transactions.
282+ Documenté dans la définition de la vue.
283+
284+ ---
285+
135286## ADR-005 — Fragmentation ECS : SoA en lieu de AoS
136287
137288** Statut** : Adopté
@@ -610,20 +761,49 @@ est direct sur les tables à forte volumétrie (`content_to_tag`, `transaction_i
610761
611762---
612763
613- ## ADR-014 — ` NUMERIC(12,2) ` pour tous les montants monétaires
764+ ## ADR-014 — ` INT8 ` centimes pour tous les montants monétaires
614765
615- ** Statut** : Adopté
766+ ** Statut** : Adopté (révisé — ADR-022)
616767
617- ### Justification
768+ ### Décision
769+
770+ Les montants monétaires (` price_cents ` dans ` product_core ` ,
771+ ` unit_price_snapshot_cents ` dans ` transaction_item ` ) sont stockés en ** centimes
772+ de la devise de référence** sous forme d'entier ` INT8 ` . La conversion décimale
773+ est déléguée à la couche applicative.
774+
775+ ### Pourquoi pas NUMERIC
776+
777+ ` NUMERIC ` est varlena dans PostgreSQL sans exception, quelle que soit la précision
778+ déclarée. Conséquences directes :
618779
619- ` FLOAT8 ` introduit des erreurs d'arrondi binaire sur les représentations
620- décimales exactes (` 0.1 + 0.2 ≠ 0.3 ` ). Inacceptable pour des montants financiers
621- archivés dans ` unit_price_snapshot ` — le prix capturé doit être exact et stable.
780+ - ** Padding d'alignement** : le moteur insère des bytes de remplissage entre les
781+ colonnes à taille fixe et l'en-tête varlena de NUMERIC. Ce padding est invisible
782+ dans ` \d ` et permanent.
783+ - ** Tuple deforming** : NUMERIC déclenche un appel indirect via parsing de l'en-tête
784+ varlena à chaque lecture de tuple, même pour une comparaison triviale.
785+ - ** Arithmétique** : les opérations sur NUMERIC sont émulées en base 10000 en
786+ logiciel. ` unit_price * quantity ` dans ` v_transaction ` ne touche pas l'ALU.
622787
623- ` NUMERIC ` sans précision produit un stockage variable non contrôlé.
788+ ### Pourquoi INT8 et non INT4
624789
625- ` NUMERIC(12,2) ` : précision décimale exacte, plage jusqu'à 9 999 999 999,99
626- (~ 10 Md€), stockage interne de 6–8 bytes pour les montants courants.
790+ INT4 max = 2 147 483 647 centimes ≈ 21,4 M€. Insuffisant pour les transactions
791+ B2B (équipements industriels, licences). INT8 max ≈ 92 000 Md€. Le surcoût est
792+ nul : INT8 est pass-by-value sur toutes les architectures 64 bits.
793+
794+ ### Gain de densité
795+
796+ | Table | Avant (NUMERIC) | Après (INT8) | Tuples/page |
797+ | --------------------------- | --------------- | ------------ | ----------- |
798+ | ` commerce.product_core ` | ~ 48 B | 24 B | ~ 341 (×2) |
799+ | ` commerce.transaction_item ` | ~ 44 B | 20 B | ~ 409 (×2,2) |
800+
801+ ### Convention de nommage
802+
803+ Les colonnes suffixées ` _cents ` rendent l'invariant d'unité visible à tout lecteur
804+ du schéma sans documentation supplémentaire. La vue expose le suffixe
805+ (` "priceCents" ` , ` "unitPriceCents" ` , ` "totalPriceCents" ` ) pour que le contrat API
806+ soit sans ambiguïté.
627807
628808---
629809
@@ -634,33 +814,39 @@ archivés dans `unit_price_snapshot` — le prix capturé doit être exact et st
634814### Décision
635815
636816Les lignes de commande sont une table dédiée :
637- ` commerce.transaction_item(transaction_id, product_id, quantity, unit_price_snapshot ) ` .
817+ ` commerce.transaction_item(unit_price_snapshot_cents, transaction_id, product_id, quantity) ` .
638818
639819### Justification
640820
641821Une liste d'identifiants sérialisée en colonne varchar rend impossible toute FK
642822référentielle, tout agrégat par produit et tout historique de prix. Le champ
643- ` unit_price_snapshot NUMERIC(12,2) ` capture le prix au moment de l'INSERT : le
644- prix courant peut évoluer sans altérer l'historique des commandes passées.
823+ ` unit_price_snapshot_cents INT8 ` capture le prix en centimes au moment de l'INSERT :
824+ le prix courant peut évoluer sans altérer l'historique des commandes passées.
645825
646826` quantity INT4 ` (et non ` SMALLINT ` ) : SMALLINT max = 32 767. Les commandes B2B
647827peuvent dépasser ce volume. INT4 couvre jusqu'à ~ 2,1 milliards.
648828
649829---
650830
651- ## ADR-010 — Typage ` CHAR(9 )` / ` CHAR(14) ` pour DUNS et SIRET
831+ ## ADR-010 — ` VARCHAR(n )` pour DUNS, SIRET et ISBN/EAN
652832
653- ** Statut** : Adopté
833+ ** Statut** : Adopté (révisé — ADR-022)
834+
835+ ### Décision
836+
837+ Les identifiants de longueur fixe (` duns VARCHAR(9) ` , ` siret VARCHAR(14) ` ,
838+ ` isbn_ean VARCHAR(13) ` ) utilisent ` VARCHAR(n) ` et non ` CHAR(n) ` .
654839
655840### Justification
656841
657- DUNS (9 chiffres) et SIRET (14 chiffres) sont des identifiants non arithmétiques :
658- ** les zéros initiaux sont significatifs ** . Un type entier les perd silencieusement.
659- ` VARCHAR ` n'impose pas la longueur fixe. ` CHAR(n) ` est le seul type garantissant
660- longueur fixe et conservation des zéros initiaux .
842+ ` CHAR(n) ` dans PostgreSQL est varlena au même titre que ` VARCHAR(n) ` — il n'offre
843+ aucun stockage fixe. La seule différence opérationnelle est le ** padding espace **
844+ à l'écriture et le ** stripping ** à la lecture : surcoût CPU pur sans contrepartie
845+ en densité ni en performance de recherche .
661846
662- Validation par contrainte CHECK avec regex (` '^[0-9]{9}$' ` , ` '^[0-9]{14}$' ` ) :
663- coût fixe à l'INSERT, zéro coût à la lecture, pas de trigger.
847+ L'invariant de longueur exacte est garanti par les contraintes CHECK avec regex.
848+ ` VARCHAR(n) ` avec CHECK est strictement équivalent en termes de validation, et
849+ absent de tout overhead de padding.
664850
665851---
666852
@@ -692,9 +878,10 @@ au même titre que les `REVOKE` qui les suivent.
692878| ` identity.person_identity ` | ~ 74 B | ~ 110 |
693879| ` identity.person_biography ` | 44 B | ~ 185 |
694880| ` org.org_hierarchy ` | 40 B | ~ 204 |
695- | ` commerce.transaction_item ` | 44 B | ~ 186 |
881+ | ` commerce.product_core ` | 24 B | ~ 341 |
882+ | ` commerce.transaction_item ` | 20 B | ~ 409 |
696883| ` geo.place_core ` (minimal) | 61 B | ~ 134 |
697884
698885---
699886
700- * Architecture ECS/DOD · PostgreSQL 18 · Projet Marius · 21 décisions*
887+ * Architecture ECS/DOD · PostgreSQL 18 · Projet Marius · 23 décisions*
0 commit comments