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;.
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.
Č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()):
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:
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#
- 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. - Admin manager entity:
use ElasticReindexTrait;a v konstruktoruEsEntityIndexer $esIndexeraDocumentSource $esDocumentSource, viz Zápisy z administrace. - 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 <p> 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.