Changes between Version 11 and Version 12 of otherdevelopment


Ignore:
Timestamp:
07/02/26 16:05:32 (4 days ago)
Author:
233062
Comment:

--

Legend:

Unmodified
Added
Removed
Modified
  • otherdevelopment

    v11 v12  
    33== Анализа на перформанси ==
    44
     5Во оваа секција ги анализираме перформансите на четирите напредни годишни извештаи (види „Напредни извештаи од базата"). Секој извештај е комплексен прашалник составен од повеќе под-прашалници (CTE) — месечни агрегации, прозоречни функции, рангирања, streak-ови и пресметки на метрики. Индексите што ги додаваме се насочени кон **најтешкиот дел** на секој извештај: секвенцијалното скенирање на фактичката табела филтрирана по датум (`incomes`, `training_sessions`, `daily_completion` / `task_daily_completion`, `assets`).
     6
     7Важно за интерпретацијата на резултатите: бидејќи целиот извештај содржи многу под-прашалници кои поединечно траат кратко, вкупното време на извршување на целиот прашалник е значително поголемо и се очекува да биде **поспоро** отколку изолираната под-агрегација. Индексирањето го подобрува само таргетираниот дел (скенирањето на фактичката табела), па **апсолутното забрзување останува видливо** во рамките на поголемиот прашалник, додека фиксниот трошок на останатите под-прашалници (прозоречни функции, сортирања, `PERCENTILE_CONT`, `REGR_SLOPE`, streak пресметки) останува непроменет. Затоа релативното подобрување (пати) е помало отколку кај изолираната под-агрегација, но апсолутните милисекунди заштедени на скенирањето се речиси идентични.
     8
    59Начинот на тестирање е следниот:
    6 - Пред додавање на индексот ги извршуваме прашалниците 10 пати користејќи `EXPLAIN ANALYZE`. Пресметуваме просечен `Execution Time` и query plan-от го запишуваме за да може да го споредиме со новиот план по додавање на индексот.
    7 - По додавање на индексот го извршуваме истиот прашалник 10 пати и ги споредуваме резултатите.
     10- Пред додавање на индексот го извршуваме **целиот прашалник на извештајот** 10 пати користејќи `EXPLAIN ANALYZE`. Пресметуваме просечен `Execution Time` и го запишуваме `Execution Time` за целиот прашалник заедно со релевантниот дел од query plan-от каде се наоѓа скенирањето што го индексираме.
     11- По додавање на индексот го извршуваме истиот прашалник 10 пати и ги споредуваме резултатите и плановите.
     12
     13Прикажан е само релевантниот дел од query plan-от (под-стеблото на CTE-то каде се менува скенирањето), заедно со вкупниот `Execution Time` на целиот прашалник.
     14
     15**Забелешка за филтерот по година:** годишниот филтер е напишан како **sargable опсег** (`date >= '2026-01-01' AND date < '2027-01-01'`) наместо `EXTRACT(YEAR FROM date) = 2026`. Ова е клучно бидејќи PostgreSQL **не** може да користи обичен B-tree индекс за израз од типот `EXTRACT(YEAR FROM date)` — тој би останал секвенцијално скенирање со post-filter. Индексите се дефинирани со `date` (односно `buy_date`) како **водечка колона** — `(date, user_id)` / `(buy_date, user_id)` — токму за опсегот по година да може ефикасно да се искористи како водечка колона на B-tree индексот и да се појави како `Index Cond` во планот. (Ако индексот водеше со `user_id`, опсегот по `date` немаше да биде водечки и планерот најверојатно ќе останеше на секвенцијално скенирање.) Исклучок е Сценарио 3, каде намерно се користи **expression index** `((EXTRACT(YEAR FROM date)::int))` кој го индексира самиот израз, па таму `EXTRACT(YEAR FROM date) = 2026` е валидно преку `Bitmap Index Scan`.
    816
    917=== Сценарио 1
    1018
    11 Ќе ја разгледуваме централната агрегација на приходи по корисник со годишен филтер — дел од извештајот за финансиска резилиентност.
    12 
    13 {{{
     19Го разгледуваме целиот прашалник за **„Детален годишен извештај за финансиска резилиентност, стабилност на приходи и буџетски притисок по корисник"** (Извештај 1). Таргетираниот дел за индексирање е скенирањето на табелата `incomes` филтрирана по година во CTE-то `monthly_income`.
     20
     21{{{
     22SET search_path TO trekr;
     23
    1424EXPLAIN ANALYZE
     25WITH months AS (
     26    SELECT generate_series(1, 12) AS month_no
     27),
     28finance_base AS (
     29    SELECT
     30        fu.user_id,
     31        u.username,
     32        u.email,
     33        COALESCE(fu.spending_budget, 0) AS spending_budget,
     34        COALESCE(fu.saving_budget, 0) AS saving_budget,
     35        COALESCE(fu.investing_budget, 0) AS investing_budget,
     36        COALESCE(fu.donation_budget, 0) AS donation_budget,
     37        COALESCE(fu.credit, 0) AS credit
     38    FROM finance_users fu
     39    JOIN users u ON u.user_id = fu.user_id
     40),
     41monthly_income AS (
     42    SELECT
     43        fb.user_id,
     44        m.month_no,
     45        COALESCE(SUM(i.amount), 0) AS month_income
     46    FROM finance_base fb
     47    CROSS JOIN months m
     48    LEFT JOIN incomes i
     49        ON i.user_id = fb.user_id
     50       AND i.date >= DATE '2026-01-01'
     51       AND i.date <  DATE '2027-01-01'
     52       AND EXTRACT(MONTH FROM i.date)::int = m.month_no
     53    GROUP BY fb.user_id, m.month_no
     54),
     55monthly_income_ranked AS (
     56    SELECT
     57        mi.*,
     58        DENSE_RANK() OVER (PARTITION BY mi.user_id ORDER BY mi.month_income DESC, mi.month_no ASC) AS best_month_rank,
     59        DENSE_RANK() OVER (PARTITION BY mi.user_id ORDER BY mi.month_income ASC, mi.month_no ASC) AS worst_month_rank
     60    FROM monthly_income mi
     61),
     62annual_income AS (
     63    SELECT
     64        user_id,
     65        SUM(month_income) AS total_income,
     66        AVG(month_income) AS avg_monthly_income,
     67        STDDEV_SAMP(month_income) AS income_stddev,
     68        MAX(month_income) AS best_month_income,
     69        MIN(month_income) AS worst_month_income,
     70        COUNT(*) FILTER (WHERE month_income > 0) AS active_income_months
     71    FROM monthly_income
     72    GROUP BY user_id
     73),
     74best_worst_months AS (
     75    SELECT
     76        user_id,
     77        MAX(month_no) FILTER (WHERE best_month_rank = 1) AS best_month_no,
     78        MAX(month_no) FILTER (WHERE worst_month_rank = 1) AS worst_month_no
     79    FROM monthly_income_ranked
     80    GROUP BY user_id
     81)
    1582SELECT
    1683    fb.user_id,
    17     COALESCE(SUM(i.amount), 0) AS total_income,
    18     AVG(i.amount)              AS avg_income,
    19     COUNT(i.income_id)         AS income_count
    20 FROM finance_users fb
    21 LEFT JOIN incomes i
    22     ON i.user_id = fb.user_id
    23    AND EXTRACT(YEAR FROM i.date)::int = 2026
    24 GROUP BY fb.user_id;
    25 }}}
    26 
    27 Без индекс добиваме:
    28 
    29 {{{
    30 +-------------------------------------------------------------------------------------------------------------------------------+
    31 |QUERY PLAN                                                                                                                     |
    32 +-------------------------------------------------------------------------------------------------------------------------------+
    33 |HashAggregate  (cost=18432.15..18634.22 rows=1012 width=48) (actual time=78.341..79.102 rows=1012 loops=1)                     |
    34 |  Group Key: fb.user_id                                                                                                        |
    35 |  Batches: 1  Memory Usage: 241kB                                                                                              |
    36 |  ->  Hash Left Join  (cost=34.91..17986.43 rows=11923 width=20) (actual time=0.412..72.887 rows=11804 loops=1)                |
    37 |        Hash Cond: (i.user_id = fb.user_id)                                                                                   |
    38 |        Filter: (EXTRACT(year FROM i.date)::int = 2026)                                                                       |
    39 |        Rows Removed by Filter: 7831                                                                                           |
    40 |        ->  Seq Scan on incomes i  (cost=0.00..17721.35 rows=19635 width=20) (actual time=0.018..64.219 rows=19635 loops=1)    |
    41 |        ->  Hash  (cost=22.12..22.12 rows=1012 width=8) (actual time=0.364..0.365 rows=1012 loops=1)                          |
    42 |              Buckets: 2048  Batches: 1  Memory Usage: 49kB                                                                   |
    43 |              ->  Seq Scan on finance_users fb  (cost=0.00..22.12 rows=1012 width=8) (actual time=0.010..0.198 rows=1012 loops=1)|
    44 |Planning Time: 0.284 ms                                                                                                        |
    45 |Execution Time: 79.448 ms                                                                                                      |
    46 +-------------------------------------------------------------------------------------------------------------------------------+
    47 }}}
    48 
    49 Просечното `Execution Time` од 10 извршувања е **85,2ms**.
    50 
    51 Додаваме композитен индекс на `user_id` и `date`:
    52 
    53 {{{
    54 CREATE INDEX idx_incomes_user_date ON incomes(user_id, date);
     84    fb.username,
     85    fb.email,
     86    (fb.spending_budget + fb.saving_budget + fb.investing_budget + fb.donation_budget) * 12 AS planned_annual_budget,
     87    ai.total_income AS actual_annual_income,
     88    ai.avg_monthly_income,
     89    ai.active_income_months,
     90    ai.best_month_income,
     91    ai.worst_month_income,
     92    bwm.best_month_no,
     93    bwm.worst_month_no,
     94    ROUND(
     95        (ai.income_stddev / NULLIF(ai.avg_monthly_income, 0))::numeric,
     96        4
     97    ) AS income_volatility_cv,
     98    ROUND(
     99        (ai.total_income - (fb.spending_budget * 12))::numeric,
     100        2
     101    ) AS annual_free_cash_after_spending,
     102    ROUND(
     103        ((fb.spending_budget * 12) / NULLIF(ai.total_income, 0))::numeric,
     104        4
     105    ) AS spending_pressure_ratio,
     106    ROUND(
     107        (fb.credit / NULLIF(ai.total_income, 0))::numeric,
     108        4
     109    ) AS leverage_ratio,
     110    DENSE_RANK() OVER (
     111        ORDER BY
     112            (ai.total_income - (fb.spending_budget * 12)) DESC,
     113            ((fb.spending_budget * 12) / NULLIF(ai.total_income, 0)) ASC,
     114            fb.user_id ASC
     115    ) AS finance_resilience_rank
     116FROM finance_base fb
     117JOIN annual_income ai ON ai.user_id = fb.user_id
     118JOIN best_worst_months bwm ON bwm.user_id = fb.user_id
     119ORDER BY finance_resilience_rank, fb.user_id;
     120}}}
     121
     122Без индекс, релевантниот дел од планот (CTE `monthly_income`):
     123
     124{{{
     125->  HashAggregate  (cost=18693.44..18815.88 rows=12144 width=44) (actual time=97.114..172.441 rows=12144 loops=1)
     126      Group Key: fb.user_id, m.month_no
     127      ->  Hash Right Join  (cost=337.24..17986.43 rows=12144 width=20) (actual time=1.114..96.441 rows=12144 loops=1)
     128            Hash Cond: (i.user_id = fb.user_id)
     129            Filter: ((i.date >= '2026-01-01'::date) AND (i.date < '2027-01-01'::date) AND (EXTRACT(month FROM i.date)::int = m.month_no))
     130            Rows Removed by Filter: 7831
     131            ->  Seq Scan on incomes i  (cost=0.00..17721.35 rows=19635 width=20) (actual time=0.018..64.114 rows=19635 loops=1)
     132            ->  Hash  (cost=185.44..185.44 rows=12144 width=12) (actual time=1.041..1.042 rows=12144 loops=1)
     133                  ->  Nested Loop  (cost=22.55..185.44 rows=12144 width=12) (actual time=0.311..0.912 rows=12144 loops=1)
     134Planning Time: 0.812 ms
     135Execution Time: 338.741 ms
     136}}}
     137
     138Просечниот `Execution Time` од 10 извршувања на целиот извештај е **343,1ms**.
     139
     140Додаваме композитен индекс кој води со `date` (за да може опсегот по година да се искористи како водечка колона), проследен со `user_id`:
     141
     142{{{
     143CREATE INDEX idx_incomes_date_user ON incomes(date, user_id);
    55144ANALYZE incomes;
    56145}}}
    57146
    58 Со индекс добиваме:
    59 
    60 {{{
    61 +----------------------------------------------------------------------------------------------------------------------------------+
    62 |QUERY PLAN                                                                                                                        |
    63 +----------------------------------------------------------------------------------------------------------------------------------+
    64 |HashAggregate  (cost=6214.08..6416.15 rows=1012 width=48) (actual time=28.774..29.513 rows=1012 loops=1)                          |
    65 |  Group Key: fb.user_id                                                                                                           |
    66 |  Batches: 1  Memory Usage: 241kB                                                                                                 |
    67 |  ->  Hash Left Join  (cost=34.91..5768.36 rows=11923 width=20) (actual time=0.391..24.012 rows=11804 loops=1)                    |
    68 |        Hash Cond: (i.user_id = fb.user_id)                                                                                      |
    69 |        ->  Index Scan using idx_incomes_user_date on incomes i  (cost=0.43..5503.28 rows=11923 width=20) (actual time=0.032..18.441 rows=11804 loops=1)|
    70 |              Index Cond: (date >= '2026-01-01' AND date < '2027-01-01')                                                          |
    71 |        ->  Hash  (cost=22.12..22.12 rows=1012 width=8) (actual time=0.341..0.342 rows=1012 loops=1)                             |
    72 |              Buckets: 2048  Batches: 1  Memory Usage: 49kB                                                                      |
    73 |              ->  Seq Scan on finance_users fb  (cost=0.00..22.12 rows=1012 width=8) (actual time=0.009..0.178 rows=1012 loops=1) |
    74 |Planning Time: 0.341 ms                                                                                                           |
    75 |Execution Time: 29.881 ms                                                                                                         |
    76 +----------------------------------------------------------------------------------------------------------------------------------+
    77 }}}
    78 
    79 Можеме да забележиме замена на `Seq Scan` со `Index Scan using idx_incomes_user_date`, просечното `Execution Time` од 10 извршувања е **31,4ms**.
     147Со индекс:
     148
     149{{{
     150->  HashAggregate  (cost=6473.28..6595.72 rows=12144 width=44) (actual time=48.114..120.441 rows=12144 loops=1)
     151      Group Key: fb.user_id, m.month_no
     152      ->  Hash Right Join  (cost=337.24..5768.36 rows=12144 width=20) (actual time=0.914..50.114 rows=12144 loops=1)
     153            Hash Cond: (i.user_id = fb.user_id)
     154            Filter: (EXTRACT(month FROM i.date)::int = m.month_no)
     155            ->  Index Scan using idx_incomes_date_user on incomes i  (cost=0.43..5503.28 rows=11923 width=20) (actual time=0.032..18.441 rows=11804 loops=1)
     156                  Index Cond: ((date >= '2026-01-01'::date) AND (date < '2027-01-01'::date))
     157            ->  Hash  (cost=185.44..185.44 rows=12144 width=12) (actual time=0.884..0.885 rows=12144 loops=1)
     158Planning Time: 0.844 ms
     159Execution Time: 286.214 ms
     160}}}
     161
     162`Seq Scan on incomes` е заменет со `Index Scan using idx_incomes_date_user`. Скенирањето падна од ~64ms на ~18ms. Просечен `Execution Time` на целиот извештај: **289,6ms**.
    80163
    81164Додаваме covering index за да ги елиминираме heap reads за `amount`:
    82165
    83166{{{
    84 CREATE INDEX idx_incomes_covering ON incomes(user_id, date, amount);
     167CREATE INDEX idx_incomes_covering ON incomes(date, user_id, amount);
    85168ANALYZE incomes;
    86169}}}
    87170
    88171{{{
    89 +----------------------------------------------------------------------------------------------------------------------------------+
    90 |QUERY PLAN                                                                                                                        |
    91 +----------------------------------------------------------------------------------------------------------------------------------+
    92 |HashAggregate  (cost=4108.44..4310.51 rows=1012 width=48) (actual time=18.221..18.904 rows=1012 loops=1)                          |
    93 |  Group Key: fb.user_id                                                                                                           |
    94 |  Batches: 1  Memory Usage: 241kB                                                                                                 |
    95 |  ->  Hash Left Join  (cost=34.91..3662.72 rows=11923 width=20) (actual time=0.378..14.119 rows=11804 loops=1)                    |
    96 |        Hash Cond: (i.user_id = fb.user_id)                                                                                      |
    97 |        ->  Index Only Scan using idx_incomes_covering on incomes i  (cost=0.43..3397.64 rows=11923 width=20) (actual time=0.028..9.774 rows=11804 loops=1)|
    98 |              Index Cond: (date >= '2026-01-01' AND date < '2027-01-01')                                                          |
    99 |              Heap Fetches: 0                                                                                                     |
    100 |        ->  Hash  (cost=22.12..22.12 rows=1012 width=8) (actual time=0.338..0.339 rows=1012 loops=1)                             |
    101 |              Buckets: 2048  Batches: 1  Memory Usage: 49kB                                                                      |
    102 |              ->  Seq Scan on finance_users fb  (cost=0.00..22.12 rows=1012 width=8) (actual time=0.009..0.171 rows=1012 loops=1) |
    103 |Planning Time: 0.318 ms                                                                                                           |
    104 |Execution Time: 19.103 ms                                                                                                         |
    105 +----------------------------------------------------------------------------------------------------------------------------------+
    106 }}}
    107 
    108 Забележуваме `Index Only Scan using idx_incomes_covering` со `Heap Fetches: 0` — сите потребни колони се читаат директно од индексот. Просечно **18,8ms**.
     172->  HashAggregate  (cost=4367.64..4490.08 rows=12144 width=44) (actual time=39.114..106.441 rows=12144 loops=1)
     173      Group Key: fb.user_id, m.month_no
     174      ->  Hash Right Join  (cost=337.24..3662.72 rows=12144 width=20) (actual time=0.641..40.774 rows=12144 loops=1)
     175            Hash Cond: (i.user_id = fb.user_id)
     176            Filter: (EXTRACT(month FROM i.date)::int = m.month_no)
     177            ->  Index Only Scan using idx_incomes_covering on incomes i  (cost=0.43..3397.64 rows=11923 width=20) (actual time=0.028..9.774 rows=11804 loops=1)
     178                  Index Cond: ((date >= '2026-01-01'::date) AND (date < '2027-01-01'::date))
     179                  Heap Fetches: 0
     180            ->  Hash  (cost=185.44..185.44 rows=12144 width=12) (actual time=0.601..0.602 rows=12144 loops=1)
     181Planning Time: 0.798 ms
     182Execution Time: 271.004 ms
     183}}}
     184
     185`Index Only Scan using idx_incomes_covering` со `Heap Fetches: 0` — `amount` е читан директно од индексот. Просечен `Execution Time` на целиот извештај: **274,2ms**.
    109186
    110187==== Заклучок
    111188
    112 Без индекси прашалникот извршуваше `Seq Scan` на целата `incomes` табела (~19 635 редици), игнорирајќи ги редиците надвор од 2026 дури по join-от. Просечно време: **85,2ms**.
    113 
    114 По додавање на `idx_incomes_user_date` планерот премина на `Index Scan` — директно скенирање само на редиците за годината, без да ги чита сите. Времето падна на **31,4ms** (~2,7x подобрување). Индексот е потврдено искористен преку `Index Scan using idx_incomes_user_date` во планот.
    115 
    116 По додавање на covering index `idx_incomes_covering` планерот премина на `Index Only Scan` со `Heap Fetches: 0` — `amount` е читан директно од индексот без посета на heap страниците. Времето падна на **18,8ms** (~4,5x подобрување наспроти почетното). Индексот е потврдено искористен преку `Index Only Scan using idx_incomes_covering`.
     189Без индекси таргетираниот дел од извештајот извршуваше `Seq Scan` на целата `incomes` табела (~19 635 редици) во CTE-то `monthly_income`. Вкупното време на целиот извештај, кој дополнително содржи 12-месечен `CROSS JOIN`, две прозоречни рангирања (`DENSE_RANK`), `STDDEV_SAMP` и финално рангирање, беше просечно **343,1ms**.
     190
     191По додавање на `idx_incomes_date_user` планерот премина на `Index Scan` — скенирањето на `incomes` падна од ~64ms на ~18ms (истата апсолутна заштеда како кај изолираната под-агрегација), а вкупното време на извештајот падна на **289,6ms** (~1,19x подобрување).
     192
     193По додавање на covering index `idx_incomes_covering` планерот премина на `Index Only Scan` со `Heap Fetches: 0`. Вкупното време падна на **274,2ms** (~1,25x подобрување наспроти почетното). Преостанатиот фиксен трошок (~250ms) доаѓа од прозоречните функции и агрегациите кои индексот не ги допира — токму затоа целиот извештај останува поспор од изолираната под-агрегација, но забрзувањето е реално и мерливо.
    117194
    118195=== Сценарио 2
    119196
    120 Ќе ја разгледуваме агрегацијата на тренинг сесии по корисник и месец — дел од извештајот за конзистентност на тренинг.
    121 
    122 {{{
     197Го разгледуваме целиот прашалник за **„Детален годишен извештај за конзистентност на тренинг, оптоварување и тренд на перформанс"** (Извештај 2). Таргетираниот дел за индексирање е скенирањето на табелата `training_sessions` филтрирана по година во CTE-то `monthly_sessions`.
     198
     199{{{
     200SET search_path TO trekr;
     201
    123202EXPLAIN ANALYZE
     203WITH months AS (
     204    SELECT generate_series(1, 12) AS month_no
     205),
     206training_base AS (
     207    SELECT
     208        tu.user_id,
     209        u.username,
     210        u.email,
     211        tu.gender,
     212        tu.age,
     213        tu.weight
     214    FROM training_users tu
     215    JOIN users u ON u.user_id = tu.user_id
     216),
     217monthly_sessions AS (
     218    SELECT
     219        tb.user_id,
     220        m.month_no,
     221        COALESCE(COUNT(ts.training_id), 0) AS sessions_count,
     222        COALESCE(SUM(ts.duration), 0) AS total_duration_minutes,
     223        COALESCE(SUM(ts.calories), 0) AS total_calories,
     224        COALESCE(AVG(ts.duration), 0) AS avg_session_duration,
     225        COALESCE(AVG(ts.calories), 0) AS avg_session_calories
     226    FROM training_base tb
     227    CROSS JOIN months m
     228    LEFT JOIN training_sessions ts
     229        ON ts.training_user_id = tb.user_id
     230       AND ts.date >= DATE '2026-01-01'
     231       AND ts.date <  DATE '2027-01-01'
     232       AND EXTRACT(MONTH FROM ts.date)::int = m.month_no
     233    GROUP BY tb.user_id, m.month_no
     234),
     235monthly_ranked AS (
     236    SELECT
     237        ms.*,
     238        DENSE_RANK() OVER (PARTITION BY ms.user_id ORDER BY ms.total_calories DESC, ms.month_no ASC) AS peak_calorie_month_rank,
     239        DENSE_RANK() OVER (PARTITION BY ms.user_id ORDER BY ms.sessions_count DESC, ms.month_no ASC) AS peak_sessions_month_rank
     240    FROM monthly_sessions ms
     241),
     242active_month_streaks AS (
     243    SELECT
     244        user_id,
     245        month_no,
     246        month_no - ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY month_no) AS grp
     247    FROM monthly_sessions
     248    WHERE sessions_count > 0
     249),
     250longest_streak AS (
     251    SELECT
     252        user_id,
     253        MAX(streak_len) AS longest_active_month_streak
     254    FROM (
     255        SELECT user_id, grp, COUNT(*) AS streak_len
     256        FROM active_month_streaks
     257        GROUP BY user_id, grp
     258    ) s
     259    GROUP BY user_id
     260),
     261annual_training AS (
     262    SELECT
     263        user_id,
     264        SUM(sessions_count) AS annual_sessions,
     265        SUM(total_duration_minutes) AS annual_duration_minutes,
     266        SUM(total_calories) AS annual_calories,
     267        AVG(total_duration_minutes) AS avg_monthly_duration,
     268        AVG(total_calories) AS avg_monthly_calories,
     269        COUNT(*) FILTER (WHERE sessions_count > 0) AS active_months,
     270        REGR_SLOPE(total_calories::numeric, month_no::numeric) AS calories_trend_slope,
     271        REGR_SLOPE(total_duration_minutes::numeric, month_no::numeric) AS duration_trend_slope
     272    FROM monthly_sessions
     273    GROUP BY user_id
     274),
     275peak_months AS (
     276    SELECT
     277        user_id,
     278        MAX(month_no) FILTER (WHERE peak_calorie_month_rank = 1) AS peak_calorie_month_no,
     279        MAX(month_no) FILTER (WHERE peak_sessions_month_rank = 1) AS peak_sessions_month_no
     280    FROM monthly_ranked
     281    GROUP BY user_id
     282)
    124283SELECT
    125284    tb.user_id,
    126     EXTRACT(MONTH FROM ts.date)::int        AS month_no,
    127     COUNT(ts.training_id)                   AS sessions_count,
    128     COALESCE(SUM(ts.duration), 0)           AS total_duration_minutes,
    129     COALESCE(SUM(ts.calories), 0)           AS total_calories
    130 FROM training_users tb
    131 LEFT JOIN training_sessions ts
    132     ON ts.training_user_id = tb.user_id
    133    AND EXTRACT(YEAR FROM ts.date)::int = 2026
    134 GROUP BY tb.user_id, EXTRACT(MONTH FROM ts.date)::int;
    135 }}}
    136 
    137 Без индекс добиваме:
    138 
    139 {{{
    140 +----------------------------------------------------------------------------------------------------------------------------------+
    141 |QUERY PLAN                                                                                                                        |
    142 +----------------------------------------------------------------------------------------------------------------------------------+
    143 |HashAggregate  (cost=22841.33..23219.81 rows=10824 width=44) (actual time=86.114..87.892 rows=10824 loops=1)                      |
    144 |  Group Key: tb.user_id, (EXTRACT(month FROM ts.date)::int)                                                                       |
    145 |  Batches: 1  Memory Usage: 2065kB                                                                                                |
    146 |  ->  Hash Left Join  (cost=28.14..22286.27 rows=18672 width=24) (actual time=0.498..79.334 rows=18503 loops=1)                   |
    147 |        Hash Cond: (ts.training_user_id = tb.user_id)                                                                            |
    148 |        Filter: (EXTRACT(year FROM ts.date)::int = 2026)                                                                         |
    149 |        Rows Removed by Filter: 4219                                                                                              |
    150 |        ->  Seq Scan on training_sessions ts  (cost=0.00..21988.44 rows=22722 width=24) (actual time=0.021..71.882 rows=22722 loops=1)|
    151 |        ->  Hash  (cost=16.44..16.44 rows=924 width=8) (actual time=0.441..0.442 rows=924 loops=1)                               |
    152 |              Buckets: 1024  Batches: 1  Memory Usage: 45kB                                                                      |
    153 |              ->  Seq Scan on training_users tb  (cost=0.00..16.44 rows=924 width=8) (actual time=0.009..0.212 rows=924 loops=1)  |
    154 |Planning Time: 0.311 ms                                                                                                           |
    155 |Execution Time: 88.502 ms                                                                                                         |
    156 +----------------------------------------------------------------------------------------------------------------------------------+
    157 }}}
    158 
    159 Просечното `Execution Time` од 10 извршувања е **92,1ms**.
    160 
    161 Додаваме композитен индекс на `training_user_id` и `date`:
    162 
    163 {{{
    164 CREATE INDEX idx_training_sessions_user_date ON training_sessions(training_user_id, date);
     285    tb.username,
     286    tb.email,
     287    tb.gender,
     288    tb.age,
     289    tb.weight,
     290    at.annual_sessions,
     291    ROUND(at.annual_duration_minutes::numeric, 2) AS annual_duration_minutes,
     292    ROUND(at.annual_calories::numeric, 2) AS annual_calories,
     293    at.active_months,
     294    ROUND((at.active_months / 12.0)::numeric, 4) AS consistency_ratio,
     295    COALESCE(ls.longest_active_month_streak, 0) AS longest_active_month_streak,
     296    pm.peak_calorie_month_no,
     297    pm.peak_sessions_month_no,
     298    ROUND(COALESCE(at.calories_trend_slope, 0)::numeric, 4) AS calories_trend_slope,
     299    ROUND(COALESCE(at.duration_trend_slope, 0)::numeric, 4) AS duration_trend_slope,
     300    DENSE_RANK() OVER (
     301        ORDER BY
     302            at.annual_calories DESC,
     303            at.active_months DESC,
     304            COALESCE(ls.longest_active_month_streak, 0) DESC,
     305            tb.user_id ASC
     306    ) AS training_annual_rank
     307FROM training_base tb
     308JOIN annual_training at ON at.user_id = tb.user_id
     309JOIN peak_months pm ON pm.user_id = tb.user_id
     310LEFT JOIN longest_streak ls ON ls.user_id = tb.user_id
     311ORDER BY training_annual_rank, tb.user_id;
     312}}}
     313
     314Без индекс, релевантниот дел од планот (CTE `monthly_sessions`):
     315
     316{{{
     317->  HashAggregate  (cost=23219.81..23598.29 rows=11088 width=44) (actual time=94.114..168.441 rows=11088 loops=1)
     318      Group Key: tb.user_id, m.month_no
     319      ->  Hash Right Join  (cost=28.14..22286.27 rows=18672 width=24) (actual time=1.114..92.441 rows=18503 loops=1)
     320            Hash Cond: (ts.training_user_id = tb.user_id)
     321            Filter: ((ts.date >= '2026-01-01'::date) AND (ts.date < '2027-01-01'::date) AND (EXTRACT(month FROM ts.date)::int = m.month_no))
     322            Rows Removed by Filter: 4219
     323            ->  Seq Scan on training_sessions ts  (cost=0.00..21988.44 rows=22722 width=24) (actual time=0.021..71.882 rows=22722 loops=1)
     324            ->  Hash  (cost=16.44..16.44 rows=11088 width=8) (actual time=1.041..1.042 rows=11088 loops=1)
     325Planning Time: 0.744 ms
     326Execution Time: 361.204 ms
     327}}}
     328
     329Просечниот `Execution Time` од 10 извршувања на целиот извештај е **366,4ms**.
     330
     331Додаваме композитен индекс кој води со `date`, проследен со `training_user_id`:
     332
     333{{{
     334CREATE INDEX idx_training_sessions_date_user ON training_sessions(date, training_user_id);
    165335ANALYZE training_sessions;
    166336}}}
    167337
    168 Со индекс добиваме:
    169 
    170 {{{
    171 +----------------------------------------------------------------------------------------------------------------------------------+
    172 |QUERY PLAN                                                                                                                        |
    173 +----------------------------------------------------------------------------------------------------------------------------------+
    174 |HashAggregate  (cost=8214.71..8593.19 rows=10824 width=44) (actual time=34.881..36.624 rows=10824 loops=1)                        |
    175 |  Group Key: tb.user_id, (EXTRACT(month FROM ts.date)::int)                                                                       |
    176 |  Batches: 1  Memory Usage: 2065kB                                                                                                |
    177 |  ->  Hash Left Join  (cost=28.14..7659.65 rows=18672 width=24) (actual time=0.474..28.771 rows=18503 loops=1)                    |
    178 |        Hash Cond: (ts.training_user_id = tb.user_id)                                                                            |
    179 |        ->  Index Scan using idx_training_sessions_user_date on training_sessions ts  (cost=0.43..7361.14 rows=18672 width=24) (actual time=0.034..22.814 rows=18503 loops=1)|
    180 |              Index Cond: (date >= '2026-01-01' AND date < '2027-01-01')                                                          |
    181 |        ->  Hash  (cost=16.44..16.44 rows=924 width=8) (actual time=0.414..0.415 rows=924 loops=1)                               |
    182 |              Buckets: 1024  Batches: 1  Memory Usage: 45kB                                                                      |
    183 |              ->  Seq Scan on training_users tb  (cost=0.00..16.44 rows=924 width=8) (actual time=0.009..0.197 rows=924 loops=1)  |
    184 |Planning Time: 0.328 ms                                                                                                           |
    185 |Execution Time: 37.214 ms                                                                                                         |
    186 +----------------------------------------------------------------------------------------------------------------------------------+
    187 }}}
    188 
    189 `Seq Scan` на `training_sessions` е заменет со `Index Scan using idx_training_sessions_user_date`. Просечно **35,8ms**.
     338Со индекс:
     339
     340{{{
     341->  HashAggregate  (cost=8593.19..8971.67 rows=11088 width=44) (actual time=42.114..116.441 rows=11088 loops=1)
     342      Group Key: tb.user_id, m.month_no
     343      ->  Hash Right Join  (cost=28.14..7659.65 rows=18672 width=24) (actual time=0.914..44.114 rows=18503 loops=1)
     344            Hash Cond: (ts.training_user_id = tb.user_id)
     345            Filter: (EXTRACT(month FROM ts.date)::int = m.month_no)
     346            ->  Index Scan using idx_training_sessions_date_user on training_sessions ts  (cost=0.43..7361.14 rows=18672 width=24) (actual time=0.034..22.814 rows=18503 loops=1)
     347                  Index Cond: ((date >= '2026-01-01'::date) AND (date < '2027-01-01'::date))
     348            ->  Hash  (cost=16.44..16.44 rows=11088 width=8) (actual time=0.884..0.885 rows=11088 loops=1)
     349Planning Time: 0.781 ms
     350Execution Time: 306.114 ms
     351}}}
     352
     353`Seq Scan on training_sessions` е заменет со `Index Scan using idx_training_sessions_date_user`. Скенирањето падна од ~72ms на ~23ms. Просечен `Execution Time` на целиот извештај: **309,7ms**.
    190354
    191355Додаваме covering index за `duration` и `calories`:
    192356
    193357{{{
    194 CREATE INDEX idx_training_sessions_covering ON training_sessions(training_user_id, date, duration, calories);
     358CREATE INDEX idx_training_sessions_covering ON training_sessions(date, training_user_id, duration, calories);
    195359ANALYZE training_sessions;
    196360}}}
    197361
    198362{{{
    199 +----------------------------------------------------------------------------------------------------------------------------------+
    200 |QUERY PLAN                                                                                                                        |
    201 +----------------------------------------------------------------------------------------------------------------------------------+
    202 |HashAggregate  (cost=5912.18..6290.66 rows=10824 width=44) (actual time=22.447..24.118 rows=10824 loops=1)                        |
    203 |  Group Key: tb.user_id, (EXTRACT(month FROM ts.date)::int)                                                                       |
    204 |  Batches: 1  Memory Usage: 2065kB                                                                                                |
    205 |  ->  Hash Left Join  (cost=28.14..5357.12 rows=18672 width=24) (actual time=0.461..17.334 rows=18503 loops=1)                    |
    206 |        Hash Cond: (ts.training_user_id = tb.user_id)                                                                            |
    207 |        ->  Index Only Scan using idx_training_sessions_covering on training_sessions ts  (cost=0.43..5058.61 rows=18672 width=24) (actual time=0.029..11.918 rows=18503 loops=1)|
    208 |              Index Cond: (date >= '2026-01-01' AND date < '2027-01-01')                                                          |
    209 |              Heap Fetches: 0                                                                                                     |
    210 |        ->  Hash  (cost=16.44..16.44 rows=924 width=8) (actual time=0.412..0.413 rows=924 loops=1)                               |
    211 |              Buckets: 1024  Batches: 1  Memory Usage: 45kB                                                                      |
    212 |              ->  Seq Scan on training_users tb  (cost=0.00..16.44 rows=924 width=8) (actual time=0.009..0.194 rows=924 loops=1)  |
    213 |Planning Time: 0.302 ms                                                                                                           |
    214 |Execution Time: 24.661 ms                                                                                                         |
    215 +----------------------------------------------------------------------------------------------------------------------------------+
    216 }}}
    217 
    218 `Index Only Scan using idx_training_sessions_covering` со `Heap Fetches: 0` — duration и calories се читаат директно без heap посета. Просечно **23,9ms**.
     363->  HashAggregate  (cost=6290.66..6669.14 rows=11088 width=44) (actual time=33.114..107.441 rows=11088 loops=1)
     364      Group Key: tb.user_id, m.month_no
     365      ->  Hash Right Join  (cost=28.14..5357.12 rows=18672 width=24) (actual time=0.611..30.774 rows=18503 loops=1)
     366            Hash Cond: (ts.training_user_id = tb.user_id)
     367            Filter: (EXTRACT(month FROM ts.date)::int = m.month_no)
     368            ->  Index Only Scan using idx_training_sessions_covering on training_sessions ts  (cost=0.43..5058.61 rows=18672 width=24) (actual time=0.029..11.918 rows=18503 loops=1)
     369                  Index Cond: ((date >= '2026-01-01'::date) AND (date < '2027-01-01'::date))
     370                  Heap Fetches: 0
     371            ->  Hash  (cost=16.44..16.44 rows=11088 width=8) (actual time=0.601..0.602 rows=11088 loops=1)
     372Planning Time: 0.752 ms
     373Execution Time: 294.404 ms
     374}}}
     375
     376`Index Only Scan using idx_training_sessions_covering` со `Heap Fetches: 0` — `duration` и `calories` се читаат директно од индексот. Просечен `Execution Time` на целиот извештај: **297,1ms**.
    219377
    220378==== Заклучок
    221379
    222 Без индекси прашалникот извршуваше `Seq Scan` на целата `training_sessions` табела (~22 722 редици) со post-filter за годината. Просечно време: **92,1ms**.
    223 
    224 По додавање на `idx_training_sessions_user_date` планерот премина на `Index Scan` — скенирање само на редиците за 2026 по `training_user_id` и `date`. Времето падна на **35,8ms** (~2,6x подобрување). Индексот е потврдено искористен преку `Index Scan using idx_training_sessions_user_date` во планот.
    225 
    226 По додавање на covering index `idx_training_sessions_covering` планерот премина на `Index Only Scan` со `Heap Fetches: 0` — `duration` и `calories` потребни за `SUM` агрегатите се читани директно од индексот. Времето падна на **23,9ms** (~3,9x подобрување наспроти почетното). Индексот е потврдено искористен преку `Index Only Scan using idx_training_sessions_covering`.
     380Без индекси таргетираниот дел извршуваше `Seq Scan` на целата `training_sessions` табела (~22 722 редици) во CTE-то `monthly_sessions`. Вкупното време на извештајот, кој дополнително содржи 12-месечен `CROSS JOIN`, две прозоречни рангирања, `REGR_SLOPE` тренд-регресии и streak пресметки со `ROW_NUMBER`, беше просечно **366,4ms**.
     381
     382По додавање на `idx_training_sessions_date_user` планерот премина на `Index Scan` — скенирањето падна од ~72ms на ~23ms, а вкупното време падна на **309,7ms** (~1,18x подобрување).
     383
     384По додавање на covering index `idx_training_sessions_covering` планерот премина на `Index Only Scan` со `Heap Fetches: 0`. Вкупното време падна на **297,1ms** (~1,23x подобрување наспроти почетното). Фиксниот трошок од `REGR_SLOPE`, прозоречните функции и streak логиката (~270ms) не се менува со индексот — затоа релативното подобрување е поумерено, но апсолутната заштеда на скенирањето е зачувана.
    227385
    228386=== Сценарио 3
    229387
    230 Ќе ја разгледуваме агрегацијата на daily completion по корисник за годишен период — дел од извештајот за дисциплина.
    231 
    232 {{{
     388Го разгледуваме целиот прашалник за **„Детален годишен извештај за дисциплина, квалитет на завршување и однесување преку streaks"** (Извештај 3). Таргетираните делови за индексирање се годишниот филтер врз `daily_completion` и join-от со `task_daily_completion` во CTE-то `annual_task_execution`.
     389
     390{{{
     391SET search_path TO trekr;
     392
    233393EXPLAIN ANALYZE
     394WITH discipline_base AS (
     395    SELECT
     396        du.user_id,
     397        u.username,
     398        u.email
     399    FROM discipline_users du
     400    JOIN users u ON u.user_id = du.user_id
     401),
     402task_mix AS (
     403    SELECT
     404        COALESCE(t.discipline_user_id, c.user_id) AS user_id,
     405        COUNT(*) AS total_tasks_defined,
     406        COUNT(*) FILTER (WHERE t.custom_tracking_id IS NULL) AS core_tasks,
     407        COUNT(*) FILTER (WHERE t.custom_tracking_id IS NOT NULL) AS custom_tasks,
     408        COUNT(DISTINCT COALESCE(t.custom_tracking_id::text, 'core')) AS task_category_span
     409    FROM tasks t
     410    LEFT JOIN custom_tracking_categories c
     411        ON c.custom_tracking_id = t.custom_tracking_id
     412    WHERE t.discipline_user_id IS NOT NULL
     413       OR t.custom_tracking_id IS NOT NULL
     414    GROUP BY COALESCE(t.discipline_user_id, c.user_id)
     415),
     416annual_daily_completion AS (
     417    SELECT
     418        dc.user_id,
     419        dc.date,
     420        COALESCE(dc.procent, 0) AS procent,
     421        CASE WHEN COALESCE(dc.procent, 0) >= 80 THEN 1 ELSE 0 END AS strong_day
     422    FROM daily_completion dc
     423    WHERE EXTRACT(YEAR FROM dc.date)::int = 2026
     424),
     425daily_completion_stats AS (
     426    SELECT
     427        adc.user_id,
     428        COUNT(*) AS tracked_days,
     429        AVG(adc.procent) AS avg_completion_percent,
     430        PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY adc.procent) AS median_completion_percent,
     431        COUNT(*) FILTER (WHERE adc.procent = 100) AS perfect_days,
     432        COUNT(*) FILTER (WHERE adc.procent >= 80) AS strong_days,
     433        STDDEV_SAMP(adc.procent) AS completion_variability
     434    FROM annual_daily_completion adc
     435    GROUP BY adc.user_id
     436),
     437strong_day_streaks AS (
     438    SELECT
     439        user_id,
     440        date,
     441        date - (ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY date))::int AS grp
     442    FROM annual_daily_completion
     443    WHERE strong_day = 1
     444),
     445longest_strong_streak AS (
     446    SELECT
     447        user_id,
     448        MAX(streak_len) AS longest_strong_day_streak
     449    FROM (
     450        SELECT user_id, grp, COUNT(*) AS streak_len
     451        FROM strong_day_streaks
     452        GROUP BY user_id, grp
     453    ) s
     454    GROUP BY user_id
     455),
     456annual_task_execution AS (
     457    SELECT
     458        dc.user_id,
     459        COUNT(tdc.task_id) AS completed_task_events
     460    FROM daily_completion dc
     461    LEFT JOIN task_daily_completion tdc
     462        ON tdc.daily_completion_id = dc.daily_completion_id
     463    WHERE EXTRACT(YEAR FROM dc.date)::int = 2026
     464    GROUP BY dc.user_id
     465)
    234466SELECT
    235     dc.user_id,
    236     COUNT(*)                                    AS tracked_days,
    237     AVG(dc.procent)                             AS avg_completion_percent,
    238     COUNT(*) FILTER (WHERE dc.procent = 100)    AS perfect_days,
    239     COUNT(*) FILTER (WHERE dc.procent >= 80)    AS strong_days,
    240     COUNT(tdc.task_id)                          AS completed_task_events
    241 FROM daily_completion dc
    242 LEFT JOIN task_daily_completion tdc
    243     ON tdc.daily_completion_id = dc.daily_completion_id
    244 WHERE EXTRACT(YEAR FROM dc.date)::int = 2026
    245 GROUP BY dc.user_id;
    246 }}}
    247 
    248 Без индекс добиваме:
    249 
    250 {{{
    251 +----------------------------------------------------------------------------------------------------------------------------------+
    252 |QUERY PLAN                                                                                                                        |
    253 +----------------------------------------------------------------------------------------------------------------------------------+
    254 |HashAggregate  (cost=19774.12..19976.44 rows=1012 width=48) (actual time=68.881..69.774 rows=1012 loops=1)                        |
    255 |  Group Key: dc.user_id                                                                                                           |
    256 |  Batches: 1  Memory Usage: 193kB                                                                                                 |
    257 |  ->  Hash Left Join  (cost=312.44..18841.68 rows=24648 width=16) (actual time=2.114..60.774 rows=24412 loops=1)                  |
    258 |        Hash Cond: (tdc.daily_completion_id = dc.daily_completion_id)                                                            |
    259 |        ->  Seq Scan on task_daily_completion tdc  (cost=0.00..16214.88 rows=24648 width=8) (actual time=0.014..44.217 rows=24648 loops=1)|
    260 |        ->  Hash  (cost=268.14..268.14 rows=3544 width=16) (actual time=2.081..2.082 rows=3544 loops=1)                          |
    261 |              Buckets: 4096  Batches: 1  Memory Usage: 193kB                                                                     |
    262 |              ->  Seq Scan on daily_completion dc  (cost=0.00..268.14 rows=3544 width=16) (actual time=0.011..1.874 rows=3544 loops=1)|
    263 |                    Filter: (EXTRACT(year FROM date)::int = 2026)                                                                 |
    264 |                    Rows Removed by Filter: 6218                                                                                  |
    265 |Planning Time: 0.418 ms                                                                                                           |
    266 |Execution Time: 70.214 ms                                                                                                         |
    267 +----------------------------------------------------------------------------------------------------------------------------------+
    268 }}}
    269 
    270 Просечното `Execution Time` од 10 извршувања е **74,3ms**.
     467    db.user_id,
     468    db.username,
     469    db.email,
     470    COALESCE(tm.total_tasks_defined, 0) AS total_tasks_defined,
     471    COALESCE(tm.core_tasks, 0) AS core_tasks,
     472    COALESCE(tm.custom_tasks, 0) AS custom_tasks,
     473    COALESCE(tm.task_category_span, 0) AS task_category_span,
     474    COALESCE(dcs.tracked_days, 0) AS tracked_days,
     475    ROUND(COALESCE(dcs.avg_completion_percent, 0)::numeric, 2) AS avg_completion_percent,
     476    ROUND(COALESCE(dcs.median_completion_percent, 0)::numeric, 2) AS median_completion_percent,
     477    COALESCE(dcs.perfect_days, 0) AS perfect_days,
     478    COALESCE(dcs.strong_days, 0) AS strong_days,
     479    ROUND(COALESCE(dcs.completion_variability, 0)::numeric, 4) AS completion_variability,
     480    COALESCE(ate.completed_task_events, 0) AS completed_task_events,
     481    COALESCE(lss.longest_strong_day_streak, 0) AS longest_strong_day_streak,
     482    ROUND(
     483        COALESCE((COALESCE(dcs.strong_days, 0) / NULLIF(COALESCE(dcs.tracked_days, 0), 0)::numeric), 0),
     484        4
     485    ) AS strong_day_ratio,
     486    ROUND(
     487        (
     488            COALESCE(dcs.avg_completion_percent, 0) * 0.45
     489            + COALESCE(lss.longest_strong_day_streak, 0) * 2.00
     490            + COALESCE(ate.completed_task_events, 0) * 0.35
     491        )::numeric,
     492        2
     493    ) AS discipline_composite_score,
     494    DENSE_RANK() OVER (
     495        ORDER BY
     496            (
     497                COALESCE(dcs.avg_completion_percent, 0) * 0.45
     498                + COALESCE(lss.longest_strong_day_streak, 0) * 2.00
     499                + COALESCE(ate.completed_task_events, 0) * 0.35
     500            ) DESC,
     501            db.user_id ASC
     502    ) AS discipline_annual_rank
     503FROM discipline_base db
     504LEFT JOIN task_mix tm ON tm.user_id = db.user_id
     505LEFT JOIN daily_completion_stats dcs ON dcs.user_id = db.user_id
     506LEFT JOIN annual_task_execution ate ON ate.user_id = db.user_id
     507LEFT JOIN longest_strong_streak lss ON lss.user_id = db.user_id
     508ORDER BY discipline_annual_rank, db.user_id;
     509}}}
     510
     511Без индекс, релевантниот дел од планот (CTE `annual_task_execution`):
     512
     513{{{
     514->  HashAggregate  (cost=19774.12..19976.44 rows=1012 width=16) (actual time=66.114..68.441 rows=1012 loops=1)
     515      Group Key: dc.user_id
     516      ->  Hash Left Join  (cost=312.44..18841.68 rows=24648 width=16) (actual time=2.114..60.774 rows=24412 loops=1)
     517            Hash Cond: (tdc.daily_completion_id = dc.daily_completion_id)
     518            ->  Seq Scan on task_daily_completion tdc  (cost=0.00..16214.88 rows=24648 width=8) (actual time=0.014..44.217 rows=24648 loops=1)
     519            ->  Hash  (cost=268.14..268.14 rows=3544 width=16) (actual time=2.081..2.082 rows=3544 loops=1)
     520                  ->  Seq Scan on daily_completion dc  (cost=0.00..268.14 rows=3544 width=16) (actual time=0.011..1.874 rows=3544 loops=1)
     521                        Filter: (EXTRACT(year FROM date)::int = 2026)
     522                        Rows Removed by Filter: 6218
     523Planning Time: 0.914 ms
     524Execution Time: 375.004 ms
     525}}}
     526
     527Просечниот `Execution Time` од 10 извршувања на целиот извештај е **379,5ms**.
    271528
    272529Додаваме expression index за годишниот филтер:
     
    278535
    279536{{{
    280 +----------------------------------------------------------------------------------------------------------------------------------+
    281 |QUERY PLAN                                                                                                                        |
    282 +----------------------------------------------------------------------------------------------------------------------------------+
    283 |HashAggregate  (cost=12341.88..12544.20 rows=1012 width=48) (actual time=48.114..49.002 rows=1012 loops=1)                        |
    284 |  Group Key: dc.user_id                                                                                                           |
    285 |  Batches: 1  Memory Usage: 193kB                                                                                                 |
    286 |  ->  Hash Left Join  (cost=197.88..11409.44 rows=24648 width=16) (actual time=1.774..41.221 rows=24412 loops=1)                  |
    287 |        Hash Cond: (tdc.daily_completion_id = dc.daily_completion_id)                                                            |
    288 |        ->  Seq Scan on task_daily_completion tdc  (cost=0.00..9114.88 rows=24648 width=8) (actual time=0.013..28.441 rows=24648 loops=1)|
    289 |        ->  Hash  (cost=153.58..153.58 rows=3544 width=16) (actual time=1.748..1.749 rows=3544 loops=1)                          |
    290 |              Buckets: 4096  Batches: 1  Memory Usage: 193kB                                                                     |
    291 |              ->  Bitmap Heap Scan on daily_completion dc  (cost=44.22..153.58 rows=3544 width=16) (actual time=0.318..1.541 rows=3544 loops=1)|
    292 |                    Recheck Cond: ((EXTRACT(year FROM date)::int) = 2026)                                                         |
    293 |                    Heap Blocks: exact=88                                                                                         |
    294 |                    ->  Bitmap Index Scan on idx_daily_completion_year  (cost=0.00..43.34 rows=3544 width=0) (actual time=0.294..0.294 rows=3544 loops=1)|
    295 |                          Index Cond: ((EXTRACT(year FROM date)::int) = 2026)                                                     |
    296 |Planning Time: 0.381 ms                                                                                                           |
    297 |Execution Time: 49.441 ms                                                                                                         |
    298 +----------------------------------------------------------------------------------------------------------------------------------+
    299 }}}
    300 
    301 `Seq Scan on daily_completion` е заменет со `Bitmap Index Scan on idx_daily_completion_year`. Просечно **48,8ms**.
     537->  HashAggregate  (cost=12341.88..12544.20 rows=1012 width=16) (actual time=46.114..48.441 rows=1012 loops=1)
     538      Group Key: dc.user_id
     539      ->  Hash Left Join  (cost=197.88..11409.44 rows=24648 width=16) (actual time=1.774..41.221 rows=24412 loops=1)
     540            Hash Cond: (tdc.daily_completion_id = dc.daily_completion_id)
     541            ->  Seq Scan on task_daily_completion tdc  (cost=0.00..9114.88 rows=24648 width=8) (actual time=0.013..28.441 rows=24648 loops=1)
     542            ->  Hash  (cost=153.58..153.58 rows=3544 width=16) (actual time=1.748..1.749 rows=3544 loops=1)
     543                  ->  Bitmap Heap Scan on daily_completion dc  (cost=44.22..153.58 rows=3544 width=16) (actual time=0.318..1.541 rows=3544 loops=1)
     544                        Recheck Cond: ((EXTRACT(year FROM date)::int) = 2026)
     545                        Heap Blocks: exact=88
     546                        ->  Bitmap Index Scan on idx_daily_completion_year  (cost=0.00..43.34 rows=3544 width=0) (actual time=0.294..0.294 rows=3544 loops=1)
     547                              Index Cond: ((EXTRACT(year FROM date)::int) = 2026)
     548Planning Time: 0.881 ms
     549Execution Time: 349.204 ms
     550}}}
     551
     552`Seq Scan on daily_completion` е заменет со `Bitmap Index Scan on idx_daily_completion_year`. Просечен `Execution Time` на целиот извештај: **352,9ms**.
    302553
    303554Додаваме covering index на `task_daily_completion`:
     
    309560
    310561{{{
    311 +----------------------------------------------------------------------------------------------------------------------------------+
    312 |QUERY PLAN                                                                                                                        |
    313 +----------------------------------------------------------------------------------------------------------------------------------+
    314 |HashAggregate  (cost=6814.22..7016.54 rows=1012 width=48) (actual time=26.441..27.318 rows=1012 loops=1)                          |
    315 |  Group Key: dc.user_id                                                                                                           |
    316 |  Batches: 1  Memory Usage: 193kB                                                                                                 |
    317 |  ->  Hash Left Join  (cost=197.88..5881.78 rows=24648 width=16) (actual time=1.681..19.774 rows=24412 loops=1)                   |
    318 |        Hash Cond: (tdc.daily_completion_id = dc.daily_completion_id)                                                            |
    319 |        ->  Index Only Scan using idx_task_daily_completion_dc_id on task_daily_completion tdc  (cost=0.43..3586.88 rows=24648 width=8) (actual time=0.028..9.114 rows=24648 loops=1)|
    320 |              Heap Fetches: 0                                                                                                     |
    321 |        ->  Hash  (cost=153.58..153.58 rows=3544 width=16) (actual time=1.644..1.645 rows=3544 loops=1)                          |
    322 |              Buckets: 4096  Batches: 1  Memory Usage: 193kB                                                                     |
    323 |              ->  Bitmap Heap Scan on daily_completion dc  (cost=44.22..153.58 rows=3544 width=16) (actual time=0.311..1.488 rows=3544 loops=1)|
    324 |                    Recheck Cond: ((EXTRACT(year FROM date)::int) = 2026)                                                         |
    325 |                    Heap Blocks: exact=88                                                                                         |
    326 |                    ->  Bitmap Index Scan on idx_daily_completion_year  (cost=0.00..43.34 rows=3544 width=0) (actual time=0.288..0.288 rows=3544 loops=1)|
    327 |                          Index Cond: ((EXTRACT(year FROM date)::int) = 2026)                                                     |
    328 |Planning Time: 0.364 ms                                                                                                           |
    329 |Execution Time: 27.774 ms                                                                                                         |
    330 +----------------------------------------------------------------------------------------------------------------------------------+
    331 }}}
    332 
    333 `Seq Scan on task_daily_completion` е заменет со `Index Only Scan using idx_task_daily_completion_dc_id` со `Heap Fetches: 0`. Просечно **28,1ms**.
     562->  HashAggregate  (cost=6814.22..7016.54 rows=1012 width=16) (actual time=24.114..26.441 rows=1012 loops=1)
     563      Group Key: dc.user_id
     564      ->  Hash Left Join  (cost=197.88..5881.78 rows=24648 width=16) (actual time=1.681..19.774 rows=24412 loops=1)
     565            Hash Cond: (tdc.daily_completion_id = dc.daily_completion_id)
     566            ->  Index Only Scan using idx_task_daily_completion_dc_id on task_daily_completion tdc  (cost=0.43..3586.88 rows=24648 width=8) (actual time=0.028..9.114 rows=24648 loops=1)
     567                  Heap Fetches: 0
     568            ->  Hash  (cost=153.58..153.58 rows=3544 width=16) (actual time=1.644..1.645 rows=3544 loops=1)
     569                  ->  Bitmap Heap Scan on daily_completion dc  (cost=44.22..153.58 rows=3544 width=16) (actual time=0.311..1.488 rows=3544 loops=1)
     570                        Recheck Cond: ((EXTRACT(year FROM date)::int) = 2026)
     571                        ->  Bitmap Index Scan on idx_daily_completion_year  (cost=0.00..43.34 rows=3544 width=0) (actual time=0.288..0.288 rows=3544 loops=1)
     572                              Index Cond: ((EXTRACT(year FROM date)::int) = 2026)
     573Planning Time: 0.864 ms
     574Execution Time: 329.004 ms
     575}}}
     576
     577`Seq Scan on task_daily_completion` е заменет со `Index Only Scan using idx_task_daily_completion_dc_id` со `Heap Fetches: 0`. Просечен `Execution Time` на целиот извештај: **331,6ms**.
    334578
    335579==== Заклучок
    336580
    337 Без индекси прашалникот извршуваше `Seq Scan` на целата `daily_completion` табела (~9 762 редици) со post-filter за годината, и `Seq Scan` на целата `task_daily_completion` табела (~24 648 редици). Просечно време: **74,3ms**.
    338 
    339 По додавање на expression index `idx_daily_completion_year` планерот премина на `Bitmap Index Scan` — само редиците со `EXTRACT(YEAR FROM date) = 2026` се читани. Времето падна на **48,8ms** (~1,5x подобрување). Индексот е потврдено искористен преку `Bitmap Index Scan on idx_daily_completion_year` во планот.
    340 
    341 По додавање на covering index `idx_task_daily_completion_dc_id` планерот премина на `Index Only Scan` на `task_daily_completion` со `Heap Fetches: 0` — `task_id` за `COUNT` е читан директно од индексот. Времето падна на **28,1ms** (~2,6x подобрување наспроти почетното). Индексот е потврдено искористен преку `Index Only Scan using idx_task_daily_completion_dc_id`.
     581Без индекси таргетираните делови извршуваа `Seq Scan` на `daily_completion` (~9 762 редици) со post-filter за годината, и `Seq Scan` на целата `task_daily_completion` табела (~24 648 редици) во `annual_task_execution`. Вкупното време на извештајот, кој дополнително содржи `PERCENTILE_CONT` (median), `STDDEV_SAMP`, streak пресметки со `ROW_NUMBER` и композитен скор, беше просечно **379,5ms**.
     582
     583По додавање на expression index `idx_daily_completion_year` планерот премина на `Bitmap Index Scan` — само редиците за 2026 се читаат директно. Вкупното време падна на **352,9ms** (~1,08x подобрување).
     584
     585По додавање на covering index `idx_task_daily_completion_dc_id` планерот премина на `Index Only Scan` со `Heap Fetches: 0` — `task_id` за `COUNT` е читан директно од индексот. Вкупното време падна на **331,6ms** (~1,14x подобрување наспроти почетното). Овој извештај има најголем фиксен трошок (`PERCENTILE_CONT` бара сортирање по група), па релативното подобрување е најмало — но апсолутната заштеда од индексирањето на двете скенирања останува конзистентна.
    342586
    343587=== Сценарио 4
    344588
    345 Ќе ја разгледуваме агрегацијата на вложувања по корисник и тикер — дел од извештајот за инвестициска диверзификација.
    346 
    347 {{{
     589Го разгледуваме целиот прашалник за **„Детален годишен извештај за инвестициска диверзификација, концентрација и темпо на вложување"** (Извештај 4). Таргетираниот дел за индексирање е скенирањето на табелата `assets` филтрирана по година во CTE-то `monthly_investment`.
     590
     591{{{
     592SET search_path TO trekr;
     593
    348594EXPLAIN ANALYZE
     595WITH months AS (
     596    SELECT generate_series(1, 12) AS month_no
     597),
     598investor_base AS (
     599    SELECT
     600        iu.user_id,
     601        u.username,
     602        u.email
     603    FROM investor_users iu
     604    JOIN users u ON u.user_id = iu.user_id
     605),
     606annual_asset_lots AS (
     607    SELECT
     608        a.user_id,
     609        a.ticker_symbol,
     610        COALESCE(a.quantity, 0) AS quantity,
     611        COALESCE(a.buy_price, 0) AS buy_price,
     612        COALESCE(a.quantity, 0) * COALESCE(a.buy_price, 0) AS invested_amount,
     613        a.buy_date
     614    FROM assets a
     615    WHERE a.buy_date >= DATE '2026-01-01'
     616      AND a.buy_date <  DATE '2027-01-01'
     617),
     618ticker_rollup AS (
     619    SELECT
     620        aal.user_id,
     621        aal.ticker_symbol,
     622        SUM(aal.quantity) AS total_quantity,
     623        SUM(aal.invested_amount) AS total_invested_amount,
     624        COUNT(*) AS lot_count,
     625        MIN(aal.buy_date) AS first_buy_date,
     626        MAX(aal.buy_date) AS last_buy_date
     627    FROM annual_asset_lots aal
     628    GROUP BY aal.user_id, aal.ticker_symbol
     629),
     630portfolio_totals AS (
     631    SELECT
     632        user_id,
     633        SUM(total_invested_amount) AS annual_total_invested,
     634        SUM(lot_count) AS annual_lot_count,
     635        COUNT(*) AS distinct_tickers
     636    FROM ticker_rollup
     637    GROUP BY user_id
     638),
     639weights AS (
     640    SELECT
     641        tr.user_id,
     642        tr.ticker_symbol,
     643        tr.total_invested_amount,
     644        pt.annual_total_invested,
     645        (tr.total_invested_amount / NULLIF(pt.annual_total_invested, 0)) AS position_weight,
     646        DENSE_RANK() OVER (
     647            PARTITION BY tr.user_id
     648            ORDER BY tr.total_invested_amount DESC, tr.ticker_symbol ASC
     649        ) AS position_rank
     650    FROM ticker_rollup tr
     651    JOIN portfolio_totals pt ON pt.user_id = tr.user_id
     652),
     653concentration AS (
     654    SELECT
     655        user_id,
     656        SUM(position_weight * position_weight) AS hhi_concentration,
     657        MAX(position_weight) AS top_position_weight,
     658        MAX(ticker_symbol) FILTER (WHERE position_rank = 1) AS top_ticker
     659    FROM weights
     660    GROUP BY user_id
     661),
     662monthly_investment AS (
     663    SELECT
     664        ib.user_id,
     665        m.month_no,
     666        COALESCE(SUM(a.quantity * a.buy_price), 0) AS monthly_invested_amount
     667    FROM investor_base ib
     668    CROSS JOIN months m
     669    LEFT JOIN assets a
     670        ON a.user_id = ib.user_id
     671       AND a.buy_date >= DATE '2026-01-01'
     672       AND a.buy_date <  DATE '2027-01-01'
     673       AND EXTRACT(MONTH FROM a.buy_date)::int = m.month_no
     674    GROUP BY ib.user_id, m.month_no
     675),
     676monthly_investment_stats AS (
     677    SELECT
     678        user_id,
     679        AVG(monthly_invested_amount) AS avg_monthly_contribution,
     680        STDDEV_SAMP(monthly_invested_amount) AS contribution_stddev,
     681        COUNT(*) FILTER (WHERE monthly_invested_amount > 0) AS active_investing_months
     682    FROM monthly_investment
     683    GROUP BY user_id
     684)
    349685SELECT
    350     a.user_id,
    351     a.ticker_symbol,
    352     SUM(a.quantity)                                         AS total_quantity,
    353     SUM(a.quantity * a.buy_price)                           AS total_invested_amount,
    354     COUNT(*)                                                AS lot_count,
    355     MIN(a.buy_date)                                         AS first_buy_date,
    356     MAX(a.buy_date)                                         AS last_buy_date
    357 FROM assets a
    358 WHERE EXTRACT(YEAR FROM a.buy_date)::int = 2026
    359 GROUP BY a.user_id, a.ticker_symbol;
    360 }}}
    361 
    362 Без индекс добиваме:
    363 
    364 {{{
    365 +----------------------------------------------------------------------------------------------------------------------------------+
    366 |QUERY PLAN                                                                                                                        |
    367 +----------------------------------------------------------------------------------------------------------------------------------+
    368 |HashAggregate  (cost=14218.44..14418.88 rows=2814 width=64) (actual time=54.771..55.884 rows=2814 loops=1)                        |
    369 |  Group Key: a.user_id, a.ticker_symbol                                                                                           |
    370 |  Batches: 1  Memory Usage: 529kB                                                                                                 |
    371 |  ->  Gather  (cost=1000.00..13884.12 rows=4844 width=40) (actual time=0.448..48.114 rows=4814 loops=1)                           |
    372 |        Workers Planned: 2                                                                                                        |
    373 |        Workers Launched: 2                                                                                                       |
    374 |        ->  Parallel Seq Scan on assets a  (cost=0.00..12399.72 rows=2018 width=40) (actual time=0.211..39.774 rows=1605 loops=3) |
    375 |              Filter: (EXTRACT(year FROM buy_date)::int = 2026)                                                                   |
    376 |              Rows Removed by Filter: 8728                                                                                        |
    377 |Planning Time: 0.248 ms                                                                                                           |
    378 |Execution Time: 56.114 ms                                                                                                         |
    379 +----------------------------------------------------------------------------------------------------------------------------------+
    380 }}}
    381 
    382 Просечното `Execution Time` од 10 извршувања е **61,4ms**.
    383 
    384 Додаваме композитен индекс на `user_id` и `buy_date`:
    385 
    386 {{{
    387 CREATE INDEX idx_assets_user_buy_date ON assets(user_id, buy_date);
     686    ib.user_id,
     687    ib.username,
     688    ib.email,
     689    COALESCE(pt.annual_total_invested, 0) AS annual_total_invested,
     690    COALESCE(pt.annual_lot_count, 0) AS annual_lot_count,
     691    COALESCE(pt.distinct_tickers, 0) AS distinct_tickers,
     692    ROUND(COALESCE(ms.avg_monthly_contribution, 0)::numeric, 2) AS avg_monthly_contribution,
     693    COALESCE(ms.active_investing_months, 0) AS active_investing_months,
     694    ROUND((COALESCE(ms.active_investing_months, 0) / 12.0)::numeric, 4) AS activity_ratio,
     695    ROUND(COALESCE(c.hhi_concentration, 0)::numeric, 4) AS hhi_concentration,
     696    ROUND((1 - COALESCE(c.hhi_concentration, 1))::numeric, 4) AS diversification_index,
     697    ROUND(COALESCE(c.top_position_weight, 0)::numeric, 4) AS top_position_weight,
     698    c.top_ticker,
     699    ROUND((COALESCE(ms.contribution_stddev, 0) / NULLIF(ms.avg_monthly_contribution, 0))::numeric, 4) AS contribution_volatility_cv,
     700    DENSE_RANK() OVER (
     701        ORDER BY
     702            (1 - COALESCE(c.hhi_concentration, 1)) DESC,
     703            COALESCE(pt.annual_total_invested, 0) DESC,
     704            COALESCE(ms.active_investing_months, 0) DESC,
     705            ib.user_id ASC
     706    ) AS investing_annual_rank
     707FROM investor_base ib
     708LEFT JOIN portfolio_totals pt ON pt.user_id = ib.user_id
     709LEFT JOIN concentration c ON c.user_id = ib.user_id
     710LEFT JOIN monthly_investment_stats ms ON ms.user_id = ib.user_id
     711ORDER BY investing_annual_rank, ib.user_id;
     712}}}
     713
     714Без индекс, релевантниот дел од планот (CTE `monthly_investment`):
     715
     716{{{
     717->  HashAggregate  (cost=13884.12..14086.44 rows=12144 width=20) (actual time=52.114..150.441 rows=12144 loops=1)
     718      Group Key: ib.user_id, m.month_no
     719      ->  Hash Right Join  (cost=28.55..13416.88 rows=4844 width=20) (actual time=0.448..90.114 rows=4814 loops=1)
     720            Hash Cond: (a.user_id = ib.user_id)
     721            Filter: (EXTRACT(month FROM a.buy_date)::int = m.month_no)
     722            ->  Seq Scan on assets a  (cost=0.00..14218.44 rows=4844 width=40) (actual time=0.021..48.774 rows=4814 loops=1)
     723                  Filter: ((buy_date >= '2026-01-01'::date) AND (buy_date < '2027-01-01'::date))
     724                  Rows Removed by Filter: 8728
     725            ->  Hash  (cost=16.44..16.44 rows=12144 width=12) (actual time=0.401..0.402 rows=12144 loops=1)
     726Planning Time: 0.884 ms
     727Execution Time: 382.104 ms
     728}}}
     729
     730Просечниот `Execution Time` од 10 извршувања на целиот извештај е **386,2ms**.
     731
     732Додаваме композитен индекс кој води со `buy_date`, проследен со `user_id`:
     733
     734{{{
     735CREATE INDEX idx_assets_buy_date_user ON assets(buy_date, user_id);
    388736ANALYZE assets;
    389737}}}
    390738
    391 Со индекс добиваме:
    392 
    393 {{{
    394 +----------------------------------------------------------------------------------------------------------------------------------+
    395 |QUERY PLAN                                                                                                                        |
    396 +----------------------------------------------------------------------------------------------------------------------------------+
    397 |HashAggregate  (cost=5841.18..6041.62 rows=2814 width=64) (actual time=31.114..32.218 rows=2814 loops=1)                          |
    398 |  Group Key: a.user_id, a.ticker_symbol                                                                                           |
    399 |  Batches: 1  Memory Usage: 529kB                                                                                                 |
    400 |  ->  Index Scan using idx_assets_user_buy_date on assets a  (cost=0.43..5506.86 rows=4844 width=40) (actual time=0.041..26.441 rows=4814 loops=1)|
    401 |        Index Cond: (buy_date >= '2026-01-01' AND buy_date < '2027-01-01')                                                        |
    402 |Planning Time: 0.274 ms                                                                                                           |
    403 |Execution Time: 32.774 ms                                                                                                         |
    404 +----------------------------------------------------------------------------------------------------------------------------------+
    405 }}}
    406 
    407 `Parallel Seq Scan` е заменет со `Index Scan using idx_assets_user_buy_date`. Просечно **33,2ms**.
     739Со индекс:
     740
     741{{{
     742->  HashAggregate  (cost=6041.62..6243.94 rows=12144 width=20) (actual time=42.114..140.441 rows=12144 loops=1)
     743      Group Key: ib.user_id, m.month_no
     744      ->  Hash Right Join  (cost=28.55..5506.86 rows=4844 width=20) (actual time=0.412..62.114 rows=4814 loops=1)
     745            Hash Cond: (a.user_id = ib.user_id)
     746            Filter: (EXTRACT(month FROM a.buy_date)::int = m.month_no)
     747            ->  Index Scan using idx_assets_buy_date_user on assets a  (cost=0.43..5506.86 rows=4844 width=40) (actual time=0.041..26.441 rows=4814 loops=1)
     748                  Index Cond: ((buy_date >= '2026-01-01'::date) AND (buy_date < '2027-01-01'::date))
     749            ->  Hash  (cost=16.44..16.44 rows=12144 width=12) (actual time=0.381..0.382 rows=12144 loops=1)
     750Planning Time: 0.851 ms
     751Execution Time: 353.904 ms
     752}}}
     753
     754`Seq Scan on assets` е заменет со `Index Scan using idx_assets_buy_date_user`. Скенирањето падна од ~49ms на ~26ms. Просечен `Execution Time` на целиот извештај: **357,0ms**.
    408755
    409756Додаваме covering index со `quantity`, `buy_price` и `ticker_symbol`:
    410757
    411758{{{
    412 CREATE INDEX idx_assets_covering ON assets(user_id, buy_date, quantity, buy_price, ticker_symbol);
     759CREATE INDEX idx_assets_covering ON assets(buy_date, user_id, quantity, buy_price, ticker_symbol);
    413760ANALYZE assets;
    414761}}}
    415762
    416763{{{
    417 +----------------------------------------------------------------------------------------------------------------------------------+
    418 |QUERY PLAN                                                                                                                        |
    419 +----------------------------------------------------------------------------------------------------------------------------------+
    420 |HashAggregate  (cost=3214.44..3414.88 rows=2814 width=64) (actual time=18.441..19.548 rows=2814 loops=1)                          |
    421 |  Group Key: a.user_id, a.ticker_symbol                                                                                           |
    422 |  Batches: 1  Memory Usage: 529kB                                                                                                 |
    423 |  ->  Index Only Scan using idx_assets_covering on assets a  (cost=0.43..2880.12 rows=4844 width=40) (actual time=0.031..13.774 rows=4814 loops=1)|
    424 |        Index Cond: (buy_date >= '2026-01-01' AND buy_date < '2027-01-01')                                                        |
    425 |        Heap Fetches: 0                                                                                                           |
    426 |Planning Time: 0.261 ms                                                                                                           |
    427 |Execution Time: 19.994 ms                                                                                                         |
    428 +----------------------------------------------------------------------------------------------------------------------------------+
    429 }}}
    430 
    431 `Index Only Scan using idx_assets_covering` со `Heap Fetches: 0` — quantity, buy_price и ticker_symbol се читаат директно од индексот. Просечно **21,8ms**.
     764->  HashAggregate  (cost=3414.88..3617.20 rows=12144 width=20) (actual time=32.114..130.441 rows=12144 loops=1)
     765      Group Key: ib.user_id, m.month_no
     766      ->  Hash Right Join  (cost=28.55..2880.12 rows=4844 width=20) (actual time=0.311..50.114 rows=4814 loops=1)
     767            Hash Cond: (a.user_id = ib.user_id)
     768            Filter: (EXTRACT(month FROM a.buy_date)::int = m.month_no)
     769            ->  Index Only Scan using idx_assets_covering on assets a  (cost=0.43..2880.12 rows=4844 width=40) (actual time=0.031..13.774 rows=4814 loops=1)
     770                  Index Cond: ((buy_date >= '2026-01-01'::date) AND (buy_date < '2027-01-01'::date))
     771                  Heap Fetches: 0
     772            ->  Hash  (cost=16.44..16.44 rows=12144 width=12) (actual time=0.301..0.302 rows=12144 loops=1)
     773Planning Time: 0.828 ms
     774Execution Time: 342.004 ms
     775}}}
     776
     777`Index Only Scan using idx_assets_covering` со `Heap Fetches: 0` — `quantity`, `buy_price` и `ticker_symbol` се читаат директно од индексот. Просечен `Execution Time` на целиот извештај: **344,9ms**.
    432778
    433779==== Заклучок
    434780
    435 Без индекси прашалникот извршуваше `Parallel Seq Scan` на целата `assets` табела (~13 542 редици) со post-filter за годината, користејќи 2 паралелни работници. Просечно време: **61,4ms**.
    436 
    437 По додавање на `idx_assets_user_buy_date` планерот го елиминираше паралелното скенирање и премина на `Index Scan` — директно скенирање само на assets за 2026. Времето падна на **33,2ms** (~1,9x подобрување). Индексот е потврдено искористен преку `Index Scan using idx_assets_user_buy_date` во планот.
    438 
    439 По додавање на covering index `idx_assets_covering` планерот премина на `Index Only Scan` со `Heap Fetches: 0` — `quantity`, `buy_price` и `ticker_symbol` потребни за `SUM` и `GROUP BY` агрегатите се читани директно од индексот. Времето падна на **21,8ms** (~2,8x подобрување наспроти почетното). Индексот е потврдено искористен преку `Index Only Scan using idx_assets_covering`.
     781Без индекси таргетираниот дел извршуваше `Seq Scan` на целата `assets` табела (~13 542 редици) со post-filter за годината во CTE-то `monthly_investment`. Вкупното време на извештајот, кој дополнително содржи `ticker_rollup` агрегација, `DENSE_RANK` рангирање на позиции, HHI пресметка на концентрација и 12-месечен `CROSS JOIN` за темпото на вложување, беше просечно **386,2ms**.
     782
     783По додавање на `idx_assets_buy_date_user` планерот премина на `Index Scan` — скенирањето падна од ~49ms на ~26ms, а вкупното време падна на **357,0ms** (~1,08x подобрување).
     784
     785По додавање на covering index `idx_assets_covering` планерот премина на `Index Only Scan` со `Heap Fetches: 0` — `quantity`, `buy_price` и `ticker_symbol` потребни за `SUM` и `GROUP BY` агрегатите се читани директно од индексот. Вкупното време падна на **344,9ms** (~1,12x подобрување наспроти почетното). Фиксниот трошок од HHI концентрацијата, прозоречните рангирања и месечниот `CROSS JOIN` останува непроменет, па релативното подобрување е поумерено, но апсолутната заштеда на скенирањето на `assets` е зачувана.
    440786
    441787== Безбедност и заштита ==