On 2026-10-16 at 19:20:18Z the kof22 web pod website-backend-74bdc7754c-dc7wc died with OutOfMemoryError: Java heap space. One admin request (req=14ad5fa6…, Database Performance home) rendered the Attribution panel, which ran an unbounded QueryAction on db_perf_attribution for the last 24 hours:
19:19:23 Large number of records in QueryOutputList noRecords=50000 table=db_perf_attribution
19:19:24 ... noRecords=100000
19:19:28 ... noRecords=250000
at DbPerfStore.query(DbPerfStore.java:65)
at DbPerfWidgetRenderer.attribution(DbPerfWidgetRenderer.java:288)
19:20:18 Terminating due to java.lang.OutOfMemoryError: Java heap space
Container working set was flat at ~620 MiB for 35 minutes, then jumped to 1,849 MiB in the minute of the crash. Every other request on the pod (4-row Site API queries) took 4-6 s during the GC thrash. The table holds ~571,000 rows for the last 24 h (538 MB in PostgreSQL; ~30,000 rows/hour since the web/worker split), so the widget can never fit in a 1.5 GB heap.
Two worker paths have the same shape:
dbPerfAnalyze (hourly) loads the whole day through RuleContext.window("attribution", 1). On 2026-10-16 the worker working set jumped from about 660 MiB to about 1.84 GiB at every :07-:08 run.
dbPerfRollupAndPurge logged 50k and then 100k attribution rows at 08:15 while it rolled up a day.
What
DbPerfWidgetRenderer.attribution() uses AggregateAction: SUM(statements), SUM(totalMs) grouped by unitKind, unitName, tableName, ordered by time, capped at 5,000 groups (prod has ~1,000). The heap holds aggregate rows only.
DbPerfStore.forEachPage(table, filter, pageSize, consumer): keyset-paged reads (id > last, ordered, limit).
RollupService.rollupAttribution folds the day in 1,000-row pages, so memory is bounded by distinct dimension keys (~8,500 in prod), not by the day's detail rows.
RuleContext.window("attribution", days) returns AggregateAction sums grouped by unitKind, unitName, tableName and operation (the only fields the rules read), not the detail rows.
Tests
AttributionBoundedReadTest puts a post-query customizer on db_perf_attribution that counts every record QQQ materializes:
the widget loads zero detail rows for 3,000 inserted rows and the sums, shares and top table are still right;
the 24-hour window still excludes older detail;
the analysis RuleContext window loads zero detail rows and returns the same totals;
forEachPage never returns more than the page size and sees each row once;
the rollup over 2,500 rows never holds more than BATCH_SIZE at once and its sums cover every row across page boundaries.
./mvnw verify passes (24 classes, JaCoCo gate).
Follow-up (not in this PR)
SnapshotEvaluator.spikes loads 7 days of db_perf_query_snapshot (~33k rows/day in kof22, so ~230k rows by day 7), plus every db_perf_statement row, on each snapshot. That is bounded by time, but large. It should read per-statement history in pages, or select only the fields it needs.
Attribution detail retention is the shared detailRetentionDays (default 30). At kof22's rate that is ~20M rows / ~16 GB. Consider a separate, shorter attribution retention or coarser flush keys.
## Why
On 2026-10-16 at 19:20:18Z the kof22 web pod `website-backend-74bdc7754c-dc7wc` died with `OutOfMemoryError: Java heap space`. One admin request (`req=14ad5fa6…`, Database Performance home) rendered the **Attribution** panel, which ran an unbounded `QueryAction` on `db_perf_attribution` for the last 24 hours:
```
19:19:23 Large number of records in QueryOutputList noRecords=50000 table=db_perf_attribution
19:19:24 ... noRecords=100000
19:19:28 ... noRecords=250000
at DbPerfStore.query(DbPerfStore.java:65)
at DbPerfWidgetRenderer.attribution(DbPerfWidgetRenderer.java:288)
19:20:18 Terminating due to java.lang.OutOfMemoryError: Java heap space
```
Container working set was flat at ~620 MiB for 35 minutes, then jumped to 1,849 MiB in the minute of the crash. Every other request on the pod (4-row Site API queries) took 4-6 s during the GC thrash. The table holds ~571,000 rows for the last 24 h (538 MB in PostgreSQL; ~30,000 rows/hour since the web/worker split), so the widget can never fit in a 1.5 GB heap.
Two worker paths have the same shape:
- `dbPerfAnalyze` (hourly) loads the whole day through `RuleContext.window("attribution", 1)`. On 2026-10-16 the worker working set jumped from about 660 MiB to about 1.84 GiB at every :07-:08 run.
- `dbPerfRollupAndPurge` logged 50k and then 100k attribution rows at 08:15 while it rolled up a day.
## What
- `DbPerfWidgetRenderer.attribution()` uses `AggregateAction`: `SUM(statements)`, `SUM(totalMs)` grouped by `unitKind, unitName, tableName`, ordered by time, capped at 5,000 groups (prod has ~1,000). The heap holds aggregate rows only.
- `DbPerfStore.forEachPage(table, filter, pageSize, consumer)`: keyset-paged reads (id > last, ordered, limit).
- `RollupService.rollupAttribution` folds the day in 1,000-row pages, so memory is bounded by distinct dimension keys (~8,500 in prod), not by the day's detail rows.
- `RuleContext.window("attribution", days)` returns `AggregateAction` sums grouped by unitKind, unitName, tableName and operation (the only fields the rules read), not the detail rows.
## Tests
`AttributionBoundedReadTest` puts a post-query customizer on `db_perf_attribution` that counts every record QQQ materializes:
- the widget loads **zero** detail rows for 3,000 inserted rows and the sums, shares and top table are still right;
- the 24-hour window still excludes older detail;
- the analysis `RuleContext` window loads **zero** detail rows and returns the same totals;
- `forEachPage` never returns more than the page size and sees each row once;
- the rollup over 2,500 rows never holds more than `BATCH_SIZE` at once and its sums cover every row across page boundaries.
`./mvnw verify` passes (24 classes, JaCoCo gate).
## Follow-up (not in this PR)
- `SnapshotEvaluator.spikes` loads 7 days of `db_perf_query_snapshot` (~33k rows/day in kof22, so ~230k rows by day 7), plus every `db_perf_statement` row, on each snapshot. That is bounded by time, but large. It should read per-statement history in pages, or select only the fields it needs.
Attribution detail retention is the shared `detailRetentionDays` (default 30). At kof22's rate that is ~20M rows / ~16 GB. Consider a separate, shorter attribution retention or coarser flush keys.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Why
On 2026-10-16 at 19:20:18Z the kof22 web pod
website-backend-74bdc7754c-dc7wcdied withOutOfMemoryError: Java heap space. One admin request (req=14ad5fa6…, Database Performance home) rendered the Attribution panel, which ran an unboundedQueryActionondb_perf_attributionfor the last 24 hours:Container working set was flat at ~620 MiB for 35 minutes, then jumped to 1,849 MiB in the minute of the crash. Every other request on the pod (4-row Site API queries) took 4-6 s during the GC thrash. The table holds ~571,000 rows for the last 24 h (538 MB in PostgreSQL; ~30,000 rows/hour since the web/worker split), so the widget can never fit in a 1.5 GB heap.
Two worker paths have the same shape:
dbPerfAnalyze(hourly) loads the whole day throughRuleContext.window("attribution", 1). On 2026-10-16 the worker working set jumped from about 660 MiB to about 1.84 GiB at every :07-:08 run.dbPerfRollupAndPurgelogged 50k and then 100k attribution rows at 08:15 while it rolled up a day.What
DbPerfWidgetRenderer.attribution()usesAggregateAction:SUM(statements),SUM(totalMs)grouped byunitKind, unitName, tableName, ordered by time, capped at 5,000 groups (prod has ~1,000). The heap holds aggregate rows only.DbPerfStore.forEachPage(table, filter, pageSize, consumer): keyset-paged reads (id > last, ordered, limit).RollupService.rollupAttributionfolds the day in 1,000-row pages, so memory is bounded by distinct dimension keys (~8,500 in prod), not by the day's detail rows.RuleContext.window("attribution", days)returnsAggregateActionsums grouped by unitKind, unitName, tableName and operation (the only fields the rules read), not the detail rows.Tests
AttributionBoundedReadTestputs a post-query customizer ondb_perf_attributionthat counts every record QQQ materializes:RuleContextwindow loads zero detail rows and returns the same totals;forEachPagenever returns more than the page size and sees each row once;BATCH_SIZEat once and its sums cover every row across page boundaries../mvnw verifypasses (24 classes, JaCoCo gate).Follow-up (not in this PR)
SnapshotEvaluator.spikesloads 7 days ofdb_perf_query_snapshot(~33k rows/day in kof22, so ~230k rows by day 7), plus everydb_perf_statementrow, on each snapshot. That is bounded by time, but large. It should read per-statement history in pages, or select only the fields it needs.Attribution detail retention is the shared
detailRetentionDays(default 30). At kof22's rate that is ~20M rows / ~16 GB. Consider a separate, shorter attribution retention or coarser flush keys.🤖 Generated with Claude Code