Fix query tier selection for sub-resolution windows (#22495)
Costa Tsaousis committed
May 17, 2026 at 22:36 UTC
2c44acdefbad6255417001100d649a46877cea9e
6 files changed
+974
-125
.agents/sow/done/SOW-0017-20260516-query-duration-below-collection-frequency.md
new
+504
@@ -0,0 +1,504 @@
1
+# SOW-0017 - Query duration below collection frequency returns no data
2
+
3
+## Status
4
+
5
+Status: completed
6
+
7
+Sub-state: completed on 2026-05-17 after implementation, installation, focused unit validation, and live direct-agent API validation.
8
+
9
+## Requirements
10
+
11
+### Purpose
12
+
13
+Make Netdata metric queries fit for troubleshooting narrow historical windows: when data exists in the requested time area, querying a duration shorter than the metric collection frequency must not silently return an empty result only because the requested window sits between collection-aligned timestamps.
14
+
15
+### User Request
16
+
17
+The user reported a hypothesis:
18
+
19
+- Metrics such as SNMP or go.d charts are often collected every 10 seconds.
20
+- A 10-minute query with 60 points should show the 10-second samples exist.
21
+- A 5-second absolute query around 5 minutes in the past, fully inside one 10-second alignment interval, returns empty data even though nearby data exists.
22
+- The first step must verify the hypothesis before creating a SOW or implementing anything.
23
+
24
+### Assistant Understanding
25
+
26
+Facts:
27
+
28
+- Local Netdata Agent was reachable at `127.0.0.1:19999`.
29
+- Local Netdata Agent version was reported by `/api/v1/info` as `v2.10.0-199-g532b0ceef2`.
30
+- `/api/v3/contexts` does not expose collection `update_every`; it exposes context metadata, labels, dimensions, instances, retention, and liveness depending on options.
31
+- `/api/v3/contexts?contexts=smartctl.device_temperature&options=labels,instances,retention,liveness,minify` confirmed the selected context is collected by `_collect_plugin=go.d` and `_collect_module=smartctl`.
32
+- `/api/v1/context?context=smartctl.device_temperature&options=instances` confirmed instances of `smartctl.device_temperature` have `update_every=10`.
33
+- Direct local `/api/v3/data` in this checkout is query-string driven; POSTing the JSON body documented by the skill was ignored for local direct-agent calls.
34
+
35
+- The verified symptom is in automatic tier selection/planning, not in collector emission or storage execution, because forced `tier=0` and forced `tier=1` return data for the same narrow window.
36
+- The empty result is not caused by retention absence: tier 0 retention covers the tested timestamps.
37
+- The broader hypothesis that any tier-2-only query shorter than tier-2 resolution returns empty was not reproduced on the local agent. Tier-2-only 5-minute windows returned data for both `system.cpu` and `smartctl.device_temperature`.
38
+
39
+Unknowns:
40
+
41
+- Relative-window variants were not exhaustively tested. The traced trigger is based on the effective window after query-window normalization, so any request form that produces the same effective window can hit it.
42
+
43
+### Acceptance Criteria
44
+
45
+- Root cause is traced in code with file:line evidence before patching.
46
+- `/api/v3/data` returns meaningful data for a historical sub-frequency window when a sample exists in the surrounding collection interval, or a deliberate product decision explains why it should not.
47
+- Existing aligned 10-second queries for 10-second go.d metrics continue to return all expected points.
48
+- Tests cover the empty-result regression using a lower-frequency metric or a query-engine fixture.
49
+- Validation records exact commands/results for baseline, failing pre-fix case, and fixed post-fix case.
50
+- Public/end-user skill documentation is changed only if a user/operator workflow changes; internal query-planner findings stay in SOW/spec artifacts.
51
+
52
+## Analysis
53
+
54
+Sources checked:
55
+
56
+- `docs/netdata-ai/skills/query-netdata-agents/SKILL.md`
57
+- `docs/netdata-ai/skills/query-netdata-agents/query-metrics.md`
58
+- `src/web/api/v2/api_v2_contexts.c`
59
+- `src/database/contexts/api_v2_contexts.c`
60
+- `src/web/api/maps/contexts_options.c`
61
+- `src/database/contexts/api_v1_contexts.c`
62
+- `src/web/api/v2/api_v2_data.c`
63
+- `src/web/api/queries/query-window.c`
64
+- `src/web/api/queries/query-plan.c`
65
+- `src/database/contexts/query_target.c`
66
+- `src/database/contexts/rrdcontext.h`
67
+- Local Netdata Agent API at `127.0.0.1:19999`
68
+
69
+Current state:
70
+
71
+- v3 context metadata can identify go.d/SNMP-style collector ownership via labels, but not collection cadence.
72
+- v1 context metadata exposes per-instance `update_every`.
73
+- v3 data responses expose `db.per_tier[].update_every`, which is database tier resolution for the query, not general context metadata.
74
+- Direct local v3 data request parsing reads URL parameters in `api_v23_data_internal()`; the parser does not read the POST JSON body in the inspected path.
75
+
76
+Risks:
77
+
78
+- A query-window fix may affect all `/api/v2/data` and `/api/v3/data` users, including Cloud-proxied metrics queries.
79
+- A naive widening fix could return data outside the user's requested interval and surprise callers expecting strict time boundaries.
80
+- A strict no-widening interpretation preserves mathematical precision but makes sub-frequency historical inspection unreliable for real troubleshooting workflows.
81
+- Tier-selection behavior may differ across tier 0, tier 1, and tier 2, so tests must not cover only tier 0.
82
+
83
+## Pre-Implementation Gate
84
+
85
+Status: diagnosis complete for the verified local reproduction
86
+
87
+Problem / root-cause model:
88
+
89
+- Verified symptom: for a 10-second go.d metric, every tested 5-second absolute query across a full 10-second cadence cycle selects the right target objects but returns no data and performs zero tier queries, while adjacent/wider windows return data from tier 0.
90
+- Corrected phase split: query-target selects the matching context, instances, and dimension; the queryable metric / planner path does not create any tier plan for them.
91
+- Root-cause model: automatic per-metric tier selection gives every overlapping tier a sentinel "unusable" weight when the requested effective duration is shorter than the tier update interval. The same sentinel is also used for non-overlapping tiers. The `>=` tie-break then selects the highest numbered tier among tied sentinel weights.
92
+- Forced-tier controls prove the query engine can answer the exact narrow window when the planner is told to use a covering tier.
93
+- The empty result is only one outcome of the trigger. If the highest tied tier covers the requested window, the query returns data from that higher/coarser tier. If the highest tied tier does not cover the requested window, planning returns false and the response is empty.
94
+
95
+Evidence reviewed:
96
+
97
+- v3 collector identity:
98
+ - Request: `/api/v3/contexts?contexts=smartctl.device_temperature&options=labels,instances,retention,liveness,minify`
99
+ - Result: selected context has `_collect_plugin=go.d` and `_collect_module=smartctl`.
100
+- v1 update frequency:
101
+ - Request: `/api/v1/context?context=smartctl.device_temperature&options=instances`
102
+ - Result: `smartctl.device_temperature` instances include `update_every=10`.
103
+- Baseline 10-minute query:
104
+ - Request: `/api/v3/data?contexts=smartctl.device_temperature&after=1778958670&before=1778959270&points=60&group_by=dimension&aggregation=average&time_group=average&format=json2&options=jsonwrap,minify,unaligned&timeout=30000`
105
+ - Result: `result.data` length was `60`; first timestamp `1778959270`; last timestamp `1778958680`; tier 0 `update_every=10`.
106
+- Failing 5-second sub-frequency query around 5 minutes in the past:
107
+ - Request: `/api/v3/data?contexts=smartctl.device_temperature&after=1778958983&before=1778958988&points=5&group_by=dimension&aggregation=average&time_group=average&format=json2&options=jsonwrap,minify,unaligned&timeout=30000`
108
+ - Result: `result.data` length was `0`; `db.per_tier[0].queries=0`; tier 0 retention covered the window.
109
+- Same failing window with `points=1` and with default points also returned `result.data` length `0`.
110
+- Adjacent wider window:
111
+ - Request: `/api/v3/data?contexts=smartctl.device_temperature&after=1778958980&before=1778958990&points=1&group_by=dimension&aggregation=average&time_group=average&format=json2&options=jsonwrap,minify,unaligned&timeout=30000`
112
+ - Result: `result.data` length was `1`, with a sample at timestamp `1778958990`.
113
+- Wider 30-second window:
114
+ - Request: `/api/v3/data?contexts=smartctl.device_temperature&after=1778958970&before=1778959000&points=3&group_by=dimension&aggregation=average&time_group=average&format=json2&options=jsonwrap,minify,unaligned&timeout=30000`
115
+ - Result: `result.data` length was `3`, with timestamps `1778959000`, `1778958990`, and `1778958980`.
116
+- Full 10-second shifted 5-second-window scan:
117
+ - Common request shape: `/api/v3/data?contexts=smartctl.device_temperature&after=<after>&before=<before>&points=5&group_by=dimension&aggregation=average&time_group=average&format=json2&options=jsonwrap,minify,unaligned&timeout=30000`
118
+ - `0-5`: `after=1778958980`, `before=1778958985` -> `result.data` length `0`, tier 0 `queries=0`.
119
+ - `1-6`: `after=1778958981`, `before=1778958986` -> `result.data` length `0`, tier 0 `queries=0`.
120
+ - `2-7`: `after=1778958982`, `before=1778958987` -> `result.data` length `0`, tier 0 `queries=0`.
121
+ - `3-8`: `after=1778958983`, `before=1778958988` -> `result.data` length `0`, tier 0 `queries=0`.
122
+ - `4-9`: `after=1778958984`, `before=1778958989` -> `result.data` length `0`, tier 0 `queries=0`.
123
+ - `5-0`: `after=1778958985`, `before=1778958990` -> `result.data` length `0`, tier 0 `queries=0`.
124
+ - `6-1`: `after=1778958986`, `before=1778958991` -> `result.data` length `0`, tier 0 `queries=0`.
125
+ - `7-2`: `after=1778958987`, `before=1778958992` -> `result.data` length `0`, tier 0 `queries=0`.
126
+ - `8-3`: `after=1778958988`, `before=1778958993` -> `result.data` length `0`, tier 0 `queries=0`.
127
+ - `9-4`: `after=1778958989`, `before=1778958994` -> `result.data` length `0`, tier 0 `queries=0`.
128
+- Planner/debug output:
129
+ - `options=debug` and `options=plan` both map to `RRDR_OPTION_DEBUG` in `src/web/api/maps/rrdr_options.c`.
130
+ - v3 emits request/debug fields with `options=jsonwrap,debug`.
131
+ - v3 emits per-metric `plans` only inside `detailed` output with debug, under `detailed.nodes...dimensions.<metric>.plans`.
132
+ - The failing 5-second query has no selected dimensions, so there are no per-metric plans to print.
133
+ - A nearby non-empty query (`after=1778958980`, `before=1778958990`, `points=1`, `options=jsonwrap,debug,details`) prints per-metric plans such as tier 0 `af=1778958985`, `bf=1778958995`.
134
+- Corrected interpretation from the failing debug response:
135
+ - `summary` and `totals` prove the matching target objects were selected.
136
+ - The failing response has no query-plan objects and `db.per_tier[0].queries=0`, proving no tier query was initialized.
137
+ - Therefore the failure is after target-object selection and before storage execution.
138
+- Tier-2-only hypothesis check, using old data where only tier 2 covered the selected window:
139
+ - Baseline tier-retention request: `/api/v3/data?contexts=system.cpu&after=0&before=0&points=1&group_by=dimension&aggregation=average&time_group=average&format=json2&options=jsonwrap,minify,debug&timeout=30000`
140
+ - Result: `system.cpu` had tier 0 retention beginning at `1778940602`, tier 1 retention beginning at `1778817840`, and tier 2 retention beginning at `1778086800`.
141
+ - One-hour tier-2-only baseline: `/api/v3/data?contexts=system.cpu&after=1778798400&before=1778802000&points=1&group_by=dimension&aggregation=average&time_group=average&format=json2&options=jsonwrap,minify,unaligned,debug,details&timeout=30000`
142
+ - Result: `result.data` length was `1`; tier 2 `queries=153`; tier 0 and tier 1 `queries=0`.
143
+ - Five-minute tier-2-only query: `/api/v3/data?contexts=system.cpu&after=1778800200&before=1778800500&points=5&group_by=dimension&aggregation=average&time_group=average&format=json2&options=jsonwrap,minify,unaligned,debug,details&timeout=30000`
144
+ - Result: `result.data` length was `5`; timestamps were `1778800500`, `1778800440`, `1778800380`, `1778800320`, `1778800260`; tier 2 `queries=153`; tier 0 and tier 1 `queries=0`.
145
+ - Five-second tier-2-only query: `/api/v3/data?contexts=system.cpu&after=1778800203&before=1778800208&points=5&group_by=dimension&aggregation=average&time_group=average&format=json2&options=jsonwrap,minify,unaligned,debug,details&timeout=30000`
146
+ - Result: `result.data` length was `5`; timestamps were `1778800208`, `1778800207`, `1778800206`, `1778800205`, `1778800204`; tier 2 `queries=153`; tier 0 and tier 1 `queries=0`.
147
+ - Five-minute tier-2-only query for the original go.d context: `/api/v3/data?contexts=smartctl.device_temperature&after=1778800200&before=1778800500&points=5&group_by=dimension&aggregation=average&time_group=average&format=json2&options=jsonwrap,minify,unaligned,debug,details&timeout=30000`
148
+ - Result: `result.data` length was `5`; tier 2 `queries=18`; tier 0 and tier 1 `queries=0`.
149
+ - Five-second tier-2-only query for the original go.d context: `/api/v3/data?contexts=smartctl.device_temperature&after=1778800203&before=1778800208&points=5&group_by=dimension&aggregation=average&time_group=average&format=json2&options=jsonwrap,minify,unaligned,debug,details&timeout=30000`
150
+ - Result: `result.data` length was `5`; timestamps were `1778800208`, `1778800207`, `1778800206`, `1778800205`, `1778800204`; tier 2 `queries=18`; tier 0 and tier 1 `queries=0`.
151
+ - Finding: the broad tier-2 hypothesis was not reproduced. Sub-resolution tier-2-only windows can return data when auto-selection lands on tier 2 and tier 2 covers the window.
152
+- Forced-tier controls for the original failing 5-second query:
153
+ - Auto-tier failing request: `/api/v3/data?contexts=smartctl.device_temperature&after=1778958983&before=1778958988&points=5&group_by=dimension&aggregation=average&time_group=average&format=json2&options=jsonwrap,minify,unaligned,debug,details&timeout=30000`
154
+ - Result: `result.data` length was `0`; no per-metric plans; all `db.per_tier[].queries=0`; tier 0 and tier 1 retention covered the window, tier 2 did not.
155
+ - Forced tier 0 request: `/api/v3/data?contexts=smartctl.device_temperature&after=1778958983&before=1778958988&points=5&group_by=dimension&aggregation=average&time_group=average&format=json2&tier=0&options=jsonwrap,minify,unaligned,debug,details&timeout=30000`
156
+ - Result: `result.data` length was `5`; tier 0 `queries=18`; per-metric plans used tier 0.
157
+ - Forced tier 1 request: `/api/v3/data?contexts=smartctl.device_temperature&after=1778958983&before=1778958988&points=5&group_by=dimension&aggregation=average&time_group=average&format=json2&tier=1&options=jsonwrap,minify,unaligned,debug,details&timeout=30000`
158
+ - Result: `result.data` length was `5`; tier 1 `queries=18`; per-metric plans used tier 1.
159
+ - Forced tier 2 request: `/api/v3/data?contexts=smartctl.device_temperature&after=1778958983&before=1778958988&points=5&group_by=dimension&aggregation=average&time_group=average&format=json2&tier=2&options=jsonwrap,minify,unaligned,debug,details&timeout=30000`
160
+ - Result: `result.data` length was `0`; no per-metric plans; tier 2 did not cover the window.
161
+- Wrong-tier-but-not-empty control where tier 0, tier 1, and tier 2 all covered the narrow window:
162
+ - Auto-tier request: `/api/v3/data?contexts=smartctl.device_temperature&after=1778935003&before=1778935008&points=5&group_by=dimension&aggregation=average&time_group=average&format=json2&options=jsonwrap,minify,unaligned,debug,details&timeout=30000`
163
+ - Result: `result.data` length was `5`; tier 2 `queries=7`; tier 0 and tier 1 `queries=0`; per-metric plans used tier 2.
164
+ - Forced tier 0 request: `/api/v3/data?contexts=smartctl.device_temperature&after=1778935003&before=1778935008&points=5&group_by=dimension&aggregation=average&time_group=average&format=json2&tier=0&options=jsonwrap,minify,unaligned,debug,details&timeout=30000`
165
+ - Result: `result.data` length was `5`; tier 0 `queries=3`; per-metric plans used tier 0.
166
+ - Finding: when all tiers overlap but the effective window is shorter than every tier update interval, auto-selection picks the highest tier instead of the most detailed covering tier. This may return data, but it is still the wrong tier choice for a narrow troubleshooting window.
167
+- Code evidence:
168
+ - `src/database/contexts/query_target.c:325` to `src/database/contexts/query_target.c:328` uses broad retention matching to decide whether a metric can enter the query target.
169
+ - `src/database/contexts/rrdcontext.h:721` to `src/database/contexts/rrdcontext.h:723` allows a two-`update_every` tolerance when checking retention overlap.
170
+ - `src/web/api/queries/query-plan.c:30` to `src/web/api/queries/query-plan.c:36` returns `-LONG_MAX` for invalid/no-overlap tiers.
171
+ - `src/web/api/queries/query-plan.c:44` to `src/web/api/queries/query-plan.c:52` also returns `-LONG_MAX` when `points_available <= 0`, which happens when the requested window is shorter than the tier update interval.
172
+ - `src/web/api/queries/query-plan.c:90` to `src/web/api/queries/query-plan.c:99` marks non-overlapping tiers with the same `-LONG_MAX` value.
173
+ - `src/web/api/queries/query-plan.c:108` to `src/web/api/queries/query-plan.c:112` chooses the later tier on equal weights because it uses `>=`.
174
+ - `src/web/api/queries/query-plan.c:117` to `src/web/api/queries/query-plan.c:176` has a separate all-invalid fallback to tier 0 for natural update-every selection, but the per-metric selector at `src/web/api/queries/query-plan.c:57` to `src/web/api/queries/query-plan.c:115` does not have the same fallback.
175
+ - `src/web/api/queries/query-plan.c:361` to `src/web/api/queries/query-plan.c:365` returns false if the selected tier does not cover the requested window, so no plan reaches `query_planer_initialize_plans()`.
176
+
177
+Trigger conditions:
178
+
179
+1. The request does not explicitly select a tier, so `query_metric_best_tier_for_timeframe()` is used.
180
+2. More than one storage tier exists.
181
+3. After `query_target_calculate_window()`, the effective planner window is shorter than the update interval of every tier that overlaps it:
182
+ - for the verified failing request, the API request was `after=1778958983`, `before=1778958988`, `points=5`;
183
+ - query-window normalized it to `after=1778958984`, `before=1778958988`, so the planner duration was `4` seconds for `5` output slots;
184
+ - tier 0 update interval for the metric was `10` seconds, tier 1 was `600` seconds, and tier 2 was `36000` seconds.
185
+4. Because `points_available = (common_last_t - common_first_t) / db_update_every_s`, each overlapping tier with an update interval larger than the effective duration gets `points_available=0` and weight `-LONG_MAX`.
186
+5. Non-overlapping tiers also get weight `-LONG_MAX`.
187
+6. The tie-break uses `>=`, so the highest-numbered tied tier is selected.
188
+7. Outcome split:
189
+ - if the selected highest tier covers the window, the query returns data from that tier;
190
+ - if the selected highest tier does not cover the window, `query_plan()` returns false, no tier query is initialized, and the response is empty.
191
+
192
+Tier switching behavior:
193
+
194
+- `query_plan()` can switch tiers in both directions in the same query, but only around the initially selected tier and only to fill coverage gaps.
195
+- If the selected tier starts after the requested `after`, the planner searches higher-numbered tiers (`selected_tier + 1` upward) to fill the older/beginning part of the query.
196
+- If the selected tier ends before the requested `before`, the planner searches lower-numbered tiers (`selected_tier - 1` downward) to fill the newer/end part of the query.
197
+- Both checks are independent, so a middle selected tier can have coarser plans prepended and finer plans appended in the same query.
198
+- The plan entries are sorted by start time before execution.
199
+- Limitation: switching is not a per-segment resolution optimizer. If the selected tier covers the whole requested window, the planner does not split the query just because another tier would provide better point density for a subsection.
200
+- Limitation: explicit `tier=` disables switching because `switch_tiers=false`.
201
+- Limitation relevant to the verified bug: the selected tier must overlap the requested window before switching can help. If automatic selection picks a non-overlapping tier, `query_plan()` returns false before the gap-filling loops run.
202
+- Code evidence:
203
+ - `src/web/api/queries/query-plan.c:367` to `src/web/api/queries/query-plan.c:370` starts with one selected-tier plan clipped to that tier's retention.
204
+ - `src/web/api/queries/query-plan.c:377` to `src/web/api/queries/query-plan.c:408` fills the beginning by scanning higher-numbered tiers.
205
+ - `src/web/api/queries/query-plan.c:411` to `src/web/api/queries/query-plan.c:445` fills the end by scanning lower-numbered tiers.
206
+ - `src/web/api/queries/query-plan.c:448` to `src/web/api/queries/query-plan.c:450` sorts multiple plan entries by start time.
207
+ - `src/web/api/queries/query-plan.c:349` to `src/web/api/queries/query-plan.c:353` disables switching for explicit selected-tier requests.
208
+
209
+Related tier-selection functions:
210
+
211
+- `query_metric_best_tier_for_timeframe()` is the execution planner selector. It is `static` and is called only by `query_plan()` when no explicit `tier=` is selected.
212
+- This execution selector already partially matches the desired model:
213
+ - it excludes zero-overlap tiers before scoring;
214
+ - it computes `min_first_time_s` and `max_last_time_s` across all tiers;
215
+ - it passes that union range plus each tier's `db_update_every_s` into `query_plan_points_coverage_weight()`;
216
+ - therefore each overlapping tier is effectively scored for resolution as if it could cover the full union/requested window.
217
+- The verified bug is in the scoring and tie-break, not in the gap-fill planner:
218
+ - sub-resolution tiers collapse to `-LONG_MAX`;
219
+ - the `+25000 * tier` bias and `>=` tie-break favor higher/coarser tiers.
220
+- `rrddim_find_best_tier_for_timeframe()` is a separate aggregate helper in the same file. It also uses `query_plan_points_coverage_weight()`, but it is not the execution planner selector.
221
+- The only call to `rrdset_find_natural_update_every_for_timeframe()` is from `query_target_calculate_window()` when `natural_points`, explicit `selected-tier`, and `tier > 0` are all set. Because the same `selected-tier` option is passed through, the aggregate `rrddim_find_best_tier_for_timeframe()` branch is not reached from the current code path.
222
+- Implication: changing the shared `query_plan_points_coverage_weight()` is less surgical because it also changes the aggregate helper if that helper becomes reachable later. Changing `query_metric_best_tier_for_timeframe()` is the narrow execution-planner change.
223
+- API/data paths using automatic execution planner selection:
224
+ - `/api/v1/data`, `/api/v2/data`, and `/api/v3/data` when no explicit `tier=` is provided;
225
+ - MCP metric queries when no explicit `tier` parameter is provided;
226
+ - weights queries and value helper paths that call `rrd2rrdr()` without `RRDR_OPTION_SELECTED_TIER`.
227
+- Paths verified not to depend on automatic tier selection:
228
+ - health database lookups pass `points=1` and `RRDR_OPTION_SELECTED_TIER` with tier `0`;
229
+ - exporting reads storage tier 0 directly through `storage_engine_query_init(rd->tiers[0]...)`;
230
+ - explicit API/MCP `tier=` requests bypass automatic selection and disable tier switching.
231
+
232
+Why the aggregate helper path exists:
233
+
234
+- Historical evidence: commit `3fefd03b94458c9f6ad4164a82b1da6fc4fa435c` (`automatic selection of tier`) introduced automatic tier selection in the old single-chart query engine.
235
+- In that first design, `rrddim_find_best_tier_for_timeframe()` served two real purposes:
236
+ - it selected the execution tier for `rrd2rrdr_do_dimension()`;
237
+ - it selected the natural-points `update_every` through `rrdset_find_natural_update_every_for_timeframe()` whenever natural points and multiple storage tiers were available.
238
+- Historical evidence: commit `41e14c83e22ed54c8e48a7b637315bbb556c3185` (`natural points should only be offered on tier 0, except a specific tier is selected`) changed the natural-points call site from `natural_points && storage_tiers > 1` to `natural_points && selected-tier && tier > 0 && storage_tiers > 1`.
239
+- That change made the automatic natural-points branch effectively unreachable, because `rrdset_find_natural_update_every_for_timeframe()` is now only called when selected-tier is already set, and therefore it returns the explicit tier instead of calling `rrddim_find_best_tier_for_timeframe()`.
240
+- Commit `00712b351b3c83a54a147ca23365458acbef3105` (`QUERY_TARGET: new query engine for Netdata Agent`) ported the logic into the query-target engine:
241
+ - execution-tier selection became `query_metric_best_tier_for_timeframe()`;
242
+ - aggregate natural update-every selection remained as `rrddim_find_best_tier_for_timeframe()` behind `rrdset_find_natural_update_every_for_timeframe()`.
243
+- Implication: the other path exists because it is legacy from the original natural-points auto-tier design and was preserved through the query-target port, but the current call site no longer exercises its automatic branch.
244
+- The user's concern is valid: if a future caller exercises the aggregate helper, it does not have the execution planner's gap-fill semantics. It chooses one tier/update_every for the query window, so coverage-sensitive scoring there could produce poor natural-points granularity decisions unless it is reviewed separately.
245
+
246
+Affected contracts and surfaces:
247
+
248
+- `/api/v2/data` and `/api/v3/data` query behavior.
249
+- Cloud-proxied metrics queries, if they use the same agent-side query path.
250
+- Query target preparation, storage tier selection, retention matching, and data grouping semantics.
251
+- Public AI skills under `docs/netdata-ai/skills/` are affected only if the user/operator query workflow changes. Internal planner diagnostics are out of scope for those artifacts.
252
+
253
+Existing patterns to reuse:
254
+
255
+- Existing query target and data API test patterns after they are located.
256
+- Existing `RRDR_OPTION_UNALIGNED` behavior must be preserved.
257
+- Existing v2/v3 data query-string parser in `src/web/api/v2/api_v2_data.c`.
258
+
259
+Risk and blast radius:
260
+
261
+- High enough to require focused tests before patching: data query semantics are user-facing and shared across dashboards, APIs, Cloud, and troubleshooting workflows.
262
+- Security risk is low for the bug itself; evidence must still avoid raw secrets and sensitive label values.
263
+- Performance risk exists if a fix expands storage scans for many narrow queries.
264
+
265
+Sensitive data handling plan:
266
+
267
+- Durable artifacts must not include raw secrets, bearer tokens, SNMP communities, customer identifiers, private endpoints, non-private customer-identifying IPs, personal data, or device serial numbers.
268
+- The verification API responses contained hardware labels. This SOW records only sanitized collector identity and metric names, not raw serial numbers or full hardware-identifying label values.
269
+- Future logs or traces added to this SOW must be summarized or redacted before being written.
270
+
271
+Implementation plan:
272
+
273
+1. Choose the tier-selection fix semantics before patching.
274
+2. Implement the smallest fix in the automatic per-metric tier selector so a covering tier is selected for sub-resolution windows.
275
+3. Add regression tests for sub-frequency historical windows, forced-tier behavior, tier-2-only sub-resolution windows, and unchanged aligned-window behavior.
276
+4. Re-run the exact local API verification commands after the fix.
277
+5. Update query skill/docs if the local direct-agent `/api/v3/data` request contract differs from the current skill text.
278
+
279
+Validation plan:
280
+
281
+- Run targeted query-engine/data API tests found during tracing.
282
+- Add a regression test that fails before the fix.
283
+- Re-run the exact local API verification commands after the fix.
284
+- Search for same-failure risks around tier 1/tier 2, relative windows, absolute windows, `points=0`, `points=1`, and `points>duration`.
285
+
286
+Artifact impact plan:
287
+
288
+- AGENTS.md: likely unaffected unless the fix exposes a durable project-wide query workflow rule.
289
+- Runtime project skills: likely unaffected unless a codebase workflow lesson emerges.
290
+- Specs: likely add or update a query/data API spec if no existing spec covers sub-frequency windows.
291
+- End-user/operator docs: possibly affected if public query semantics are documented.
292
+- End-user/operator skills: no planned update for internal query-planner diagnostics. The user clarified that `docs/netdata-ai/skills/` is for user/operator how-tos, not maintainer implementation notes.
293
+- SOW lifecycle: this SOW moved to `current/` after the user explicitly prioritized it, and will move to `done/` with `Status: completed` when committed.
294
+
295
+Open-source reference evidence:
296
+
297
+- None checked. This is an internal Netdata query-engine/API behavior issue; external observability implementations are not needed until a semantic design fork appears.
298
+
299
+Open decisions:
300
+
301
+- None before implementation.
302
+
303
+## Implications And Decisions
304
+
305
+- User decision already applied: verify the hypothesis before creating this SOW.
306
+- User decision on 2026-05-17: choose option `1A`, cleanup the dormant natural-points aggregate tier-selection path instead of leaving it commented or reactivating it.
307
+- Implication: implementation should remove or simplify the unreachable `rrddim_find_best_tier_for_timeframe()` path so future maintainers do not confuse it with the execution planner selector.
308
+- User decision on 2026-05-17: implement the planner change in addition to cleanup.
309
+- User-approved planner semantics: zero-overlap tiers are non-candidates; among overlapping tiers, score point density as if the tier had full-window coverage; select the sparsest tier that can provide at least 50% of requested point density; if no tier can provide 50%, select the densest overlapping tier.
310
+- Boundary: existing explicit `tier=` semantics remain unchanged.
311
+- User clarification on 2026-05-17: do not put internal/developer query-planner findings in `docs/netdata-ai/skills/`; use `.agents/sow/` specs or project runtime skills for maintainer-facing memory.
312
+
313
+## Plan
314
+
315
+1. Keep unrelated SOWs unchanged and complete the SOW-0017 lifecycle.
316
+2. Remove or simplify the dormant natural-points aggregate tier-selection path.
317
+3. Replace automatic execution-tier scoring with the user-approved point-density semantics.
318
+4. Add focused regression tests or equivalent validation.
319
+5. Re-run local API reproduction URLs after installing/restarting a patched agent, or record the approval blocker.
320
+6. Update internal artifacts before close.
321
+
322
+## Execution Log
323
+
324
+### 2026-05-16
325
+
326
+- Verified the symptom using local direct-agent API requests.
327
+- Created this pending SOW after verification only.
328
+- Verified that tier-2-only sub-resolution windows return data in the tested cases, so the broader tier-2 hypothesis was not reproduced.
329
+- Traced the observed empty result to automatic tier selection choosing a non-covering tier when all tier weights tie at `-LONG_MAX`.
330
+- Verified a second outcome of the same trigger: when all tiers cover a narrow sub-resolution window, automatic selection chooses tier 2 and returns data from tier 2 instead of tier 0.
331
+- No code changes made.
332
+
333
+### 2026-05-17
334
+
335
+- Worked SOW-0017 after the user explicitly prioritized it; unrelated SOWs are outside this SOW's scope.
336
+- Removed the dormant automatic aggregate-tier path:
337
+ - deleted `query_plan_points_coverage_weight()`;
338
+ - deleted `rrddim_find_best_tier_for_timeframe()`;
339
+ - replaced `rrdset_find_natural_update_every_for_timeframe()` with `query_target_min_update_every_for_tier()`, which only computes the minimum update-every for an explicit selected tier.
340
+- Implemented automatic execution-tier selection using the approved density model:
341
+ - zero-overlap tiers get sentinel weight and cannot win;
342
+ - candidate tiers use fixed-point point-density weights, so sub-resolution windows do not collapse to zero;
343
+ - if any tier can provide at least 50% of requested point density, the sparsest acceptable tier wins;
344
+ - if none can provide 50%, the densest overlapping tier wins.
345
+- Hardened the automatic selector to treat reversed or zero-duration windows as invalid before adjusting `points_wanted`.
346
+- Split pure plan-entry building from storage query initialization so unit tests can assert tier selection and head/tail gap-filling without mocking storage engines.
347
+- Added `query_plan_unittest()` and `-W queryplantest` coverage for:
348
+ - sub-resolution window with a non-overlapping coarser tier;
349
+ - sub-resolution window where all tiers overlap;
350
+ - 50% tolerance choosing a sparse acceptable tier;
351
+ - exact 50% tolerance boundary;
352
+ - under-resolution requests choosing the densest tier;
353
+ - zero-overlap tiers not being candidates;
354
+ - invalid duration returning the first working tier;
355
+ - selected tier covering the full window with one plan;
356
+ - coarser-tier head gap fill;
357
+ - finer-tier tail gap fill;
358
+ - simultaneous head and tail gap fill;
359
+ - explicit selected tier disabling gap fill;
360
+ - no-overlap planning failure;
361
+ - selected-tier natural-points update-every cleanup.
362
+- Added internal spec `.agents/sow/specs/query-planner-tier-selection.md`.
363
+- Did not update `docs/netdata-ai/skills/`; those are end-user/operator skills and this work is maintainer/internal query-planner behavior.
364
+- Ran `./install.sh` after explicit user request, and reran it after the final selector guard. Result: install completed and restarted the local `netdata`; non-fatal `git fetch -t` failed due local GitHub SSH permission, but the installer continued and completed.
365
+- Verified the installed local agent reports `v2.10.0-215-ge6e45f29ee` at `/api/v1/info`.
366
+- Ran `/usr/sbin/netdata -W queryplantest`; result: passed all six focused query planner checks.
367
+- Re-ran the live direct-agent `/api/v3/data` baseline and short-window checks against the installed agent. The original failing 5-second automatic-tier query now returns 5 points and uses tier 0.
368
+
369
+## Validation
370
+
371
+Acceptance criteria evidence:
372
+
373
+- Root cause was traced before patching and is recorded in the Pre-Implementation Gate with code evidence.
374
+- The implementation changes `src/web/api/queries/query-plan.c` so sub-resolution overlapping tiers remain candidates with fractional density weights instead of being assigned the same sentinel as non-overlapping tiers.
375
+- Explicit tier behavior remains unchanged: `query_plan()` still disables tier switching for valid `RRDR_OPTION_SELECTED_TIER`.
376
+- Live post-fix API validation against the installed local agent passed. The original failing automatic-tier 5-second query now returns 5 points and initializes tier 0 storage queries.
377
+
378
+Tests or equivalent validation:
379
+
380
+- `git diff --check -- src/web/api/queries/query-plan.c src/web/api/queries/query-window.c src/web/api/queries/query-internal.h src/daemon/main.c`
381
+ - Result: passed.
382
+- `cmake --build build-clion --target netdata -j 8`
383
+ - Result: configure/build did not reach the changed code. CMake attempted to refresh bundled Sentry crashpad content and failed fetching `mini_chromium` from the external submodule with HTTP 400.
384
+- `cmake -S . -B .local/build-sow17 -G Ninja -DENABLE_SENTRY=OFF -DENABLE_ML=OFF -DCMAKE_BUILD_TYPE=Debug`
385
+ - Result: failed because the fresh cache enabled `ENABLE_PLUGIN_XENSTAT=ON` and local `xenstat` dependencies were not available.
386
+- `cmake -S . -B .local/build-sow17 -G Ninja -DENABLE_SENTRY=OFF -DENABLE_ML=OFF -DENABLE_PLUGIN_XENSTAT=OFF -DCMAKE_BUILD_TYPE=Debug`
387
+ - Result: passed.
388
+- `cmake --build .local/build-sow17 --target netdata -j 8`
389
+ - Result: passed. The changed files `src/daemon/main.c`, `src/web/api/queries/query-window.c`, and `src/web/api/queries/query-plan.c` compiled and linked into `.local/build-sow17/netdata`.
390
+- `.local/build-sow17/netdata -W queryplantest`
391
+ - Result: passed all six focused query planner checks.
392
+- `cmake --build .local/build-sow17 --target netdata -j 8` after extracting pure plan-entry building and expanding planner coverage
393
+ - Result: passed; `src/web/api/queries/query-plan.c` rebuilt and linked.
394
+- `.local/build-sow17/netdata -W queryplantest` after extracting pure plan-entry building and expanding planner coverage
395
+ - Result: passed all fourteen focused query planner checks.
396
+- `cmake --build .local/build-sow17 --target netdata -j 8` after the final selector guard
397
+ - Result: passed; `src/web/api/queries/query-plan.c` rebuilt and linked.
398
+- `.local/build-sow17/netdata -W queryplantest` after the final selector guard
399
+ - Result: passed all six focused query planner checks.
400
+- `./install.sh`
401
+ - Result: passed twice; local `netdata` was restarted. A non-fatal `git fetch -t` step failed due local GitHub SSH permission, but the install completed both times.
402
+- `curl -sS 'http://127.0.0.1:19999/api/v1/info' | jq -r '.version'`
403
+ - Result: `v2.10.0-215-ge6e45f29ee`.
404
+- `/usr/sbin/netdata -W queryplantest`
405
+ - Result: passed all six focused query planner checks.
406
+- `/usr/sbin/netdata -W queryplantest` after the final reinstall
407
+ - Result: passed all six focused query planner checks.
408
+
409
+Real-use evidence:
410
+
411
+- Pre-fix local direct-agent `/api/v3/data` calls reproduced the symptom against `smartctl.device_temperature`.
412
+- Post-fix 10-minute baseline:
413
+ - Request: `/api/v3/data?contexts=smartctl.device_temperature&after=1778958670&before=1778959270&points=60&group_by=dimension&aggregation=average&time_group=average&format=json2&options=jsonwrap,minify,unaligned&timeout=30000`
414
+ - Result: `result.data` length `60`; first timestamp `1778959270`; last timestamp `1778958680`; tier 0 `queries=18`, `update_every=10`; tier 1 and tier 2 `queries=0`.
415
+- Post-fix original failing 5-second automatic-tier query:
416
+ - Request: `/api/v3/data?contexts=smartctl.device_temperature&group_by=dimension&aggregation=average&time_group=average&format=json2&options=jsonwrap,minify,unaligned,debug,details&timeout=30000&after=1778958983&before=1778958988&points=5`
417
+ - Result: `result.data` length `5`; timestamps `1778958988,1778958987,1778958986,1778958985,1778958984`; tier 0 `queries=18`; tier 1 and tier 2 `queries=0`.
418
+- Post-fix forced tier 0 control for the same 5-second window:
419
+ - Request: `/api/v3/data?contexts=smartctl.device_temperature&group_by=dimension&aggregation=average&time_group=average&format=json2&options=jsonwrap,minify,unaligned,debug,details&timeout=30000&after=1778958983&before=1778958988&points=5&tier=0`
420
+ - Result: `result.data` length `5`; timestamps `1778958988,1778958987,1778958986,1778958985,1778958984`; tier 0 `queries=18`; tier 1 and tier 2 `queries=0`.
421
+- Post-fix shifted 5-second automatic-tier scan across one 10-second cadence interval:
422
+ - Common request shape: `/api/v3/data?contexts=smartctl.device_temperature&group_by=dimension&aggregation=average&time_group=average&format=json2&options=jsonwrap,minify,unaligned,debug,details&timeout=30000&after=<after>&before=<before>&points=5`
423
+ - `0-5`: `after=1778958980`, `before=1778958985` -> `result.data` length `5`, tier 0 `queries=18`.
424
+ - `1-6`: `after=1778958981`, `before=1778958986` -> `result.data` length `5`, tier 0 `queries=18`.
425
+ - `2-7`: `after=1778958982`, `before=1778958987` -> `result.data` length `5`, tier 0 `queries=18`.
426
+ - `3-8`: `after=1778958983`, `before=1778958988` -> `result.data` length `5`, tier 0 `queries=18`.
427
+ - `4-9`: `after=1778958984`, `before=1778958989` -> `result.data` length `5`, tier 0 `queries=18`.
428
+ - `5-0`: `after=1778958985`, `before=1778958990` -> `result.data` length `5`, tier 0 `queries=18`.
429
+ - `6-1`: `after=1778958986`, `before=1778958991` -> `result.data` length `5`, tier 0 `queries=18`.
430
+ - `7-2`: `after=1778958987`, `before=1778958992` -> `result.data` length `5`, tier 0 `queries=18`.
431
+ - `8-3`: `after=1778958988`, `before=1778958993` -> `result.data` length `5`, tier 0 `queries=18`.
432
+ - `9-4`: `after=1778958989`, `before=1778958994` -> `result.data` length `5`, tier 0 `queries=18`.
433
+- Post-fix older two-tier overlap check:
434
+ - Request: `/api/v3/data?contexts=smartctl.device_temperature&after=1778935003&before=1778935008&points=5&group_by=dimension&aggregation=average&time_group=average&format=json2&options=jsonwrap,minify,unaligned,debug,details&timeout=30000`
435
+ - Result: `result.data` length `5`; tier 0 `queries=0`, `first_entry=1778948390`; tier 1 `queries=18`, `update_every=600`; tier 2 `queries=0`, `last_entry=1778940000`.
436
+ - Interpretation: after install/restart, tier 0 and tier 2 no longer overlap on the local database, so the earlier all-three-overlap control is no longer reproducible. This check validates that when only tier 1 and tier 2 overlap a sub-resolution window, the denser overlapping tier 1 wins.
437
+
438
+Reviewer findings:
439
+
440
+- Self-review found one additional hardening point: the automatic selector should treat reversed windows the same as zero-duration windows before adjusting `points_wanted`. This guard was added and revalidated.
441
+- External AI reviewers were not run; the user did not request them for this PR.
442
+
443
+Same-failure scan:
444
+
445
+- Source-code search found no remaining references to `query_plan_points_coverage_weight()`, `rrddim_find_best_tier_for_timeframe()`, or `rrdset_find_natural_update_every_for_timeframe()`.
446
+- Automatic execution-tier selection call sites remain centralized through `query_metric_best_tier_for_timeframe()`.
447
+
448
+Sensitive data gate:
449
+
450
+- This SOW records no raw secrets, bearer tokens, SNMP communities, private endpoints, customer data, personal names, or device serial numbers.
451
+- Hardware-identifying labels observed during verification were not copied into this durable artifact.
452
+
453
+Artifact maintenance gate:
454
+
455
+- AGENTS.md: no update. Existing artifact-boundary instructions already distinguish internal specs/project skills from public end-user/operator skills.
456
+- Runtime project skills: no update. This change adds project behavior, not a new "how to work here" workflow.
457
+- Specs: added `.agents/sow/specs/query-planner-tier-selection.md`.
458
+- End-user/operator docs: no update. The behavior is internal planner selection; no user-facing API parameter or documented workflow changed.
459
+- End-user/operator skills: no update. Public skills under `docs/netdata-ai/skills/` are for user/operator work, not maintainer debugging notes.
460
+- SOW lifecycle: `Status: completed` and file move to `.agents/sow/done/` are part of this commit.
461
+
462
+Specs update:
463
+
464
+- Added `.agents/sow/specs/query-planner-tier-selection.md`.
465
+
466
+Project skills update:
467
+
468
+- No project skill update needed; no durable assistant workflow changed.
469
+
470
+End-user/operator docs update:
471
+
472
+- No end-user/operator docs update needed; the user-visible API contract did not gain a new parameter or required workflow.
473
+
474
+End-user/operator skills update:
475
+
476
+- No end-user/operator skill update needed; this is not a public/operator AI skill workflow.
477
+
478
+Lessons:
479
+
480
+- Internal query-planner diagnostics and maintainer implementation notes belong in the SOW/spec layer, not under `docs/netdata-ai/skills/`.
481
+- The direct-agent public skills may still need separate product-doc review, but that is not part of this internal planner SOW unless a user/operator workflow changes.
482
+
483
+Follow-up mapping:
484
+
485
+- No remaining behavioral follow-up identified.
486
+
487
+## Outcome
488
+
489
+Completed. Automatic tier selection now uses overlapping-tier point density, so sub-resolution query windows pick the densest overlapping tier instead of collapsing overlapping and non-overlapping tiers to the same sentinel score.
490
+
491
+## Lessons Extracted
492
+
493
+- Sub-resolution query windows must be ordered by fractional point density, not by integer point counts, because integer division collapses valid overlapping tiers to the same sentinel value as non-overlapping tiers.
494
+- Internal maintainer findings belong in `.agents/sow/` specs and SOWs; public skills under `docs/netdata-ai/skills/` should only change for user/operator workflows.
495
+
496
+## Followup
497
+
498
+No behavioral follow-up identified.
499
+
500
+## Regression Log
501
+
502
+None yet.
503
+
504
+Append regression entries here only after this SOW was completed or closed and later testing or use found broken behavior. Use a dated `## Regression - YYYY-MM-DD` heading at the end of the file. Never prepend regression content above the original SOW narrative.
.agents/sow/specs/query-planner-tier-selection.md
new
+78
@@ -0,0 +1,78 @@
1
+# Spec - Query planner tier selection
2
+
3
+## Status
4
+
5
+Active. Added by SOW-0017.
6
+
7
+## Scope
8
+
9
+This spec describes automatic storage-tier selection for metric data
10
+queries when the caller does not explicitly request `tier=`.
11
+
12
+It applies to the query-target planner used by `/api/v1/data`,
13
+`/api/v2/data`, `/api/v3/data`, MCP metric queries, weights/value
14
+helpers, and other callers that reach `rrd2rrdr()` without
15
+`RRDR_OPTION_SELECTED_TIER`.
16
+
17
+Explicit `tier=` requests are outside this automatic selection rule.
18
+They keep the existing behavior: the requested tier is used when valid,
19
+and automatic tier switching is disabled.
20
+
21
+## Contract
22
+
23
+Automatic tier selection is resolution-driven among tiers that overlap
24
+the requested effective query window.
25
+
26
+1. A tier with no overlap with the requested effective window is not a
27
+ candidate.
28
+2. A candidate tier is scored by point density as if it had full-window
29
+ coverage:
30
+
31
+ ```text
32
+ candidate_points = effective_duration / tier_update_every
33
+ ```
34
+
35
+ The implementation uses fixed-point integer weights so sub-resolution
36
+ windows retain fractional ordering instead of collapsing to zero.
37
+
38
+3. The acceptable-density threshold is 50% of the requested output point
39
+ count.
40
+4. If one or more candidate tiers meet the 50% threshold, select the
41
+ sparsest acceptable tier. This avoids reading much denser data when a
42
+ coarser tier can satisfy the requested output density well enough.
43
+5. If no candidate tier meets the 50% threshold, select the densest
44
+ candidate tier. This handles short windows below every tier's
45
+ resolution and preserves the best available fidelity.
46
+6. After the initial tier is selected, the existing query planner may
47
+ fill beginning/end coverage gaps with neighboring tiers. Tier
48
+ switching is a coverage-gap mechanism, not a full per-segment
49
+ resolution optimizer.
50
+
51
+## Important Edge Cases
52
+
53
+- A requested window shorter than a 10-second collector cadence must not
54
+ make all overlapping tiers unusable. The densest overlapping tier wins
55
+ when no tier reaches the 50% threshold.
56
+- A non-overlapping tier must never win just because every tier has poor
57
+ density.
58
+- When all tiers cover a narrow sub-resolution window, automatic
59
+ selection should choose the densest tier, not the highest-numbered
60
+ tier.
61
+- When a coarser tier can provide at least 50% of the requested point
62
+ density, it may be selected over a much denser tier to reduce source
63
+ reads while preserving acceptable output fidelity.
64
+
65
+## Natural Points
66
+
67
+The legacy automatic aggregate-tier helper is not part of this contract.
68
+Natural-points update-every selection for an explicit selected tier uses
69
+the minimum `db_update_every_s` for that selected tier across the query
70
+target metrics.
71
+
72
+## Code References
73
+
74
+- `src/web/api/queries/query-plan.c` - automatic per-metric tier
75
+ selection and planner gap-fill.
76
+- `src/web/api/queries/query-window.c` - selected-tier natural-points
77
+ update-every calculation.
78
+
src/daemon/main.c
+6
@@ -226,6 +226,7 @@ int health_config_unittest(void);
226
int utf8_sanitizer_unittest(void);
227
int yaml_unittest(void);
228
int json_c_parser_unittest(void);
229
+int query_plan_unittest(void);
230
#ifdef ENABLE_ML
231
int ml_unittest(void);
232
#endif
@@ -441,6 +442,7 @@ int netdata_main(int argc, char **argv) {
442
if (rrdlabels_unittest()) return 1;
443
if (rrdhost_labels_unittest()) return 1;
444
if (ctx_unittest()) return 1;
445
+ if (query_plan_unittest()) return 1;
446
if (uuid_unittest()) return 1;
447
if (dyncfg_unittest()) return 1;
448
if (eval_unittest()) return 1;
@@ -550,6 +552,10 @@ int netdata_main(int argc, char **argv) {
552
unittest_running = true;
553
return utf8_sanitizer_unittest();
554
}
555
+ else if(strcmp(optarg, "queryplantest") == 0) {
556
+ unittest_running = true;
557
+ return query_plan_unittest();
558
+ }
559
#ifdef ENABLE_DBENGINE
560
else if(strcmp(optarg, "mctest") == 0) {
561
unittest_running = true;
src/web/api/queries/query-internal.h
+2
-1
@@ -87,7 +87,8 @@ bool query_planer_next_plan(QUERY_ENGINE_OPS *ops, time_t now, time_t last_point
87
void query_planer_finalize_remaining_plans(QUERY_ENGINE_OPS *ops);
88
QUERY_ENGINE_OPS *rrd2rrdr_query_ops_prep(RRDR *r, size_t query_metric_id);
89
void rrd2rrdr_query_ops_release(QUERY_ENGINE_OPS *ops);
90
-time_t rrdset_find_natural_update_every_for_timeframe(QUERY_TARGET *qt, time_t after_wanted, time_t before_wanted, size_t points_wanted, RRDR_OPTIONS options, size_t tier);
90
+time_t query_target_min_update_every_for_tier(QUERY_TARGET *qt, size_t tier);
91
+int query_plan_unittest(void);
92
void rrd2rrdr_query_ops_freeall(RRDR *r);
93
94
// query execution
src/web/api/queries/query-plan.c
+381
-121
@@ -27,176 +27,141 @@ static size_t query_metric_first_working_tier(QUERY_METRIC *qm) {
27
return 0;
28
}
29
30
-long query_plan_points_coverage_weight(time_t db_first_time_s, time_t db_last_time_s, time_t db_update_every_s, time_t after_wanted, time_t before_wanted, size_t points_wanted, size_t tier __maybe_unused) {
31
- if(db_first_time_s == 0 ||
32
- db_last_time_s == 0 ||
33
- db_update_every_s == 0 ||
34
- db_first_time_s > before_wanted ||
35
- db_last_time_s < after_wanted)
36
- return -LONG_MAX;
30
+#define QUERY_PLAN_POINTS_WEIGHT_SCALE 1000000ULL
31
+#define QUERY_PLAN_ACCEPTABLE_POINTS_NUMERATOR 1ULL
32
+#define QUERY_PLAN_ACCEPTABLE_POINTS_DENOMINATOR 2ULL
33
38
- long long common_first_t = MAX(db_first_time_s, after_wanted);
39
- long long common_last_t = MIN(db_last_time_s, before_wanted);
34
+static bool query_metric_tier_overlaps_timeframe(QUERY_METRIC *qm, size_t tier, time_t after_wanted, time_t before_wanted) {
35
+ if(!query_metric_is_valid_tier(qm, tier))
36
+ return false;
37
41
- long long time_coverage = (common_last_t - common_first_t) * 1000000LL / (before_wanted - after_wanted);
42
- long long points_wanted_in_coverage = (long long)points_wanted * time_coverage / 1000000LL;
38
+ return qm->tiers[tier].db_first_time_s <= before_wanted &&
39
+ qm->tiers[tier].db_last_time_s >= after_wanted;
40
+}
41
44
- long long points_available = (common_last_t - common_first_t) / db_update_every_s;
45
- long long points_delta = (long)(points_available - points_wanted_in_coverage);
46
- long long points_coverage = (points_delta < 0) ? (long)(points_available * time_coverage / points_wanted_in_coverage) : time_coverage;
42
+static long query_plan_points_density_weight(time_t db_update_every_s, time_t after_wanted, time_t before_wanted) {
43
+ if(db_update_every_s <= 0 || before_wanted <= after_wanted)
44
+ return -LONG_MAX;
45
48
- // a way to benefit higher tiers
49
- // points_coverage += (long)tier * 10000;
46
+ uint64_t duration_s = (uint64_t)(before_wanted - after_wanted);
47
51
- if(points_available <= 0)
52
- return -LONG_MAX;
48
+ if(duration_s > (uint64_t)LONG_MAX / QUERY_PLAN_POINTS_WEIGHT_SCALE)
49
+ return LONG_MAX;
50
54
- return (long)(points_coverage + (25000LL * tier)); // 2.5% benefit for each higher tier
51
+ return (long)((duration_s * QUERY_PLAN_POINTS_WEIGHT_SCALE) / (uint64_t)db_update_every_s);
52
}
53
57
-static size_t query_metric_best_tier_for_timeframe(QUERY_METRIC *qm, time_t after_wanted, time_t before_wanted, size_t points_wanted) {
58
- if(unlikely(nd_profile.storage_tiers < 2))
54
+static long query_plan_minimum_acceptable_points_weight(size_t points_wanted) {
55
+ if(!points_wanted)
56
return 0;
57
61
- if(unlikely(after_wanted == before_wanted || points_wanted <= 0))
62
- return query_metric_first_working_tier(qm);
58
+ if((uint64_t)points_wanted > (uint64_t)LONG_MAX / QUERY_PLAN_POINTS_WEIGHT_SCALE)
59
+ return LONG_MAX;
60
64
- if(points_wanted < QUERY_PLAN_MIN_POINTS)
65
- // when selecting tiers, aim for a resolution of at least QUERY_PLAN_MIN_POINTS points
66
- points_wanted = (before_wanted - after_wanted) > QUERY_PLAN_MIN_POINTS ? QUERY_PLAN_MIN_POINTS : before_wanted - after_wanted;
61
+ uint64_t wanted_scaled = (uint64_t)points_wanted * QUERY_PLAN_POINTS_WEIGHT_SCALE;
62
68
- time_t min_first_time_s = 0;
69
- time_t max_last_time_s = 0;
63
+ if(wanted_scaled > UINT64_MAX / QUERY_PLAN_ACCEPTABLE_POINTS_NUMERATOR)
64
+ return LONG_MAX;
65
71
- for(size_t tier = 0; tier < nd_profile.storage_tiers; tier++) {
72
- time_t first_time_s = qm->tiers[tier].db_first_time_s;
73
- time_t last_time_s = qm->tiers[tier].db_last_time_s;
66
+ uint64_t acceptable_scaled =
67
+ (wanted_scaled * QUERY_PLAN_ACCEPTABLE_POINTS_NUMERATOR + QUERY_PLAN_ACCEPTABLE_POINTS_DENOMINATOR - 1) /
68
+ QUERY_PLAN_ACCEPTABLE_POINTS_DENOMINATOR;
69
75
- if(!min_first_time_s || (first_time_s && first_time_s < min_first_time_s))
76
- min_first_time_s = first_time_s;
70
+ if(acceptable_scaled > (uint64_t)LONG_MAX)
71
+ return LONG_MAX;
72
78
- if(!max_last_time_s || (last_time_s && last_time_s > max_last_time_s))
79
- max_last_time_s = last_time_s;
80
- }
81
-
82
- for(size_t tier = 0; tier < nd_profile.storage_tiers; tier++) {
83
-
84
- // find the db time-range for this tier for all metrics
85
- STORAGE_METRIC_HANDLE *smh = qm->tiers[tier].smh;
86
- time_t first_time_s = qm->tiers[tier].db_first_time_s;
87
- time_t last_time_s = qm->tiers[tier].db_last_time_s;
88
- time_t update_every_s = qm->tiers[tier].db_update_every_s;
73
+ return (long)acceptable_scaled;
74
+}
75
90
- if( !smh ||
91
- !first_time_s ||
92
- !last_time_s ||
93
- !update_every_s ||
94
- first_time_s > before_wanted ||
95
- last_time_s < after_wanted
96
- ) {
97
- qm->tiers[tier].weight = -LONG_MAX;
98
- continue;
99
- }
76
+static bool query_plan_points_density_is_better(
77
+ size_t tier, long weight, bool acceptable,
78
+ size_t best_tier, long best_weight, bool best_acceptable) {
79
+ if(acceptable) {
80
+ if(!best_acceptable)
81
+ return true;
82
101
- internal_fatal(first_time_s > before_wanted || last_time_s < after_wanted, "QUERY: invalid db durations");
83
+ if(weight < best_weight)
84
+ return true;
85
103
- qm->tiers[tier].weight = query_plan_points_coverage_weight(
104
- min_first_time_s, max_last_time_s, update_every_s,
105
- after_wanted, before_wanted, points_wanted, tier);
86
+ return weight == best_weight && tier > best_tier;
87
}
88
108
- size_t best_tier = 0;
109
- for(size_t tier = 1; tier < nd_profile.storage_tiers; tier++) {
110
- if(qm->tiers[tier].weight >= qm->tiers[best_tier].weight)
111
- best_tier = tier;
112
- }
89
+ if(best_acceptable)
90
+ return false;
91
+
92
+ if(weight > best_weight)
93
+ return true;
94
114
- return best_tier;
95
+ return weight == best_weight && tier < best_tier;
96
}
97
117
-static size_t rrddim_find_best_tier_for_timeframe(QUERY_TARGET *qt, time_t after_wanted, time_t before_wanted, size_t points_wanted) {
98
+static size_t query_metric_best_tier_for_timeframe(QUERY_METRIC *qm, time_t after_wanted, time_t before_wanted, size_t points_wanted) {
99
if(unlikely(nd_profile.storage_tiers < 2))
100
return 0;
101
121
- if(unlikely(after_wanted == before_wanted || points_wanted <= 0)) {
122
- internal_error(true, "QUERY: '%s' has invalid params to tier calculation", qt->id);
123
- return 0;
124
- }
102
+ if(unlikely(before_wanted <= after_wanted || points_wanted <= 0))
103
+ return query_metric_first_working_tier(qm);
104
+
105
+ if(points_wanted < QUERY_PLAN_MIN_POINTS)
106
+ // when selecting tiers, aim for a resolution of at least QUERY_PLAN_MIN_POINTS points
107
+ points_wanted = (before_wanted - after_wanted) > QUERY_PLAN_MIN_POINTS ? QUERY_PLAN_MIN_POINTS : before_wanted - after_wanted;
108
126
- long weight[RRD_STORAGE_TIERS];
109
+ long minimum_acceptable_weight = query_plan_minimum_acceptable_points_weight(points_wanted);
110
128
- // cap at the compile-time maximum to guard the fixed-size weight[] array
129
- size_t tiers = MIN(nd_profile.storage_tiers, RRD_STORAGE_TIERS);
111
+ size_t best_tier = 0;
112
+ long best_weight = -LONG_MAX;
113
+ bool best_acceptable = false;
114
+ bool found_candidate = false;
115
131
- for(size_t tier = 0; tier < tiers; tier++) {
116
+ for(size_t tier = 0; tier < nd_profile.storage_tiers; tier++) {
117
133
- time_t common_first_time_s = 0;
134
- time_t common_last_time_s = 0;
135
- time_t common_update_every_s = 0;
118
+ time_t update_every_s = qm->tiers[tier].db_update_every_s;
119
137
- // find the db time-range for this tier for all metrics
138
- for(size_t i = 0, used = qt->query.used; i < used ; i++) {
139
- QUERY_METRIC *qm = query_metric(qt, i);
140
-
141
- time_t first_time_s = qm->tiers[tier].db_first_time_s;
142
- time_t last_time_s = qm->tiers[tier].db_last_time_s;
143
- time_t update_every_s = qm->tiers[tier].db_update_every_s;
144
-
145
- if(!first_time_s || !last_time_s || !update_every_s)
146
- continue;
147
-
148
- if(!common_first_time_s)
149
- common_first_time_s = first_time_s;
150
- else
151
- common_first_time_s = MIN(first_time_s, common_first_time_s);
152
-
153
- if(!common_last_time_s)
154
- common_last_time_s = last_time_s;
155
- else
156
- common_last_time_s = MAX(last_time_s, common_last_time_s);
157
-
158
- if(!common_update_every_s)
159
- common_update_every_s = update_every_s;
160
- else
161
- common_update_every_s = MIN(update_every_s, common_update_every_s);
120
+ if(!query_metric_tier_overlaps_timeframe(qm, tier, after_wanted, before_wanted)) {
121
+ qm->tiers[tier].weight = -LONG_MAX;
122
+ continue;
123
}
124
164
- weight[tier] = query_plan_points_coverage_weight(common_first_time_s, common_last_time_s, common_update_every_s, after_wanted, before_wanted, points_wanted, tier);
165
- }
125
+ qm->tiers[tier].weight = query_plan_points_density_weight(update_every_s, after_wanted, before_wanted);
126
+ if(qm->tiers[tier].weight == -LONG_MAX)
127
+ continue;
128
167
- size_t best_tier = 0;
168
- for(size_t tier = 1; tier < tiers; tier++) {
169
- if(weight[tier] >= weight[best_tier])
129
+ bool acceptable = qm->tiers[tier].weight >= minimum_acceptable_weight;
130
+
131
+ if(!found_candidate ||
132
+ query_plan_points_density_is_better(
133
+ tier, qm->tiers[tier].weight, acceptable,
134
+ best_tier, best_weight, best_acceptable)) {
135
best_tier = tier;
136
+ best_weight = qm->tiers[tier].weight;
137
+ best_acceptable = acceptable;
138
+ found_candidate = true;
139
+ }
140
}
141
173
- if(weight[best_tier] == -LONG_MAX)
174
- best_tier = 0;
175
-
176
- return best_tier;
142
+ return found_candidate ? best_tier : query_metric_first_working_tier(qm);
143
}
144
179
-time_t rrdset_find_natural_update_every_for_timeframe(QUERY_TARGET *qt, time_t after_wanted, time_t before_wanted, size_t points_wanted, RRDR_OPTIONS options, size_t tier) {
180
- size_t best_tier;
181
- if((options & RRDR_OPTION_SELECTED_TIER) && tier < nd_profile.storage_tiers)
182
- best_tier = tier;
183
- else
184
- best_tier = rrddim_find_best_tier_for_timeframe(qt, after_wanted, before_wanted, points_wanted);
145
+time_t query_target_min_update_every_for_tier(QUERY_TARGET *qt, size_t tier) {
146
+ if(tier >= nd_profile.storage_tiers)
147
+ return nd_profile.update_every;
148
149
// find the db minimum update every for this tier for all metrics
187
- time_t common_update_every_s = nd_profile.update_every;
150
+ time_t common_update_every_s = 0;
151
for(size_t i = 0, used = qt->query.used; i < used ; i++) {
152
QUERY_METRIC *qm = query_metric(qt, i);
153
191
- time_t update_every_s = qm->tiers[best_tier].db_update_every_s;
154
+ time_t update_every_s = qm->tiers[tier].db_update_every_s;
155
+ if(!update_every_s)
156
+ continue;
157
193
- if(!i)
158
+ if(!common_update_every_s)
159
common_update_every_s = update_every_s;
160
else
161
common_update_every_s = MIN(update_every_s, common_update_every_s);
162
}
163
199
- return common_update_every_s;
164
+ return common_update_every_s ? common_update_every_s : nd_profile.update_every;
165
}
166
167
static size_t query_planer_expand_duration_in_points(time_t this_update_every, time_t next_update_every) {
@@ -339,7 +304,7 @@ static int compare_query_plan_entries_on_start_time(const void *a, const void *b
304
return (p1->after < p2->after)?-1:1;
305
}
306
342
-static bool query_plan(QUERY_ENGINE_OPS *ops, time_t after_wanted, time_t before_wanted, size_t points_wanted) {
307
+static bool query_plan_build_entries(QUERY_ENGINE_OPS *ops, time_t after_wanted, time_t before_wanted, size_t points_wanted) {
308
QUERY_METRIC *qm = ops->qm;
309
310
// put our selected tier as the first plan
@@ -460,6 +425,13 @@ static bool query_plan(QUERY_ENGINE_OPS *ops, time_t after_wanted, time_t before
425
}
426
#endif
427
428
+ return true;
429
+}
430
+
431
+static bool query_plan(QUERY_ENGINE_OPS *ops, time_t after_wanted, time_t before_wanted, size_t points_wanted) {
432
+ if(!query_plan_build_entries(ops, after_wanted, before_wanted, points_wanted))
433
+ return false;
434
+
435
query_planer_initialize_plans(ops);
436
query_planer_activate_plan(ops, 0, 0);
437
@@ -519,3 +491,291 @@ QUERY_ENGINE_OPS *rrd2rrdr_query_ops_prep(RRDR *r, size_t query_metric_id) {
491
492
return ops;
493
}
494
+
495
+static void query_plan_unittest_set_tier(
496
+ QUERY_METRIC *qm, size_t tier, time_t first_time_s, time_t last_time_s, time_t update_every_s) {
497
+ static char smh_stub;
498
+
499
+ qm->tiers[tier].smh = (STORAGE_METRIC_HANDLE *)&smh_stub;
500
+ qm->tiers[tier].db_first_time_s = first_time_s;
501
+ qm->tiers[tier].db_last_time_s = last_time_s;
502
+ qm->tiers[tier].db_update_every_s = update_every_s;
503
+}
504
+
505
+static int query_plan_unittest_expect_best_tier(
506
+ const char *name, QUERY_METRIC *qm, time_t after, time_t before, size_t points, size_t expected) {
507
+ size_t got = query_metric_best_tier_for_timeframe(qm, after, before, points);
508
+ if(got == expected) {
509
+ fprintf(stderr, "OK query plan tier selection: %s\n", name);
510
+ return 0;
511
+ }
512
+
513
+ fprintf(stderr,
514
+ "FAILED query plan tier selection: %s, expected tier %zu, got tier %zu\n",
515
+ name, expected, got);
516
+
517
+ for(size_t tier = 0; tier < nd_profile.storage_tiers; tier++)
518
+ fprintf(stderr,
519
+ " tier %zu: first %ld, last %ld, update_every %ld, weight %ld\n",
520
+ tier,
521
+ qm->tiers[tier].db_first_time_s,
522
+ qm->tiers[tier].db_last_time_s,
523
+ qm->tiers[tier].db_update_every_s,
524
+ qm->tiers[tier].weight);
525
+
526
+ return 1;
527
+}
528
+
529
+static bool query_plan_unittest_build_entries(
530
+ QUERY_METRIC *qm, RRDR_OPTIONS options, size_t selected_tier,
531
+ time_t after, time_t before, size_t points) {
532
+ RRDR r = {0};
533
+ QUERY_TARGET qt = {0};
534
+ QUERY_ENGINE_OPS ops = {
535
+ .r = &r,
536
+ .qm = qm,
537
+ };
538
+
539
+ r.internal.qt = &qt;
540
+ qt.window.options = options;
541
+ qt.window.tier = selected_tier;
542
+
543
+ return query_plan_build_entries(&ops, after, before, points);
544
+}
545
+
546
+static int query_plan_unittest_expect_plan(
547
+ const char *name, QUERY_METRIC *qm, RRDR_OPTIONS options, size_t selected_tier,
548
+ time_t after, time_t before, size_t points,
549
+ const QUERY_PLAN_ENTRY *expected, size_t expected_used) {
550
+ if(!query_plan_unittest_build_entries(qm, options, selected_tier, after, before, points)) {
551
+ fprintf(stderr, "FAILED query plan entries: %s, planner returned false\n", name);
552
+ return 1;
553
+ }
554
+
555
+ if(qm->plan.used != expected_used) {
556
+ fprintf(stderr,
557
+ "FAILED query plan entries: %s, expected %zu entries, got %zu\n",
558
+ name, expected_used, qm->plan.used);
559
+ return 1;
560
+ }
561
+
562
+ for(size_t i = 0; i < expected_used; i++) {
563
+ if(qm->plan.array[i].tier == expected[i].tier &&
564
+ qm->plan.array[i].after == expected[i].after &&
565
+ qm->plan.array[i].before == expected[i].before)
566
+ continue;
567
+
568
+ fprintf(stderr,
569
+ "FAILED query plan entries: %s, entry %zu expected tier %zu after %ld before %ld, got tier %zu after %ld before %ld\n",
570
+ name, i,
571
+ expected[i].tier, expected[i].after, expected[i].before,
572
+ qm->plan.array[i].tier, qm->plan.array[i].after, qm->plan.array[i].before);
573
+ return 1;
574
+ }
575
+
576
+ fprintf(stderr, "OK query plan entries: %s\n", name);
577
+ return 0;
578
+}
579
+
580
+static int query_plan_unittest_expect_no_plan(
581
+ const char *name, QUERY_METRIC *qm, RRDR_OPTIONS options, size_t selected_tier,
582
+ time_t after, time_t before, size_t points) {
583
+ if(!query_plan_unittest_build_entries(qm, options, selected_tier, after, before, points)) {
584
+ fprintf(stderr, "OK query plan entries: %s\n", name);
585
+ return 0;
586
+ }
587
+
588
+ fprintf(stderr, "FAILED query plan entries: %s, expected no plan, got %zu entries\n", name, qm->plan.used);
589
+ return 1;
590
+}
591
+
592
+static int query_plan_unittest_expect_update_every(QUERY_TARGET *qt, size_t tier, time_t expected) {
593
+ time_t got = query_target_min_update_every_for_tier(qt, tier);
594
+ if(got == expected) {
595
+ fprintf(stderr, "OK query plan selected-tier natural update_every\n");
596
+ return 0;
597
+ }
598
+
599
+ fprintf(stderr,
600
+ "FAILED query plan selected-tier natural update_every: expected %ld, got %ld\n",
601
+ expected, got);
602
+
603
+ return 1;
604
+}
605
+
606
+int query_plan_unittest(void) {
607
+ size_t old_storage_tiers = nd_profile.storage_tiers;
608
+ time_t old_update_every = nd_profile.update_every;
609
+ int errors = 0;
610
+
611
+ nd_profile.storage_tiers = 3;
612
+ nd_profile.update_every = 1;
613
+
614
+ {
615
+ QUERY_METRIC qm = {0};
616
+ query_plan_unittest_set_tier(&qm, 0, 1, 200, 10);
617
+ query_plan_unittest_set_tier(&qm, 1, 1, 200, 600);
618
+ query_plan_unittest_set_tier(&qm, 2, 1, 100, 36000);
619
+
620
+ errors += query_plan_unittest_expect_best_tier(
621
+ "sub-resolution window ignores non-overlapping coarser tier", &qm, 103, 108, 5, 0);
622
+ }
623
+
624
+ {
625
+ QUERY_METRIC qm = {0};
626
+ query_plan_unittest_set_tier(&qm, 0, 1, 200, 10);
627
+ query_plan_unittest_set_tier(&qm, 1, 1, 200, 600);
628
+ query_plan_unittest_set_tier(&qm, 2, 1, 200, 36000);
629
+
630
+ errors += query_plan_unittest_expect_best_tier(
631
+ "sub-resolution window chooses densest overlapping tier", &qm, 103, 108, 5, 0);
632
+ }
633
+
634
+ {
635
+ QUERY_METRIC qm = {0};
636
+ query_plan_unittest_set_tier(&qm, 0, 1, 400000, 1);
637
+ query_plan_unittest_set_tier(&qm, 1, 1, 400000, 600);
638
+ query_plan_unittest_set_tier(&qm, 2, 1, 400000, 36000);
639
+
640
+ errors += query_plan_unittest_expect_best_tier(
641
+ "50 percent tolerance chooses sparsest acceptable tier", &qm, 1000, 301000, 500, 1);
642
+ }
643
+
644
+ {
645
+ QUERY_METRIC qm = {0};
646
+ query_plan_unittest_set_tier(&qm, 0, 1, 1000, 1);
647
+ query_plan_unittest_set_tier(&qm, 1, 1, 1000, 10);
648
+ query_plan_unittest_set_tier(&qm, 2, 1, 1000, 11);
649
+
650
+ errors += query_plan_unittest_expect_best_tier(
651
+ "50 percent tolerance includes exact threshold", &qm, 100, 400, 60, 1);
652
+ }
653
+
654
+ {
655
+ QUERY_METRIC qm = {0};
656
+ query_plan_unittest_set_tier(&qm, 0, 1, 1000, 10);
657
+ query_plan_unittest_set_tier(&qm, 1, 1, 1000, 600);
658
+ query_plan_unittest_set_tier(&qm, 2, 1, 1000, 36000);
659
+
660
+ errors += query_plan_unittest_expect_best_tier(
661
+ "under-resolution request chooses densest tier", &qm, 100, 700, 600, 0);
662
+ }
663
+
664
+ {
665
+ QUERY_METRIC qm = {0};
666
+ query_plan_unittest_set_tier(&qm, 0, 1, 50, 10);
667
+ query_plan_unittest_set_tier(&qm, 1, 100, 200, 600);
668
+ query_plan_unittest_set_tier(&qm, 2, 1, 50, 36000);
669
+
670
+ errors += query_plan_unittest_expect_best_tier(
671
+ "zero-overlap tiers are not candidates", &qm, 103, 108, 5, 1);
672
+ }
673
+
674
+ {
675
+ QUERY_METRIC qm = {0};
676
+ query_plan_unittest_set_tier(&qm, 1, 1, 200, 600);
677
+ query_plan_unittest_set_tier(&qm, 2, 1, 200, 36000);
678
+
679
+ errors += query_plan_unittest_expect_best_tier(
680
+ "invalid duration returns first working tier", &qm, 108, 108, 5, 1);
681
+ }
682
+
683
+ {
684
+ QUERY_METRIC qm = {0};
685
+ query_plan_unittest_set_tier(&qm, 0, 1, 300, 10);
686
+ query_plan_unittest_set_tier(&qm, 1, 1, 300, 600);
687
+
688
+ QUERY_PLAN_ENTRY expected[] = {
689
+ { .tier = 0, .after = 100, .before = 200 },
690
+ };
691
+
692
+ errors += query_plan_unittest_expect_plan(
693
+ "selected tier covers full window", &qm, 0, 0, 100, 200, 10, expected, _countof(expected));
694
+ }
695
+
696
+ {
697
+ QUERY_METRIC qm = {0};
698
+ query_plan_unittest_set_tier(&qm, 0, 100, 180, 10);
699
+ query_plan_unittest_set_tier(&qm, 1, 50, 150, 30);
700
+
701
+ QUERY_PLAN_ENTRY expected[] = {
702
+ { .tier = 1, .after = 50, .before = 100 },
703
+ { .tier = 0, .after = 100, .before = 180 },
704
+ };
705
+
706
+ errors += query_plan_unittest_expect_plan(
707
+ "coarser tier fills head gap", &qm, 0, 0, 50, 180, 10, expected, _countof(expected));
708
+ }
709
+
710
+ {
711
+ QUERY_METRIC qm = {0};
712
+ query_plan_unittest_set_tier(&qm, 0, 180, 260, 10);
713
+ query_plan_unittest_set_tier(&qm, 1, 100, 200, 30);
714
+
715
+ QUERY_PLAN_ENTRY expected[] = {
716
+ { .tier = 1, .after = 100, .before = 200 },
717
+ { .tier = 0, .after = 200, .before = 250 },
718
+ };
719
+
720
+ errors += query_plan_unittest_expect_plan(
721
+ "finer tier fills tail gap", &qm, 0, 0, 100, 250, 10, expected, _countof(expected));
722
+ }
723
+
724
+ {
725
+ QUERY_METRIC qm = {0};
726
+ query_plan_unittest_set_tier(&qm, 0, 180, 260, 10);
727
+ query_plan_unittest_set_tier(&qm, 1, 100, 200, 30);
728
+ query_plan_unittest_set_tier(&qm, 2, 50, 150, 60);
729
+
730
+ QUERY_PLAN_ENTRY expected[] = {
731
+ { .tier = 2, .after = 50, .before = 100 },
732
+ { .tier = 1, .after = 100, .before = 200 },
733
+ { .tier = 0, .after = 200, .before = 250 },
734
+ };
735
+
736
+ errors += query_plan_unittest_expect_plan(
737
+ "planner fills both head and tail gaps", &qm, 0, 0, 50, 250, 10, expected, _countof(expected));
738
+ }
739
+
740
+ {
741
+ QUERY_METRIC qm = {0};
742
+ query_plan_unittest_set_tier(&qm, 0, 180, 260, 10);
743
+ query_plan_unittest_set_tier(&qm, 1, 100, 200, 30);
744
+ query_plan_unittest_set_tier(&qm, 2, 50, 150, 60);
745
+
746
+ QUERY_PLAN_ENTRY expected[] = {
747
+ { .tier = 1, .after = 100, .before = 200 },
748
+ };
749
+
750
+ errors += query_plan_unittest_expect_plan(
751
+ "explicit selected tier disables gap filling", &qm, RRDR_OPTION_SELECTED_TIER, 1,
752
+ 50, 250, 10, expected, _countof(expected));
753
+ }
754
+
755
+ {
756
+ QUERY_METRIC qm = {0};
757
+ query_plan_unittest_set_tier(&qm, 0, 1, 50, 10);
758
+ query_plan_unittest_set_tier(&qm, 1, 60, 90, 30);
759
+ query_plan_unittest_set_tier(&qm, 2, 100, 150, 60);
760
+
761
+ errors += query_plan_unittest_expect_no_plan(
762
+ "no overlapping tier fails planning", &qm, 0, 0, 200, 250, 10);
763
+ }
764
+
765
+ {
766
+ QUERY_METRIC metrics[2] = {0};
767
+ QUERY_TARGET qt = {0};
768
+
769
+ metrics[0].tiers[1].db_update_every_s = 600;
770
+ metrics[1].tiers[1].db_update_every_s = 300;
771
+ qt.query.array = metrics;
772
+ qt.query.used = 2;
773
+
774
+ errors += query_plan_unittest_expect_update_every(&qt, 1, 300);
775
+ }
776
+
777
+ nd_profile.storage_tiers = old_storage_tiers;
778
+ nd_profile.update_every = old_update_every;
779
+
780
+ return errors;
781
+}
src/web/api/queries/query-window.c
+3
-3
@@ -131,9 +131,9 @@ bool query_target_calculate_window(QUERY_TARGET *qt) {
131
rrdr_relative_window_to_absolute_query(&after_wanted, &before_wanted, NULL, unittest_running);
132
query_debug_log(":relative2absolute after %ld, before %ld", after_wanted, before_wanted);
133
134
- if (natural_points && (options & RRDR_OPTION_SELECTED_TIER) && tier > 0 && nd_profile.storage_tiers > 1) {
135
- update_every = rrdset_find_natural_update_every_for_timeframe(
136
- qt, after_wanted, before_wanted, points_wanted, options, tier);
134
+ if (natural_points && (options & RRDR_OPTION_SELECTED_TIER) &&
135
+ tier > 0 && tier < nd_profile.storage_tiers && nd_profile.storage_tiers > 1) {
136
+ update_every = query_target_min_update_every_for_tier(qt, tier);
137
138
if (update_every <= 0) update_every = qt->db.minimum_latest_update_every_s;
139
query_debug_log(":natural update every %ld", update_every);