Aller au contenu

P1.5A — Capacity Measurement Contract

Status: CURRENT CONTRACT / FOUNDATION / NOT RUNTIME CERTIFIED

Objet

P1.5A fixe le vocabulaire de mesure qui sera utilisé pour certifier la capacité réelle du VPS CMonChoix. Ce lot ne déploie aucun exporter supplémentaire, n'ajoute aucun endpoint Runtime, ne modifie aucun scrape Prometheus et n'exécute aucun benchmark live.

L'autorité machine-readable unique est :

monitoring/capacity-metrics.json

Cette page explique cette autorité. En cas d'écart entre une copie documentaire et le registre JSON, le registre JSON doit être corrigé ou la documentation doit être réalignée ; il ne doit jamais exister deux contrats de métriques concurrents.

Audit de départ :

main=b064a04a3d729589171343ab28485310bb8922c3

Principe

La stratégie reste :

calculer lorsque les données changent, servir rapidement lorsque les utilisateurs lisent.

Et l'objectif infrastructure reste :

single-node industrial-grade, distributed-ready

P1.5 ne cherche donc pas à prouver un nombre arbitraire de marchands. Il doit mesurer la relation entre :

volume d'offres
× taux de changement
× fréquence de refresh
× coût ingestion / normalisation / persistence / identity / projection
× concurrence
× trafic frontend

Ce qui existe déjà dans le Runtime

P1.5A ne part pas d'une page blanche.

Sync runs

ccx_sync_runs_v2 porte déjà notamment :

  • source_feed ;
  • status ;
  • worker_id ;
  • batch_size ;
  • total_rows_estimated ;
  • processed_rows ;
  • success_rows ;
  • skipped_rows ;
  • error_rows ;
  • quarantined_rows ;
  • started_at ;
  • finished_at ;
  • last_heartbeat_at.

Ces données restent la vérité opérationnelle des runs. Prometheus ne doit pas devenir une nouvelle base d'autorité pour leur état.

Sync batches

ccx_sync_batches_v2 porte déjà :

  • rows_seen ;
  • rows_ok ;
  • rows_skipped ;
  • rows_error ;
  • elapsed_ms ;
  • avg_ms_per_row ;
  • peak_memory_mb ;
  • current_memory_mb ;
  • metrics_json.

Le runner mesure déjà séparément dans metrics_json :

  • claim ;
  • JSON decode ;
  • normalisation ;
  • row-to-item ;
  • marquage des statuts ;
  • persistence.

Ces mesures sont des preuves utiles pour les benchmarks historiques et pour valider la future instrumentation Prometheus.

Queue canonique de traitement

ccx_sync_stage_raw_v2 porte :

  • source_feed ;
  • status ;
  • created_at ;
  • claimed_at ;
  • claim_expires_at ;
  • processed_at.

Le queue_depth P1.5 désigne les lignes pending + processing des runs reprenables. Les lignes terminales ne sont pas du backlog.

Le queue_lag est l'âge de la plus ancienne ligne encore pending ou processing, calculé depuis created_at. Une queue vide a un lag de zéro.

Catalogue

total_offers est dérivé du nombre de lignes de la table canonique ccx_offers_norm_v2.

Aucune table de comptage spécifique P1.5 ne doit être créée pour recopier ce nombre.

Infrastructure Prometheus déjà disponible

Sans nouvelle instrumentation, le Runtime dispose déjà via cAdvisor/node-exporter de signaux utilisables pour :

  • CPU du conteneur ccx-platform-worker ;
  • RAM working set du worker ;
  • lectures/écritures disque ;
  • IOPS host ;
  • CPU/RAM host.

P1.5B pourra créer des recording rules stables au-dessus de ces séries sans changer leur source de vérité.

Métriques obligatoires

Le registre JSON fixe les sorties de capacité suivantes :

total_offers
offers_changed_per_day
source_rows_per_sec
normalized_rows_per_sec
persisted_rows_per_sec
identity_products_per_sec
projection_products_per_sec
jobs_per_min
DB_writes_per_sec
DB_query_latency
worker_CPU
worker_RAM
disk_IOPS
queue_depth
queue_lag
projection_lag
frontend_p95
frontend_p99
HTTP_5xx

HTTP_5xx est inclus explicitement dans P1.5 car un benchmark de throughput qui dégrade le frontend n'est pas un PASS.

Fenêtre de calcul

La fenêtre standard d'observation est :

5 minutes

Les taux et quantiles dashboard/SLO utilisent cette fenêtre sauf justification documentée.

Un benchmark borné peut utiliser son intervalle exact de mesure, à condition :

  1. de conserver la même sémantique de métrique ;
  2. d'enregistrer les timestamps début/fin ;
  3. de ne pas comparer une moyenne de run à un p95 dashboard comme s'il s'agissait du même indicateur.

Sémantique des métriques

total_offers

Sortie :

ccx_capacity_total_offers

Unité : offres.

Source : COUNT(*) de ccx_offers_norm_v2.

offers_changed_per_day

Sortie :

ccx_capacity_offers_changed_24h

Unité : offres modifiées sur 24 h.

La future instrumentation doit compter uniquement les changements sémantiques effectivement commités. Un retry idempotent ne doit pas compter deux fois la même mutation.

Throughput pipeline

Les débits sont calculés à partir de compteurs monotones :

ccx_sync_source_rows_total
ccx_sync_normalized_rows_total
ccx_sync_persisted_rows_total
ccx_identity_products_total
ccx_projection_products_total
ccx_jobs_completed_total

Les sorties *_per_second ou *_per_minute sont des dérivations Prometheus, pas des compteurs persistés séparément.

DB writes

DB_writes_per_sec est requis mais P1.5A ne prétend pas qu'un exporter MariaDB existe déjà.

P1.5C devra sélectionner et vérifier une source de compteur MariaDB réelle avant de matérialiser :

ccx_capacity_db_writes_per_second

Aucun nom de série exporter non vérifié n'est déclaré certifié dans P1.5A.

DB query latency

La mesure primaire de certification est le p95 sur la fenêtre standard :

ccx_capacity_db_query_latency_p95_seconds

La source future est un histogramme borné :

ccx_db_query_duration_seconds

Les labels SQL haute cardinalité sont interdits.

Worker CPU

Sortie :

ccx_capacity_worker_cpu_ratio

Le ratio représente le CPU utilisé par ccx-platform-worker rapporté au nombre de cores de la machine.

Worker RAM

Sortie :

ccx_capacity_worker_memory_bytes

Source : working set cAdvisor du conteneur ccx-platform-worker.

disk IOPS

Sortie :

ccx_capacity_disk_iops

Le calcul additionne les opérations de lecture et d'écriture terminées sur les block devices réels, en excluant les devices loop.

Projection lag

Sortie :

ccx_capacity_projection_lag_seconds

Le lag signifie :

timestamp du commit métier exigeant une projection
→ timestamp de fin de la projection affectée

Il est interdit d'approximer ce lag en soustrayant deux MAX(timestamp) globaux sans corrélation entre la mutation et la projection.

Cette métrique nécessite donc une instrumentation corrélée dans un lot ultérieur.

Frontend p95 / p99

Source future :

ccx_frontend_request_duration_seconds

Les quantiles sont calculés côté Prometheus depuis l'histogramme. Le code applicatif ne doit pas calculer ou stocker un p95/p99 local.

HTTP 5xx

Sortie :

ccx_capacity_http_5xx_ratio

Il s'agit du ratio de requêtes publiques classées 5xx sur le total de requêtes observées sur la même fenêtre.

Cardinalité

Les labels dynamiques non bornés sont interdits.

Interdits notamment :

run_id
batch_id
product_id
offer_id
aw_product_id
url
path
query
sql
error_message
trace_id

Un identifiant utile à l'investigation reste dans les logs, les tables canoniques ou les traces de benchmark ; il ne devient pas automatiquement un label Prometheus.

feed

Le label feed est autorisé uniquement parce qu'il provient du registre canonique borné des feeds.

Il ne doit jamais recevoir une URL, un nom libre provenant du flux ou un identifiant créé par utilisateur.

labels à valeurs fermées

Le registre JSON borne également les valeurs de :

  • result ;
  • change_type ;
  • projection ;
  • job_type ;
  • query_class ;
  • route_class ;
  • status_class ;
  • operation ;
  • quantile.

Toute extension de ces ensembles exige une modification explicite du contrat.

Aucune nouvelle persistance métier

P1.5 ne crée pas une deuxième vérité.

Interdit :

  • table SQL de métriques recopiant les runs ;
  • table SQL de queue uniquement pour Prometheus ;
  • stockage Redis durable de compteurs métier ;
  • copie des IDs de run/produit/offre dans les labels ;
  • recalcul métier dans Grafana ;
  • calcul de capacité à partir d'un dashboard manuel non versionné.

Les métriques peuvent lire ou observer les frontières existantes, mais leur disparition ne doit pas supprimer la donnée métier durable.

Frontière de collecte

P1.5A ne choisit pas encore le mécanisme exact d'export applicatif.

P1.5B devra préférer la solution minimale qui respecte :

  • lecture seule pour les gauges dérivées des tables ;
  • instrumentation aux frontières canoniques pour les counters/histograms ;
  • aucun bootstrap métier lourd lors d'un scrape ;
  • aucune dépendance du Domain à Prometheus ;
  • aucune obligation WordPress pour une métrique appartenant à la Platform ;
  • échec de l'observabilité sans mutation fonctionnelle ;
  • coût de collecte borné et mesuré.

P1.5B1 — événements Runtime sync

P1.5B1 ajoute une instrumentation structurée mais non encore exportée dans Prometheus.

Préfixe de log :

[CCX_CAPACITY_METRIC]

Quatre séries sont désormais instrumentées sur le chemin sync :

ccx_sync_source_rows_total
ccx_sync_normalized_rows_total
ccx_sync_persisted_rows_total
ccx_jobs_completed_total   # sync_batch success path uniquement

Ces événements transportent un delta, des labels bornés et un contexte de déduplication séparé des labels Prometheus.

Le contexte peut contenir :

run_id
batch_index
offset_end
rows_seen_total

Ces champs sont des preuves opérationnelles et ne deviennent pas des labels Prometheus.

Frontière source rows

L'événement source n'est émis qu'après la persistance du checkpoint reader dans ccx_sync_runs_v2.

Cela évite de considérer un slice comme définitivement observé avant que le Runtime ait enregistré son avancement.

Frontière normalized / persisted

Les événements de batch sont émis après :

  1. persistence applicative réussie ;
  2. marquage des raw rows ;
  3. enregistrement du batch réussi via finishBatch().

ccx_sync_persisted_rows_total{result="written"} utilise kept_items après le retour réussi de persist(). La distinction unchanged n'est pas encore instrumentée et ne doit pas être inventée.

Job completion

P1.5B1 instrumente uniquement :

job_type=sync_batch
result=success

Les erreurs batch, les rebuilds de projections et les autres familles de jobs restent explicitement hors scope de ce slice.

Sémantique de ces événements

Ces logs sont des delta events d'instrumentation.

Ils ne sont pas encore :

  • un compteur Prometheus ;
  • une source de vérité métier ;
  • une garantie exactly-once ;
  • une capacité VPS certifiée.

Un futur collector/exporter devra utiliser les clés de contexte bornées pour éviter les doubles comptes liés aux retries/crash windows avant de matérialiser des compteurs monotones.

Le registre machine-readable expose cet état via runtime_event_contract.live_export_status=NOT_PROMETHEUS_EXPORTED.

Une erreur de série, label ou delta métrique est rejetée avec le préfixe [CCX_CAPACITY_METRIC_REJECTED] et ne doit jamais interrompre la sync métier.

P1.5B2 — événements Runtime projections

P1.5B2 étend l'instrumentation structurée au refresh de projections déclenché par la finalisation sync.

Séries concernées :

ccx_projection_products_total
ccx_jobs_completed_total{job_type="projection_rebuild"}

Grain produit obligatoire

ccx_projection_products_total ne compte pas les lignes physiques des tables de projection lorsqu'une projection peut contenir plusieurs lignes par produit.

La correspondance canonique est :

projection compteur produit
product_models rebuilt_models
variants models_count
specifications specs_count
gallery gallery_count

En particulier, rebuilt_variants est volontairement interdit comme compteur de produits projetés : plusieurs variantes peuvent appartenir au même modèle.

Seules les étapes dont le statut est ok émettent un delta result="success". Une projection unsupported_vertical ne fabrique aucun faux nombre de produits traités.

Frontière d'émission

L'instrumentation reste dans le sync runner, après retour réussi de ccx_sync_refresh_product_projections().

Les services transactionnels de write restent inchangés et ne dépendent pas du logger Runtime :

CCX_ProductModelProjectionWriteService
CCX_ProductVariantProjectionWriteService
CCX_ProductSpecificationProjectionWriteService
CCX_ProductGalleryProjectionWriteService

Cela maintient la séparation suivante :

projection write transaction
        ↓
projection refresh result
        ↓
runtime capacity event

Une erreur de métrique reste non bloquante et ne peut pas transformer une projection métier réussie en échec.

Si un refresh multi-vertical échoue après avoir déjà effectué des writes sur une verticale précédente, P1.5B2 émet le job projection_rebuild{result="error"} mais ne reconstruit pas des deltas produits partiels qui n'ont pas été retournés par la frontière de refresh. Un compteur approximatif serait pire qu'une sous-observation explicitement documentée.

Job projection_rebuild

Le compteur job utilise une exécution complète du refresh de projections du run sync comme unité :

success = ccx_sync_refresh_product_projections() retourne avec succès
error   = le critical step refresh_product_projections échoue

Le contexte peut conserver run_id, feed et vertical_count, mais ces valeurs ne deviennent pas des labels.

P1.5B2 ne prétend pas couvrir les rebuilds manuels/CLI hors chemin Runtime sync. Le statut du registre reste donc explicitement limité au SYNC_REFRESH_PATH.

Toujours pas Prometheus live

Comme B1, B2 n'ajoute encore :

  • aucun endpoint métrique ;
  • aucun collector Prometheus ;
  • aucun scrape target ;
  • aucune recording rule ;
  • aucune mutation VPS.

Les événements restent des preuves d'instrumentation structurées avec :

runtime_event_contract.live_export_status=NOT_PROMETHEUS_EXPORTED

P1.5B3 — échecs sync batch et gaps sémantiques

P1.5B3 ferme le chemin sync_batch{result="error"} et formalise deux métriques qui ne peuvent pas encore être instrumentées honnêtement.

sync_batch error

Un batch de travail non vide est considéré comme error uniquement si une exception remonte avant que finishBatch(status=success) ait retourné avec succès.

Le Runtime utilise un marqueur local :

batch_recorded_success=false

Puis :

exception avant durable success
→ ccx_jobs_completed_total{job_type="sync_batch",result="error"}
→ relance de la même exception

Une exception postérieure à la réussite durable du batch n'émet pas un second événement error, afin d'éviter de compter le même batch comme success et error.

Le contexte de l'événement peut contenir run_id, batch_index, feed et exception_class. Aucun de ces identifiants ne devient un label Prometheus.

Le comportement métier reste inchangé : l'exception originale est relancée.

offer_changes — instrumentation volontairement bloquée

Le chemin CURRENT ccx_feeds_upsert_items_to_offers_norm() retourne le nombre de lignes affectées par MySQL pour un :

INSERT ... ON DUPLICATE KEY UPDATE

Le code CURRENT documente lui-même :

MySQL affected rows for ON DUP can be misleading

Il est donc interdit de dériver ccx_offer_changes_total depuis :

  • norm_affected_rows ;
  • kept_items ;
  • final_rows ;
  • rows_ok.

La future frontière devra classer de vrais changements sémantiques commités :

created
updated
deleted

avec une déduplication retry-safe.

identity_products — instrumentation volontairement bloquée

Le pipeline CURRENT porte bien sur les offres :

product_identity_key
variant_identity_key
identity_level
identity_confidence
identity_reason_codes

Mais cette persistance row-level n'est pas une frontière de décision terminale au grain produit.

Il est donc interdit de dériver ccx_identity_products_total depuis :

  • le nombre d'offres ;
  • rows_ok ;
  • kept_items ;
  • les occurrences répétées de product_identity_key.

La future frontière devra produire des décisions terminales bornées au grain produit, notamment :

resolved
unknown
ambiguous
conflict

avec sémantique retry-safe.

Règle fail-closed

P1.5B3 préfère un statut explicite BLOCKED_* à une série disponible mais fausse.

Le registre machine-readable porte désormais :

ccx_offer_changes_total
  BLOCKED_SEMANTIC_DIFF_BOUNDARY_REQUIRED

ccx_identity_products_total
  BLOCKED_PRODUCT_GRAIN_IDENTITY_BOUNDARY_REQUIRED

Ces blockers ne constituent pas un échec de B3 : ils constituent la preuve que la mesure ne sera pas fabriquée à partir de proxys incorrects.

P1.5B4A — frontières sémantiques offer-change et identity

P1.5B4A introduit une frontière pure et réutilisable :

CCX_CapacitySemanticBoundary

Elle ne lit ni SQL, ni WordPress, ni Prometheus.

Offer change

La classification canonique est :

before absent + after présent  -> created
before présent + after absent  -> deleted
before != after                -> updated
before == after                -> unchanged

Les snapshots sont normalisés par clé avant comparaison afin qu'un simple ordre différent des clés ne devienne pas un changement métier.

Le chemin CURRENT réellement câblé est limité à :

offer replay apply

et l'ordre obligatoire est :

write applicatif
→ relecture du snapshot canonique
→ COMMIT réussi
→ classification before/after
→ ccx_offer_changes_total{change_type="updated"}

Un dry-run n'émet rien.

Un second replay sans changement n'émet rien.

Un rollback n'émet rien.

Une panne du sink métrique après commit est absorbée : elle ne peut pas transformer un replay métier déjà commité en échec.

Les valeurs created et deleted sont définies dans la frontière et bornées dans le logger, mais ne sont pas encore prétendues câblées sur le sync normal.

Identity outcome

La frontière définit également la traduction métrique du vocabulaire terminal identity existant :

statut identity résultat métrique
resolved resolved
resolved_with_minor_divergence resolved
unknown unresolved
ambiguous unresolved
invalid unresolved
conflict conflict
conflict_blocking conflict

Le label ccx_identity_products_total{result=...} est donc désormais borné à :

resolved
unresolved
conflict

Mais aucun counter identity n'est encore émis.

Raison : le Runtime CURRENT ne possède toujours pas de frontière terminale exactly-once au grain produit. Les endroits suivants restent explicitement interdits pour incrémenter ce compteur :

  • read-service identity ;
  • review queue offer-level ;
  • nombre de lignes d'offres ;
  • rows_ok ;
  • kept_items ;
  • occurrences de product_identity_key.

P1.5B4B devra raccorder cette traduction à une vraie matérialisation terminale product-grain, sans déplacer la décision métier dans l'observabilité.

Statut

P1.5B4A est une progression réelle mais partielle :

ccx_offer_changes_total:
  RUNTIME_EVENT_INSTRUMENTED_OFFER_REPLAY_UPDATED_PATH_ONLY

ccx_identity_products_total:
  SEMANTIC_BOUNDARY_DEFINED_RUNTIME_PRODUCT_GRAIN_REQUIRED

P1.5B ne peut donc pas encore être déclaré fermé.

P1.5B4B1 — diff sémantique des offres du sync normal

P1.5B4B1 étend ccx_offer_changes_total au chemin de finalisation d'une sync normale.

Le lot n'utilise toujours pas le nombre MySQL de lignes affectées.

Source canonique

La comparaison s'effectue directement sur :

ccx_offers_norm_v2

avec :

key   = offer_key
scope = source_feed

Avant upsertNormalizedOffers(), le Runtime capture un snapshot temporaire de chaque offer_key et d'un hash des colonnes canoniques.

Après retour réussi de l'upsert — y compris son nettoyage des offres résiduelles — il classe :

clé absente avant, présente après     -> created
clé présente avant et après, hash !=  -> updated
clé présente avant, absente après     -> deleted
hash identique                        -> unchanged / aucun event

Hash sémantique

La liste de champs n'est pas recopiée manuellement.

Le service énumère les colonnes CURRENT de ccx_offers_norm_v2, trie leur nom et exclut uniquement les champs opérationnels :

offer_id
run_id
offer_key
raw_hash
row_hash
first_seen
last_seen
created_at
updated_at
run_id_last_seen
source_updated_at

Les champs métier restent donc dans le hash, notamment :

price
stock_status
product_identity_key
identity_confidence
review_required
vertical_id
...

Chaque valeur est encadrée avec un marqueur NULL/non-NULL et sa longueur avant SHA-256, afin d'éviter les collisions triviales de concaténation.

row_hash de ccx_offer_items n'est pas utilisé : son contrat est volontairement plus étroit et ne couvre pas tous les champs canoniques nécessaires à une mesure de changement d'offre.

Stockage temporaire

Le before-snapshot est conservé dans une table :

CREATE TEMPORARY TABLE tmp_ccx_offer_sem_<hash>

InnoDB, locale à la connexion, avec seulement :

offer_key BINARY(16)
semantic_hash BINARY(32)

Cette table :

  • n'est pas une nouvelle persistance métier ;
  • ne survit pas à la connexion ;
  • n'est pas une table Prometheus ;
  • est créée/supprimée par CCX_TemporarySchemaService, pas par le sync runner.

Ordre d'exécution

capture before-snapshot
        ↓
upsertNormalizedOffers()
        ↓
cleanup canonique inclus dans l'upsert
        ↓
diff created / updated / deleted
        ↓
ccx_offer_changes_total
        ↓
refresh projections

Si le snapshot ou le diff échoue, la sync métier continue.

Si l'upsert métier échoue, le snapshot temporaire est supprimé et aucun événement de changement d'offre n'est émis.

Retry et crash-window

Un retry effectué après un upsert canonique complètement terminé voit normalement le nouvel état comme baseline et produit zéro diff. Cela évite le double comptage simple.

Mais P1.5B4B1 n'est pas exactly-once.

Crash critique :

upsert canonique réussi
→ crash avant calcul/émission du diff
→ perte du snapshot TEMPORARY
→ retry avec nouvel état en baseline
→ sous-comptage possible

Supprimer ce crash-window nécessiterait une preuve durable/outbox corrélée à la mutation canonique. P1.5B4B1 n'introduit pas une nouvelle table durable uniquement pour les métriques.

Le contexte des événements indique donc explicitement :

source=sync_finalize
crash_window=post_upsert_pre_emit_may_undercount

Couverture restante

Après B4B1 :

  • replay d'offre commité : instrumenté ;
  • sync normale created/updated/deleted : instrumentée ;
  • writers canoniques directs hors sync/replay : pas encore tous instrumentés ;
  • identity terminale au grain produit : toujours non câblée ;
  • export Prometheus : toujours absent.

Le statut machine-readable reste volontairement explicite :

ccx_offer_changes_total:
  RUNTIME_EVENT_INSTRUMENTED_SYNC_FINALIZE_AND_REPLAY_WITH_CRASH_WINDOW

runtime_event_contract.live_export_status:
  NOT_PROMETHEUS_EXPORTED

P1.5B4B2A1 — writers taxonomy V2

P1.5B4B2A1 couvre les mutations directes de ccx_offers_norm_v2 effectuées par Taxonomy V2 en dehors du sync normal et de l'Offer Replay.

Les trois chemins couverts sont :

taxonomy-v2 apply
taxonomy-v2 rollback
taxonomy-v2 cleanup

Ils ne produisent que :

ccx_offer_changes_total{change_type="updated"}

car ces opérations ne créent ni ne suppriment d'offres canoniques.

Apply

Le snapshot Taxonomy V2 contient l'état ancien et la cible nouvelle par offer_id.

Avant l'UPDATE, le writer compte uniquement les offres dont l'état canonique courant diffère réellement de la cible :

category_norm
subcategory_norm
vertical_id
spec_type_final
classifier_reason_primary

Le delta métrique est ce nombre sémantique, pas le retour MySQL de l'UPDATE.

L'émission intervient uniquement après :

applySnapshotToOffers()
→ markSnapshotDone()
→ capacity event

Rollback

Le même principe est appliqué contre l'état old_* du snapshot.

Le delta est calculé avant mutation, puis émis seulement après :

restoreSnapshotToOffers()
→ createRollbackSnapshot()
→ capacity event

Un rollback déjà matériellement revenu à sa cible ne doit donc pas être compté à partir du simple nombre de lignes du snapshot.

Cleanup

ccx_taxonomy_v2_apply_cleanup_spec() fonctionne par batches transactionnels.

Pour chaque batch, le Runtime compte avant l'UPDATE les lignes sélectionnées par le même predicate, le même ordre et la même limite que le batch à muter.

semantic batch selection
→ UPDATE
→ COMMIT
→ callback capacity

Le callback n'est jamais appelé avant COMMIT.

Cette granularité batch évite de perdre tous les batches déjà committés si un batch ultérieur échoue.

Le compteur ne dépend ni de updated_total ni de affected_rows.

Séparation des couches

CCX_TaxonomyWriteService ne dépend pas de CCX_MetricsLogger.

Il reçoit un callback optionnel. L'interface CLI compose ce callback avec le logger Runtime.

Une exception du callback est absorbée après la mutation réussie : l'observabilité ne peut pas convertir une écriture taxonomy réussie en erreur métier.

Limite exactly-once

Comme pour les autres événements B4, il reste un crash-window post-write / pré-émission.

mutation réussie
→ crash avant event
→ sous-comptage possible

P1.5B4B2A1 ne prétend donc pas fournir de l'exactly-once.

Couverture restante après A1

Le registre couvre désormais :

  • Offer Replay ;
  • sync normale ;
  • Taxonomy V2 apply ;
  • Taxonomy V2 rollback ;
  • Taxonomy V2 cleanup.

Le writer canonique encore explicitement non couvert est :

governance apply / governance rollback
→ ccx_governance_rebuild_offers_norm_for_run()

Ce chemin est traité séparément en P1.5B4B2A2, car son journal est au grain offer_items avant reprojection canonique.

L'identity terminale au grain produit reste un chantier distinct P1.5B4B2B : les tables de résolution opérateur et les projections groupées par product_identity_key ne sont pas le résultat terminal du Resolver Domain.

P1.5B4B2A2 — writers governance

P1.5B4B2A2 couvre les reprojections directes de ccx_offers_norm_v2 déclenchées par :

governance apply
governance rollback

La mutation governance se fait en deux temps :

UPDATE ccx_offer_items
→ COMMIT
→ ccx_governance_rebuild_offers_norm_for_run()
→ INSERT ... ON DUPLICATE KEY UPDATE ccx_offers_norm_v2

Le journal ccx_vertical_apply_run_items décrit les lignes intermédiaires offer_items. Il ne constitue donc pas le compteur canonique d'offres modifiées.

Snapshot ciblé par run

Juste avant le rebuild canonique, CCX_GovernanceOfferSemanticDiffService crée une table TEMPORARY :

tmp_ccx_gov_offer_sem_<hash>

Elle contient uniquement les offer_key reliées au run_uuid via :

ccx_vertical_apply_run_items
→ offer_item_id
→ ccx_offer_items.offer_key

Pour chaque clé, elle stocke :

offer_key
was_present
semantic_hash

was_present est nécessaire car un apply governance peut rendre une offre nouvellement éligible à la projection canonique.

Contrat de hash

A2 ne réimplémente pas le hash.

Le service réutilise directement :

CCX_OfferSemanticDiffService::semanticColumnsFrom()
CCX_OfferSemanticDiffService::semanticHashSql()

Le contrat est donc identique à B4B1 : les champs opérationnels sont exclus, tandis que les champs métier/classification présents dans ccx_offers_norm_v2 participent au hash.

Classification après rebuild

Après retour réussi de ccx_governance_rebuild_offers_norm_for_run(), le diff ciblé classe :

was_present=0 + ligne présente après   → created
was_present=1 + hash différent         → updated
was_present=1 + ligne absente après    → deleted
sinon                                   → unchanged / aucun event

Puis seulement :

ccx_offer_changes_total{change_type="created|updated|deleted"}

est émis.

Le retour SQL du rebuild reste disponible pour son usage historique, mais n'est jamais utilisé comme delta de capacité.

Sources explicitement interdites

Ne doivent pas alimenter ccx_offer_changes_total :

rebuild affected_rows
governance updated_count
governance run_items_count
nombre de lignes du journal offer_items

Ordre et isolation

Le wrapper Application suit :

offer_items COMMIT
→ targeted before-snapshot
→ canonical rebuild
→ targeted semantic diff
→ capacity event

Si le rebuild échoue :

snapshot TEMPORARY supprimé
→ aucun event

Si le snapshot ou le diff échoue alors que le rebuild canonique réussit, le résultat métier reste réussi et l'observabilité échoue fermée à zéro.

Une exception du sink métrique est également absorbée après réussite du rebuild.

CCX_GovernanceWriteService ne dépend pas de CCX_MetricsLogger. Le callback est composé dans runtime-admin.php, où le logger Runtime est déjà disponible.

Concurrence et exactly-once

Le snapshot est restreint aux seules offer_key du run, ce qui évite de compter les modifications sans rapport faites ailleurs dans le même feed.

Il ne constitue toutefois pas une isolation transactionnelle contre un writer concurrent sur la même offer_key entre capture et diff. Une telle course peut affecter l'attribution.

A2 conserve aussi le crash-window :

rebuild canonique réussi
→ crash avant émission
→ sous-comptage possible

Le contexte porte :

source=governance_apply|governance_rollback
semantic_boundary=governance_run_offer_keys
crash_window=post_rebuild_pre_emit_may_undercount

P1.5B4B2A2 n'est donc pas exactly-once.

Couverture des writers après A2

Pour la révision auditée, les frontières connues de mutation canonique sont maintenant couvertes :

  • Offer Replay ;
  • sync normale ;
  • Taxonomy V2 apply ;
  • Taxonomy V2 rollback ;
  • Taxonomy V2 cleanup ;
  • governance apply ;
  • governance rollback.

Aucun writer direct connu de ccx_offers_norm_v2 ne reste hors instrumentation dans cette révision.

Le gap restant pour offer_changes est la durabilité/exactly-once. Toute introduction future d'un nouveau writer canonique impose de refaire l'inventaire.

L'identity terminale au grain produit reste séparément ouverte en P1.5B4B2B.

P1.5B4B2B1 — boundary terminale Identity

L'audit post-A2 confirme qu'aucune frontière automatique terminale au grain produit n'existait dans le Runtime CURRENT.

Les éléments suivants ne sont pas des autorités terminales automatiques :

  • ccx_canonical_products_v1 : projection commerciale groupée ;
  • ccx_manual_product_resolution_v1 : décision opérateur ;
  • plugins/ccx-feeds-industrial/includes/platform/domain-core/identity/* : logique legacy/transitoire marquée REWRITE/MIGRATE par ADR ;
  • les champs identity au grain offre ;
  • la répétition d'un product_identity_key.

P1.5B4B2B1 crée donc la boundary métier avant toute émission métrique.

Modèle Domain

Le modèle terminal vit dans :

src/Domain/Product/IdentityResolution/

Le vocabulaire Domain canonique est strictement borné à :

resolved
unknown
ambiguous
conflict

Le mot unresolved n'est pas un statut Domain. Il reste uniquement le label métrique borné qui agrège unknown et ambiguous.

ProductIdentityGroupResolver est pur, déterministe et indépendant de WordPress/SQL.

La policy B1 est volontairement conservatrice :

un EAN non vide unique et cohérent
→ resolved

plusieurs EAN non vides différents
→ conflict

subject_kind=ean mais EAN de preuve différent du sujet
→ conflict

sans EAN :
  au moins deux sources indépendantes
  + même marque normalisée
  + même modèle normalisé
→ resolved

sans EAN :
  plusieurs modèles complets plausibles pour la même marque
→ ambiguous

sans EAN :
  marques complètes contradictoires
→ conflict

sinon
→ unknown

Une product_identity_key existante peut devenir en B2 une clé de regroupement pour des offres sans EAN, mais n'est jamais une preuve suffisante de résolution.

Fingerprints

Chaque décision produit deux SHA-256 déterministes :

evidence_fingerprint
decision_fingerprint

L'evidence fingerprint est indépendant de l'ordre d'entrée des observations.

Le decision fingerprint inclut :

  • sujet ;
  • statut terminal ;
  • clé canonique éventuelle ;
  • nombres de candidats/sources ;
  • reason codes ;
  • evidence fingerprint ;
  • version du resolver.

Il change donc lorsqu'une vraie nouvelle base de preuve est finalisée, même si le label terminal reste identique.

Autorité durable

La nouvelle table métier canonique est :

ccx_product_identity_resolutions_v1

Elle est créée par la migration moderne :

component=identity-resolution
migration=IDENTITY-001

via le framework src/Application/SchemaMigration.

Cette table n'est pas une persistance de métriques. Elle matérialise l'état métier automatique terminal requis indépendamment de P1.5.

Une ligne est unique par :

subject_kind
+
SHA256(subject_key)

Elle conserve notamment :

resolution_status
canonical_identity_key
candidate_count
source_count
reason_codes_json
evidence_fingerprint
decision_fingerprint
resolver_version
generation
decided_at

Idempotence

WpdbIdentityResolutionStore utilise un UPSERT atomique conditionné par le decision_fingerprint.

retry avec même decision_fingerprint
→ generation inchangée
→ decided_at inchangé
→ aucun nouveau terminal generation

decision_fingerprint différent
→ generation + 1
→ nouvelle décision terminale persistée

Cette idempotence ne repose pas sur affected_rows.

Ce que B1 ne fait volontairement pas

P1.5B4B2B1 ne câble pas encore le sync Runtime.

Il ne :

  • collecte pas encore les groupes de preuves depuis les offres ;
  • n'exécute pas encore le resolver pendant le finalize ;
  • n'émet aucun ccx_identity_products_total ;
  • ne crée pas encore l'outbox/event durable nécessaire à l'exactly-once métrique ;
  • ne modifie aucun scrape Prometheus.

Après l'alignement B1A, le statut devient :

TERMINAL_MODEL_AND_STORE_CANONICAL_VOCABULARY_ALIGNED_RUNTIME_ORCHESTRATION_REQUIRED

Compatibilité de lecture : la brève révision B1 ayant persisté resolution_status=unresolved est relue comme unknown. Aucune nouvelle décision Domain ne persiste unresolved.

P1.5B4B2B1A — alignement du vocabulaire terminal

L'audit post-B1 a détecté une divergence entre l'implémentation et le contrat normatif docs/contracts/identity-resolver.md.

B1 avait momentanément réduit le vocabulaire Domain à :

resolved / unresolved / conflict

B1A corrige cette erreur de modélisation. L'autorité Domain expose désormais exactement :

resolved
unknown
ambiguous
conflict

et la frontière capacity conserve séparément :

resolved  -> result="resolved"
unknown   -> result="unresolved"
ambiguous -> result="unresolved"
conflict  -> result="conflict"

Ainsi, l'observabilité peut rester volontairement basse cardinalité sans écraser les états d'incertitude métier.

P1.5B4B2B2A — outbox capacity durable

B2A ferme d'abord le crash-window entre la décision terminale durable et la création de son événement capacity.

La migration Platform :

component=identity-resolution
migration=IDENTITY-002

crée :

ccx_product_identity_capacity_outbox_v1

Cette table n'est ni une projection métier, ni une copie Prometheus. Elle est la frontière de transport durable entre une génération terminale Identity et sa future livraison au compteur capacity.

Une ligne est unique par :

resolution_id
+
generation

et conserve notamment :

resolution_status
decision_fingerprint
delivery_status
attempt_count
lease_token
leased_until
next_attempt_at
delivered_at
last_error

Le staging est atomique avec l'état terminal :

START TRANSACTION
→ UPSERT ccx_product_identity_resolutions_v1
→ relecture resolution_id + generation persistés
→ INSERT ccx_product_identity_capacity_outbox_v1\n  ON DUPLICATE KEY UPDATE event_id=event_id
→ COMMIT

Ainsi :

retry avec même decision_fingerprint
→ même generation
→ même clé outbox
→ aucun doublon durable

nouvelle décision
→ generation + 1
→ exactement un nouvel event durable

échec du staging outbox
→ ROLLBACK
→ aucune nouvelle generation sans event durable

L'idempotence ne dépend pas de affected_rows.

La déduplication n'utilise pas INSERT IGNORE : seul le doublon attendu sur resolution_id + generation devient un no-op explicite via ON DUPLICATE KEY UPDATE event_id=event_id. Les autres erreurs SQL restent donc visibles et provoquent le rollback.

B2A n'émet toujours aucun ccx_identity_products_total.

Il ne collecte pas encore les preuves product-grain du Runtime et ne draine pas encore l'outbox. Le Domain reste indépendant du transport capacity et WpdbIdentityResolutionStore ne dépend ni de CCX_MetricsLogger, ni de Prometheus.

Le statut après B2A est donc :

TERMINAL_MODEL_AND_STORE_DURABLE_OUTBOX_READY_RUNTIME_ORCHESTRATION_REQUIRED

P1.5B4B2B2B — orchestration product-grain Runtime

B2B câble désormais le sync Runtime sur la boundary terminale certifiée, sans court-circuit métrique :

touched product-grain subjects
→ batched canonical evidence
→ ProductIdentityGroupResolver
→ FinalizeProductIdentityResolution
→ WpdbIdentityResolutionStore
→ terminal generation + durable outbox

Aucun ccx_identity_products_total n'est émis dans ce lot.

Activation schema-gated

Le factory Runtime ne devient actif que si les trois autorités existent :

ccx_offers_norm_v2
ccx_product_identity_resolutions_v1   (IDENTITY-001)
ccx_product_identity_capacity_outbox_v1 (IDENTITY-002)

Si les migrations Identity ne sont pas encore installées, le service retourne null, le chemin sync historique continue, et le résumé du run indique :

identity_resolution.runtime_status=schema_not_ready

B2B ne crée donc aucune dépendance implicite à un schéma non déployé.

Subjects touchés avant/après l'upsert

Pour ne pas full-scan tous les produits à chaque sync, le Runtime capture les subjects du feed :

avant upsert canonique
+
après upsert canonique

puis déduplique leur union.

Cela permet de traiter les produits :

  • ajoutés ;
  • modifiés ;
  • encore présents ;
  • retirés du feed courant.

Un subject qui existait avant mais n'a plus aucune preuve canonique actuelle est conservé comme historique terminal et n'est pas artificiellement réécrit en unknown.

Grain et priorité des subjects

La policy Runtime est :

ean_norm non vide
→ subject_kind=ean
→ subject_key=ean_norm

sinon, product_identity_key non vide
→ subject_kind=product_key
→ subject_key=product_identity_key

La product_identity_key legacy n'est donc qu'une clé de regroupement no-EAN. Elle n'est jamais une preuve positive de résolution.

Le Domain resolver reste seul responsable de conclure resolved / unknown / ambiguous / conflict.

Evidence canonique multi-source

WpdbIdentityEvidenceSource relit pour les subjects touchés toutes les offres canoniques actuelles, tous feeds confondus.

Les preuves utilisent uniquement des signaux présents dans l'offre canonique :

ean_norm
brand_norm
model_hint
title_clean
source_feed
merchant fallback

Les requêtes sont batchées par chunks, séparément pour :

  • les subjects EAN ;
  • les subjects product_key sans EAN.

Le Runtime ne fait donc pas un SELECT par produit.

Déduplication des observations

Deux lignes d'offres produisant exactement la même observation normalisée ne doivent pas fabriquer deux preuves indépendantes.

Chaque payload IdentityEvidence normalisé est SHA-256 dédupliqué avant l'appel au resolver.

La source indépendante préférée est source_feed. Le merchant puis l'offer_key ne servent que de fallback si l'identité du feed est absente.

Ordre dans le finalize sync

L'ordre certifié devient :

capture identity subjects before
→ capture offer semantic snapshot
→ upsert ccx_offers_norm_v2
→ offer semantic diff
→ capture identity subjects after
→ finalize touched product identities
→ terminal state + outbox transaction
→ refresh product projections

Quand la boundary Identity est active, une erreur de collecte/finalisation est une erreur de finalize : un run ne doit pas être déclaré success si son autorité terminale Identity n'a pas pu être tenue à jour.

Frontières interdites

B2B ne dérive jamais une décision terminale depuis :

  • le nombre de lignes d'offres ;
  • rows_ok ou kept_items ;
  • une répétition de product_identity_key ;
  • ccx_canonical_products_v1 ;
  • ccx_manual_product_resolution_v1 ;
  • les previews/simulations legacy platform/domain-core/identity ;
  • la review queue.

Statut après B2B

terminal Domain model          READY
durable terminal state         READY
transactional capacity outbox  READY
Runtime product-grain evidence READY
Runtime terminal finalization  READY
capacity outbox delivery       MISSING
ccx_identity_products_total    NOT EMITTED YET

Il ne reste donc plus que P1.5B4B2B2C pour l'Identity capacity Runtime.

P1.5B4B2B2C — delivery durable capacity

B2C draine désormais uniquement les lignes durables de :

ccx_product_identity_capacity_outbox_v1

et traduit le vocabulaire Domain sans l'écraser :

resolved  → result="resolved"
unknown   → result="unresolved"
ambiguous → result="unresolved"
conflict  → result="conflict"

Lease durable

WpdbIdentityCapacityOutbox sélectionne uniquement :

pending + next_attempt_at ready
OU
leased + leased_until expiré

dans une transaction :

START TRANSACTION
→ SELECT event_id ... FOR UPDATE
→ delivery_status=leased
→ attempt_count + 1
→ lease_token + leased_until
→ relecture des events loués
→ COMMIT

Un lease expiré est donc récupérable après crash.

L'ACK et le retry exigent tous deux :

event_id
+
lease_token courant

Une perte de propriété du lease est une erreur explicite.

Ordre de livraison

Pour chaque événement :

lease durable
→ mapping Domain → label capacity borné
→ ccx_identity_products_total delta=1
→ ACK durable

Le contexte de l'événement porte notamment :

event_id
resolution_id
generation
decision_fingerprint
terminal_status
delivery_attempt
delivery_semantics=at_least_once_stable_event_id

Le terminal store ne dépend toujours ni de CCX_MetricsLogger, ni du transport capacity.

Retry

Si le sink métrique échoue avant émission réussie :

delivery_status=pending
lease libéré
last_error renseigné
next_attempt_at = backoff exponentiel

Le backoff part de 15 secondes et est borné à une heure.

Si l'émission réussit mais que l'ACK durable échoue, le service ne force pas un retour immédiat à pending : le lease reste la frontière d'exclusion et l'événement redevient récupérable uniquement après expiration.

Triggers Runtime

Le drain est appelé :

  • en best-effort pendant le finalize sync, après la finalisation terminale Identity ;
  • par CCX_SyncRunner::maintain(), afin de récupérer les pending et leases expirés même sans nouvelle décision ;
  • manuellement via ccx sync maintain, avec --identity-capacity-limit optionnel.

Une panne du delivery est une panne d'observabilité et ne transforme pas une finalisation métier valide en run failed.

Garantie réelle

L'outbox fournit l'exactly-once durable source boundary par :

resolution_id + generation

La livraison du log capacity est volontairement déclarée :

AT-LEAST-ONCE

Cas limite :

metric emitted
→ crash avant ACK
→ lease expire
→ même event_id livré à nouveau

B2C ne prétend donc pas à un exactly-once de bout en bout.

Le event_id stable permet au futur matérialisateur/exporter de dédupliquer explicitement s'il doit produire un compteur monotone exactly-once.

Statut après B2C

terminal Domain model          READY
durable terminal state         READY
transactional capacity outbox  READY
Runtime product-grain evidence READY
Runtime terminal finalization  READY
leased outbox delivery         READY
ccx_identity_products_total    RUNTIME EVENT WIRED
live Prometheus export         NOT YET WIRED

P1.5B est alors fermé pour les événements Runtime.

La suite logique devient P1.5C — DB + frontend observability gaps / matérialisation live, sans prétendre que P1.5 complet est déjà certifié quantitativement.

Fermeture P1.5B — événements Runtime

P1.5B est CLOSED_PASS pour les événements Runtime sur la révision auditée :

main=9cc0462b75f9b14b1bcbf6fbbbec1cd04413d0eb

Les frontières Runtime suivantes sont instrumentées depuis leurs sources sémantiques canoniques :

ccx_offer_changes_total
ccx_sync_source_rows_total
ccx_sync_normalized_rows_total
ccx_sync_persisted_rows_total
ccx_identity_products_total
ccx_projection_products_total
ccx_jobs_completed_total

La fermeture B ne signifie pas que P1.5 global est terminé.

Restent volontairement dans P1.5C :

ccx_catalog_offers
ccx_db_query_duration_seconds
ccx_sync_queue_depth
ccx_sync_queue_lag_seconds
ccx_projection_lag_seconds
ccx_frontend_request_duration_seconds
ccx_frontend_http_requests_total

P1.5B certifie :

  • les boundaries événementielles Runtime ;
  • le grain produit terminal Identity ;
  • l'outbox durable Identity ;
  • la couverture des writers canoniques connus pour les changements d'offres ;
  • les labels bornés et l'absence d'identifiants haute cardinalité dans les labels.

P1.5B ne certifie pas :

  • l'export Prometheus live ;
  • un transport end-to-end exactly-once ;
  • la capacité quantitative du VPS ;
  • les SLO DB/frontend ;
  • les dashboards finaux.

Le statut machine-readable est :

status=RUNTIME_EVENTS_CERTIFIED_NOT_LIVE_EXPORTED
phase_status.p1_5b=CLOSED_PASS_RUNTIME_EVENTS
phase_status.p1_5c=NEXT_NOT_STARTED
phase_status.p1_5_global=OPEN

La suite logique est P1.5C — DB + frontend observability / live materialization gaps.

P1.5C1 — gauges DB read-only

P1.5C1 implémente les trois gauges dérivées de l'état canonique sans créer de nouvelle persistance métrique :

ccx_catalog_offers
ccx_sync_queue_depth
ccx_sync_queue_lag_seconds

La collecte est portée par :

CollectReadOnlyCapacityGauges
→ ReadOnlyCapacityGaugeSource
→ WpdbReadOnlyCapacityGaugeSource

La couche Infrastructure ne dépend ni de WordPress Runtime, ni de CCX_MetricsLogger.

Catalogue

ccx_catalog_offers est lu exclusivement depuis :

SELECT COUNT(*) FROM ccx_offers_norm_v2

Aucune table shadow ou copie de compteur n'est créée.

Queue depth

Le backlog canonique est défini par la même sémantique que le Runtime :

run.status ∈ pending | running | partial
raw.status ∈ pending | processing

Les lignes terminales ou appartenant à des runs non reprenables ne sont pas comptées.

Queue lag

Le lag utilise l'âge de la plus ancienne ligne encore exécutable/in-flight :

UTC_TIMESTAMP() - MIN(ccx_sync_stage_raw_v2.created_at)

Pour un feed borné sans backlog :

depth = 0
lag_seconds = 0

Cardinalité du feed

Le collector ne découvre pas librement des labels depuis la DB.

La liste des feeds autorisés est fournie par l'appelant depuis le registre canonique borné. Une ancienne valeur de DB qui n'appartient pas à cette liste est ignorée.

Lecture seule stricte

L'adapter wpdb C1 ne possède aucun chemin :

INSERT
UPDATE
DELETE
TRUNCATE
CREATE TABLE
ALTER TABLE

Une erreur SQL de collecte lève une erreur d'observabilité après nettoyage de $wpdb->last_error, sans mutation fonctionnelle.

Statut C1

Le registre machine-readable porte désormais :

ccx_catalog_offers
  READ_ONLY_COLLECTOR_IMPLEMENTED_AWAITING_PROMETHEUS_EXPORT

ccx_sync_queue_depth
  READ_ONLY_COLLECTOR_IMPLEMENTED_AWAITING_PROMETHEUS_EXPORT

ccx_sync_queue_lag_seconds
  READ_ONLY_COLLECTOR_IMPLEMENTED_AWAITING_PROMETHEUS_EXPORT

P1.5C1 n'ajoute encore :

  • aucun endpoint /metrics ;
  • aucun scrape target Prometheus ;
  • aucun sidecar/exporter ;
  • aucune mutation VPS.

Le live export reste donc :

NOT_PROMETHEUS_EXPORTED

P1.5C2 — source native MariaDB pour latence et writes

P1.5C2 sélectionne une source MariaDB native capable de fournir une distribution réelle des temps de requête.

La source retenue est le plugin MariaDB :

query_response_time

Le choix évite deux faux raccourcis :

  • une moyenne Performance Schema ne devient pas artificiellement un p95 ;
  • avg_ms_per_row du pipeline ne devient pas une métrique DB.

Configuration versionnée

Le dépôt contient :

docker/mariadb/observability.cnf

monté read-only dans MariaDB :

/etc/mysql/conf.d/zz-ccx-observability.cnf

avec :

plugin_load_add=query_response_time
query_response_time_stats=ON
query_response_time_range_base=2

Cette configuration n'est pas appliquée au VPS dans C2.

Classes de requêtes

Le collector lit les distributions MariaDB séparées :

QUERY_RESPONSE_TIME_READ   → query_class="read"
QUERY_RESPONSE_TIME_WRITE  → query_class="write"

Le label transaction reste réservé par le contrat mais n'est pas inventé en C2 faute de distribution transactionnelle équivalente.

Aucun des éléments suivants ne devient un label :

SQL text
digest
digest text
table name
URL
query string

Histogramme

Chaque ligne MariaDB fournit un intervalle :

TIME
COUNT
TOTAL

C2 transforme les COUNT par intervalle en buckets cumulatifs compatibles avec un futur histogramme Prometheus.

Le bucket TOO LONG est accepté uniquement avec COUNT=0. Si ce bucket devient non nul, C2 refuse de fabriquer un _sum puisque MariaDB ne fournit pas de durée exacte pour ce bucket.

Avec query_response_time_range_base=2, la plage finie couvre très largement les durées opérationnelles attendues.

Connexion dédiée

Le collector exige :

@@SESSION.query_response_time_session_stats = OFF

La future connexion d'export C4 devra être dédiée à l'observabilité.

Cette règle empêche les SELECT de collecte de contaminer eux-mêmes la distribution observée.

DB writes

Le contrat DB_writes_per_sec est désormais relié à des compteurs MariaDB vérifiables :

Handler_write
Handler_update
Handler_delete

Ils représentent des requêtes de write/update/delete au storage engine et excluent leurs équivalents de tables temporaires.

C2 additionne ces compteurs cumulatifs comme source de DB row writes total. Le rate Prometheus sera matérialisé plus tard.

Statut C2

ccx_db_query_duration_seconds
  MARIADB_QUERY_RESPONSE_TIME_SOURCE_CONFIGURED_AWAITING_PROMETHEUS_EXPORT

P1.5C2 ne crée encore :

  • aucun endpoint Prometheus ;
  • aucun exporter ;
  • aucune recording rule ;
  • aucun dashboard ;
  • aucune mutation live.

P1.5C3 — projection lag corrélé

P1.5C3 instrumente ccx_projection_lag_seconds sans approximation par timestamps globaux.

La frontière de départ est prise dans le même finalize sync, immédiatement après le retour réussi de upsertNormalizedOffers() :

canonical offer upsert committed
→ hrtime(true)

Le Runtime n'active cette mesure que si le diff sémantique canonique prouve au moins un :

created
updated
deleted

Si le diff est indisponible ou si le run est un no-op sémantique, aucun lag n'est inventé.

Completion boundary

CCX_ProductModelRebuildOrchestrator enregistre un timestamp monotone juste après le retour de chaque étape :

models
variants
specifications
gallery

Le calcul pur CCX_ProjectionLagCorrelator corrèle ces timestamps au timestamp post-commit du même finalize.

Pour plusieurs verticales affectées, la série bornée par projection conserve le maximum du lag observé pour ce type de projection. Cela représente le temps nécessaire pour que toutes les instances affectées de cette projection aient terminé.

Les étapes unsupported_vertical ne produisent pas de lag.

Horloge

La mesure utilise :

PHP hrtime(true)

donc une horloge monotone. Elle ne dépend ni de l'horloge murale du DB server, ni d'un décalage NTP, ni des colonnes updated_at.

Frontières interdites

P1.5C3 interdit explicitement :

  • la soustraction de deux MAX(updated_at) globaux sans corrélation ;
  • le timestamp maximum d'une table de projection comme approximation du lag du run courant ;
  • la durée totale du sync comme substitut du lag projection.

Emission

Après refresh réussi :

ccx_projection_lag_seconds{projection="product_models|variants|specifications|gallery"}

est émis sous forme de gauge_observation structurée.

run_id, feed, lag_source et la portée de corrélation restent dans le contexte et pas dans les labels Prometheus.

Une panne du sink observabilité ne transforme jamais un refresh projection réussi en échec métier.

Statut C3

ccx_projection_lag_seconds
  CORRELATED_SYNC_REFRESH_INSTRUMENTED_AWAITING_PROMETHEUS_EXPORT

Le live export reste :

NOT_PROMETHEUS_EXPORTED

P1.5C4 — frontend request observability

P1.5C4 ferme les deux gaps d'instrumentation frontend :

ccx_frontend_request_duration_seconds
ccx_frontend_http_requests_total

La frontière choisie est le cycle de requête frontend public, pas un template ou un renderer particulier.

Le stack est activé uniquement lorsque :

ccx_feeds_should_load_frontend_stack() = true

Sont donc exclus avant instrumentation :

  • WP-CLI ;
  • CLI PHP ;
  • cron ;
  • AJAX ;
  • REST ;
  • admin ;
  • feed enqueue/worker ;
  • health endpoint.

Horloge

Le début de mesure est capturé dans le bootstrap frontend avec :

hrtime(true)

La fin est capturée dans un callback shutdown.

La durée utilise donc une horloge monotone et ne dépend ni de l'horloge murale, ni du DB server, ni de timestamps de contenu.

route_class

Le label est strictement borné :

home
catalog
product
other_public

Classification :

/                                  -> home
/produit/{ean}/                    -> product
/produits/{slug}/                  -> product
route contract kind=catalog        -> catalog
route contract kind=product_model  -> product
reste du frontend public           -> other_public

Aucun EAN, slug, path, URL ou product ID ne devient un label ou un contexte métrique.

status_class

Le compteur HTTP utilise uniquement :

2xx
3xx
4xx
5xx

Le code HTTP brut n'est pas un label.

Un status non initialisé/invalide est traité comme le status frontend par défaut 200/2xx.

Émission

À la fin de chaque requête publique instrumentée :

ccx_frontend_request_duration_seconds{route_class="..."}
  histogram_observation

ccx_frontend_http_requests_total{
  route_class="...",
  status_class="..."
}
  counter_delta=1

La durée et le compteur sont émis indépendamment. Une panne de l'un des sinks n'altère jamais la réponse publique déjà produite.

Séparation des couches

FrontendRequestObservation vit dans src/Application/Observation/Frontend et contient uniquement :

  • vocabulaire borné ;
  • classification ;
  • conversion de status ;
  • calcul monotone de durée.

L'adapter WordPress frontend gère :

  • REQUEST_URI uniquement pour classification locale ;
  • http_response_code() ;
  • register_shutdown_function().

Le logger concret est composé dans le bootstrap frontend. L'adapter ne dépend pas directement de CCX_MetricsLogger.

Statut C4

ccx_frontend_request_duration_seconds
  FRONTEND_REQUEST_HISTOGRAM_INSTRUMENTED_AWAITING_PROMETHEUS_EXPORT

ccx_frontend_http_requests_total
  FRONTEND_HTTP_COUNTER_INSTRUMENTED_AWAITING_PROMETHEUS_EXPORT

Le live export reste :

NOT_PROMETHEUS_EXPORTED

Avec C4, les gaps d'instrumentation applicative de P1.5C sont fermés. La suite de P1.5C doit matérialiser les sources/événements CURRENT dans une exposition Prometheus bornée et certifiable, sans introduire une nouvelle vérité métier.

P1.5C1A — autorité de collecte Alloy

Après l'implémentation des sources C1–C4, P1.5C1A remplace l'agent de collecte de logs actif sans modifier la sémantique de ces mesures.

L'autorité active devient :

Grafana Alloy v1.19.2
image manifest:
sha256:b8ec653c44235fbe910879145dac3597d66b0aaecf60bcbbe82580767771a839

Le lot conserve à parité les quatre sources de logs existantes :

Docker json-file logs
/var/log/auth.log
/var/log/syslog
/var/log/kern.log

et les envoie toujours à Loki.

Promtail reste uniquement une autorité legacy de positions pour le premier démarrage :

legacy_positions_file=/var/lib/promtail/positions.yaml

Son state est monté en lecture seule. Le state Alloy est séparé sous /mnt/data/cmonchoix-infra/observability/alloy.

Le répertoire legacy Promtail doit exister pour que le bind mount reste explicite avec create_host_path: false. Le fichier positions.yaml, lui, peut légitimement être absent sur un hôte où aucune position durable n'a été matérialisée. Dans ce cas :

preflight state = MISSING_RESTART_FROM_END
legacy mutation = NO
Alloy fallback  = on_positions_file_error=restart_from_end

Le preflight ne crée jamais un faux positions.yaml. S'il existe, le fichier doit être régulier, non symlinké et lisible ; sinon le déploiement échoue fermé.

Les deux paths utilisent create_host_path: false afin que Docker ne crée jamais silencieusement un state host vide.

Limite volontaire de C1A

C1A ne contient aucun matérialisateur de métriques métier.

Donc :

logs → Alloy → Loki        PREPARED
C1–C4 sources              READY
ccx_* → Prometheus         NOT YET MATERIALIZED

monitoring/promtail.yml reste versionné uniquement comme référence legacy/rollback ; le Compose actif ne démarre plus Promtail.

La suite logique est la matérialisation Prometheus bornée des événements Runtime et des collectors read-only déjà certifiés.

P1.5C1B — matérialisation Prometheus des événements Runtime via Alloy

C1B matérialise les événements structurés [CCX_CAPACITY_METRIC] déjà certifiés sans introduire une seconde vérité métier.

Le chemin Git-configuré devient :

boundary métier canonique
→ CCX_MetricsLogger
→ stderr container json-file
→ Alloy loki.process
→ stage.metrics
→ Alloy /metrics
→ Prometheus scrape job=alloy

Le transport capacity est désormais always-on : il écrit directement vers stderr même lorsque WP_DEBUG est désactivé. Les logs Runtime ordinaires restent soumis au comportement historique de ccx_feeds_log().

Séries matérialisées par C1B

ccx_offer_changes_total
ccx_sync_source_rows_total
ccx_sync_normalized_rows_total
ccx_sync_persisted_rows_total
ccx_identity_products_total
ccx_projection_products_total
ccx_jobs_completed_total
ccx_projection_lag_seconds
ccx_frontend_request_duration_seconds
ccx_frontend_http_requests_total

C1B ne matérialise pas encore :

ccx_catalog_offers
ccx_db_query_duration_seconds
ccx_sync_queue_depth
ccx_sync_queue_lag_seconds

Ces séries proviennent de collectors read-only/DB et gardent une frontière d'export séparée.

Cardinalité Alloy

La source Docker fan-out vers deux branches :

docker logs
├─ C1A normal branch → Loki (inchangée)
└─ C1B capacity sidecar → stage.metrics → drop sidecar copy

Chaque série possède un stage.match dédié dans le sidecar. Avant stage.metrics, un stage.label_keep ne conserve que les labels autorisés dans le registre.

Le sidecar est ensuite droppé et n'est jamais envoyé à Loki. La branche C1A conserve donc ses labels d'origine, y compris le filename fourni par loki.source.file.

Les labels Docker, filename, chemins, container IDs et champs de contexte ne peuvent pas fuiter vers les séries ccx_*.

Le feed conserve en plus son contrôle syntaxique historique et, lorsque l'autorité de déploiement est disponible, doit appartenir à ccx_runtime_enabled_feeds().

Endpoint et scrape

Alloy expose les custom metrics sur son endpoint HTTP interne déjà présent :

http://alloy:12345/metrics

Aucun port hôte supplémentaire n'est ouvert.

Prometheus ajoute :

job_name=alloy
target=alloy:12345
metric_relabel keep __name__=ccx_.*

Les métriques internes Alloy ne sont donc pas ingérées par ce job, tandis que up{job="alloy"} reste disponible comme santé de scrape.

Types

Counters :

stage.metrics metric.counter
source=delta
action=add
prefix=""

Gauge :

ccx_projection_lag_seconds
source=value
action=set

Histogram :

ccx_frontend_request_duration_seconds
buckets seconds:
0.005 0.01 0.025 0.05 0.1 0.25 0.5 1 2.5 5 10

Les séries custom restent présentes jusqu'à max_idle_duration=168h sans activité.

Sémantique de restart/reload

Les custom metrics stage.metrics sont en mémoire Alloy et peuvent repartir à zéro lors d'un restart/reload de configuration.

C1B ne transforme donc pas ces counters en journal durable.

Prometheus conserve l'historique de scrape et doit interpréter une baisse comme un reset normal du counter pour rate() / increase().

Les garanties source restent inchangées :

  • identity reste at-least-once avec event_id stable ; C1B ne déduplique pas event_id ;
  • offer changes conserve ses crash-windows non exactly-once ;
  • C1B ne prétend pas améliorer ces garanties.

Statut

C1B est un lot Git/off-live.

Prometheus materialization config   READY IN GIT
Alloy /metrics live                 NOT LIVE CERTIFIED
Prometheus live scrape              NOT LIVE CERTIFIED
VPS mutation                        NONE

Le champ status des sources continue de décrire leur frontière métier. C1B ajoute séparément prometheus_materialization_status=GIT_CONFIGURED_VIA_ALLOY_NOT_LIVE_CERTIFIED.

La prochaine étape de P1.5C est l'exposition des collectors read-only et MariaDB déjà certifiés, pas une nouvelle instrumentation métier.

Normalisation du namespace Alloy vers Prometheus

Le Runtime Alloy expose les métriques créées par stage.metrics sous son namespace interne loki_process_custom_. Le contrat CMonChoix reste toutefois défini sur les noms canoniques ccx_*.

La frontière Prometheus doit donc appliquer, sur le job alloy, les opérations dans cet ordre :

loki_process_custom_ccx_*
        ↓ metric_relabel replace
ccx_*
        ↓ metric_relabel keep
Prometheus

La normalisation ne change ni les événements Runtime, ni leurs labels métier, ni les recording rules P1.5D. Elle retire uniquement le namespace d'exposition propre à Alloy avant ingestion Prometheus et conserve ensuite exclusivement les familles ccx_*.

Cette frontière a été ajoutée à l'autorité Git après l'audit live P1.5F qui a établi simultanément :

  • les événements [CCX_CAPACITY_METRIC] présents dans le json-file Docker ;
  • leur fichier source visible et suivi par Alloy ;
  • les familles matérialisées sur /metrics sous loki_process_custom_ccx_* ;
  • le scrape Prometheus du job alloy actif mais filtrant ces familles avant ingestion.

Le correctif reste Git-only tant qu'il n'a pas été déployé et recertifié sur le VPS.

P1.5C1C — matérialisation des gauges read-only

P1.5C1C raccorde les collectors C1 déjà certifiés au transport Prometheus C1B sans dupliquer leur SQL.

La chaîne devient :

systemd timer (repo-managed, 5 min)
→ wp ccx sync capacity-gauges
→ CCX_ReadOnlyCapacityGaugeDelivery
→ CollectReadOnlyCapacityGauges
→ WpdbReadOnlyCapacityGaugeSource
→ CCX_MetricsLogger gauge_observation
→ Docker stderr/json-file
→ Alloy capacity_metrics sidecar
→ gauge/set
→ Prometheus job=alloy

Les séries sont :

ccx_catalog_offers
ccx_sync_queue_depth{feed}
ccx_sync_queue_lag_seconds{feed}

Le bridge Runtime ne contient aucun SQL. La définition canonique reste dans WpdbReadOnlyCapacityGaugeSource.

La liste des feeds vient exclusivement de :

ccx_runtime_enabled_feeds()

et le logger applique la même autorité bornée aux labels feed que pour les counters C1B.

Scheduler

Les artefacts versionnés sont :

operations/systemd/ccx-capacity-gauges.service
operations/systemd/ccx-capacity-gauges.timer

Le timer utilise :

OnBootSec=2min
OnUnitInactiveSec=5min
AccuracySec=15s

Il n'utilise pas Persistent=true : une gauge représente l'état courant et ne doit pas rejouer des mesures historiques après une indisponibilité.

Le WP-Cron Runtime reste désactivé. C1C ne réactive pas une ancienne autorité de scheduling.

Staleness

Ces trois gauges utilisent :

max_idle_duration=15m

dans Alloy.

Si le scheduler/collector cesse de fonctionner, une ancienne valeur n'est donc pas conservée pendant 168 heures comme si elle était fraîche.

État de déploiement

C1C est uniquement configuré/certifié en Git :

prometheus_materialization_status =
GIT_CONFIGURED_VIA_RUNTIME_ALLOY_NOT_LIVE_CERTIFIED

Le lot ne :

  • n'installe ni n'active le timer systemd sur le VPS ;
  • ne redémarre aucun container ;
  • ne recharge pas Alloy/Prometheus ;
  • ne modifie aucune donnée métier ;
  • ne change aucune définition SQL du collector C1.

P1.5C2B — matérialisation Prometheus DB exacte

P1.5C2B matérialise les deux sorties DB déjà certifiées par C2 sans transformer la distribution MariaDB en observations artificielles.

Chaîne d'autorité

MariaDB query_response_time READ/WRITE
+ GLOBAL_STATUS Handler_write/update/delete
        ↓
collector read-only dédié
        ↓
Prometheus classic text exposition
        ↓
/mnt/data/cmonchoix-infra/observability/textfile/ccx-db-observability.prom
        ↓
node-exporter textfile collector
        ↓
Prometheus job=node

Aucune nouvelle cible Prometheus n'est créée.

Node exporter réutilise son rootfs existant monté en lecture seule et lit :

/host/mnt/data/cmonchoix-infra/observability/textfile

Histogramme de latence

Le collector C2 fournit déjà des buckets cumulatifs, un count et une somme en secondes.

C2B les conserve exactement sous la forme Prometheus classique :

ccx_db_query_duration_seconds_bucket{query_class="read|write",le="..."}
ccx_db_query_duration_seconds_count{query_class="read|write"}
ccx_db_query_duration_seconds_sum{query_class="read|write"}

Il est interdit de convertir ces buckets en une série d'observations synthétiques pour Alloy : une telle reconstruction modifierait la distribution et rendrait les quantiles p95/p99 non fiables.

Le vocabulaire de label reste strictement borné :

query_class = read | write

SQL, digest, table, URL et path restent interdits.

Compteur de writes DB

Le collector publie également :

ccx_db_row_writes_total

dont la valeur cumulative est :

Handler_write
+ Handler_update
+ Handler_delete

La sortie capacity devient donc :

rate(ccx_db_row_writes_total[5m])

Prometheus gère les resets normaux de compteur lors d'un redémarrage MariaDB.

Session d'observation dédiée

Avant toute collecte, le Runtime exécute sur la session du collector :

SET SESSION query_response_time_session_stats=OFF

La requête d'observation elle-même n'alimente donc pas les buckets query_response_time.

Le bridge reste read-only vis-à-vis des données métier et ne dépend pas de CCX_MetricsLogger.

Publication atomique

Le writer hôte :

  1. écrit dans un fichier temporaire dans le même répertoire ;
  2. vérifie que les familles histogram/count/sum/counter attendues existent ;
  3. exécute promtool check metrics avec le binaire de l'image Prometheus versionnée ;
  4. applique le mode 0644 ;
  5. remplace atomiquement le fichier final par mv.

Une collecte DB ou une validation qui échoue ne remplace donc jamais la dernière exposition valide.

État textfile

Le répertoire :

/mnt/data/cmonchoix-infra/observability/textfile

doit être préparé explicitement via :

bash scripts/observability-textfile-state-prepare.sh

Le preflight vérifie notamment que /mnt/data est bien un filesystem distinct et refuse un path symlink.

Scheduling

La collecte est versionnée comme oneshot systemd :

ccx-db-observability.service
ccx-db-observability.timer

Cadence :

OnBootSec=3min
OnUnitInactiveSec=5min
AccuracySec=15s

Le démarrage à 3 minutes est volontairement décalé du timer read-only capacity gauges qui démarre à 2 minutes.

Ce lot Git :

  • n'installe ni n'active le timer ;
  • ne redémarre pas node-exporter ;
  • ne recharge pas Prometheus ;
  • ne modifie aucune donnée métier ;
  • ne certifie aucune série live.

Statut :

ccx_db_query_duration_seconds
  GIT_CONFIGURED_VIA_NODE_EXPORTER_TEXTFILE_NOT_LIVE_CERTIFIED

ccx_db_row_writes_total
  GIT_CONFIGURED_VIA_NODE_EXPORTER_TEXTFILE_NOT_LIVE_CERTIFIED

P1.5D — recording rules capacité et dashboard SLO

P1.5D ne crée aucune nouvelle métrique source. Il matérialise exclusivement les dérivations déjà définies par P1.5A au-dessus des sources C1–C4 rendues disponibles en Git par P1.5C.

Recording rules

Le groupe Prometheus :

cmonchoix-capacity-recording

produit les 19 séries contractuelles :

ccx_capacity_total_offers
ccx_capacity_offers_changed_24h
ccx_capacity_source_rows_per_second
ccx_capacity_normalized_rows_per_second
ccx_capacity_persisted_rows_per_second
ccx_capacity_identity_products_per_second
ccx_capacity_projection_products_per_second
ccx_capacity_jobs_per_minute
ccx_capacity_db_writes_per_second
ccx_capacity_db_query_latency_p95_seconds
ccx_capacity_worker_cpu_ratio
ccx_capacity_worker_memory_bytes
ccx_capacity_disk_iops
ccx_capacity_queue_depth
ccx_capacity_queue_lag_seconds
ccx_capacity_projection_lag_seconds
ccx_capacity_frontend_p95_seconds
ccx_capacity_frontend_p99_seconds
ccx_capacity_http_5xx_ratio

Toutes ces sorties sont des agrégats sans label. Les labels métier bornés des métriques sources et les labels techniques de scrape ne sont pas propagés dans les séries ccx_capacity_*.

La fenêtre standard reste :

5m

Pour offers_changed_24h, la fenêtre contractuelle reste explicitement 24h.

Le ratio HTTP utilise un zéro explicite pour l'absence de série 5xx lorsqu'un trafic total existe :

(sum(rate(ccx_frontend_http_requests_total{status_class="5xx"}[5m])) or vector(0))
/
clamp_min(sum(rate(ccx_frontend_http_requests_total[5m])), 1)

Aucun code HTTP brut ni route dynamique n'est réintroduit.

Dashboard

Le dashboard versionné :

monitoring/grafana/dashboards/platform/capacity-slo.json
uid=ccx-capacity-slo
title=Capacity & SLO

consomme uniquement les séries ccx_capacity_*.

Grafana n'y recalcule donc ni SQL, ni règles métier, ni formules de capacité parallèles. Les dérivations PromQL restent centralisées dans les recording rules.

Le dashboard couvre notamment :

  • volumes d'offres ;
  • débit source/normalisé/persisté ;
  • débit Identity/projections ;
  • jobs/minute ;
  • CPU/RAM worker ;
  • DB writes et p95 DB ;
  • IOPS disque ;
  • queue depth/lag ;
  • projection lag ;
  • frontend p95/p99 ;
  • ratio HTTP 5xx.

Design targets

P1.5D ne transforme pas une intuition en SLO certifié.

Seuls trois objectifs déjà versionnés dans performance.md deviennent des alertes de design target :

frontend p95      > 300 ms
HTTP 5xx ratio    > 0.1 %
projection lag    > 300 s

Le label d'alerte :

objective=design_target_unvalidated

rappelle que ces seuils doivent encore être validés quantitativement par P1.5F/G/H.

Aucun seuil n'est inventé pour :

CPU
RAM
DB latency
DB writes
disk IOPS
queue depth
queue lag
throughput

Ces seuils dépendent de la baseline et du benchmark réel.

État de déploiement

P1.5D reste Git/off-live :

recording rules       GIT CONFIGURED
dashboard             GIT CONFIGURED
design-target alerts  GIT CONFIGURED
Prometheus live       NOT LIVE CERTIFIED
Grafana live          NOT LIVE CERTIFIED
VPS mutation          NONE

P1.5C peut être considéré fermé pour la matérialisation Git des sources obligatoires, pas pour leur présence live.

P1.5E versionne désormais le harness reproductible. La prochaine étape après certification Git de ce harness est P1.5F : baseline production read-only.

Autorité systemd live des collectors P1.5

Les timers C1C/C2B ne doivent pas dépendre du checkout historique /mnt/data/cmonchoix-platform.

L'autorité d'installation est désormais :

ops/systemd/capacity-observability-systemd-artifacts.manifest
scripts/capacity-observability-systemd-install.sh

Le manifeste contient exactement cinq artefacts :

1 helper
  collect-db-observability-prometheus.sh

4 unités
  ccx-capacity-gauges.service
  ccx-capacity-gauges.timer
  ccx-db-observability.service
  ccx-db-observability.timer

Le helper DB est installé dans :

/usr/local/libexec/cmonchoix-capacity/

Les unités sont installées dans :

/etc/systemd/system/

Le service gauge exécute directement le worker Docker et ne requiert aucun working directory Git.

Le service DB exécute uniquement le helper installé dans /usr/local/libexec et déclare explicitement RequiresMountsFor=/mnt/data, car le helper écrit le textfile Prometheus sous cette autorité de stockage.

Contrat d'installation

L'installateur exige :

  • CCX_TARGET_SHA exact sur 40 caractères ;
  • CCX_RELEASE_ROOT propre et au HEAD exact ;
  • manifeste et sources suivis par Git ;
  • aucune source/cible sous forme de symlink ;
  • validation bash -n du helper ;
  • vérification exacte des fichiers installés avant reload ;
  • systemctl daemon-reload sans activation de service/timer ;
  • validation des quatre unités via le manager systemd chargé (LoadState + FragmentPath) ;
  • validation de mnt-data.mount via le manager live (LoadState=loaded, active, Where=/mnt/data) ;
  • validation des dépendances Requires/After Docker et du mount DB après reload.

La vérification offline systemd-analyze verify est volontairement interdite ici : mnt-data.mount provient de systemd-fstab-generator et n'appartient pas à l'autorité offline utilisée par cet outil, ce qui peut produire un faux Unit mnt-data.mount not found.

Les substitutions sont atomiques et les cibles précédentes sont sauvegardées avant la première mutation.

Tout échec après armement déclenche un rollback des cinq cibles et un daemon-reload de récupération.

Aucun changement de planning implicite

L'installateur peut appeler uniquement :

systemctl is-enabled
systemctl is-active
systemctl show
systemctl daemon-reload

Il lui est interdit d'appeler :

start
stop
restart
enable
disable
mask
unmask

Les états enabled/active des deux timers sont capturés avant installation.

Pour une unit déjà présente, l'état doit rester strictement identique.

Pour une première installation où la unit est absente, la transition suivante est considérée comme équivalente du point de vue du scheduling :

is-enabled: not-found -> disabled

Le contrat se fonde sur l'état textuel retourné par systemd, pas sur un code retour numérique figé. En particulier, un vrai systemctl is-enabled peut retourner not-found avec un code distinct de celui de disabled (par exemple 4 contre 1). Cette différence de code retour ne constitue pas une activation.

Elle matérialise uniquement le fichier unit sans l'activer. Toute transition vers enabled, enabled-runtime, linked, masked ou un état actif reste interdite.

L'état textuel is-active doit rester strictement inchangé. Le code retour numérique n'est pas une autorité de scheduling lorsqu'il ne fait qu'évoluer avec la matérialisation de l'unit. Une première installation peut donc observer inactive avant et après avec des codes retour différents (par exemple unit absente puis unit chargée) sans constituer une activation. Toute transition textuelle vers active, activating, deactivating, failed ou un autre état différent reste interdite.

L'activation live des timers reste donc une décision opérateur séparée, postérieure à l'installation et à la validation du stack P1.5.

Marqueurs

Une installation certifiée localement retourne :

CCX_CAPACITY_SYSTEMD_INSTALL_CHANGED=0|1
CCX_CAPACITY_SYSTEMD_INSTALL_TARGET_SHA=<sha>
CCX_CAPACITY_SYSTEMD_INSTALL_STATUS=PASS

Ce contrat prépare le rollout live ; il ne constitue pas à lui seul une activation des collectors ni une certification P1.5F.

P1.5E — harness de benchmark reproductible

P1.5E transforme le contrat de benchmark en exécutable versionné, sans certifier encore une capacité quantitative du VPS.

Autorités :

benchmarks/capacity/harness-contract.json
scripts/capacity-benchmark-harness.sh

Le mode par défaut est strictement :

--plan

Il valide le scénario et les métadonnées sans lancer de workload, sans contacter Docker et sans écrire un résultat de benchmark.

Une exécution réelle exige explicitement :

--execute
--expected-git-sha <SHA exact>
--workload-script <fichier exécutable>
--verifier-script <fichier exécutable>

Le worktree doit être propre et le SHA courant doit être exactement celui annoncé.

Le harness n'interprète aucune chaîne de commande dynamique. eval et bash -c sont interdits. Le workload et le verifier sont des fichiers exécutables séparés et leurs SHA-256 sont conservés dans le manifest.

Répétitions

Le contrat impose :

1 warm-up minimum
3 measured runs minimum

Le résumé conserve au minimum la durée médiane, le pire run et la preuve business_correctness=PASS.

Preuves figées

Le manifest conserve notamment :

git_sha
worker_image_reference
worker_image_id
config_hashes
dataset_id
dataset_sha256
offers
products
merchant_count
change_percent
worker_count
scenario_id
benchmark_harness_version
started_at
finished_at

Les fichiers Compose, Alloy, Prometheus, recording rules, contrat capacité et dashboard sont hashés avant exécution.

Les résultats sont écrits par défaut hors du dépôt :

/mnt/data/cmonchoix-benchmarks/p1-5

Mesures par run

Chaque répétition suit :

runtime state before
capacity before
workload
business verifier
capacity after
capacity max/avg window
runtime state after

Les 19 séries ccx_capacity_* de P1.5D sont interrogées directement via l'autorité Prometheus.

Le harness capture aussi l'état des containers critiques, notamment :

status
restart_count
OOMKilled
started_at

avant et après le workload.

Un workload ou verifier non nul rend le run FAIL.

Scénarios

Le registre implémente les sept IDs contractuels :

S0 current baseline
S1 large feed
S2 high churn price/stock
S3 concurrent merchants
S4 worker scaling 1/2/4 lorsque le Runtime l'autorise
S5 projection rebuild
S6 frontend traffic during heavy sync

La présence de S4 ne constitue pas une autorisation d'augmenter la concurrence live. Le contrat Runtime CURRENT reste prioritaire.

Autorité de mutation

Le harness n'a aucune autorité pour :

systemctl start|stop|restart|enable|disable
docker compose up|down|restart

Toute mutation future de P1.5G appartient au workload explicitement fourni et doit avoir sa propre preuve de recovery.

État P1.5E

benchmark contract     GIT CONFIGURED
benchmark runner       GIT CONFIGURED
CI architecture guard GIT CONFIGURED
plan-mode smoke        GIT CONFIGURED
live benchmark         NOT EXECUTED
VPS capacity           NOT CERTIFIED

P1.5E certifie le mécanisme de mesure, pas le résultat de capacité.

La prochaine phase est P1.5F : baseline production read-only avec le même contrat d'artefacts.

P1.5F dispose également d'un certifier opérateur unique :

scripts/capacity-certify-p1-5f-s0.sh

Il chaîne le dataset preflight, le plan obligatoire, exactement 1 warm-up + 3 runs mesurés, puis valide les manifests et la stabilité des sept containers sur toute la fenêtre. Il reprend automatiquement les autorités dataset depuis preflight.env, refuse un SHA/worktree divergent et conserve toutes ses preuves hors dépôt. Le mode --plan-only ne ferme jamais la phase. Une exécution complète réussie produit LIVE_EVIDENCE_PASS_PENDING_GIT_CLOSURE : la preuve live doit encore être revue et enregistrée par un lot Git avant P1.5F=CLOSED_PASS.

P1.5G définit le contrat fail-closed des scénarios S1-S6 dans benchmarks/capacity/p1-5g-controlled-live-contract.json. P1.5F est CLOSED_PASS; S1 large_feed, S2 high_churn_price_stock et S5 projection_rebuild sont CLOSED_PASS avec preuves live Git-recorded respectivement dans benchmarks/capacity/p1-5g-s1-live-closure.json, benchmarks/capacity/p1-5g-s2-live-closure.json et benchmarks/capacity/p1-5g-s5-live-closure.json. S3 et S6 restent requis pour la clôture globale ; S6 reste en plus bloqué avant préparation sur son workload-source authority. S4 reste SKIPPED_NOT_AUTHORIZED sans autorité explicite de concurrence. Le harness conserve son absence d'autorité de mutation.

P1.5H-prep définit le contrat de certificat final dans benchmarks/capacity/p1-5h-certificate-contract.json. Le template reste NOT_CERTIFIED tant que P1.5F/P1.5G ne fournissent pas de preuves live compatibles. Le certificat est obligatoirement scopé à l'enveloppe observée, conserve médiane + pire run, identifie la ressource limitante et sa marge, interdit toute extrapolation et ne valide un design target qu'avec sa provenance mesurée.

Benchmark reproductible

Chaque benchmark P1.5 devra figer au minimum :

git_sha
worker_image_digest
compose/config hashes
dataset_id
dataset_sha256
offers
products
merchant_count
change_percent
worker_count
scenario_id
benchmark_harness_version
started_at
finished_at

Puis conserver :

business correctness result
throughput metrics
CPU/RAM/IO peaks
DB latency
queue max + drain time
projection lag
frontend p95/p99
HTTP 5xx ratio
errors/restarts/OOM

Répétitions

Pour une certification :

1 warm-up
3 measured runs minimum

Conserver au minimum la médiane et le pire run mesuré.

Scénarios de certification

Le futur harness doit couvrir :

S0 current baseline
S1 large feed
S2 high churn price/stock
S3 concurrent merchants
S4 1/2/4 workers when current architecture safely permits it
S5 projection rebuild
S6 frontend traffic during heavy sync

Un scénario 2/4 workers n'est exécuté que si le contrat Runtime courant permet cette concurrence sans bricolage live.

PASS / FAIL

Hard FAIL

Un scénario est FAIL indépendamment du throughput en cas de :

  • résultat métier différent ;
  • perte de données ;
  • projection incohérente ;
  • OOM ;
  • restart inattendu ;
  • deadlock non récupéré ;
  • queue qui ne se draine pas ;
  • job abandonné silencieusement ;
  • erreur 5xx au-delà du SLO ;
  • saturation disque provoquant un échec fonctionnel.

Cibles initiales à mesurer

Les cibles de conception déjà présentes dans performance.md restent :

Indicateur Cible initiale
HTTP 5xx < 0,1 %
frontend p95 < 300 ms
projection lag normal < 5 min

Ces valeurs ne sont pas déclarées certifiées par P1.5A.

Safety margin

Le certificat final doit identifier le goulot limitant et sa marge.

Une configuration qui ne respecte les SLO qu'à saturation quasi permanente n'est pas considérée comme ayant une marge industrielle.

P1.5A ne fixe pas un pourcentage universel de marge : P1.5H devra documenter la marge observée par ressource et le premier seuil limitant.

Ordre des lots

P1.5A   Capacity Measurement Contract
P1.5B1  Sync runtime capacity events
P1.5B2  Projection product-grain events + projection refresh job success/error
P1.5B3  Sync batch failure events + semantic gap closure
P1.5B4A Semantic boundary + committed offer-replay change events
P1.5B4B1 Normal-sync canonical offer semantic diff
P1.5B4B2A1 Taxonomy V2 direct canonical offer writers
P1.5B4B2A2 Governance direct canonical offer writers
P1.5B4B2B1 Terminal product-grain identity model + durable state
P1.5B4B2B2A Durable identity capacity outbox
P1.5B4B2B2B Runtime product-grain identity orchestration
P1.5B4B2B2C Durable identity capacity delivery
P1.5C1A Alloy log authority / Promtail migration
P1.5C1B Runtime event metrics → Alloy /metrics → Prometheus
P1.5C1C Read-only capacity gauges → Alloy /metrics → Prometheus
P1.5C2B MariaDB histogram + row-write counter → node-exporter textfile
P1.5D   Capacity & SLO dashboard/recording rules
P1.5E   Reproducible benchmark harness
P1.5F   Read-only production baseline
P1.5G   Controlled live benchmark — S1/S2/S5 CLOSED_PASS; S3/S6 still required; S4 SKIPPED_NOT_AUTHORIZED
P1.5H   Capacity certificate and SLO closure — Git template prepared, live evidence required

P1.5A est Git-only.

Les premières mutations VPS ne sont justifiées qu'après certification Git/off-live des lots qui doivent être déployés.

Critères de sortie P1.5A

P1.5A est fermé lorsque :

  • le registre machine-readable contient toutes les métriques obligatoires ;
  • noms, types, unités et labels sont explicites ;
  • cardinalité haute interdite ;
  • sources CURRENT déjà disponibles distinguées des métriques manquantes ;
  • aucune série MariaDB inexistante n'est prétendue disponible ;
  • les formules p95/p99/5xx/throughput sont explicites ;
  • queue depth/lag ont une définition canonique ;
  • projection lag ne repose pas sur une approximation temporelle invalide ;
  • le contrat est gardé automatiquement en CI ;
  • aucune configuration Runtime n'a changé.

Voir aussi