@cryptotaxi247 / netdata / commits / 2c44acdef

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);