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.

Tvar odpovědi#

Shodný s getGetAllData() 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.

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.

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ístechesSearcher 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í.

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.

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.

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
Inkrementální sync /cron/{modul}/elastic/sync à 10 minut doindexuje změněné od značky (5 min překryv) + úklid osiřelých
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.

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.

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.

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. 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á.

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.