Přeskočit obsah
V
Pro vývojáře
Architektura, konvence, jádro systému a bezpečnost
Jádro / Elasticsearch

Elasticsearch#

Fulltextové hledání, relevanční řazení, zvýrazněné úryvky a fasetové počty.

Jádro: app/Core/Integrations/Elasticsearch/. Zdroje dokumentů: app/UI/Api/{modul}/Models/Search/*DocumentSource.php. Endpoint: app/Core/Traits/Api/Presenters/SearchTrait.php. Konfigurace: config/Shared/elasticsearch.neon.

Základní pravidlo#

Elasticsearch je zdroj identifikátorů a pořadí, ne zdroj dat

Z ES se nikdy nečtou těla dokumentů (_source: false). Vrací seznam id, případně jejich pořadí dle skóre a zvýrazněné úryvky. Data se pak načtou z databáze. Index proto nemusí nést všechna pole a nedokáže vrátit zastaralý obsah.

Rozhodčím viditelnosti zůstává databáze

Filtry volajícího (aktivní, publikováno, zaplaceno, kategorie…) se aplikují vždy a v obou cestách. Zvětralý dokument v indexu proto nikdy neprozradí entitu, kterou uživatel nemá vidět — nanejvýš zkrátí stránku. Předzúžení popsané níž je jen optimalizace, ne bezpečnostní opatření.

1. Dvě cesty, jeden endpoint#

Každý indexovaný modul má Api akci api/{modul}/{entita}/search. Tvar je modulově neutrální — dodává ho SearchTrait, modul doplňuje jen tři háčky.

Situace Kdo řadí Kdo stránkuje totalCount fromEs Úryvky
hledání bez orderBys ES (skóre) ES (from/size) ES (track_total_hits) true ano
hledání s orderBys (ruční volba) DB DB DB false ne
ES vypnutý / výpadek / breaker / dotaz kratší než 2 znaky DB DB DB false ne

Ruční volba řazení přebíjí relevanci — je to záměr

Kdyby se i při volbě „Nejnovější“ stránkovalo v ES, dostal by uživatel nejnovější jen v rámci stránky nejrelevantnějších. V té větvi se proto z ES bere jen id-set (fáze 1) a řadí, stránkuje i počítá databáze — přesně jako před zavedením ES.

Dotaz je vždy multi_match, nikdy query_string

Obě hledací metody EsSearcher skládají multi_match nad poli zdroje. query_string by z uživatelského vstupu udělal syntaxi dotazu: speciální znaky (", (, :, /) by z hledání udělaly chybu 400. Kdo dotaz rozšiřuje, přidává klauzule kolem multi_match, ne místo něj.

Tvar odpovědi#

Shodný s internalActionGetAll() plus dva klíče navíc:

[
    'items'       => [...],   // už přerovnané do pořadí dle skóre
    'totalCount'  => 42,
    '_references' => [...],
    'fromEs'      => true,    // pořadí i totalCount pocházejí z ES
    'highlights'  => [        // id entity => logické pole => bezpečné HTML
        17 => ['name' => 'Prodám <em>vrtačku</em>', 'text' => '…'],
    ],
]

fromEs je jediný spolehlivý signál pro front

Konfigurace o dostupnosti ES nic neříká — breaker může být otevřený, index nemusí existovat. Front se proto ptá payloadu, ne configu. Podle fromEs se rozhoduje, jestli je výpis řazený relevancí a jestli se v sortboxu zvýrazní nějaká volba.

Klíč highlights je v odpovědi VŽDY

I když je prázdný. Volající ho tedy nemusí hlídat podmínkou podle fromEs. Šablona, která na něj zapomene (highlights: [] u vnořeného vypsání seznamu), spadne na nedefinované proměnné.

Proč se tu nepoužívá query cache#

Cache by servírovala stará pořadí

SearchIdFilter je NonCacheableFilter — klíč by musel obsahovat celý id-set. Navíc se odpověď ES mění bez zápisu do databáze (reindex, změna skóre), takže by cache neměla podle čeho invalidovat.

2. Konfigurace#

parameters:
    elasticsearch:
        enabled: false                    # master vypínač — false = všechny služby no-op
        hosts: ['http://127.0.0.1:9200']
        username: null
        password: null
        indexPrefix: cms
        enabledSources: []                # allowlist klíčů zdrojů

Hosts a přihlašovací údaje patří do gitignorovaného config/Shared/local.neon.

Víc instalací na jednom clusteru: každá vlastní indexPrefix

Se stejným prefixem zapisují instalace do stejných indexů a sdílejí i klíče es:<prefix>:… v Redisu. Viz Více instalací na jednom serveru.

Vrstva vypínače Efekt
enabled: false žádné spojení se nenaváže, vše se chová jako před ES
enabledSources bez klíče zdroje ten jeden zdroj jede na MySQL, ostatní na ES
circuit breaker otevřený dočasně (60 s) se ES vůbec nevolá

Postupné zapínání po modulech

enabledSources je celý smysl toho allowlistu — dá se mít stav „ES zapnutý pro Blog, inzerce ještě na MySQL". Nový modul se zapíná až po ověřeném reindexu.

Dva klienti#

Služba Timeout Použití
esClient krátký hledání, monitoring — při výpadku musí selhat rychle
esIndexingClient 60 s indexace a bulk reindex — stovky dokumentů nesmí padat na 2,5 s

Konstrukce klienta nenavazuje spojení

Proto je bezpečné mít služby v kontejneru i při enabled: false.

Timeouty drží jen vlastní instance Guzzle

EsClientFactory předává klientovi hotový GuzzleHttp\Client přes setHttpClient() a timeouty jsou v jeho konstruktoru. Kdo přidá setHttpClientOptions() nebo volby SSL, vendor adaptér postaví nový Guzzle klient jen z těch voleb a timeouty se ztratí: hledání pak při výpadku čeká bez omezení (výchozí timeout Guzzle je 0) místo 2,5 s.

Přihlášení k ES: obě hodnoty, nebo žádná

Vyplněné jen username nebo jen password vyhodí LogicException hned při stavbě klienta. Vendor by neúplné údaje tiše zahodil a výsledek by vypadal jako výpadek (401).

3. Indexy, aliasy a reindex bez výpadku#

Fyzický index se jmenuje {prefix}_{base}_v{N}, například cms_blog_article_cs_v5. Aplikace na něj nikdy nesahá přímo — mluví se dvěma aliasy:

Alias Kdo ho používá Kam míří
cms_{base} (read) vyhledávání, monitoring stabilní verze
cms_{base}_write indexace ze save hooků během reindexu už nová verze

Průběh úplného reindexu (EsIndexManager):

beginReindex()   založí _v(N+1), přepne WRITE alias na novou verzi
                 └─ souběžné save hooky tak píšou rovnou do nové verze
   ...bulk...    naplní novou verzi po dávkách po 200
finishReindex()  refresh nové verze → atomický swap READ aliasu → smazání starých
rollbackReindex()při selhání: write alias zpět na stabilní verzi + zahodit rozpracovanou

Bez refresh() před přepnutím aliasu vidí hledání prázdno

Bulk zápisy jsou bez refreshe a výchozí interval je 1 s. Kdyby se read alias přepnul hned, těsně po swapu by hledání i monitoring viděly neúplnou novou verzi. Explicitní refresh je proto součást finishReindex().

Konkrétní index se jménem aliasu zablokuje celý bootstrap

Vznikne typicky auto-createm ze zápisu, který přišel dřív než založení indexů. ensureIndexes() na to vyhodí srozumitelnou výjimku s příkazem k nápravě (DELETE /cms_{base}), protože jinak by updateAliases selhávalo napořád.

Analýza češtiny#

Každé fulltextové pole má dva analyzery:

Analyzer Filtry K čemu
text_czech lowercase, české stopwords, český stemmer, asciifolding základní pole — najde i skloňované tvary
text_folded lowercase, asciifolding podpole .folded — chytá dotazy psané bez diakritiky

Změna analýzy vyžaduje nový index

Analýzu existujícího indexu nejde měnit za běhu. Postaví se nová verze a přepne se na ni — přesně to, co dělá úplný reindex.

4. Zdroj dokumentů#

Jedna třída na entitu, žije v Api vrstvě modulu (má tedy přístup ke službám a managerům), Core na ni závisí jen přes rozhraní. Zapojuje se ručně v config/Shared/elasticsearch.neon na dvou místech — esSearcher a esMonitor.

Metoda Co vrací Na co si dát pozor
getKey() klíč pro allowlist a searcher musí sedět s enabledSources
getIndexBases() základy názvů, u překládaných entit jeden per jazyk
getIndexBaseForLanguage() základ pro alias jazyka, null = neznámý jazyk null znamená fallback na DB cestu
getIndexDefinition() settings + mappings zdroj pravdy o analýze
getSearchFields() pole s boosty pro multi_match vždy i .folded varianty
fetchFresh() čerstvé entity po commitu useCache: false, entita z hooku není autoritativní
buildDocuments() base => (_id => dokument) prázdné pole = entita se smaže z indexu
getAllDocumentIds() všechna možná _id napříč jazyky cílené mazání, nikdy delete_by_query
fetchReindexBatch() dávka pro úplný reindex
getEntityIdField() název pole s id entity řazení a search_after při hledání osiřelých
fetchIdsChangedSince() id změněná od značky vstup inkrementálního syncu
fetchExistingIds() která id v DB opravdu jsou úklid osiřelých dokumentů
expectedDocumentBounds() {min, max} očekávaného počtu musí přesně zrcadlit guardy buildDocuments()

expectedDocumentBounds() musí sedět s buildDocuments()

Je to vstup monitoringu. Když se rozejdou, hlásí se falešný drift při každé kontrole — a na skutečný se pak nikdo nedívá.

Zdroj, který umí jen zapsat, nechá v indexu smazané záznamy

Ve výsledcích se pak objevují id, která už v databázi nejsou. Proto je mazání součástí kontraktu (getAllDocumentIds + prázdný buildDocuments).

_id dokumentu#

Jednotný tvar napříč zdroji: "{entityId}_{languageId}". Searcher z něj čte id entity přes strtok('_'), takže jiný tvar rozbije čtení. U jednojazyčné entity se použije jazyk vložení.

Dvě volitelná rozšíření#

interface HighlightableDocumentSourceInterface extends DocumentSourceInterface
{
    /** logický klíč => ES pole v pořadí preference */
    public function getHighlightFields(): array;
}

interface VisibilityConstrainedDocumentSourceInterface extends DocumentSourceInterface
{
    /** klauzule do bool.filter — jen zužují, skóre neovlivňují */
    public function getVisibilityFilters(): array;
}

Obojí je opt-in a zpětně neutrální

Zdroj bez rozhraní posílá bajt po bajtu stejný dotaz jako předtím. To je důvod, proč se rollout na další modul nemůže projevit na těch ostatních.

5. Zvýrazněné úryvky#

Bezpečnostní jádro#

EsHighlighter je jediné místo, kde se z odpovědi ES stává HTML

Syrový fragment z ES se ze searcheru ven nikdy nedostane. Kdo chce úryvek vypsat, dostane už hotový bezpečný řetězec.

ES neobaluje shody značkou <em>, ale dvěma znaky z Private Use Area (U+E000 / U+E001). Převod pak má pevné pořadí:

1. htmlspecialchars(CELÝ fragment)   → v řetězci neexistuje žádný spustitelný markup
2. teprve pak sentinely → <em> / </em>

Obrácené pořadí těch dvou kroků je stored XSS

Obsah jde z editoru, takže <script> nebo "><img onerror=… v něm být může. Kdyby se nejdřív vkládaly značky a escapovalo se až potom, escapovaly by se i ony — a kdyby se escapování zúžilo jen na „zbytek“, je to přesně ta úloha, na které se XSS dělá.

Výsledný allowlist je proto strukturální, ne textový: jediné tagy, které v výstupu mohou vzniknout, jsou <em> a </em> bez jediného atributu. Výstup je navíc vždy párový — nepárové nebo vnořené sentinely se zahodí.

Pole pro úryvky#

getHighlightFields() vrací logický klíč => seznam ES polí v pořadí preference. Logický klíč (name, perex, text) je ten, pod kterým šablona úryvek najde.

Pravidlo Proč
uveď základní pole i jeho .folded česká a folded analýza se ve shodách liší; dotaz, který zabral jen na jedné, by na druhé úryvek nedostal
základní pole první stemmer zvýrazní i skloňované tvary, .folded je záloha pro dotaz bez diakritiky
jen pole, která výpis opravdu vypisuje number_of_fragments: 0 vrací celé pole; úryvek bez místa ve výpisu jen zdraží dotaz (content článku, keywords a city inzerátu se hledají, ale nezvýrazňují)
prázdné pole = žádné úryvky do dotazu nejde blok highlight
pole bez shody v mapě chybí ES ho v highlight nevrátí, šablona vypíše původní text

Escapuje jen PHP, ES nic

Blok highlight posílá encoder: default, takže ES do fragmentu nic nekóduje. Přepnutí na encoder: html by fragment escapovalo podruhé a v úryvku by se objevily entity jako &amp;amp;.

Vypsání v šabloně#

Renderer si úryvek vytáhne dopředu do proměnné ($nameHl, $textHl); šablona pak jen rozhoduje, jestli je čím nahradit původní hodnotu:

{* tělo elementu: úryvek přes |noescape, jinak původní hodnota *}
<a class="list-item__title" href="{$link}" title="{$name}">
    {if $nameHl}{$nameHl|noescape}{else}{$name}{/if}
</a>

{* popis: BEZ |truncate — zkrácení už udělal EsHighlightSnippet *}
<p class="list-item__desc">
    {if $textHl}{$textHl|noescape}{else}{$perex|stripHtml|truncate:150}{/if}
</p>

Úryvek nikdy nepatří do HTML atributu

Escapování je počítané pro tělo elementu, ne pro hodnotu atributu. Do title= a alt= patří holý text z databáze — všimněte si, že title="{$name}" výš úryvek nepoužívá.

Na úryvek se |truncate už nepouští

Zkrácení proběhlo na správném místě (kolem shody). Další krácení v šabloně by z něj useklo právě to zvýrazněné slovo.

Zkrácení na délku výpisu#

EsHighlighter si vyžádá number_of_fragments: 0, tedy celé pole. U názvu a perexu to sedí, u popisu inzerátu ne — ten má klidně 1 200 znaků. Krátí ho proto EsHighlightSnippet::around().

Naivní |truncate na hotovém HTML nestačí

Useklo by uprostřed <em> nebo uprostřed entity (&amp; → &am), počítalo by značky do limitu — a hlavně by krátilo od začátku, takže shoda na 400. znaku by ve výsledku vůbec nebyla vidět.

around() proto vybírá okno kolem první shody a krájí po tokenech: nikdy nevznikne nový tag, <em> se vždy dozavře a entita se nerozpůlí.

Zúžení na položky výpisu#

ES highlightuje i dokumenty, které databáze zahodila

Zvětralý dokument deaktivované entity se ze items vypadne, ale jeho název a text by bez filtru zůstaly v highlights — tedy únik textu, který volající nemá vidět. SearchTrait::highlightsForItems() proto mapu zúží na id, která opravdu jsou ve výpisu. Podmínka je „položka je ve výpisu“, ne „id je v ES“.

6. Předzúžení viditelností#

Volitelné rozšíření zdroje. Vzniklo pro inzeráty: v indexu podle návrhu zůstávají i expirované a neaktivní, takže proti dvěma viditelným stálo devětašedesát dokumentů. Relevanční cesta stránkuje v ES, takže stránky chodily kratší než limit a totalCount byl výrazně nadhodnocený.

public function getVisibilityFilters(): array
{
    return [
        ['term'  => ['active' => true]],
        ['range' => ['expirationDate' => ['gte' => 'now/d']]],
    ];
}
Vlastnost Proč tak
klauzule jdou do bool.filter, ne must filtr neovlivňuje skóre — relevanční pořadí zůstane totožné
active je denormalizovaný příznak v databázi generovaný sloupec; hodnota se bere z DB, nedopočítává se v PHP
expirace je query-time range NOW() nejde do generovaného sloupce a v indexu by zvětralo každou půlnoc

Definice active se mezi moduly LIŠÍ

U inzerce obsahuje i příznak „prodáno“, u katalogu firem ne. Proto se hodnota vždy čte z databáze přes getter generovaného sloupce a nikdy se neskládá v PHP znovu.

Sem patří jen podmínky, jejichž změnu umí zdroj propsat do indexu

Opačný směr (v indexu neviditelný, v databázi už viditelný) je cena za předzúžení: taková entita z hledání vypadne, dokud ji nedožene hook nebo reindex. Podmínky závislé na nastavení sem nepatří vůbec — dotaz by se choval jinak v CLI a jinak ve webu.

Článek předzužuje datem publikace: published se mění bez zápisu entity (článek „vyjde“ o půlnoci), takže nemůže být podmínkou buildDocuments(), a v indexu jsou proto i články s datem v budoucnu. Filtr zrcadlí frontový DateBeforeFilter('published', new DateTime()):

['range' => ['published' => ['lt' => 'now/d', 'time_zone' => date_default_timezone_get()]]]

Datum v range potřebuje time_zone

Bez něj ES zaokrouhlí now/d na půlnoc UTC, ne na půlnoc aplikace. V letním čase pak mezi 00:00 a 02:00 pražského času z hledání zmizí články publikované včera, přestože je databáze ukazuje. Podmínka závislá na přístupu uživatele (kategorie „zdarma“) sem nepatří, tu vyhodnocuje jen databáze.

7. Cesta na frontu#

Front nikdy nemluví s ES ani s databází přímo — jde přes Api bridge:

Presenter trait ──► Manager::search() ──► Service ──► Mapper ──► Repository
                                                                     │
                                                        ApiBridge::internalCall
                                                                     ▼
                                                      Api presenter · SearchTrait
                                                          ├─ EsSearcher (ES)
                                                          └─ Manager::findAll (DB)

Manager vrací čtveřici [entity, totalCount, fromEs, highlights].

Hydratace drží pořadí payloadu — nesmí ho otočit

Api stranu přerovná orderItemsByIds(), front pak hydratuje v pořadí, v jakém položky přišly. Jakýkoli zásah do pořadí v mapperu tiše zahodí relevanci; na dvouprvkovém výsledku to navíc nemusí být vidět.

Presenter z toho staví tři věci:

Proměnná šablony Význam
items výpis v pořadí dle skóre
searchHighlights id => pole => HTML; prázdné = výpis bez zvýraznění
sortActiveOrder 0 při aktivní relevanci (žádná volba sortboxu není zvýrazněná), jinak zvolené řazení

Vědomá odchylka: v relevanční cestě se neuplatní topování

Bez hledání i při ruční volbě řazení topování funguje beze změny. Při řazení podle skóre do pořadí mluví ES a databáze do něj nezasahuje.

Kdy front hledání přes endpoint zahodí#

searchViaApi() v presenterovém traitu výpisu (Blog, Inzerce, Katalog firem, Diskuze, sady e-shopu) vrací null a výpis pak jede vlastní DB cestou presenteru (prepareFilters() s SearchIdFilter, případně MySQL MATCH_AGAINST):

Situace Proč
mapper vrátil null odpověď není výpis: DENY obálka, výpadek bridge, prázdný payload z repository (fail-soft [])
bez ruční volby řazení a fromEs = false ES neběží nebo je dotaz krátký; DB větev endpointu bez orderBys neřadí, vlastní cesta dá deterministické výchozí řazení výpisu

Mapper nesmí místo null vrátit prázdný výpis

Prázdné pole by presenter vykreslil jako „nic nenalezeno“, i když hledání jen nedoběhlo. null znamená „spadni na DB cestu“, prázdné items znamená „opravdu nic“.

8. Oprávnění a routy#

Bez privilegia je hledání TICHÉ prázdno

Api RBAC běží v režimu vynucování a chybějící privilegium není chyba — je to prázdný výsledek. Každý modul proto potřebuje záznam :Api:{Modul}:{Entita}:search; skripty jsou v docs/sql/ (*-search-privilege.sql).

Routa výpisu musí umět vyjádřit „řadí relevance“. Slouží k tomu sentinel v masce:

o-<order=0>      výchozí hodnota 0 = relevance

Nette výchozí hodnotu z URL vypouští

Segment rovný výchozí hodnotě se z adresy odstraní a příchozí /o-0 se přesměruje (301) na tvar bez něj. Relevance tedy bydlí na holé URL; /o-0 existuje jen jako kanonizace. Skripty jsou v docs/sql/ (*-route-order-default.sql).

9. Provoz#

Úloha URL Frekvence Co dělá
Úplný reindex /cron/{modul}/elastic/reindex 1× denně v noci postaví novou verzi a přepne alias — primární záruka konzistence; běží po dílech, viz níže
Inkrementální sync /cron/{modul}/elastic/sync à 10 minut doindexuje změněné od značky (5 min překryv) + úklid osiřelých; co „změněné“ znamená, záleží na zdroji, viz níže
Stav /cron/system/elastic/status dle potřeby health clusteru a počty dokumentů
Hlídání /cron/system/elastic/watch à 15 minut kontroly + souhrnný e-mail při nálezu

Zápis do indexu jde přes hooky completedSave a completedDelete, tedy po commitu.

Hromadné operace mimo hooky index nevidí

Naplánované úlohy, platby, updateColumn a raw SQL zapisují mimo hooky. Inkrementální sync je nezachytí — dorovná je až noční úplný reindex. Kde na čerstvosti záleží, jsou cílené reindex triggery v kódu.

Úplný reindex po dílech#

EsEntityIndexer::reindexAll() zpracovává zdroj po dávkách přes klíč (id > kurzor) a jedno volání trvá nejvýš REINDEX_SLICE_SECONDS (20 s, aspoň jedna dávka po 200 dokumentech). Postup (kurzor, rozpracovaná verze indexu) je v Redisu. Dokud není hotovo, cron akce …/elastic/reindex odpoví {"status":"skipped","reason":"reindex_continues"} a plánovač ji zavolá v dalším ticku; hotovo je, až vrátí status: ok. Read alias se přepne až po poslední části, do té doby se hledá ve staré verzi a průběžné zápisy z hooků jdou jen do nové. Rozpracovaný běh dotáhne i sync téhož zdroje: místo vlastní práce zavolá další díl reindexu (pojistka pro případ, že reindex už nikdo nevolá). Běh, který hodinu nepostoupil (REINDEX_STALE_SECONDS), sync zahodí. Bez Redisu jede reindex vcelku. $timeBudget = null pustí celý reindex v jednom požadavku (testy, ruční skript).

Publikace a návrat#

Situace Co se stane
selže byť jediná položka bulku celý běh se vrátí: write alias zpět na stabilní verzi, rozpracovaná _vN se smaže
refresh nové verze hlásí selhaný shard výjimka před přepnutím aliasu; read alias míří pořád na starou verzi
přepnutí read aliasu proběhlo verze je publikovaná; úklid starých verzí je best-effort, co zbude, smaže příští reindex
návrat po přepnutí (selhal až úklid nebo smazání stavu v Redisu) rollbackReindex() rozhoduje podle aktuálních aliasů: na verzi, na kterou míří read alias, jen srovná write alias a index nesmaže
reindex zabitý uprostřed (OOM, timeout, kill) write alias zůstal na nové verzi, ve které nikdo nehledá; sync ho mimo běžící reindex vrátí na cíl read aliasu

Index, na který míří read alias, nikdy nemaž ručně

Smazáním fyzického indexu zmizí i alias, který na něj ukazuje, a hledání zůstane bez indexu. Rozpracovanou verzi uklízí rollback nebo příští úspěšný reindex (maže všechny staré verze podle vzoru názvu).

Reindex pouštěj přes plánovač

Hostingový cron, který volá úlohu přímo, ji musí volat opakovaně, dokud nevrátí status: ok. Úpravy, které proběhnou během reindexu, se ve vyhledávání projeví až po přepnutí aliasu.

Co zachytí inkrementální sync#

Sync bere id z fetchIdsChangedSince() zdroje (od značky v Redisu es:<indexPrefix>:watermark:<zdroj> minus 5 minut, SYNC_OVERLAP_MINUTES), k nim frontu zápisů, které se při výpadku ES nepovedly, a pak maže dokumenty bez řádku v DB. Které změny značka pozná, určuje sloupec, který entita má:

Zdroj Podle čeho hledá Co z toho plyne
článek (ArticleDocumentSource) insertTime nebo updateDatetime nové i upravené články
prodejní karta (ProductSetDocumentSource) insertTime nebo updateTime hlavně nové karty — updateTime se při uložení spolehlivě neposouvá
inzerát (Bazaar\…\AdvertDocumentSource) insertDatetime jen nové inzeráty — čas úpravy entita nemá
firemní profil (Comcat\…\AdvertDocumentSource) insertDatetime jen nové profily
vlákno diskuze (MessageDocumentSource) insertDatetime a level = 1 jen nová vlákna; odpovědi se neindexují

Úpravy těch zdrojů, které čas úpravy nemají, propisuje hook completedSave hned po commitu; platby a expirace inzerátů a profilů cílené triggery. U nich je hlavní přínos syncu úklid osiřelých dokumentů — například vláken smazaných stromovým deleteNode(), které hook z indexu nesmaže.

Změna mimo hook se do indexu dostane až nočním reindexem

updateColumn() nebo raw SQL nad inzerátem, profilem či vláknem sync nevidí, protože nemění čas vložení. Ve vyhledávání pak až do noci zůstane stará hodnota. Kde na tom záleží, zavolej po zápisu EsEntityIndexer::index($source, $ids) s dotčenými id.

Zápisy z administrace#

Admin managery nemají hooky completedSave/completedDelete a čas úpravy na admin cestě většinou nikdo neposune, takže by úpravu správce sync neviděl. Indexované entity proto v admin manageru používají ElasticReindexTrait (článek, prodejní karta, inzerát, firemní profil, příspěvek diskuze). Trait přetěžuje save(), updateColumn(), updateColumns() a delete() a po úspěchu zavolá EsEntityIndexer::index() s dotčenými id. Smazaný nebo deaktivovaný záznam indexer sám z indexu odebere. Výpadek ES se jen zaloguje (kanál elasticsearch.adminReindex), zápis v databázi zůstane.

Volitelný touchForEsWatermark() posune před uložením čas úpravy (setUpdateTime(), setUpdateDatetime()), aby změnu viděl i sync jako druhou pojistku.

Vlastní save() v admin manageru reindex vypne

Metoda třídy má přednost před metodou traitu. Kdo si save() přepíše, musí po úspěchu zavolat esReindexBestEffort([$id]) sám, jinak se jeho uložení do indexu propíše až nočním reindexem.

Přepočet cen inzerátů (AdvertManager::recountPrices()) indexuje jen inzeráty se změněnou cenou; od 200 změn (RECOUNT_FULL_REINDEX_FROM) spustí plný reindex.

Co hlídá monitoring#

Kontrola Kdy je to problém
dostupnost a health clusteru stav jiný než green
drift počtu dokumentů proti databázi mimo toleranci (5 %, minimálně 5 dokumentů)
stáří posledního úspěšného reindexu přes 26 hodin

Nálezy se před odesláním deduplikují (Redis, 6 hodin), aby se stejný problém neposílal každou čtvrthodinu.

Kontroly dělá EsMonitor a e-mail sám neposílá (Core nezná Api EmailManager). Vrací report; kontrolu a případný e-mail spojuje App\UI\Api\System\Models\Managers\ElasticWatchManager. Chyba jedné kontroly nezastaví ostatní a sama se vykáže jako nález.

Výpadek#

Při výpadku hledání vrátí výsledky z databáze, ne chybu

Circuit breaker (Redis, klíč s 60s platností) po opakovaném selhání přestane ES oslovovat a nečeká se na timeouty. Volající se propadne na MySQL fulltext. Znamená to ale, že „našlo se míň“ může znamenat „index neběží“ — proto ten monitoring.

Odpověď 4xx breaker NEOTEVÍRÁ

Chybějící index před bootstrapem nebo vadný dotaz nejsou výpadek clusteru a nesmí vypnout hledání všem. Breaker se otevírá jen na skutečné výpadkové chyby.

Chyba databáze při indexaci breaker taky neotevírá

Zápis z hooku má tři fáze: bootstrap (ES), sestavení dokumentů ze zdroje (DB, ORM) a bulk (ES). Breaker otevírá jen chyba ES fáze. Výjimka v fetchFresh() nebo buildDocuments() se zaloguje a id jdou do fronty nedoručených zápisů; kdyby otevřela breaker, přišly by všechny zdroje téhož prefixu na 60 s o hledání kvůli chybě, která s ES nesouvisí.

Fronta nedoručených zápisů#

Zápis, který neproběhl (otevřený breaker, výpadek, selhaná položka bulku), zařadí id do fronty v Redisu. Sync ji přečte, id přeindexuje a po úspěchu je z fronty odebere. Bez ní by se při výpadku ztratily zápisy, které neposunou čas úpravy (updateColumn, raw SQL, zpráva diskuze bez sloupce úprav), až do nočního reindexu. Chyba Redisu se jen zaloguje.

Klíč (es:<indexPrefix>:…) Obsah
pendingz:<zdroj> ZSET id, skóre = generace posledního nedoručeného zápisu
pendingseq:<zdroj> čítač generací (INCR)
pending:<zdroj> starý tvar (SET bez generace); jen se dočítá, nic nového do něj nejde

Potvrzení odebere id jen tehdy, když nese pořád generaci, kterou sync přečetl. Novější nedoručený zápis stejného id tak úspěch staršího syncu přežije.

Frontu a čítač maž jen spolu

Smazaný samotný čítač začne generace znovu od 1. Nový zápis pak může dostat stejné číslo jako id v rozpracovaném snímku syncu a jeho potvrzení ho smaže, takže se do indexu nedostane. Při ručním úklidu smaž pendingz:<zdroj> i pendingseq:<zdroj>, nebo ani jeden.

Bez Redisu breaker nefunguje

Degradace pak jde jen přes krátké timeouty klienta — pomaleji a pro každý request znovu.

Meze#

Mez Hodnota Co se stane při překročení
okno from + size 10 000 hlubší stránka → fallback na DB cestu (ta tam dosáhne)
id-set fáze 1 10 000 přebytek se zaloguje jako overflow
buckety faset 10 000 neúplné počty → fallback na přesnou SQL agregaci
minimální délka dotazu 2 znaky ES se vůbec nevolá

Fasetové počty odmítnou i nepřesnost, nejen nedostupnost

Když počet zasažených dokumentů nesedí s počtem viditelných id, vrátí se nedostupnost a jde se na SQL. Neúplné počty by tiše lhaly, a to je horší než pomalejší dotaz.

10. Přidání nového zdroje#

  1. Zdroj dokumentů do app/UI/Api/{Modul}/Models/Search/ — vzorem je nejbližší existující (ArticleDocumentSource pro článek se sloupci, AdvertDocumentSource pro entitu s texty v parametrech).
  2. Zapojit v elasticsearch.neon na obou místech — esSearcher i esMonitor.
  3. Api presenter: použít SearchTrait a doplnit tři háčky — getEsSourceKey(), getEsSearcher(), getFulltextFallbackFields().
  4. SQL privilegium :Api:{Modul}:{Entita}:search a maska routy se sentinelem.
  5. Frontový stack: search() v repository, mapperu, service a manageru; v presenteru větev pro hledání.
  6. Šablony: vypsat úryvek přes |noescape, do atributů holý text.
  7. Admin manager entity: use ElasticReindexTrait; a v konstruktoru EsEntityIndexer $esIndexer a DocumentSource $esDocumentSource, viz Zápisy z administrace.
  8. Reindex a teprve pak přidat klíč do enabledSources.

Fallbacková pole musí odpovídat indexovaným

getFulltextFallbackFields() je MySQL varianta téhož hledání. Když se rozejde s getSearchFields(), dostane uživatel při výpadku ES jiné výsledky — a nikdo si toho nevšimne, protože obě cesty „fungují“.

U entity s texty v parametrech se indexuje RENDEROVANÁ hodnota

Hodnoty jsou v databázi surové a náhradu odkazů a zakázaných slov dělá až render. Zdroj je proto musí pustit stejnou cestou jako šablona — jinak do indexu (a odtud do úryvku) uniknou slova, která render skrývá.

HTML z editoru indexuj jako prostý text

Perex a obsah článku i popis inzerátu jsou HTML. Zdroj je pouští přes strip_tags() a html_entity_decode() (u inzerátu ještě s mezerou před každou značkou, aby konec</p><p>Začátek nesplynulo v jedno slovo). Indexované s markupem by se našly i názvy značek (dotaz „strong“ dá shodu) a úryvek by vypsal &lt;p&gt; jako viditelný text, protože EsHighlighter escapuje celý fragment. Každá změna tvaru dokumentu potřebuje úplný reindex zdroje, jinak index drží starý text.

11. Testy a ověřování#

Vrstva Kde
jednotkové testy tests/Unit/Integrations/Elasticsearch/
ruční harnessy proti živému ES tests/Manual/{Modul}/f6_4_*.php
prokliky v prohlížeči var/ds-baseline/tools/pages-es.json

Tvrzení o pořadí nad jedním prvkem nedokazuje nic

Nad jednoprvkovým výsledkem projde i obrácené pořadí. Když data na důkaz nestačí, patří do harnessu tripwire — tvrzení, které spadne, jakmile dat přibude — ne tiché vynechání.

Ani nad dvěma prvky, když mají stejné skóre

Při shodném skóre určuje pořadí vnitřní tie-break, ne relevance. Před testem pořadí si skóre vypište a vyberte dotaz, který shodu rozdělí. Pro ověření frontu je naopak správný dotaz ten, jehož pořadí je opačné než výchozí databázové — jen tak se pozná ES cesta od databázové.

Ke každé opravě protipokus

Vrácená vada musí harness shodit. Bez toho se neví, jestli test testuje.

Nastavení cookies