Nastaveni prezije nasazeni: seed/records.json se pri prazdnem ulozisti nacte misto ukazkovych dat (zive uloziste se nikdy neprepisuje). Soubor nese soucasny stav produkce (firmy s provozovatelem, role, typ ticketu, akce, widgety, skupiny, rozlozeni). Novy GET /api/admin/export a skript npm run seed:export pro dalsi exporty, Dockerfile slozku kopiruje. Vykonnostni testy (npm run test:perf) nad 200 firmami a 10 000 tickety a zatezovy skript (npm run load) proti bezici instanci vcetne davky udalosti na webhook. Mereni odhalilo strop workeru: po obsazeni vsech mist spal sekundu, takze fronta odbavila nejvys 4 behy za sekundu. Ted ceka na prvni dokonceny beh: 500 udalosti za 1,3 s (395 behu/s). Strop posluchacu streamu zvednut na 2 000. Dialogy: prekryv modalu a menu v portalu bez backdrop-blur, tecka Zive pulzuje jen pri navazovani spojeni - rozmazani cele obrazovky pod trvalou animaci sekalo video vedle portalu. Bublina udalosti drzi 0,5 s. Dokumentace 14, 19, 20, 22, 04, 01, 03, 15 a 99 aktualizovana. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
18 KiB
Kapacita: 200 firem, 10 automatizací, 15 lidí na firmu
Otázka zněla: zvládne to současný stav s Postgresem, a co to bude chtít za server. Odpověď je změřená, ne odhadnutá.
Krátce: v současném stavu ne. Runtime běží, ale datová vrstva je psaná na stovky až tisíce ticketů, ne na miliony. Co konkrétně to zastaví a v jakém pořadí to opravit, je níž.
Co se měřilo
Jeden proces, režim souboru, tickety se posílaly přes příjem událostí. Měřeno na vývojovém stroji, tedy horní hranice latence, ne serveru.
| Ticketů | Výpis seznamu | Statistiky řešitelů | Soubor |
|---|---|---|---|
| 103 | 1,9 ms | 1,6 ms | 153 kB |
| 503 | 6,1 ms | 12,1 ms | 729 kB |
| 1 003 | 14,2 ms | 2,7 ms | 1,4 MB |
| 2 003 | 18,8 ms | 21,1 ms | 2,9 MB |
| 5 003 | 52,9 ms | 1,7 ms | 7,2 MB |
Příjem událostí: 130 až 190 událostí za sekundu včetně celého kola HTTP, uložení a zápisu do logu.
Z toho plyne:
- jeden ticket = 1,4 kB v uložišti,
- výpis roste lineárně, zhruba 10 mikrosekund na ticket, protože se filtruje pole v paměti,
- v režimu souboru se při každé změně přepisuje celý soubor. Při pěti tisících ticketech je to 7 MB zápisu kvůli jednomu komentáři. S Postgresem to odpadá, tam je to jeden řádek.
Kolik toho bude
200 firem, 10 automatizací každá, 15 lidí. Odhad provozu:
| Veličina | Výpočet | Za den | Za měsíc |
|---|---|---|---|
| Běhy automatizací | 2 000 automatizací, 100 běhů denně | 200 000 | 6 mil. |
| Kroky | 3 kroky na běh | 600 000 | 18 mil. |
| Tickety | 200 firem, 200 denně | 40 000 | 1,2 mil. |
| Řádky logu | 5 na ticket plus kroky | ~800 000 | 24 mil. |
Špička není průměr. 7 kroků za sekundu v průměru znamená ve špičce klidně 100 za sekundu, protože e-shopy neposílají objednávky rovnoměrně.
Co to v současném stavu zastaví
1. Všechna data jsou v paměti procesu
withMirror drží tickety, automatizace a incidenty v poli a při startu je
všechny načte. Při 1,2 milionu ticketů měsíčně je to 1,7 GB jen na tickety,
a to bez logu. Aplikace se nenastartuje.
Opravit se musí tak, že se tickety čtou dotazem s indexem a stránkováním,
ne z pole. withMirror má zůstat na tom, co je malé a čte se pořád (rozložení
dashboardu), ne na provozních datech.
2. Výpis prochází všechno
listTickets filtruje pole přes všechny firmy. Změřeno 10 mikrosekund na
ticket, takže při milionu ticketů je jeden výpis 10 sekund. Musí to být
WHERE tenant_id = ... AND status = ... ORDER BY updated_at LIMIT 50 nad
indexem, tedy jednotky milisekund bez ohledu na objem.
3. Log je uvnitř ticketu
Ticket si nese trace i events jako součást jednoho záznamu. Každý řádek
logu tím přepisuje celý ticket i s celým dosavadním logem. Po padesáti krocích
je to padesát zápisů, každý delší než ten předchozí.
Log patří do vlastní tabulky s cizím klíčem na ticket a s retencí. Při 24 milionech řádků měsíčně a plné odpovědi služby u každého je to řádově stovky GB za měsíc, takže retence není detail, ale podmínka provozu.
4. Runtime běží v požadavku
runFlow vykoná strom rovnou v HTTP požadavku. U ruční akce je to správně,
u webhooku ne:
- odesílatel čeká, než doběhnou všechny kroky,
- při pádu procesu se rozdělaný běh ztratí, protože není kde by byl zapsaný,
- není retry, není omezení souběhu na jednu službu, nic to nebrzdí.
5. Statistiky se počítají celé
getAgentStats projde všechny tickety firmy při každém zobrazení widgetu.
Při desítkách tisíc na firmu to jde, při milionu ne. Patří to do agregace
počítané dávkově, nebo aspoň do dotazu s GROUP BY nad indexem.
6. Jedna instance
Živý stream (SSE), mezipaměť widgetů nad konektory i kopie konfigurace
v paměti jsou vázané na proces. Při dvou instancích za load balancerem se
rozejdou. Řeší to LISTEN/NOTIFY na obnovu kopií a sdílený kanál na stream.
Konektory nejsou v naší moci
Za rychlost a výsledek cizí služby neručíme, a proto se s tím musí počítat v návrhu, ne v provozu:
| Riziko | Co s tím |
|---|---|
| Služba odpovídá pomalu | Timeout na krok, ne na celý běh. Běh se uspí a pokračuje. |
| Služba je chvíli mimo | Opakování s rostoucí prodlevou, ne hned a ne donekonečna. |
| Služba je mimo dlouho | Vypnout ji po sérii chyb a nezkoušet každý běh znovu, ať netrpí ostatní. |
| Služba má limit volání | Strop souběžných volání na dvojici firma a služba, ne globálně. |
| Služba odpoví dvakrát jinak | Klíč proti dvojímu provedení u kroku, aby se nevystavila druhá faktura. |
| Služba je pomalá jen pro jednu firmu | Fronta po firmách, aby jedna firma nezablokovala ostatní. |
Timeout a rozlišení "zkusit znovu" a "marné" už v src/runtime/scripts/http.ts je,
klíč proti dvojímu provedení taky. Chybí to, co je nad tím: fronta, opakování
a vypínání služby po sérii chyb.
Co je potřeba udělat, v pořadí
- Tickety, běhy a log do Postgresu jako tabulky, ne jako JSON v paměti.
Indexy na
(tenant_id, updated_at),(tenant_id, external_id)unikátní,(tenant_id, assignee_id, status). Stránkování na všech výpisech. - Log a události zvlášť od ticketu, s retencí. Bez toho to zaroste.
- Fronta běhů. Tabulka
run_queue, výběr přesSELECT ... FOR UPDATE SKIP LOCKED, worker jako druhý proces téhož obrazu. Webhook jen zapíše událost a odpoví, běh se stane na pozadí. - Opakování a vypínání služby kolem kroku.
- Agregace statistik dávkově, ne při každém zobrazení.
LISTEN/NOTIFYna obnovu kopií v paměti a sdílený kanál na živý stream.
Body 1 až 3 jsou podmínka, aby to vůbec šlo pustit. Zbytek je o tom, aby to bylo použitelné.
Kolik serveru to bude chtít
Po těch úpravách, pro zadaných 200 firem:
| Část | Kolik | Proč |
|---|---|---|
| Web a API | 2 instance, 1 vCPU a 1 GB každá | Požadavky jsou krátké, jde hlavně o dostupnost při restartu. |
| Worker běhů | 2 instance, 1 vCPU a 512 MB každá | Kroky čekají na cizí službu, procesor se skoro nepoužije. Jeden proces zvládne stovky souběžných kroků. |
| Postgres | 4 vCPU, 8 GB RAM, 200 GB disku | 10 až 20 zápisů za sekundu v průměru je málo, disk sežere log. |
| Celkem | ~8 vCPU, ~11 GB RAM |
Kritické číslo není procesor, ale disk pod databází a retence logu. S plnou odpovědí služby u každého kroku je to zhruba 20 GB měsíčně na každých 20 milionů řádků, takže 200 GB je půl roku provozu. Buď se odpovědi po měsíci zahazují a nechá se jen shrnutí, nebo se počítá s tím, že disk poroste.
Škálování je vodorovné: víc událostí znamená víc workerů, ne větší stroj. Jediné, co se škáluje svisle, je databáze, a ta má u tohoto objemu ještě velkou rezervu.
Co funguje už teď
Aby to nevypadalo hůř, než to je: runtime běží. Webhook vykoná strom, akce na ticketu vykoná strom, kroky si předávají výstupy, podmínka větví a celý průběh se zapíše do logu ticketu i s tím, co která služba vrátila. Na jednu firmu s desítkami ticketů denně je současný stav použitelný. Na 200 firem ne.
Měření 2026-09-09
Dvě sady měření, obě spustitelné znovu:
npm run test:perf- výkonové testy v procesu (tests/perf/, konfiguracevitest.perf.config.ts). Postaví si v paměti 200 firem, 1 000 účtů, 50 vlastních rolí a 10 000 ticketů a měří jednotlivé funkce bez HTTP. Každý scénář se měří třikrát a bere se medián; rozpočet je 4x až 5x nad hodnotou z tohoto měření, aby test nepadal na pomalejším stroji, ale chytil řádový propad. Donpm testtyto testy nepatří (vitest.config.ts je vylučuje), trvají zhruba 12 s.npm run load- zátěžový test přes HTTP (scripts/load-test.mjs). Virtuální uživatelé dělají to, co prohlížeč po otevření přehledu: práva, seznam ticketů, detail, souhrn, data widgetů výchozího rozložení, lidé. Každý desátý drží otevřený živý stream (SSE). ProměnnéLOAD_URL,LOAD_USERS,LOAD_DURATION_SEC,LOAD_EMAIL,LOAD_PASSWORD;LOAD_WEBHOOK_TOKEN(aLOAD_WEBHOOK_EVENTS, výchozí 2 000) přidá dávku na/webhook/<token>a čeká, až fronta doběhne;LOAD_START_LOCAL=1spustínode dist/index.jsna volném portu s dočasnýmDATA_DIR(režim souboru) a na konci ho ukončí. Výsledek jde na terminál a doload-report.jsonv pracovním adresáři (je v.gitignore). Přihlášení je jedno pro všechny uživatele, protože server pouští z jedné adresy 20 přihlášení za čtvrt hodiny.
Měřeno na vývojovém stroji (Windows, Node 22), tedy horní hranice latence.
V procesu (npm run test:perf)
| Scénář | Medián | Rozpočet | Poznámka |
|---|---|---|---|
Založení 10 000 ticketů (createTicket + intakeEvent) |
337 ms | - | 29 700 ticketů/s včetně zápisu do úložiště |
listTickets jedné firmy nad 10 000 tickety, 100x |
17 ms | 100 ms | 0,17 ms na volání, filtr jde přes celé pole |
listTickets s filtrem stav+kanál+řešitel a stránkou, 100x |
14 ms | 100 ms | |
getWorkload, 100x |
20 ms | 100 ms | |
getAgentStats za 30 dní, 100x |
24 ms | 120 ms | |
intakeEvent 1 000 událostí na existující tickety |
13 ms | 80 ms | 78 500 událostí/s, polovina jako opakování |
findByExternalId, 10 000x |
12 ms | 60 ms | index, ne pole |
permissionsOf studené, 1 997 členství |
11 ms | 60 ms | po zahození cache |
hasPermission, 100 000x |
24 ms | 120 ms | 4,2 mil. volání/s z cache |
accessFor, 10 000x |
49 ms | 250 ms | 205 000 volání/s |
accessFor správce platformy přes 200 firem, 1 000x |
91 ms | 400 ms | 0,09 ms na volání, řadí 200 firem localeCompare |
runFlow 100 běhů (podmínka + smyčka 200 x 4 kroky) |
229 ms | 1 000 ms | 350 000 kroků/s, kroky jsou atrapy |
enqueue 5 000 běhů |
84 ms | 1 000 ms | 59 000 běhů/s |
claimBatch + markDone 5 000 běhů po 4 |
486 ms | 2 000 ms | 10 300 běhů/s, worker bez práce |
enqueue s klíčem proti dvojímu zařazení nad 5 000 čekajícími, 1 000x |
32 ms | 150 ms | lineární hledání klíče |
POST /widget-data s 24 dlaždicemi nad 10 000 tickety |
6,6 ms | 30 ms | přes supertest, včetně autorizace |
POST /widget-data výchozí rozložení (2 dlaždice) |
2,7 ms | 15 ms | |
publish 1 000 událostí pro 500 posluchačů |
11 ms | 60 ms | 0,02 us na doručení, cena na klienta neroste |
Co z toho plyne:
- Datová vrstva v paměti je při 10 000 ticketech rychlá: výpis 0,17 ms, přehled s 24 dlaždicemi 7 ms. Lineární průchod polem je při tomto objemu levný, protože filtr na firmu je jedno porovnání řetězce na ticket. Problém z kapitoly výše (miliony ticketů) tím nezmizel, jen začíná o dva řády dál, než se odhadovalo.
- Práva jsou zadarmo: cache za dvojici účet a firma drží 5 s a studený
výpočet je 6 us.
accessForse počítá jednou za request (attachAccess) a stojí 5 us, u správce platformy 90 us kvůli řazení 200 firem. - Fronta a executor stíhají řádově tisíce běhů za sekundu, když kroky nečekají na cizí službu. Skutečný strop je jinde, viz zátěž níže.
- Sběrnice událostí doručí 1 000 událostí 500 klientům za 11 ms. Při 500
posluchačích ale Node vypíše
MaxListenersExceededWarning, protožebus.tsnastavuje strop 200. Je to jen varování, doručení funguje; při větším počtu spojení je třeba strop zvednout nebo klienty sdružit.
Zátěž přes HTTP (npm run load)
LOAD_START_LOCAL=1 LOAD_USERS=100 LOAD_DURATION_SEC=20 LOAD_WEBHOOK_TOKEN=... npm run load,
sestavená aplikace v režimu souboru s ukázkovými daty (SEED_DEMO=1, tedy
desítky ticketů, ne tisíce). Sto virtuálních uživatelů bez prodlevy mezi
požadavky, 10 otevřených streamů, bez databáze, jeden proces.
| Endpoint | Požadavků | Chyb | p50 ms | p95 ms | p99 ms | max ms |
|---|---|---|---|---|---|---|
GET /api/dashboard/access |
9 500 | 0 | 31,7 | 35,0 | 42,1 | 69,1 |
GET /api/dashboard/tickets?limit=50 |
9 500 | 0 | 34,1 | 38,3 | 41,9 | 76,4 |
GET /api/dashboard/tickets/:id |
9 500 | 0 | 35,4 | 38,8 | 42,5 | 59,5 |
GET /api/dashboard/summary |
9 500 | 0 | 36,6 | 40,0 | 43,1 | 49,2 |
POST /api/dashboard/widget-data |
9 500 | 0 | 39,1 | 42,1 | 44,9 | 49,5 |
GET /api/dashboard/people |
9 500 | 0 | 35,5 | 39,7 | 41,3 | 45,1 |
POST /webhook/:token (dávka po běhu) |
2 000 | 0 | 16,0 | 20,3 | 24,9 | 28,8 |
Celkem 57 000 požadavků za 20 s = 2 830 req/s, 0 chyb. SSE: 10 spojení, 41 730 přijatých událostí, 0 chyb. Paměť serveru (RSS): nejvýš 198 MB, na konci 116 MB.
Co ta čísla znamenají:
- Latence 32 až 39 ms při 100 souběžných uživatelích je čekání ve frontě jednoho vlákna, ne práce: 100 uživatelů / 2 830 req/s dává 35 ms. Server sám stráví na požadavku zhruba 0,35 ms včetně JWT, výpočtu práv a logu na stdout. S 10 uživateli by p50 bylo pod 5 ms.
- Rozdíl mezi endpointy je malý, protože ukázková data jsou malá. Objemová stránka je v tabulce výše: seznam nad 10 000 tickety přidá 0,17 ms, přehled s 24 dlaždicemi 7 ms.
/healthpaměť nevystavuje; skript ji čte ze systému jen u lokálně spuštěného serveru. 198 MB při 100 uživatelích a 10 streamech je v pořádku, špička byla během dávky webhooku (zápis ticketů do souboru).
Dávka 2 000 událostí na webhook: přijato 2 000 za 1,7 s (server odpoví
202 a zařadí do fronty), ale fronta se nevyprázdnila ani za 180 s:
hotovo 724, tedy 4 běhy za sekundu. To není úložiště - v procesu zvládá
claimBatch + markDone 10 000 běhů/s - ale smyčka workeru
(src/runtime/worker.ts, funkce loop): když jsou všechna čtyři místa
(CONCURRENCY) obsazená, claimBatch vrátí prázdno a smyčka spí IDLE_MS
(1 s), i když se místo uvolní za pár milisekund. Strop je proto
CONCURRENCY / IDLE_MS = 4 běhy/s bez ohledu na to, jak rychlé kroky jsou.
Při 200 firmách a 100 bězích denně na automatizaci (7 za sekundu v průměru,
100 ve špičce) fronta poroste. Oprava je čekat na uvolnění místa
(Promise.race nad běžícími běhy), ne na časovač; spát jen když je fronta
opravdu prázdná.
Po opravě (tentýž den): worker při plném bazénu čeká na první dokončený
běh. Kontrolní měření s dávkou 500 událostí (LOAD_USERS=20,
LOAD_DURATION_SEC=10): přijato za 251 ms, fronta prázdná za 1,3 s, tedy
395 běhů za sekundu (hotovo 500, selhalo 0), při souběžných 2 825
dotazech za sekundu od dvaceti uživatelů. Strop už neurčuje smyčka, ale
délka kroků a CONCURRENCY.
Co se změní na Postgresu
- Zápis ticketu je jeden řádek, ne přepis celého
ticket.json. Špička paměti při dávce webhooku (198 MB) tím spadne, protožesnapshot.tskopíruje při každémsavecelé pole záznamů, i když zápis na disk je sloučený. listTicketspřestane být průchod polem v paměti a stane se dotazem s indexem: 0,17 ms nad 10 000 tickety se vymění za jednotky milisekund bez ohledu na objem, tedy u malých dat pomaleji, u milionů jedině možné.claimBatchmusí jít přesSELECT ... FOR UPDATE SKIP LOCKED(víc workerů). Strop 4 běhy/s ze smyčky workeru se tím neopraví, ten je v kódu smyčky, ne v úložišti.- Práva, přístup a sběrnice se nemění: kopie konfigurace v paměti zůstává,
jen se obnovuje přes
LISTEN/NOTIFY.