# 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í 1. **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. 2. **Log a události zvlášť** od ticketu, s retencí. Bez toho to zaroste. 3. **Fronta běhů.** Tabulka `run_queue`, výběr přes `SELECT ... 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í. 4. **Opakování a vypínání služby** kolem kroku. 5. **Agregace statistik** dávkově, ne při každém zobrazení. 6. **`LISTEN/NOTIFY`** na 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/`, konfigurace `vitest.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. Do `npm test` tyto 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` (a `LOAD_WEBHOOK_EVENTS`, výchozí 2 000) přidá dávku na `/webhook/` a čeká, až fronta doběhne; `LOAD_START_LOCAL=1` spustí `node dist/index.js` na volném portu s dočasným `DATA_DIR` (režim souboru) a na konci ho ukončí. Výsledek jde na terminál a do `load-report.json` v 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. `accessFor` se 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že `bus.ts` nastavuje 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. - `/health` paměť 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že `snapshot.ts` kopíruje při každém `save` celé pole záznamů, i když zápis na disk je sloučený. - `listTickets` př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é. - `claimBatch` musí jít přes `SELECT ... 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`.