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í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í.
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 (& → &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:
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#
- Zdroj dokumentů do
app/UI/Api/{Modul}/Models/Search/— vzorem je nejbližší existující (ArticleDocumentSourcepro článek se sloupci,AdvertDocumentSourcepro entitu s texty v parametrech). - Zapojit v
elasticsearch.neonna obou místech —esSearcheriesMonitor. - Api presenter: použít
SearchTraita doplnit tři háčky —getEsSourceKey(),getEsSearcher(),getFulltextFallbackFields(). - SQL privilegium
:Api:{Modul}:{Entita}:searcha maska routy se sentinelem. - Frontový stack:
search()v repository, mapperu, service a manageru; v presenteru větev pro hledání. - Šablony: vypsat úryvek přes
|noescape, do atributů holý text. - 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.