# Scale Lab 2026: metodologija

**Autor:** FIN STUDIO
**Datum mjerenja:** 24. rujna 2026. (`generatedAt` u `results.json`: 2026-09-24T11:45:49Z)
**Licenca:** CC BY 4.0. Slobodno koristite, prerađujte i citirajte uz navođenje izvora: „FIN STUDIO, Scale Lab 2026”.
**Podaci:** `results.json`, `results.csv`, `plans/<veličina>-<upit>.txt`
**Kod za reprodukciju:** `scripts/scale-lab/` (`schema.sql`, `generate.sql`, `indexes.sql`, `run.mjs`, `README.md`)

## Što smo htjeli provjeriti

Tvrdnja koju testiramo: **veličina kataloga je arhitektonska odluka, a ne ograničenje platforme.**
Na istom računalu, s istom shemom i istim indeksima, izmjerili smo kako se ponašaju upiti koje
webshop stvarno izvršava kada katalog naraste s 10.000 na 100.000, 1.000.000 i 5.000.000 proizvoda.
Ovo mjerenje ne uspoređuje platforme: mjerena je samo PostgreSQL shema opisana niže. Pokazuje koji upiti
ostaju ravni kada katalog raste, a koji traže drugačiji dizajn.

## Rezultati ukratko

Latencija u milisekundama, **p50 / p95**, 300 mjerenih iteracija po upitu (nakon 20 zagrijavanja).
`n/p` = nije primjenjivo (premalo proizvoda za tu dubinu stranice).

| Upit | 10.000 | 100.000 | 1.000.000 | 5.000.000 |
|---|---:|---:|---:|---:|
| Q0 `SELECT 1` (donja granica mreže i drivera) | 0,537 / 0,82 | 0,417 / 0,572 | 0,402 / 0,576 | 0,466 / 0,658 |
| Q1 stranica proizvoda (slug + brend + kategorija) | 0,687 / 0,998 | 0,706 / 1,176 | 0,681 / 0,987 | 0,715 / 1,019 |
| Q2 kategorija, 1. stranica (24, po popularnosti) | 0,706 / 1,003 | 0,709 / 1,034 | 0,759 / 1,098 | 0,858 / 1,218 |
| Q3 duboka paginacija ~str. 200, keyset | 0,645 / 1,059 | 0,589 / 0,815 | 0,72 / 1,072 | 0,69 / 0,973 |
| Q3 ~str. 200, OFFSET (antiobrazac) | 1,672 / 3,292 | 2,373 / 3,415 | 4,143 / 5,501 | 4,773 / 6,318 |
| Q3 ~str. 2000, keyset | n/p | 0,558 / 0,774 | 0,569 / 0,762 | 0,691 / 0,984 |
| Q3 ~str. 2000, OFFSET (antiobrazac) | n/p | 16,311 / 27,305 | 31,464 / 42,728 | 40,294 / 50,592 |
| Q4 filtriranje (podstablo + brendovi + cijena + zaliha + boja) | 0,826 / 1,134 | 1,24 / 2,896 | 5,989 / 33,391 | 16,046 / 164,03 |
| Q5 brojači faceta (top 20 brendova) | 1,233 / 1,749 | 2,644 / 5,739 | 18,647 / 38,606 | 102,491 / 236,328 |
| Q6 pretraga, poredak po `ts_rank` | 0,835 / 1,468 | 0,951 / 2,62 | 2,684 / 21,721 | 12,558 / 215,22 |
| Q6 pretraga, poredak po popularnosti | 0,822 / 1,354 | 0,943 / 3,964 | 2,307 / 32,376 | 4,253 / 81,232 |
| Q6 broj rezultata, ograničen na „1000+” | 0,644 / 1,061 | 0,801 / 1,528 | 2,506 / 4,862 | 7,576 / 32,24 |
| Q7 B2B cijene (24 proizvoda × grupa kupca) | 0,639 / 0,885 | 0,694 / 0,944 | 0,741 / 1,049 | 0,923 / 1,28 |

| Sinkronizacija i učitavanje | 10.000 | 100.000 | 1.000.000 | 5.000.000 |
|---|---:|---:|---:|---:|
| Q8 paket od 1.000 izmjena cijene/zalihe, p50 / p95 (ms) | 32,833 / 40,702 | 52,073 / 59,884 | 38,27 / 48,606 | 109,345 / 137,335 |
| Q8 od toga UPDATE p50 / COMMIT p50 (ms) | 23,564 / 7,648 | 43,917 / 7,609 | 30,56 / 7,054 | 55,72 / 52,895 |
| Q8 redaka u sekundi (pri p50) | 30.457 | 19.204 | 26.130 | 9.145 |
| Q9 početni unos, proizvoda u sekundi | 68.493 | 75.815 | 71.190 | 63.741 |
| Ukupno učitavanje (s) | 0,744 | 2,94 | 21,195 | 130,73 |
| od toga generiranje / indeksi / VACUUM ANALYZE (s) | 0,193 / 0,208 / 0,259 | 1,405 / 0,914 / 0,516 | 14,423 / 5,96 / 0,54 | 80,617 / 47,409 / 1,203 |
| Tablice / indeksi / ukupno (MiB) | 3,7 / 3,1 / 6,8 | 33,1 / 24,7 / 57,8 | 326,7 / 231,7 / 558,4 | 1635,9 / 1135,7 / 2771,6 |
| Kategorije / brendovi / B2B cijene (redaka) | 100 / 5.000 / 2.599 | 1.000 / 5.000 / 24.729 | 2.000 / 5.000 / 250.326 | 2.000 / 5.000 / 1.250.416 |

### Što brojke pokazuju

- **Upiti koji idu točno po indeksu gotovo ne ovise o veličini kataloga.** Stranica proizvoda (Q1), prva
  stranica kategorije (Q2), keyset paginacija (Q3) i B2B cijene (Q7) ostaju ispod 1 ms p50 od 10 tisuća
  do 5 milijuna proizvoda (500 puta više proizvoda; p50 najviše +0,3 ms, Q7 s 0,639 na 0,923 ms).
  Većina tog vremena je sam povratni put do baze (Q0).
- **OFFSET paginacija raste s dubinom i veličinom.** Na ~2000. stranici OFFSET je na 5 milijuna
  proizvoda 40,294 ms p50, a keyset za istu stranicu 0,691 ms. Keyset i OFFSET vratili su iste retke
  u provjeri na ~200. stranici (prvi kursor, svaka veličina); na ~2000. stranici to nije zasebno provjereno.
- **Filtriranje, brojači faceta i pretraga su mjesta gdje veličina postaje arhitektonska odluka.**
  Do milijun proizvoda ti su upiti ispod 20 ms p50. Na 5 milijuna brojači faceta (Q5) su 102,491 ms p50,
  a rangirana pretraga (Q6) ima p95 215,22 ms. S istom shemom to više nije dovoljno za interaktivni
  filtar. Rješenje je promjena arhitekture, ne platforme: tipizirani stupci za najčešće facete,
  unaprijed izračunati brojači ili zaseban pretraživač. Ta rješenja **nisu** primijenjena u ovom mjerenju.

## Što je izmjereno

| Upit | Opis |
|---|---|
| Q0 | `SELECT 1`: donja granica povratnog puta (driver i Docker port forwarding) |
| Q1 | Proizvod po `slug`, spojen s brendom i kategorijom |
| Q2 | Prva stranica kategorije: 24 aktivna proizvoda, poredak po popularnosti |
| Q3 | Duboka paginacija cijelog kataloga po popularnosti: keyset (kursor `(popularity, id)`) i OFFSET za istu dubinu. Stranica slučajno 150–250 i 1500–2500 |
| Q4 | Podstablo kategorije (grupa 2. razine), 2–3 brenda, raspon cijene, na zalihi, `attrs @> {"color": ...}`, najviše 24 retka (filtar je uzak: prosječno 2–12 vraćenih redaka, vidi `rowsAvg`) |
| Q5 | Broj proizvoda po brendu za isti filtar kao Q4, **bez** filtra brenda (uobičajena semantika faceta), top 20 |
| Q6 | Full-text pretraga s 1–2 riječi (`plainto_tsquery('simple', unaccent(...))`): top 24 po `ts_rank`; varijanta po popularnosti; brojanje zaustavljeno na 1.001 („1000+”) umjesto točnog broja |
| Q7 | Efektivna cijena za 24 proizvoda i grupu kupca: `coalesce(ugovorna cijena, cjenik)`; 4 od 24 proizvoda sigurno imaju ugovornu cijenu |
| Q8 | `UPDATE ... FROM unnest(...)` cijene i zalihe za 1.000 slučajnih proizvoda u jednoj transakciji; 5 paketa zagrijavanja + 20 mjerenih |
| Q9 | Brzina početnog unosa: broj proizvoda / trajanje koraka `products` u `generate.sql` |

Parametri se biraju iz stvarno učitanih podataka generatorom slučajnih brojeva sa sjemenom (mulberry32,
sjeme 20260924 + veličina). Svaki upit vuče iz unaprijed pripremljenog bazena parametara (40 kursora za
Q3, 100 kombinacija filtra za Q4/Q5 i Q7, do 400 pojmova za Q6, 500 izvlačenja iz ~3.000 slugova za Q1),
pa se isti parametri ponavljaju tijekom 300 iteracija i predmemorija je topla. Latencija je vrijeme od slanja upita do primljenog rezultata u
node-postgres klijentu (`performance.now()`). Percentili su izračunati metodom najbližeg ranga.
Za svaki upit i veličinu spremljen je jedan `EXPLAIN (ANALYZE, BUFFERS)` plan u `plans/`.

## Što NIJE izmjereno

- **Mrežna latencija do kupca.** Klijent i baza su na istom računalu. Stvarni posjetitelj vidi i put
  do poslužitelja, TLS, CDN i vrijeme aplikacije.
- **CDN, predmemorija i renderiranje.** Nema Next.js-a, HTML-a ni slika. Mjerena je samo baza.
- **Istovremeni korisnici.** Jedna veza, upiti jedan za drugim. Nema testa opterećenja, zaključavanja
  ni natjecanja za resurse.
- **Hladna predmemorija.** Svi upiti mjereni su nakon zagrijavanja. Na 5 milijuna skup podataka
  (2.771,6 MiB, oko 2,9 GB) veći je od `shared_buffers` (1 GB). Dio stranica čitan je izvan
  `shared_buffers` (`read=` u planovima), najvjerojatnije iz predmemorije WSL2 virtualke (15,5 GiB);
  to nismo zasebno provjerili.
- **Stvarni katalog.** Podaci su sintetički (vidi dolje).
- **Druge platforme.** Nismo mjerili WooCommerce, Shopify, Magento ni bilo koji drugi sustav. Brojke se
  ne smiju čitati kao usporedba s njima.

## Okruženje

| | |
|---|---|
| Procesor | 13th Gen Intel Core i7-13700K, 16 jezgri / 24 logičke |
| RAM računala | 31,8 GB |
| OS | Windows 11 Pro (10.0.26200) |
| Docker | Docker Desktop 27.5.1, WSL2 kernel 6.18.33.2; VM: 24 CPU, 15,5 GiB RAM |
| Baza | PostgreSQL 16.15, službena slika `postgres:16` |
| Klijent | Node.js v24.0.0, node-postgres 8.23.0, veza na `127.0.0.1:55432` |

Postavke PostgreSQL-a su zadane, osim:

| Postavka | Vrijednost | Razlog |
|---|---|---|
| `shared_buffers` | 1GB | Skromna produkcijska postavka za mali namjenski poslužitelj (zadano 128MB) |
| `effective_cache_size` | 4GB | Zadana vrijednost, navedena izričito |
| `work_mem` | 32MB | Sortiranja i agregacije faceta ne prelaze na disk (zadano 4MB) |
| `maintenance_work_mem` | 512MB | Brža izgradnja indeksa pri učitavanju; ne utječe na planove upita |
| `max_wal_size` | 4GB | Rjeđi checkpointi pri velikom unosu; ne utječe na planove upita |

JIT je uključen (zadano), ali nijedan spremljeni plan ne pokazuje JIT kompilaciju.
`synchronous_commit` je uključen: svaki COMMIT u Q8 čeka zapis WAL-a na disk.

## Sintetički podaci

Generator (`generate.sql`) je deterministički: svaka „slučajna” vrijednost je hash identifikatora
retka (`hashint8extended`), pa ista veličina uvijek daje identičan skup podataka.

- **Kategorije:** 1 % broja proizvoda, najmanje 20, najviše 2.000. Tri razine (korijen ~2 %,
  grupe ~18 %, listovi ostatak), roditelji dodijeljeni hashom pa su grane nejednake. Stablo je
  spremljeno kao materijalizirana putanja (`/korijen/grupa/list/`) s `text_pattern_ops` indeksom;
  podstablo je `path LIKE '/1/12/%'`. Odabrano umjesto `ltree` (bez dodatnog proširenja) i closure
  tablice (stablo je plitko i rijetko se mijenja).
- **Proizvodi po kategoriji:** kvadratna distribucija; najveći list ima ~1/√(broj listova) kataloga
  (125.288 proizvoda na 5 milijuna).
- **Brendovi:** 5.000, distribucija ~1/k (Zipf, s = 1): najčešći brend ima ~8 % kataloga.
- **Popularnost:** log-uniformna 0–99.998 (mnogo niskih vrijednosti, malo hitova).
- **Cijene:** log-uniformne od ~1,99 do ~4.975 EUR, završavaju na ,99.
- **Zaliha:** 15 % proizvoda bez zalihe. **Aktivno:** 95 %.
- **Atributi (`attrs` jsonb):** boja (12 vrijednosti, nejednako), materijal, podrijetlo uvijek;
  veličina 50 %, jamstvo 60 %, energetski razred 30 % proizvoda (3–6 ključeva).
- **Nazivi:** „Pridjev imenica Brend Model broj” iz hrvatskih popisa riječi (s dijakriticima);
  imenice su vezane uz korijensku kategoriju. `search` je `to_tsvector('simple', unaccent(naziv))`,
  izračunat pri unosu jer `unaccent` nije IMMUTABLE pa ne može biti generirani stupac.
- **B2B:** 50 grupa kupaca. ~5 % proizvoda ima ugovornu cijenu za ~10 % grupa (prosječno 5),
  75–95 % cjenika. Ukupno ~0,25 × broj proizvoda redaka u `customer_prices`.

## Indeksi

| Indeks | Svrha |
|---|---|
| `products_pkey (id)` | Primarni ključ, Q7, Q8 |
| `products_slug_key`, `products_sku_key` (unique) | Stranica proizvoda (Q1), povezivanje s ERP-om po šifri |
| `products_cat_pop_idx (category_id, popularity DESC, id DESC) WHERE is_active` | Kategorija po popularnosti s keyset paginacijom (Q2) |
| `products_cat_price_idx (category_id, price_cents, id) WHERE is_active` | Kategorija po cijeni; u praksi ga koriste i Q4/Q5 |
| `products_active_pop_idx (popularity DESC, id DESC) WHERE is_active` | Cijeli katalog po popularnosti, duboka paginacija (Q3) |
| `products_brand_idx (brand_id)` | Stranice brenda, filtar brenda (Q4) |
| `products_search_idx` GIN `(search)` | Full-text pretraga (Q6) |
| `products_attrs_idx` GIN `(attrs jsonb_path_ops)` | Filtar atributa `@>` (Q4, Q5) |
| `categories_path_idx (path text_pattern_ops)`, `categories_parent_idx` | Podstablo kategorije |
| `customer_prices_pkey (customer_group_id, product_id)` | B2B cijena jednim dohvatom (Q7) |

Indeksi i strani ključevi grade se nakon unosa podataka, kao kod stvarnog početnog uvoza.

## Poznata ograničenja

- **Jedno računalo, jedno objavljeno pokretanje.** Druga konfiguracija, disk ili verzija daju druge apsolutne
  brojke. Oblik krivulje (što raste s veličinom, a što ne) je važniji od pojedinačne brojke. Probno
  pokretanje istim kodom dvije minute ranije (samo 10.000–1.000.000) dalo je p50 unutar oko 15 % objavljenih
  vrijednosti; ti brojevi nisu objavljeni.
- **Windows + Docker Desktop.** Mrežni put prolazi kroz Docker Desktop port forwarding. Na ovom
  računalu `localhost` (IPv6 `::1`) dodavao je ~44 ms paketu od 1.000 parametara, a `127.0.0.1`
  ~1 ms, pa su sva mjerenja preko `127.0.0.1`.
- **Q5 na 5 milijuna.** Planer kombinira indeks kategorije s GIN indeksom atributa; česta boja u
  GIN-u pogađa ~420.000 redaka. To je stvarno ponašanje ove sheme i razlog zašto bismo na toj
  veličini boju izdvojili u zaseban stupac ili koristili unaprijed izračunate brojače.
- **Q6 po `ts_rank`** mora ocijeniti svaki pogodak prije `LIMIT 24`; česte riječi (npr. najčešći brend)
  daju dugi rep (p95).
- **Q8** mijenja `price_cents`, koji je indeksiran, pa izmjene nisu HOT: svaki redak ažurira sve
  indekse proizvoda, uključujući dva GIN indeksa. Mjereno je 20 paketa, ne 300, pa je p95 zapravo
  19. od 20 uzoraka. Na 5 milijuna COMMIT p50 porastao je na 52,895 ms (oko 7 ms na manjim veličinama).
  Q8 se izvodi posljednji, oko dvije minute nakon završetka unosa 5 milijuna redaka, pa je vjerojatan
  uzrok checkpoint i zapis WAL-a u pozadini, ali to nismo zasebno provjerili. I sam UPDATE na 5 milijuna
  gotovo se udvostručio (55,72 ms p50), uz čitanje stranica izvan `shared_buffers`.
  Q8 na 100.000 (52,073 ms) sporiji je nego na 1.000.000 (38,27 ms); isto se ponovilo u probnom
  pokretanju i uzrok nismo utvrdili. Q8 zato ne treba čitati kao glatku krivulju po veličini.
- **Q9** mjeri generiranje podataka unutar baze (hash, nizovi znakova, `unaccent`, `to_tsvector`)
  i `INSERT` bez sekundarnih indeksa (samo primarni ključ). To nije `COPY` iz datoteke; stvarni uvoz iz ERP-a obično
  ograničava parsiranje i mreža, a ne PostgreSQL.
- **Planovi u `plans/`** su jedan reprezentativni parametar po upitu, snimljen nakon mjerenja
  (topla predmemorija).

Sve bilješke s mjerenja nalaze se i u polju `notes` u `results.json`.

## Kako ponoviti

Potrebni su Docker i Node.js 18+. Detalji su u `scripts/scale-lab/README.md`.

```bash
docker run -d --name finsite-scalelab -p 55432:5432 -e POSTGRES_PASSWORD=bench \
  -v finsite-scalelab-data:/var/lib/postgresql/data --shm-size=1g postgres:16 \
  -c shared_buffers=1GB -c work_mem=32MB -c effective_cache_size=4GB \
  -c maintenance_work_mem=512MB -c max_wal_size=4GB

cp -r scripts/scale-lab /tmp/scale-lab && cd /tmp/scale-lab
npm init -y && npm i pg
SCALELAB_OUT="$OLDPWD/public/data/scale-lab-2026" SCALELAB_SIZES=10000,100000,1000000,5000000 node run.mjs

docker rm -f finsite-scalelab && docker volume rm finsite-scalelab-data
```

Ponovno pokretanje prepisuje `results.json`, `results.csv` i briše `plans/` u izlaznoj mapi,
a polje `notes` vraća na prazno.

Mjerenje sve četiri veličine na opisanom računalu trajalo je oko 5 minuta
(11:45:49 – 11:51:02 UTC) i trebalo je ~3 GB prostora za skup od 5 milijuna proizvoda.
