Module 3 — Documents : CRUD, versions et import massif avec _bulk
Le cluster répond, les concepts sont posés. Sami peut enfin manipuler des documents : créer un article, le modifier, le récupérer, le supprimer, comprendre comment Elasticsearch évite les écritures concurrentes qui écrasent les données. Puis, avec Inès, il indexe le corpus complet — 200 853 articles en moins de trente secondes grâce à l'API _bulk.
Créer, lire, modifier, supprimer un document
Toutes les requêtes du module se collent dans Kibana Dev Tools. On pratique d'abord sur un mini-index dédié pour ne pas mélanger avec le vrai corpus.
PUT bac_a_sable
{
"settings": { "number_of_shards": 1, "number_of_replicas": 0 },
"mappings": {
"properties": {
"titre": { "type": "text" },
"auteur": { "type": "keyword" },
"vues": { "type": "integer" },
"date": { "type": "date", "format": "yyyy-MM-dd" }
}
}
}
PUT avec un _id choisi : idempotent
PUT bac_a_sable/_doc/1
{
"titre": "Change Is Here. Climate Change.",
"auteur": "Inès",
"vues": 0,
"date": "2026-09-09"
}
Réponse type :
{
"_index": "bac_a_sable",
"_id": "1",
"_version": 1,
"result": "created",
"_shards": { "total": 1, "successful": 1, "failed": 0 },
"_seq_no": 0,
"_primary_term": 1
}
Relancez la même requête : result devient updated, _version passe à 2, _seq_no s'incrémente. PUT /index/_doc/1 remplace intégralement le document ; c'est l'écriture idempotente que vous voulez quand vous avez une clé métier (numéro d'article, référence produit).
POST sans _id : Elasticsearch en génère un
POST bac_a_sable/_doc
{
"titre": "Trump Abandons Commitment To 2-State Solution",
"auteur": "Sami",
"vues": 0,
"date": "2026-09-09"
}
_id est un identifiant de vingt caractères base64 (OFm5s5UB…). Utile quand vos documents n'ont pas de clé métier ; à éviter dès que vous risquez de réimporter, sinon vous créerez des doublons à chaque exécution.
PUT _create : refuse si l'_id existe déjà
PUT bac_a_sable/_create/1
{ "titre": "Doublon", "auteur": "Test", "vues": 0, "date": "2026-09-09" }
Renvoie une erreur version_conflict_engine_exception parce que 1 existe déjà. À utiliser quand vous voulez la garantie « n'écrase surtout pas ».
GET un document précis
GET bac_a_sable/_doc/1
Renvoie le _source (le JSON envoyé) plus les métadonnées _seq_no, _primary_term, _version, found: true. Un _id absent renvoie found: false avec un statut HTTP 404.
Deux variantes utiles :
GET bac_a_sable/_source/1
Renvoie uniquement le _source, sans enveloppe.
GET bac_a_sable/_doc/1?_source_includes=titre,auteur
Récupère un sous-ensemble des champs — pratique quand _source est volumineux.
POST _update avec doc : merge partiel
POST bac_a_sable/_update/1
{
"doc": {
"vues": 12
}
}
Seul le champ vues change ; les autres champs restent. Elasticsearch relit le document, applique le patch, réindexe. result renvoie updated ou noop si le contenu était déjà celui demandé.
POST _update avec script : incrémenter atomiquement
Pour incrémenter les vues à chaque lecture, sans avoir à lire puis à écrire :
POST bac_a_sable/_update/1
{
"script": {
"source": "ctx._source.vues += params.n",
"lang": "painless",
"params": { "n": 1 }
}
}
Le langage painless est sandboxé, compatible entre versions, et l'ordonnancement au niveau du shard rend l'opération atomique. C'est le moyen propre d'agréger des compteurs sans course entre requêtes.
DELETE
DELETE bac_a_sable/_doc/1
Marque le document comme supprimé. Le shard le nettoie physiquement au prochain merge Lucene. Le compteur docs.deleted dans _cat/indices monte, puis redescend après un merge.
_mget : plusieurs documents en une requête
GET bac_a_sable/_mget
{ "ids": ["1", "2", "3"] }
Retourne un tableau docs avec, pour chaque identifiant, found et éventuellement _source. Utile pour recharger vingt clés d'un coup.
Versions optimistes : _seq_no et _primary_term
Karim veut mettre à jour un article depuis l'API. Léa, en parallèle, fait la même chose depuis Kibana. Comment éviter que l'écriture la plus lente écrase la plus récente ? Elasticsearch expose deux couples de valeurs :
_version: entier qui s'incrémente à chaque écriture, informatif ;_seq_no+_primary_term: la vraie identité de la version, portée par le shard.
Le protocole de contrôle optimiste tient dans une phrase : lisez, écrivez en repassant le _seq_no et le _primary_term que vous avez lus. Si un autre client a écrit entre les deux, votre requête est rejetée avec version_conflict_engine_exception. Vous relisez, vous refaites votre calcul, vous réécrivez.
GET bac_a_sable/_doc/1
Notons dans la réponse _seq_no: 5, _primary_term: 1. Nous mettons à jour :
PUT bac_a_sable/_doc/1?if_seq_no=5&if_primary_term=1
{
"titre": "Titre revu",
"auteur": "Inès",
"vues": 99,
"date": "2026-09-09"
}
Si le document n'a pas bougé, la requête passe. Sinon, statut 409 : version_conflict_engine_exception. Rejouer la lecture, rejouer l'écriture. Ce mécanisme est ce qui protège vos compteurs et vos statuts sans avoir à verrouiller.
Elasticsearch ne connaît pas les transactions ACID entre plusieurs documents. Chaque document est atomique en isolation ; la cohérence de plusieurs documents ensemble se gère côté application (idempotence, reprise). Pour un vrai modèle transactionnel, Neo4j (module 12) ou une base relationnelle sont les bons outils.
L'API _bulk : le format NDJSON
Envoyer 200 853 articles avec 200 853 POST prend des heures. _bulk accepte des lots de milliers d'actions dans une seule requête HTTP.
Le format s'appelle NDJSON (« newline-delimited JSON ») : une ligne d'action, une ligne de document, à la ligne, une ligne d'action, une ligne de document, à la ligne. Chaque ligne se termine par \n, y compris la dernière. Aucun tableau, aucune virgule entre les objets.
POST _bulk
{ "index": { "_index": "bac_a_sable", "_id": "10" } }
{ "titre": "Un article", "auteur": "Léa", "vues": 0, "date": "2026-09-09" }
{ "index": { "_index": "bac_a_sable", "_id": "11" } }
{ "titre": "Un autre", "auteur": "Karim", "vues": 0, "date": "2026-09-09" }
{ "delete": { "_index": "bac_a_sable", "_id": "10" } }
{ "update": { "_index": "bac_a_sable", "_id": "11" } }
{ "doc": { "vues": 42 } }
Quatre actions possibles : index (remplace), create (échoue si existe), update (patch), delete (pas de ligne document). Elasticsearch renvoie un tableau items de même longueur que le nombre d'actions, avec le statut de chacune.
Un _bulk sans \n final est refusé avec The bulk request must be terminated by a newline. C'est LE piège classique quand on écrit _bulk à la main. Dans Kibana Dev Tools, la console ajoute automatiquement le saut de ligne ; dans un script, il faut le forcer.
Indexer 200 853 articles avec ./lab.sh import-news
Le kit encapsule tout ce mécanisme dans une seule commande. Depuis le dossier du kit :
./lab.sh import-news # macOS, Linux, WSL2, Git Bash
.\lab.ps1 import-news # Windows PowerShell
Sortie observée sur un poste standard (Docker Desktop, 8 Go alloués, disque SSD) :
[import-news] démarrage
[import-news] connexion à http://elasticsearch:9200 en tant que elastic...
[import-news] cluster « veille » en état green
[import-news] jeu de données déjà présent : /data/News_Category_Dataset_v2.json (83 Mo), téléchargement sauté
[import-news] index « news » créé avec le mapping news.json
[import-news] indexation par lots de 2000 documents
[import-news] 20000 documents indexés (3 s)
[import-news] 40000 documents indexés (6 s)
[import-news] ...
[import-news] 200000 documents indexés (28 s)
[import-news] 200853 documents envoyés en 28 s
[import-news] vérification : _count = 200853 ✔
[import-news] fichier Neo4j écrit : /neo4j-import/news.csv (43 Mo)
[import-news] terminé. Dans Kibana → Dev Tools : GET news/_count
Confirmez dans Dev Tools :
GET news/_count
{ "count": 200853, "_shards": { "total": 1, "successful": 1, "skipped": 0, "failed": 0 } }
Cent quarante méga-octets de stockage pour 200 853 documents, indexation en moins d'une minute. C'est ce que promet un _bulk bien réglé sur un cluster à un nœud d'atelier — sur un cluster de production correctement dimensionné, on parle en dizaines de milliers de documents par seconde et par nœud.
Lecture guidée de importer/import_news.py
Le script tient en environ deux cent cinquante lignes de Python standard — pas une seule dépendance à installer sur votre machine, tout tourne dans un conteneur. On le parcourt fonction par fonction : chaque choix cache une leçon.
es(method, chemin, corps=…) : le client HTTP maison
Une seule fonction fait tous les appels REST. Elle porte l'authentification Basic, elle réessaie cinq fois avec un backoff exponentiel sur les timeouts et sur les 429 Too Many Requests, elle décode le JSON de la réponse. Retenez la leçon : quand un service peut renvoyer 429 sous forte charge, un client naïf plante ; le retry avec backoff est le minimum syndical.
telecharger() : téléchargement idempotent
Si /data/News_Category_Dataset_v2.json existe déjà et pèse plus de dix méga-octets, la fonction saute le téléchargement. Sinon elle télécharge dans un fichier .part et renomme à la fin (mouvement atomique sur le système de fichiers). Une interruption laisse le .part incomplet, qui sera écrasé au prochain lancement ; jamais un News_Category_Dataset_v2.json tronqué à moitié.
creer_index() : index propre à chaque exécution
Si l'index news existe, la fonction le supprime puis le recrée avec elasticsearch/mappings/news.json. Un import est donc idempotent dans les deux sens : deux exécutions successives donnent exactement le même état. Cela évite le piège du réimport qui ajoute au précédent et gonfle silencieusement le compte.
Un pipeline d'indexation qui se relance après une panne doit toujours arriver dans le même état. En posant _id = numéro_de_ligne et en supprimant l'index avant recréation, import-news est rejouable à volonté sans effet secondaire. En production, on préfère souvent réindexer dans un nouvel index (news-2026-09-09), pointer un alias news dessus (module 8), puis supprimer l'ancien — même logique, sans couper la lecture.
envoyer_lot(lignes) : le vrai _bulk
Le corps est un tableau Python de chaînes déjà sérialisées, joint par des \n, encodé en UTF-8, terminé par un \n. La ligne d'action est le minimum possible : {"index": {"_index": "news", "_id": "42"}}. La ligne document est le JSON brut. Le paramètre d'URL ?filter_path=errors,items.*.error demande à Elasticsearch de ne pas renvoyer les 2 000 lignes de succès — juste errors: false s'il n'y a rien à signaler, et le détail des seuls documents refusés sinon. Sur 200 853 documents, cela économise plusieurs méga-octets de JSON à parser côté client.
indexer() : lots de deux mille, CSV en parallèle
Deux mille est un compromis publié dans la documentation Elasticsearch : un lot trop petit (100) fait perdre le bénéfice du batching, un lot trop gros (20 000) risque le rejet 413 Request Entity Too Large et pénalise la mémoire du coordinating node. Deux mille est un point d'équilibre sur des documents courts comme les articles News.
En parallèle du _bulk, chaque ligne est aussi écrite dans /neo4j-import/news.csv (un csv.writer en QUOTE_ALL). Une seule passe sur le fichier source suffit ainsi à alimenter les deux moteurs — c'est le fichier que Neo4j lira au module 11 avec LOAD CSV.
verifier(attendu) : un dernier _refresh, un _count
Le script termine par :
POST /news/_refresh
PUT /news/_settings { "index": { "refresh_interval": "1s" } }
GET /news/_count
Le _refresh force l'ouverture d'un nouveau segment Lucene visible en recherche. Le second appel remet le refresh_interval à 1s — le mapping l'avait mis à 30s pour accélérer l'import. C'est le second grand levier de performance : pendant un import massif, chaque refresh par défaut à 1s re-crée un segment que Lucene devra ensuite fusionner. Espacer les refreshs à 30s réduit la pression sur les merges et double presque le débit d'indexation. Une fois l'import terminé, on remet 1s pour retrouver la visibilité temps réel côté requêtes.
_id = numéro de ligne : la clé du réimport idempotent
L'article n° 42 du fichier source est indexé avec _id = "42" et écrit dans news.csv avec id = 42. Rejouer l'import donne exactement les mêmes 200 853 documents avec exactement les mêmes identifiants. Le graphe Neo4j du module 11 utilisera cet id comme clé de contrainte d'unicité : deux moteurs, une seule identité.
À vous
Exercice 1 — Cycle CRUD complet
Dans Dev Tools, créez un document dans bac_a_sable avec _id = 42, un titre et un auteur au choix. Lisez-le. Modifiez-le avec _update en ajoutant un champ note de valeur 5. Relisez-le. Supprimez-le. Confirmez sa disparition avec un _mget.
Solution
PUT bac_a_sable/_doc/42
{ "titre": "Test CRUD", "auteur": "Sami", "vues": 0, "date": "2026-09-09" }
GET bac_a_sable/_doc/42
POST bac_a_sable/_update/42
{ "doc": { "note": 5 } }
GET bac_a_sable/_doc/42
DELETE bac_a_sable/_doc/42
GET bac_a_sable/_mget
{ "ids": ["42"] }
Le dernier _mget renvoie docs[0].found = false. Confirmez que le nombre de documents est bien ce que vous attendez avec GET bac_a_sable/_count.
Exercice 2 — Version optimiste et conflit
Créez un document, notez _seq_no et _primary_term. Écrivez deux fois de suite avec ces mêmes valeurs. Que se passe-t-il à la seconde requête ?
Solution
PUT bac_a_sable/_doc/100
{ "titre": "V1", "auteur": "Léa", "vues": 0, "date": "2026-09-09" }
Retenez _seq_no et _primary_term.
PUT bac_a_sable/_doc/100?if_seq_no=<n>&if_primary_term=<t>
{ "titre": "V2", "auteur": "Léa", "vues": 1, "date": "2026-09-09" }
Succès : le document passe en V2, _seq_no s'incrémente. Répétez la même commande avec les anciennes valeurs if_seq_no et if_primary_term : statut 409, version_conflict_engine_exception. Message qui rappelle qu'il faut relire avant de réécrire.
Exercice 3 — Confirmer l'import
Après ./lab.sh import-news, exécutez ces trois requêtes dans Dev Tools et lisez ce qu'elles racontent :
GET news/_count
GET news/_doc/1
GET news/_search
{
"size": 1,
"query": { "match": { "headline": "climate change" } }
}
Solution
_count renvoie exactement 200 853. GET news/_doc/1 renvoie le premier article du fichier, avec ses champs headline, short_description, category, authors, link, date. Le _search sur « climate change » renvoie 2 834 résultats totaux (hits.total.value) ; le premier document a un _score élevé et son headline est « Change Is Here. Climate Change. ». Vous venez de confirmer que le corpus est complet, que le mapping est actif et que le moteur de recherche répond.
Points à retenir
PUT /index/_doc/<id>remplace,POST /index/_docgénère l'_id,PUT /index/_create/<id>refuse d'écraser.POST _updateacceptedoc(patch partiel) ouscript(calcul atomique côté shard).- Le contrôle optimiste combine
_seq_noet_primary_term: rejouable, sans verrou. _bulkattend du NDJSON : une action, un document, à la ligne, saut de ligne final compris.- Deux mille documents par lot est un bon point d'équilibre pour des documents courts.
?filter_path=errors,items.*.errorallège les réponses_bulkde plusieurs méga-octets sur les gros imports.- Passer
refresh_intervalà30spendant l'import puis à1sensuite double le débit — le kit le fait à votre place. _id = numéro de lignerend l'import idempotent et donne une identité stable partagée avec Neo4j.
Si ça ne marche pas
./lab.sh import-newsrenvoieElasticsearch n'est pas démarré→ le healthcheck n'est pas au vert../lab.sh status, puis./lab.sh logs elasticsearchsi le conteneur n'est pashealthy._bulkrenvoie 413Request Entity Too Large→ le lot est trop gros ou vos documents sont anormalement volumineux ; réduisez la taille de lot ou augmentezhttp.max_content_length(par défaut 100 mo).version_conflict_engine_exceptionsur unPUT _create→ l'_idexiste déjà. C'est l'effet recherché de_create— utilisezPUT /index/_doc/<id>si vous voulez remplacer._countrenvoie moins que 200 853 après un import → un_bulks'est terminé en erreur silencieuse ; relancez./lab.sh import-news(idempotent), et si le problème persiste,./lab.sh doctorpuis./lab.sh logs elasticsearch.
Pour aller plus loin
- Documentation Elasticsearch 9 — Document APIs (index, get, update, delete)
- Documentation Elasticsearch 9 — Bulk API
- Documentation Elasticsearch 9 — Optimistic concurrency control
- Documentation Elasticsearch 9 — Tune for indexing speed