Compare commits

..

2 Commits

Author SHA1 Message Date
hagen1778
3e943e3b68 update tests
Signed-off-by: hagen1778 <roman@victoriametrics.com>
2026-05-22 12:22:29 +02:00
hagen1778
73fe42946b app/vmalert: clarify parser type in expr validation
Before, having `prometheus` or `graphite` could have been confusing for users.
It was also inconsistent with `LogsQL` for `vlogs`.
Also removed extra spaces.

Signed-off-by: hagen1778 <roman@victoriametrics.com>
2026-05-22 11:07:53 +02:00
30 changed files with 286 additions and 899 deletions

View File

@@ -113,15 +113,15 @@ func (g *Group) Validate(validateTplFn ValidateTplFn, validateExpressions bool)
// because correct types must be inherited after unmarshalling.
exprValidator := g.Type.ValidateExpr
if err := exprValidator(r.Expr); err != nil {
return fmt.Errorf("invalid expression for rule %q: %w", ruleName, err)
return fmt.Errorf("invalid expression for rule %q: %w", ruleName, err)
}
}
if validateTplFn != nil {
if err := validateTplFn(r.Annotations); err != nil {
return fmt.Errorf("invalid annotations for rule %q: %w", ruleName, err)
return fmt.Errorf("invalid annotations for rule %q: %w", ruleName, err)
}
if err := validateTplFn(r.Labels); err != nil {
return fmt.Errorf("invalid labels for rule %q: %w", ruleName, err)
return fmt.Errorf("invalid labels for rule %q: %w", ruleName, err)
}
}
}

View File

@@ -121,7 +121,7 @@ func TestParse_Failure(t *testing.T) {
f([]string{"testdata/dir/rules2-bad.rules"}, "function \"unknown\" not defined")
f([]string{"testdata/dir/rules3-bad.rules"}, "either `record` or `alert` must be set")
f([]string{"testdata/dir/rules4-bad.rules"}, "either `record` or `alert` must be set")
f([]string{"testdata/rules/rules1-bad.rules"}, "bad graphite expr")
f([]string{"testdata/rules/rules1-bad.rules"}, "bad GraphiteQL expr")
f([]string{"testdata/rules/vlog-rules0-bad.rules"}, "bad LogsQL expr")
f([]string{"testdata/dir/rules6-bad.rules"}, "missing ':' in header")
f([]string{"testdata/rules/rules-multi-doc-bad.rules"}, "unknown fields")
@@ -283,7 +283,7 @@ func TestGroupValidate_Failure(t *testing.T) {
Expr: "up | 0",
},
},
}, true, "bad prometheus expr")
}, true, "bad MetricsQL expr")
f(&Group{
Name: "test graphite expr",
@@ -293,7 +293,7 @@ func TestGroupValidate_Failure(t *testing.T) {
"description": "some-description",
}},
},
}, true, "bad graphite expr")
}, true, "bad GraphiteQL expr")
f(&Group{
Name: "test vlogs expr",
@@ -327,7 +327,7 @@ func TestGroupValidate_Failure(t *testing.T) {
Expr: "sum(up == 0 ) by (host)",
},
},
}, true, "bad graphite expr")
}, true, "bad GraphiteQL expr")
f(&Group{
Name: "test vlogs with prometheus exp",
@@ -351,7 +351,7 @@ func TestGroupValidate_Failure(t *testing.T) {
For: promutil.NewDuration(10 * time.Millisecond),
},
},
}, true, "bad prometheus expr")
}, true, "bad MetricsQL expr")
}
func TestGroupValidate_Success(t *testing.T) {

View File

@@ -66,11 +66,11 @@ func (t *Type) ValidateExpr(expr string) error {
switch t.String() {
case "graphite":
if _, err := graphiteql.Parse(expr); err != nil {
return fmt.Errorf("bad graphite expr: %q, err: %w", expr, err)
return fmt.Errorf("bad GraphiteQL expr: %q, err: %w", expr, err)
}
case "prometheus":
if _, err := metricsql.Parse(expr); err != nil {
return fmt.Errorf("bad prometheus expr: %q, err: %w", expr, err)
return fmt.Errorf("bad MetricsQL expr: %q, err: %w", expr, err)
}
case "vlogs":
q, err := logstorage.ParseStatsQuery(expr, 0)

View File

@@ -20,9 +20,6 @@ func TestGetTime_Failure(t *testing.T) {
// negative time
f("-292273086-05-16T16:47:06Z")
// relative duration that resolves to a timestamp before 1970
f("-9223372036.855")
}
func TestGetTime_Success(t *testing.T) {
@@ -80,6 +77,9 @@ func TestGetTime_Success(t *testing.T) {
// float timestamp representation",
f("1562529662.324", time.Date(2019, 7, 7, 20, 01, 02, 324e6, time.UTC))
// negative timestamp
f("-9223372036.855", time.Date(1970, 01, 01, 00, 00, 00, 00, time.UTC))
// big timestamp
f("1223372036855", time.Date(2008, 10, 7, 9, 33, 56, 855e6, time.UTC))

View File

@@ -990,6 +990,9 @@ func ExportBlocks(qt *querytracer.Tracer, sq *storage.SearchQuery, deadline sear
return fmt.Errorf("timeout exceeded before starting data export: %s", deadline.String())
}
tr := sq.GetTimeRange()
if err := vmstorage.CheckTimeRange(tr); err != nil {
return err
}
tfss, err := setupTfss(qt, tr, sq.TagFilterss, sq.MaxMetrics, deadline)
if err != nil {
return err
@@ -1095,6 +1098,9 @@ func SearchMetricNames(qt *querytracer.Tracer, sq *storage.SearchQuery, deadline
// Setup search.
tr := sq.GetTimeRange()
if err := vmstorage.CheckTimeRange(tr); err != nil {
return nil, err
}
tfss, err := setupTfss(qt, tr, sq.TagFilterss, sq.MaxMetrics, deadline)
if err != nil {
return nil, err
@@ -1121,6 +1127,9 @@ func ProcessSearchQuery(qt *querytracer.Tracer, sq *storage.SearchQuery, deadlin
// Setup search.
tr := sq.GetTimeRange()
if err := vmstorage.CheckTimeRange(tr); err != nil {
return nil, err
}
tfss, err := setupTfss(qt, tr, sq.TagFilterss, sq.MaxMetrics, deadline)
if err != nil {
return nil, err

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -37,11 +37,11 @@
<meta property="og:title" content="UI for VictoriaMetrics">
<meta property="og:url" content="https://victoriametrics.com/">
<meta property="og:description" content="Explore and troubleshoot your VictoriaMetrics data">
<script type="module" crossorigin src="./assets/index-BjJ7fDL7.js"></script>
<script type="module" crossorigin src="./assets/index-C7gvW_Zn.js"></script>
<link rel="modulepreload" crossorigin href="./assets/rolldown-runtime-COnpUsM8.js">
<link rel="modulepreload" crossorigin href="./assets/vendor-C8Kwp93_.js">
<link rel="stylesheet" crossorigin href="./assets/vendor-CnsZ1jie.css">
<link rel="stylesheet" crossorigin href="./assets/index-BL7jEFBa.css">
<link rel="stylesheet" crossorigin href="./assets/index-D2OEy8Ra.css">
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>

View File

@@ -56,8 +56,8 @@ var (
logNewSeries = flag.Bool("logNewSeries", false, "Whether to log new series. This option is for debug purposes only. It can lead to performance issues "+
"when big number of new series are ingested into VictoriaMetrics")
denyQueriesOutsideRetention = flag.Bool("denyQueriesOutsideRetention", false, "Whether to deny queries outside the configured -retentionPeriod and -futureRetention. "+
"When set, then /api/v1/query_range will return an error for queries with 'from' value outside -retentionPeriod or 'to' value beyond -futureRetention. "+
denyQueriesOutsideRetention = flag.Bool("denyQueriesOutsideRetention", false, "Whether to deny queries outside the configured -retentionPeriod. "+
"When set, then /api/v1/query_range would return '503 Service Unavailable' error for queries with 'from' value outside -retentionPeriod. "+
"This may be useful when multiple data sources with distinct retentions are hidden behind query-tee")
maxHourlySeries = flag.Int64("storage.maxHourlySeries", 0, "The maximum number of unique series can be added to the storage during the last hour. "+
"Excess series are logged and dropped. This can be useful for limiting series cardinality. See https://docs.victoriametrics.com/victoriametrics/single-server-victoriametrics/#cardinality-limiter . "+
@@ -103,6 +103,21 @@ var (
"If set to 0 or a negative value, defaults to 1% of allowed memory.")
)
// CheckTimeRange returns true if the given tr is denied for querying.
func CheckTimeRange(tr storage.TimeRange) error {
if !*denyQueriesOutsideRetention {
return nil
}
minAllowedTimestamp := int64(fasttime.UnixTimestamp()*1000) - retentionPeriod.Milliseconds()
if tr.MinTimestamp > minAllowedTimestamp {
return nil
}
return &httpserver.ErrorWithStatusCode{
Err: fmt.Errorf("the given time range %s is outside the allowed -retentionPeriod=%s according to -denyQueriesOutsideRetention", &tr, retentionPeriod),
StatusCode: http.StatusServiceUnavailable,
}
}
// Init initializes vmstorage.
func Init(resetCacheIfNeeded func(mrs []storage.MetricRow)) {
if err := encoding.CheckPrecisionBits(uint8(*precisionBits)); err != nil {
@@ -136,15 +151,14 @@ func Init(resetCacheIfNeeded func(mrs []storage.MetricRow)) {
startTime := time.Now()
WG = syncwg.WaitGroup{}
opts := storage.OpenOptions{
Retention: retentionPeriod.Duration(),
FutureRetention: futureRetention.Duration(),
DenyQueriesOutsideRetention: *denyQueriesOutsideRetention,
MaxHourlySeries: getMaxHourlySeries(),
MaxDailySeries: getMaxDailySeries(),
DisablePerDayIndex: *disablePerDayIndex,
TrackMetricNamesStats: *trackMetricNamesStats,
IDBPrefillStart: *idbPrefillStart,
LogNewSeries: *logNewSeries,
Retention: retentionPeriod.Duration(),
FutureRetention: futureRetention.Duration(),
MaxHourlySeries: getMaxHourlySeries(),
MaxDailySeries: getMaxDailySeries(),
DisablePerDayIndex: *disablePerDayIndex,
TrackMetricNamesStats: *trackMetricNamesStats,
IDBPrefillStart: *idbPrefillStart,
LogNewSeries: *logNewSeries,
}
strg := storage.MustOpenStorage(*DataPath, opts)
Storage = strg

View File

@@ -3,7 +3,6 @@ export interface MetricBase {
metric: {
[key: string]: string;
};
nullTimestamps?: number[];
}
export interface MetricResult extends MetricBase {

View File

@@ -16,7 +16,6 @@ export interface ChartTooltipProps {
point: { top: number, left: number };
unit?: string;
statsFormatted?: SeriesItemStatsFormatted;
description?: ReactNode;
isSticky?: boolean;
info?: ReactNode;
marker?: string;
@@ -35,7 +34,6 @@ const ChartTooltip: FC<ChartTooltipProps> = ({
unit = "",
info,
statsFormatted,
description,
isSticky,
marker,
duplicateCount = 0,
@@ -175,7 +173,6 @@ const ChartTooltip: FC<ChartTooltipProps> = ({
))}
</table>
)}
{description && <p className="vm-chart-tooltip__description">{description}</p>}
{info && <p className="vm-chart-tooltip__info">{info}</p>}
</div>
), u.root);

View File

@@ -143,10 +143,4 @@ $chart-tooltip-y: -1 * ($padding-global + $chart-tooltip-half-icon);
word-break: break-all;
white-space: pre-wrap;
}
&__description {
word-break: break-word;
white-space: pre-wrap;
opacity: 0.85;
}
}

View File

@@ -15,65 +15,19 @@ interface LineTooltipHook {
unit?: string;
}
// Pixel proximity for detecting hover over null-timestamp X markers drawn at chart bottom.
const NULL_HOVER_PROX = 8;
// Half the visual marker height in CSS px (BASE_POINT_SIZE * 1.4 / 2 from scatter.ts).
// scatter.ts lifts the marker center by this amount above yMin so the icon sits inside
// the plot area; the hover y-anchor must match that offset.
const NULL_MARKER_HALF_CSS = 2.8;
interface NullHover {
seriesIdx: number;
timestamp: number;
}
const findNullHover = (u: uPlot): NullHover | null => {
const cursorLeft = u.cursor.left ?? -1;
const cursorTop = u.cursor.top ?? -1;
if (cursorLeft < 0 || cursorTop < 0) return null;
const scaleY = u.scales["1"];
if (!scaleY || scaleY.min == null) return null;
const yPos = u.valToPos(scaleY.min, "1") - NULL_MARKER_HALF_CSS;
if (Math.abs(cursorTop - yPos) > NULL_HOVER_PROX) return null;
let best: { seriesIdx: number; timestamp: number; dist: number } | null = null;
for (let s = 1; s < u.series.length; s++) {
const seriesItem = u.series[s] as SeriesItem;
if (!seriesItem.show) continue;
const nullTs = seriesItem.nullTimestamps;
if (!nullTs || !nullTs.length) continue;
for (let i = 0; i < nullTs.length; i++) {
const t = nullTs[i];
const xPos = u.valToPos(t, "x");
const dist = Math.abs(cursorLeft - xPos);
if (dist < NULL_HOVER_PROX && (best === null || dist < best.dist)) {
best = { seriesIdx: s, timestamp: t, dist };
}
}
}
return best ? { seriesIdx: best.seriesIdx, timestamp: best.timestamp } : null;
};
const NULL_DESCRIPTION = "\"null\" can be a staleness marker or an actual NaN/null value produced by exporter.";
const useLineTooltip = ({ u, metrics, series, unit }: LineTooltipHook) => {
const [showTooltip, setShowTooltip] = useState(false);
const [tooltipIdx, setTooltipIdx] = useState({ seriesIdx: -1, dataIdx: -1 });
const [nullTooltip, setNullTooltip] = useState<NullHover | null>(null);
const [stickyTooltips, setStickyToolTips] = useState<ChartTooltipProps[]>([]);
const resetTooltips = () => {
setStickyToolTips([]);
setTooltipIdx({ seriesIdx: -1, dataIdx: -1 });
setNullTooltip(null);
};
const setCursor = (u: uPlot) => {
const dataIdx = u.cursor.idx ?? -1;
setTooltipIdx(prev => ({ ...prev, dataIdx }));
setNullTooltip(findNullHover(u));
};
const seriesFocus = (u: uPlot, sidx: (number | null)) => {
@@ -81,36 +35,7 @@ const useLineTooltip = ({ u, metrics, series, unit }: LineTooltipHook) => {
setTooltipIdx(prev => ({ ...prev, seriesIdx }));
};
const getNullTooltipProps = (hit: NullHover): ChartTooltipProps => {
const { seriesIdx, timestamp } = hit;
const metricItem = metrics[seriesIdx - 1];
const seriesItem = series[seriesIdx] as SeriesItem;
const groups = new Set(metrics.map(m => m.group));
const group = metricItem?.group || 0;
const yMin = u?.scales?.[1]?.min ?? 0;
const point = {
top: u ? u.valToPos(yMin, seriesItem?.scale || "1") - NULL_MARKER_HALF_CSS : 0,
left: u ? u.valToPos(timestamp, "x") : 0,
};
return {
u,
id: `null_${seriesIdx}_${timestamp}`,
title: groups.size > 1 ? `Query ${group}` : "",
dates: [dayjs(timestamp * 1000).tz().format(DATE_FULL_TIMEZONE_FORMAT)],
value: "null",
info: getMetricName(metricItem, seriesItem),
description: NULL_DESCRIPTION,
marker: `${seriesItem?.stroke}`,
point,
};
};
const getTooltipProps = useCallback((): ChartTooltipProps => {
if (nullTooltip) return getNullTooltipProps(nullTooltip);
const { seriesIdx, dataIdx } = tooltipIdx;
const metricItem = metrics[seriesIdx - 1];
const seriesItem = series[seriesIdx] as SeriesItem;
@@ -159,7 +84,7 @@ const useLineTooltip = ({ u, metrics, series, unit }: LineTooltipHook) => {
marker: `${seriesItem?.stroke}`,
duplicateCount,
};
}, [u, tooltipIdx, metrics, series, unit, nullTooltip]);
}, [u, tooltipIdx, metrics, series, unit]);
const handleClick = useCallback(() => {
if (!showTooltip) return;
@@ -174,9 +99,8 @@ const useLineTooltip = ({ u, metrics, series, unit }: LineTooltipHook) => {
};
useEffect(() => {
const normalHit = tooltipIdx.dataIdx !== -1 && tooltipIdx.seriesIdx !== -1;
setShowTooltip(normalHit || nullTooltip !== null);
}, [tooltipIdx, nullTooltip]);
setShowTooltip(tooltipIdx.dataIdx !== -1 && tooltipIdx.seriesIdx !== -1);
}, [tooltipIdx]);
useEventListener("click", handleClick);

View File

@@ -20,7 +20,7 @@ describe("convertMetricsDataToCSV", () => {
},
];
const result = convertMetricsDataToCSV(data);
expect(result).toBe("header1,header2,__timestamp__,__value__\n123,value2,1623945600,123");
expect(result).toBe("header1,header2\n123,value2");
});
it("should return a valid CSV string for multiple metric entries with values", () => {
@@ -43,7 +43,7 @@ describe("convertMetricsDataToCSV", () => {
},
];
const result = convertMetricsDataToCSV(data);
expect(result).toBe("header1,header2,__timestamp__,__value__\n123,value2,1623945600,123\n456,value4,1623949200,456");
expect(result).toBe("header1,header2\n123,value2\n456,value4");
});
it("should handle metric entries with multiple values field", () => {
@@ -58,7 +58,7 @@ describe("convertMetricsDataToCSV", () => {
},
];
const result = convertMetricsDataToCSV(data);
expect(result).toBe("header1,header2,__timestamp__,__value__\n123-456,values,-,-");
expect(result).toBe("header1,header2\n123-456,values");
});
it("should handle a combination of metric entries with value and values", () => {
@@ -81,19 +81,6 @@ describe("convertMetricsDataToCSV", () => {
},
];
const result = convertMetricsDataToCSV(data);
expect(result).toBe("header1,header2,__timestamp__,__value__\n123,first,1623945600,123\n456-789,second,-,-");
expect(result).toBe("header1,header2\n123,first\n456-789,second");
});
it("should return value and timestamp if metric field is empty", () => {
const data: InstantMetricResult[] = [
{
value: [1623945600, "123"],
group: 0,
metric: {}
},
];
const result = convertMetricsDataToCSV(data);
expect(result).toBe("__timestamp__,__value__\n1623945600,123");
});
});

View File

@@ -3,22 +3,16 @@ import { getColumns, MetricCategory } from "../../hooks/useSortedCategories";
import { formatValueToCSV } from "../../utils/csv";
const getHeaders = (data: InstantMetricResult[]): string => {
const metricHeaders = getColumns(data).map(({ key }) => key);
return [...metricHeaders, "__timestamp__", "__value__"].join(",");
return getColumns(data).map(({ key }) => key).join(",");
};
const getRows = (data: InstantMetricResult[], headers: MetricCategory[]) => {
return data?.map(d => {
const metricPart = headers.map(c => formatValueToCSV(d.metric[c.key] || "-"));
const timestamp = d.value ? formatValueToCSV(String(d.value[0])) : "-";
const value = d.value ? formatValueToCSV(d.value[1]) : "-";
return [...metricPart, timestamp, value].join(",");
});
return data?.map(d => headers.map(c => formatValueToCSV(d.metric[c.key] || "-")).join(","));
};
export const convertMetricsDataToCSV = (data: InstantMetricResult[]): string => {
if (!data.length) return "";
const headers = getHeaders(data);
if (!headers.length) return "";
const rows = getRows(data, getColumns(data));
return [headers, ...rows].join("\n");
};

View File

@@ -149,21 +149,15 @@ export const useFetchExport = ({ hideQuery, showAllSeries }: FetchQueryParams):
const pointsToTake = shouldDownsample ? maxPointsPerSeries : totalPoints;
const step = shouldDownsample ? totalPoints / maxPointsPerSeries : 1;
const values: [number, number][] = new Array(pointsToTake);
const nullTimestamps: number[] = [];
for (let i = 0; i < pointsToTake; i++) {
const values: [number, number][] = Array.from({ length: pointsToTake }, (_, i) => {
const idx = shouldDownsample ? Math.floor(i * step) : i;
const ts = rawTimestamps[idx] / 1000;
const raw = rawValues[idx];
if (raw === null) nullTimestamps.push(ts);
values[i] = [ts, raw as number];
}
return [rawTimestamps[idx] / 1000, rawValues[idx]];
});
tempData.push({
group: counter,
metric: jsonLine.metric,
values,
nullTimestamps,
} as MetricBase);
}

View File

@@ -11,7 +11,6 @@ export interface SeriesItem extends Series {
statsFormatted: SeriesItemStatsFormatted;
median: number;
hasAlias?: boolean;
nullTimestamps?: number[];
}
export interface HideSeriesArgs {

View File

@@ -103,28 +103,6 @@ export const drawPoints = (u: uPlot, seriesIdx: number) => {
u.ctx.lineWidth = 1.4 * uPlot.pxRatio;
u.ctx.strokeStyle = u.ctx.fillStyle;
u.ctx.stroke(squaresPath);
const nullTs = (series as unknown as { nullTimestamps?: number[] }).nullTimestamps;
if (nullTs && nullTs.length) {
const xSize = BASE_POINT_SIZE * 1.4 * uPlot.pxRatio;
const xHalf = xSize / 2;
// Lift the marker by half its size so the entire icon sits inside the plot area
// (yMin maps to the plot's bottom edge, so centering on it would clip the lower half).
const cy = valToPosY(yMin, scaleY, yDim, yOff) - xHalf;
const xPath = new Path2D();
for (let i = 0; i < nullTs.length; i++) {
const t = nullTs[i];
if (t < xMin || t > xMax) continue;
const cx = valToPosX(t, scaleX, xDim, xOff);
xPath.moveTo(cx - xHalf, cy - xHalf);
xPath.lineTo(cx + xHalf, cy + xHalf);
xPath.moveTo(cx + xHalf, cy - xHalf);
xPath.lineTo(cx - xHalf, cy + xHalf);
}
u.ctx.lineWidth = 1.6 * uPlot.pxRatio;
u.ctx.strokeStyle = u.ctx.fillStyle;
u.ctx.stroke(xPath);
}
};
uPlot.orient(u, seriesIdx, orientCallback);

View File

@@ -38,7 +38,6 @@ export const getSeriesItemContext = (data: MetricResult[], hideSeries: string[],
show: !includesHideSeries(label, hideSeries),
scale: "1",
paths: isRawQuery ? drawPoints : undefined,
nullTimestamps: d.nullTimestamps,
...getSeriesStatistics(d),
};
};

View File

@@ -26,10 +26,6 @@ See also [LTS releases](https://docs.victoriametrics.com/victoriametrics/lts-rel
## tip
## [v1.144.0](https://github.com/VictoriaMetrics/VictoriaMetrics/releases/tag/v1.144.0)
Released at 2026-05-22
* FEATURE: all VictoriaMetrics components: improve logging for the `-memory.allowedBytes` flag to warn about excessively low value (less than 1MB). See issue [#10935](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/10935).
* FEATURE: [vmagent](https://docs.victoriametrics.com/victoriametrics/vmagent/) and [vmalert](https://docs.victoriametrics.com/victoriametrics/vmalert/): add `basicAuth.usernameFile` command-line flags for reading basic auth username from a file, similar to the existing `basicAuth.passwordFile`. The file is re-read every second. See [#9436](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/9436). Thanks to @kimjune01 for the contribution.
* FEATURE: `vminsert` in [VictoriaMetrics cluster](https://docs.victoriametrics.com/victoriametrics/cluster-victoriametrics/): add `clusternative.tls` `vminsert` configuration flags for [multi-level cluster setups](https://docs.victoriametrics.com/victoriametrics/cluster-victoriametrics/#multi-level-cluster-setup). See [#10958](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/10958).
@@ -38,7 +34,6 @@ Released at 2026-05-22
* FEATURE: [vmagent](https://docs.victoriametrics.com/victoriametrics/vmagent/): drain in-memory remote write queue on shutdown within the 5-second grace period before falling back to persisting blocks to disk. See [#9996](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/9996)
* FEATURE: `vminsert` in [VictoriaMetrics cluster](https://docs.victoriametrics.com/victoriametrics/cluster-victoriametrics/): Improve [slowness-based rerouting](https://docs.victoriametrics.com/victoriametrics/cluster-victoriametrics/#slowness-based-re-routing) to prevent rerouting storms under high cluster load. Previously, rerouting could cascade across storage nodes when the whole cluster was saturated, making the situation worse. Now rerouting only activates when the cluster p90 saturation is below 60%, and the slowest node is more than 20% slower than p90. See [#10876](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/10876).
* FEATURE: [vmauth](https://docs.victoriametrics.com/victoriametrics/vmauth/): add `{{.MetricsAccountID}}` and `{{.MetricsProjectID}}` [JWT claim placeholders](https://docs.victoriametrics.com/victoriametrics/vmauth/#jwt-claim-based-request-templating) for use in `headers` and `url_prefix` config fields. Previously, only the combined `{{.MetricsTenant}}` (`accountID:projectID`) JWT placeholder was supported, making it impossible to configure [multitenancy via headers](https://docs.victoriametrics.com/victoriametrics/cluster-victoriametrics/#multitenancy-via-headers). See [#10927](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/10927). Thanks to @Vinayak9769 for the contribution.
* FEATURE: [vmui](https://docs.victoriametrics.com/victoriametrics/single-server-victoriametrics/#vmui): display `null` values on `Raw Query` chart. `null` values can be actual `NaN` or `null` values exposed by the exporter, or [stale markers](https://docs.victoriametrics.com/victoriametrics/vmagent/#prometheus-staleness-markers). Before, vmui Raw Query was silently dropping non-numeric values. Displaying such values on the chart could improve the debugging experience. See [#10986](https://github.com/VictoriaMetrics/VictoriaMetrics/pull/10986).
* BUGFIX: [stream aggregation](https://docs.victoriametrics.com/victoriametrics/stream-aggregation/): stop emitting stale values for `quantiles(...)` outputs when a time series has no samples during the current aggregation interval. See [#10918](https://github.com/VictoriaMetrics/VictoriaMetrics/pull/10918). Thanks to @alexei38 for the contribution.
* BUGFIX: [stream aggregation](https://docs.victoriametrics.com/victoriametrics/stream-aggregation/): extend delay on aggregation windows flush by the biggest lag among pushed samples. Before, the delay was calculated as 95th percentile across samples, which could underrepresent outliers and reject them from aggregation as "too old". See [#10402](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/10402).
@@ -52,9 +47,6 @@ Released at 2026-05-22
* BUGFIX: [vmagent](https://docs.victoriametrics.com/victoriametrics/vmagent/): return error on startup if `-remoteWrite.disableOnDiskQueue` is not configured uniformly across all `-remoteWrite.url` targets when `-remoteWrite.shardByURL` is enabled. Either all targets must have it enabled or all must have it disabled. See [#10507](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/10507).
* BUGFIX: [vmsingle](https://docs.victoriametrics.com/victoriametrics/single-server-victoriametrics/) and `vmselect` in [VictoriaMetrics cluster](https://docs.victoriametrics.com/victoriametrics/cluster-victoriametrics/): hide values passed to `vmalert.proxyURL` in startup logs, `/metrics`, and `/flags`, since they can contain sensitive HTTP headers such as `Authorization` and API keys.
* BUGFIX: [vmui](https://docs.victoriametrics.com/victoriametrics/single-server-victoriametrics/#vmui): preserve exact series values in graph tooltips instead of rounding them by significant digits. See [#10952](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/10952).
* BUGFIX: all VictoriaMetrics components: fix int64 overflow when parsing [timestamp parameters](https://docs.victoriametrics.com/victoriametrics/single-server-victoriametrics/#timestamp-formats) with relative durations. See [#10880](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/10880).
* BUGFIX: [vmsingle](https://docs.victoriametrics.com/victoriametrics/single-server-victoriametrics/) and `vmstorage` in [VictoriaMetrics cluster](https://docs.victoriametrics.com/victoriametrics/cluster-victoriametrics/): `-denyQueriesOutsideRetention` now also rejects queries whose end time is beyond `-futureRetention`. See [#10879](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/10879).
* BUGFIX: [vmui](https://docs.victoriametrics.com/victoriametrics/single-server-victoriametrics/#vmui): add missing `__timestamp__` and `__value__` columns to CSV exported from the table view on the Query tab. See [#10975](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/10975).
## [v1.143.0](https://github.com/VictoriaMetrics/VictoriaMetrics/releases/tag/v1.143.0)

View File

@@ -51,6 +51,8 @@ func TestGetTimeSuccess(t *testing.T) {
f("-292273086-05-16T16:47:06Z", minTimeMsecs)
f("292277025-08-18T07:12:54.999999999Z", maxTimeMsecs)
f("1562529662.324", 1562529662324)
f("-9223372036.854", minTimeMsecs)
f("-9223372036.855", minTimeMsecs)
f("1223372036.855", 1223372036855)
}
@@ -83,8 +85,4 @@ func TestGetTimeError(t *testing.T) {
f("292277025-08-18T07:12:54.999999998Z")
f("123md")
f("-12.3md")
// relative duration that resolves to a timestamp before 1970
f("-9223372036.854")
f("-9223372036.855")
}

View File

@@ -228,7 +228,6 @@ func testAssertSearchResult(st *Storage, tr TimeRange, tfs *TagFilters, want []M
var s Search
s.Init(nil, st, []*TagFilters{tfs}, tr, 1e5, noDeadline)
defer s.MustClose()
var mbs []metricBlock
for s.NextMetricBlock() {
var b Block
@@ -242,6 +241,7 @@ func testAssertSearchResult(st *Storage, tr TimeRange, tfs *TagFilters, want []M
if err := s.Error(); err != nil {
return fmt.Errorf("search error: %w", err)
}
s.MustClose()
var got []MetricRow
var mn MetricName

View File

@@ -61,11 +61,10 @@ type Storage struct {
// indexdb rotation.
legacyNextRotationTimestamp atomic.Int64
path string
cachePath string
retentionMsecs int64
futureRetentionMsecs int64
denyQueriesOutsideRetention bool
path string
cachePath string
retentionMsecs int64
futureRetentionMsecs int64
// lock file for exclusive access to the storage on the given path.
flockF *os.File
@@ -162,15 +161,14 @@ type Storage struct {
// OpenOptions optional args for MustOpenStorage
type OpenOptions struct {
Retention time.Duration
FutureRetention time.Duration
DenyQueriesOutsideRetention bool
MaxHourlySeries int
MaxDailySeries int
DisablePerDayIndex bool
TrackMetricNamesStats bool
IDBPrefillStart time.Duration
LogNewSeries bool
Retention time.Duration
FutureRetention time.Duration
MaxHourlySeries int
MaxDailySeries int
DisablePerDayIndex bool
TrackMetricNamesStats bool
IDBPrefillStart time.Duration
LogNewSeries bool
}
// MustOpenStorage opens storage on the given path with the given retentionMsecs.
@@ -192,13 +190,12 @@ func MustOpenStorage(path string, opts OpenOptions) *Storage {
idbPrefillStart = time.Hour
}
s := &Storage{
path: path,
cachePath: filepath.Join(path, cacheDirname),
retentionMsecs: retention.Milliseconds(),
futureRetentionMsecs: futureRetention.Milliseconds(),
denyQueriesOutsideRetention: opts.DenyQueriesOutsideRetention,
stopCh: make(chan struct{}),
idbPrefillStartSeconds: idbPrefillStart.Milliseconds() / 1000,
path: path,
cachePath: filepath.Join(path, cacheDirname),
retentionMsecs: retention.Milliseconds(),
futureRetentionMsecs: futureRetention.Milliseconds(),
stopCh: make(chan struct{}),
idbPrefillStartSeconds: idbPrefillStart.Milliseconds() / 1000,
}
s.logNewSeries.Store(opts.LogNewSeries)
@@ -1228,25 +1225,6 @@ func searchAndMergeUniq(qt *querytracer.Tracer, s *Storage, tr TimeRange, search
return res, nil
}
// checkTimeRange returns an error if time range is outside the allowed
// -retentionPeriod or -futureRetention window when
// -denyQueriesOutsideRetention flag is set
func (s *Storage) checkTimeRange(tr TimeRange) error {
if !s.denyQueriesOutsideRetention {
return nil
}
minTimestamp, maxTimestamp := s.tb.getMinMaxTimestamps()
if minTimestamp <= tr.MinTimestamp && tr.MaxTimestamp <= maxTimestamp {
return nil
}
retention := time.Duration(s.retentionMsecs) * time.Millisecond
futureRetention := time.Duration(s.futureRetentionMsecs) * time.Millisecond
return fmt.Errorf("the given time range %s is outside the allowed -retentionPeriod=%s, -futureRetention=%s "+
"according to -denyQueriesOutsideRetention", &tr, retention, futureRetention)
}
// SearchTSIDs searches the TSIDs that correspond to filters within the given
// time range.
//
@@ -1258,10 +1236,6 @@ func (s *Storage) SearchTSIDs(qt *querytracer.Tracer, tfss []*TagFilters, tr Tim
qt = qt.NewChild("search TSIDs: filters=%s, timeRange=%s, maxMetrics=%d", tfss, &tr, maxMetrics)
defer qt.Done()
if err := s.checkTimeRange(tr); err != nil {
return nil, err
}
search := func(qt *querytracer.Tracer, idb *indexDB, tr TimeRange) ([]TSID, error) {
return idb.SearchTSIDs(qt, tfss, tr, maxMetrics, deadline)
}
@@ -1297,9 +1271,6 @@ func (s *Storage) SearchTSIDs(qt *querytracer.Tracer, tfss []*TagFilters, tr Tim
// MetricName.UnmarshalString().
func (s *Storage) SearchMetricNames(qt *querytracer.Tracer, tfss []*TagFilters, tr TimeRange, maxMetrics int, deadline uint64) ([]string, error) {
qt = qt.NewChild("search metric names: filters=%s, timeRange=%s, maxMetrics: %d", tfss, &tr, maxMetrics)
if err := s.checkTimeRange(tr); err != nil {
return nil, err
}
search := func(qt *querytracer.Tracer, idb *indexDB, tr TimeRange) ([]string, error) {
return idb.SearchMetricNames(qt, tfss, tr, maxMetrics, deadline)
}

View File

@@ -6,7 +6,6 @@ import (
"fmt"
"math/rand"
"path/filepath"
"slices"
"testing"
"testing/synctest"
"time"
@@ -1145,268 +1144,3 @@ func TestStorage_partitionsOutsideRetentionAreRemoved(t *testing.T) {
s.MustClose()
})
}
func TestStorage_denyQueriesOutsideRetention(t *testing.T) {
defer testRemoveAll(t)
tfsAll := NewTagFilters()
if err := tfsAll.Add(nil, []byte(".*"), false, true); err != nil {
t.Fatalf("TagFilters.Add() failed unexpectedly: %v", err)
}
tfssAll := []*TagFilters{tfsAll}
assertData := func(t *testing.T, s *Storage, tr TimeRange, wantData []MetricRow, wantErr bool) {
t.Helper()
err := testAssertSearchResult(s, tr, tfsAll, wantData)
gotErr := err != nil
if gotErr != wantErr {
t.Fatalf("Search: unmet error expectation for timeRange=%v: got %t, want %t (err: %v)", &tr, gotErr, wantErr, err)
}
}
assertMetricNames := func(t *testing.T, s *Storage, tr TimeRange, wantData []string, wantErr bool) {
t.Helper()
metricNames, err := s.SearchMetricNames(nil, tfssAll, tr, 1e9, noDeadline)
gotErr := err != nil
if gotErr != wantErr {
t.Fatalf("SearchMetricNames(): unmet error expectation for timeRange=%v: got %t, want %t (err: %v)", &tr, gotErr, wantErr, err)
}
var gotData []string
for _, name := range metricNames {
var mn MetricName
if err := mn.UnmarshalString(name); err != nil {
t.Fatalf("Could not unmarshal metric name %q: %v", name, err)
}
gotData = append(gotData, string(mn.MetricGroup))
}
if diff := cmp.Diff(wantData, gotData); diff != "" {
t.Fatalf("unexpected metric names (-want, +got):\n%s", diff)
}
}
assertLabelNames := func(t *testing.T, s *Storage, tr TimeRange, wantData []string, wantErr bool) {
t.Helper()
gotData, err := s.SearchLabelNames(nil, nil, tr, 1e9, 1e9, noDeadline)
gotErr := err != nil
if gotErr != wantErr {
t.Fatalf("SearchLabelNames(): unmet error expectation for timeRange=%v: got %t, want %t (err: %v)", &tr, gotErr, wantErr, err)
}
slices.Sort(gotData)
slices.Sort(wantData)
if diff := cmp.Diff(wantData, gotData); diff != "" {
t.Fatalf("unexpected label names (-want, +got):\n%s", diff)
}
}
assertLabelValues := func(t *testing.T, s *Storage, tr TimeRange, wantData []string, wantErr bool) {
t.Helper()
gotData, err := s.SearchLabelValues(nil, "__name__", nil, tr, 1e9, 1e9, noDeadline)
gotErr := err != nil
if gotErr != wantErr {
t.Fatalf("SearchLabelValues(): unmet error expectation for timeRange=%v: got %t, want %t (err: %v)", &tr, gotErr, wantErr, err)
}
slices.Sort(gotData)
slices.Sort(wantData)
if diff := cmp.Diff(wantData, gotData); diff != "" {
t.Fatalf("unexpected label values (-want, +got):\n%s", diff)
}
}
assertTagValueSuffixes := func(t *testing.T, s *Storage, tr TimeRange, wantData []string, wantErr bool) {
t.Helper()
gotData, err := s.SearchTagValueSuffixes(nil, tr, "", "", '.', 1e9, noDeadline)
gotErr := err != nil
if gotErr != wantErr {
t.Fatalf("SearchTagValueSuffixes(): unmet error expectation for timeRange=%v: got %t, want %t (err: %v)", &tr, gotErr, wantErr, err)
}
slices.Sort(gotData)
if diff := cmp.Diff(wantData, gotData); diff != "" {
t.Errorf("unexpected tag value suffixes (-want, +got):\n%s", diff)
}
}
assertGraphitePaths := func(t *testing.T, s *Storage, tr TimeRange, wantData []string, wantErr bool) {
t.Helper()
gotData, err := s.SearchGraphitePaths(nil, tr, []byte("*"), 1e9, noDeadline)
gotErr := err != nil
if gotErr != wantErr {
t.Fatalf("SearchTagValueSuffixes(): unmet error expectation for timeRange=%v: got %t, want %t (err: %v)", &tr, gotErr, wantErr, err)
}
slices.Sort(gotData)
if diff := cmp.Diff(wantData, gotData); diff != "" {
t.Errorf("unexpected graphite paths (-want, +got):\n%s", diff)
}
}
synctest.Test(t, func(t *testing.T) {
// synctests start at 2000-01-01T00:00:00Z
now := time.Now().UTC()
retention := 30 * 24 * time.Hour
futureRetention := 30 * 24 * time.Hour
day := 24 * time.Hour
trMatches := TimeRange{
MinTimestamp: now.Add(-retention).UnixMilli(),
MaxTimestamp: now.Add(futureRetention).UnixMilli(),
}
trContains := TimeRange{
MinTimestamp: now.Add(-(retention + day)).UnixMilli(),
MaxTimestamp: now.Add(futureRetention + day).UnixMilli(),
}
trInside := TimeRange{
MinTimestamp: now.Add(-(retention - day)).UnixMilli(),
MaxTimestamp: now.Add(futureRetention - day).UnixMilli(),
}
trOverlapsLeft := TimeRange{
MinTimestamp: now.Add(-(retention + day)).UnixMilli(),
MaxTimestamp: now.Add(futureRetention - day).UnixMilli(),
}
trOverlapsRight := TimeRange{
MinTimestamp: now.Add(-(retention - day)).UnixMilli(),
MaxTimestamp: now.Add(futureRetention + day).UnixMilli(),
}
trOutsideLeft := TimeRange{
MinTimestamp: now.Add(-(retention + 2*day)).UnixMilli(),
MaxTimestamp: now.Add(-(retention + day)).UnixMilli(),
}
trOutsideRight := TimeRange{
MinTimestamp: now.Add(futureRetention + day).UnixMilli(),
MaxTimestamp: now.Add(futureRetention + 2*day).UnixMilli(),
}
mn := MetricName{
MetricGroup: []byte("metric"),
}
metricNameRaw := mn.marshalRaw(nil)
mr := MetricRow{
MetricNameRaw: metricNameRaw,
Timestamp: now.UnixMilli(),
Value: 123,
}
wantMRs := []MetricRow{mr}
wantMetricNames := []string{"metric"}
wantLabelNames := []string{"__name__"}
emptyLabelNames := []string{}
wantLabelValues := []string{"metric"}
emptyLabelValues := []string{}
wantTagValueSuffixes := []string{"metric"}
emptyTagValueSuffixes := []string{}
wantGraphitePaths := []string{"metric"}
emptyGraphitePaths := []string{}
s := MustOpenStorage(t.Name(), OpenOptions{
Retention: retention,
FutureRetention: futureRetention,
})
s.AddRows([]MetricRow{mr}, defaultPrecisionBits)
s.DebugFlush()
assertData(t, s, trInside, wantMRs, false)
assertData(t, s, trMatches, wantMRs, false)
assertData(t, s, trContains, wantMRs, false)
assertData(t, s, trOverlapsLeft, wantMRs, false)
assertData(t, s, trOverlapsRight, wantMRs, false)
assertData(t, s, trOutsideLeft, nil, false)
assertData(t, s, trOutsideRight, nil, false)
assertMetricNames(t, s, trInside, wantMetricNames, false)
assertMetricNames(t, s, trMatches, wantMetricNames, false)
assertMetricNames(t, s, trContains, wantMetricNames, false)
assertMetricNames(t, s, trOverlapsLeft, wantMetricNames, false)
assertMetricNames(t, s, trOverlapsRight, wantMetricNames, false)
assertMetricNames(t, s, trOutsideLeft, nil, false)
assertMetricNames(t, s, trOutsideRight, nil, false)
assertLabelNames(t, s, trInside, wantLabelNames, false)
assertLabelNames(t, s, trMatches, wantLabelNames, false)
assertLabelNames(t, s, trContains, wantLabelNames, false)
assertLabelNames(t, s, trOverlapsLeft, wantLabelNames, false)
assertLabelNames(t, s, trOverlapsRight, wantLabelNames, false)
assertLabelNames(t, s, trOutsideLeft, emptyLabelNames, false)
assertLabelNames(t, s, trOutsideRight, emptyLabelNames, false)
assertLabelValues(t, s, trInside, wantLabelValues, false)
assertLabelValues(t, s, trMatches, wantLabelValues, false)
assertLabelValues(t, s, trContains, wantLabelValues, false)
assertLabelValues(t, s, trOverlapsLeft, wantLabelValues, false)
assertLabelValues(t, s, trOverlapsRight, wantLabelValues, false)
assertLabelValues(t, s, trOutsideLeft, emptyLabelValues, false)
assertLabelValues(t, s, trOutsideRight, emptyLabelValues, false)
assertTagValueSuffixes(t, s, trInside, wantTagValueSuffixes, false)
assertTagValueSuffixes(t, s, trMatches, wantTagValueSuffixes, false)
assertTagValueSuffixes(t, s, trContains, wantTagValueSuffixes, false)
assertTagValueSuffixes(t, s, trOverlapsLeft, wantTagValueSuffixes, false)
assertTagValueSuffixes(t, s, trOverlapsRight, wantTagValueSuffixes, false)
assertTagValueSuffixes(t, s, trOutsideLeft, emptyTagValueSuffixes, false)
assertTagValueSuffixes(t, s, trOutsideRight, emptyTagValueSuffixes, false)
assertGraphitePaths(t, s, trInside, wantGraphitePaths, false)
assertGraphitePaths(t, s, trMatches, wantGraphitePaths, false)
assertGraphitePaths(t, s, trContains, wantGraphitePaths, false)
assertGraphitePaths(t, s, trOverlapsLeft, wantGraphitePaths, false)
assertGraphitePaths(t, s, trOverlapsRight, wantGraphitePaths, false)
assertGraphitePaths(t, s, trOutsideLeft, emptyGraphitePaths, false)
assertGraphitePaths(t, s, trOutsideRight, emptyGraphitePaths, false)
// Restart storage with DenyQueriesOutsideRetention on.
s.MustClose()
s = MustOpenStorage(t.Name(), OpenOptions{
Retention: retention,
FutureRetention: futureRetention,
DenyQueriesOutsideRetention: true,
})
defer s.MustClose()
assertData(t, s, trInside, wantMRs, false)
assertData(t, s, trMatches, wantMRs, false)
assertData(t, s, trContains, nil, true)
assertData(t, s, trOverlapsLeft, nil, true)
assertData(t, s, trOverlapsRight, nil, true)
assertData(t, s, trOutsideLeft, nil, true)
assertData(t, s, trOutsideRight, nil, true)
assertMetricNames(t, s, trInside, wantMetricNames, false)
assertMetricNames(t, s, trMatches, wantMetricNames, false)
assertMetricNames(t, s, trContains, nil, true)
assertMetricNames(t, s, trOverlapsLeft, nil, true)
assertMetricNames(t, s, trOverlapsRight, nil, true)
assertMetricNames(t, s, trOutsideLeft, nil, true)
assertMetricNames(t, s, trOutsideRight, nil, true)
// DenyQueriesOutsideRetention does not apply to searching label names.
assertLabelNames(t, s, trInside, wantLabelNames, false)
assertLabelNames(t, s, trMatches, wantLabelNames, false)
assertLabelNames(t, s, trContains, wantLabelNames, false)
assertLabelNames(t, s, trOverlapsLeft, wantLabelNames, false)
assertLabelNames(t, s, trOverlapsRight, wantLabelNames, false)
assertLabelNames(t, s, trOutsideLeft, emptyLabelNames, false)
assertLabelNames(t, s, trOutsideRight, emptyLabelNames, false)
// DenyQueriesOutsideRetention does not apply to searching label values.
assertLabelValues(t, s, trInside, wantLabelValues, false)
assertLabelValues(t, s, trMatches, wantLabelValues, false)
assertLabelValues(t, s, trContains, wantLabelValues, false)
assertLabelValues(t, s, trOverlapsLeft, wantLabelValues, false)
assertLabelValues(t, s, trOverlapsRight, wantLabelValues, false)
assertLabelValues(t, s, trOutsideLeft, emptyLabelValues, false)
assertLabelValues(t, s, trOutsideRight, emptyLabelValues, false)
// DenyQueriesOutsideRetention does not apply to searching tag value suffixes.
assertTagValueSuffixes(t, s, trInside, wantTagValueSuffixes, false)
assertTagValueSuffixes(t, s, trMatches, wantTagValueSuffixes, false)
assertTagValueSuffixes(t, s, trContains, wantTagValueSuffixes, false)
assertTagValueSuffixes(t, s, trOverlapsLeft, wantTagValueSuffixes, false)
assertTagValueSuffixes(t, s, trOverlapsRight, wantTagValueSuffixes, false)
assertTagValueSuffixes(t, s, trOutsideLeft, emptyTagValueSuffixes, false)
assertTagValueSuffixes(t, s, trOutsideRight, emptyTagValueSuffixes, false)
// DenyQueriesOutsideRetention does not apply to searching graphite paths.
assertGraphitePaths(t, s, trInside, wantGraphitePaths, false)
assertGraphitePaths(t, s, trMatches, wantGraphitePaths, false)
assertGraphitePaths(t, s, trContains, wantGraphitePaths, false)
assertGraphitePaths(t, s, trOverlapsLeft, wantGraphitePaths, false)
assertGraphitePaths(t, s, trOverlapsRight, wantGraphitePaths, false)
assertGraphitePaths(t, s, trOutsideLeft, emptyGraphitePaths, false)
assertGraphitePaths(t, s, trOutsideRight, emptyGraphitePaths, false)
})
}

View File

@@ -1,25 +1,16 @@
package timeutil
import (
"fmt"
"time"
"github.com/VictoriaMetrics/metricsql"
)
var (
minDuration = time.Duration(minValidMilli * time.Millisecond)
maxDuration = time.Duration(maxValidMilli * time.Millisecond)
)
// ParseDuration parses duration string in Prometheus format
func ParseDuration(s string) (time.Duration, error) {
ms, err := metricsql.DurationValue(s, 0)
if err != nil {
return 0, err
}
if ms < minValidMilli || maxValidMilli < ms {
return 0, fmt.Errorf("duration %q must be in the range [%v, %v]", s, minDuration, maxDuration)
}
return time.Duration(ms) * time.Millisecond, nil
}

View File

@@ -1,7 +1,6 @@
package timeutil
import (
"fmt"
"testing"
"time"
)
@@ -28,126 +27,3 @@ func TestParseDuration(t *testing.T) {
f("-1m30s", -(time.Minute + time.Second*30))
f("1d-4h", time.Hour*20)
}
func TestParseDurationLimits(t *testing.T) {
f := func(s string, want time.Duration) {
t.Helper()
got, err := ParseDuration(s)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != want {
t.Fatalf("unexpected result: got %v, want %v", got, want)
}
}
var s string
var want time.Duration
s = fmt.Sprintf("%dms", int64(minValidMilli))
f(s, minDuration)
s = fmt.Sprintf("%dms", int64(maxValidMilli))
f(s, maxDuration)
s = fmt.Sprintf("%ds", int64(minValidSecond))
want = minValidSecond * time.Second
f(s, want)
s = fmt.Sprintf("%ds", int64(maxValidSecond))
want = maxValidSecond * time.Second
f(s, want)
// When no unit is specified, seconds are assumed.
s = fmt.Sprintf("%d", int64(minValidSecond))
want = minValidSecond * time.Second
f(s, want)
s = fmt.Sprintf("%d", int64(maxValidSecond))
want = maxValidSecond * time.Second
f(s, want)
minValidMinute := int64(minValidSecond) / 60
maxValidMinute := int64(maxValidSecond) / 60
s = fmt.Sprintf("%dm", minValidMinute)
want = time.Duration(minValidMinute) * time.Minute
f(s, want)
s = fmt.Sprintf("%dm", maxValidMinute)
want = time.Duration(maxValidMinute) * time.Minute
f(s, want)
minValidHour := minValidMinute / 60
maxValidHour := maxValidMinute / 60
s = fmt.Sprintf("%dh", minValidHour)
want = time.Duration(minValidHour) * time.Hour
f(s, want)
s = fmt.Sprintf("%dh", maxValidHour)
want = time.Duration(maxValidHour) * time.Hour
f(s, want)
minValidDay := minValidHour / 24
maxValidDay := maxValidHour / 24
s = fmt.Sprintf("%dd", minValidDay)
want = time.Duration(minValidDay) * 24 * time.Hour
f(s, want)
s = fmt.Sprintf("%dd", maxValidDay)
want = time.Duration(maxValidDay) * 24 * time.Hour
f(s, want)
minValidWeek := minValidDay / 7
maxValidWeek := maxValidDay / 7
s = fmt.Sprintf("%dw", minValidWeek)
want = time.Duration(minValidWeek) * 7 * 24 * time.Hour
f(s, want)
s = fmt.Sprintf("%dw", maxValidWeek)
want = time.Duration(maxValidWeek) * 7 * 24 * time.Hour
f(s, want)
minValidYear := minValidDay / 365
maxValidYear := maxValidDay / 365
s = fmt.Sprintf("%dy", minValidYear)
want = time.Duration(minValidYear) * 365 * 24 * time.Hour
f(s, want)
s = fmt.Sprintf("%dy", maxValidYear)
want = time.Duration(maxValidYear) * 365 * 24 * time.Hour
f(s, want)
}
func TestParseDurationOutsideLimits(t *testing.T) {
f := func(s string) {
t.Helper()
got, err := ParseDuration(s)
gotDuration := time.Duration(got) * time.Millisecond
if err == nil {
t.Fatalf("ParseDuration(%s) unexpected result: got %d (%s), want error", s, got, gotDuration)
}
}
f(fmt.Sprintf("%dms", int64(minValidMilli)-1))
f(fmt.Sprintf("%dms", int64(maxValidMilli)+1))
f(fmt.Sprintf("%ds", int64(minValidSecond)-1))
f(fmt.Sprintf("%ds", int64(maxValidSecond)+1))
minValidMinute := int64(minValidSecond)/60 - 1
f(fmt.Sprintf("%dm", minValidMinute))
maxValidMinute := int64(maxValidSecond)/60 + 1
f(fmt.Sprintf("%dm", maxValidMinute))
minValidHour := minValidMinute/60 - 1
f(fmt.Sprintf("%dh", minValidHour))
maxValidHour := maxValidMinute/60 + 2
f(fmt.Sprintf("%dh", maxValidHour))
minValidDay := minValidHour/24 - 1
f(fmt.Sprintf("%dd", minValidDay))
maxValidDay := maxValidHour/24 + 1
f(fmt.Sprintf("%dd", maxValidDay))
minValidWeek := minValidDay/7 - 1
f(fmt.Sprintf("%dw", minValidWeek))
maxValidWeek := maxValidDay/7 + 1
f(fmt.Sprintf("%dw", maxValidWeek))
minValidYear := minValidDay/365 - 1
f(fmt.Sprintf("%dy", minValidYear))
maxValidYear := maxValidDay/365 + 1
f(fmt.Sprintf("%dy", maxValidYear))
}

View File

@@ -74,11 +74,7 @@ func ParseTimeAt(s string, currentTimestamp int64) (int64, error) {
if d > 0 {
d = -d
}
nsec := currentTimestamp + int64(d)
if nsec < 0 {
return 0, fmt.Errorf("time %s (%v) must be in the range [%v, %v]", sOrig, time.Unix(0, nsec).UTC(), minTime, maxTime)
}
return nsec, nil
return currentTimestamp + int64(d), nil
}
if len(s) == 4 {
// Parse YYYY

View File

@@ -1,8 +1,6 @@
package timeutil
import (
"fmt"
"math"
"strings"
"testing"
"time"
@@ -206,11 +204,10 @@ func TestParseTimeAtSuccess(t *testing.T) {
}
func TestParseTimeAtLimits(t *testing.T) {
now := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
f := func(s string, wantTime time.Time) {
t.Helper()
got, err := ParseTimeAt(s, now.UnixNano())
currentTimestamp := time.Now().UnixNano()
got, err := ParseTimeAt(s, currentTimestamp)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -229,14 +226,6 @@ func TestParseTimeAtLimits(t *testing.T) {
}
east := location(t, "Etc/GMT-14") // UTC+14:00
west := location(t, "Etc/GMT+12") // UTC-12:00
var s string
// min timestamp
f("0", time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC))
s = fmt.Sprintf("-%d", now.Unix())
f(s, time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC))
s = fmt.Sprintf("now-%d", now.Unix())
f(s, time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC))
// min year
f("1970Z", time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC))
@@ -297,39 +286,13 @@ func TestParseTimeAtLimits(t *testing.T) {
f("2262-04-11T23:47:16Z", time.Date(2262, 4, 11, 23, 47, 16, 0, time.UTC))
f("2262-04-12T13:47:16+14:00", time.Date(2262, 4, 12, 13, 47, 16, 0, east))
f("2262-04-11T11:47:16-12:00", time.Date(2262, 4, 11, 11, 47, 16, 0, west))
// max timestamp
s = fmt.Sprintf("%d", int64(maxValidSecond))
f(s, time.Date(2262, 4, 11, 23, 47, 16, 0, time.UTC))
s = fmt.Sprintf("%d", int64(maxValidMilli))
f(s, time.Date(2262, 4, 11, 23, 47, 16, 854_000_000, time.UTC))
s = fmt.Sprintf("%d", int64(maxValidMicro))
f(s, time.Date(2262, 4, 11, 23, 47, 16, 854_775_000, time.UTC))
s = fmt.Sprintf("%d", int64(math.MaxInt64))
f(s, time.Date(2262, 4, 11, 23, 47, 16, 854_775_807, time.UTC))
// timestamps beyond max valid second are still valid but are treated as
// milliseconds.
s = fmt.Sprintf("%d", int64(maxValidSecond)+1)
f(s, time.Date(1970, 4, 17, 18, 2, 52, 37_000_000, time.UTC))
// timestamps beyond max valid millisecond are still valid but are treated
// as microseconds.
s = fmt.Sprintf("%d", int64(maxValidMilli)+1)
f(s, time.Date(1970, 4, 17, 18, 2, 52, 36_855_000, time.UTC))
// timestamps beyond max valid microsecond are still valid but are treated
// as nanoseconds.
s = fmt.Sprintf("%d", int64(maxValidMicro)+1)
f(s, time.Date(1970, 4, 17, 18, 2, 52, 36_854_776, time.UTC))
}
func TestParseTimeAtOutsideLimits(t *testing.T) {
now := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
f := func(s string) {
t.Helper()
got, err := ParseTimeAt(s, now.UnixNano())
currentTimestamp := time.Now().UnixNano()
got, err := ParseTimeAt(s, currentTimestamp)
if err == nil {
t.Fatalf("expected error but got %d", got)
}
@@ -338,10 +301,6 @@ func TestParseTimeAtOutsideLimits(t *testing.T) {
}
}
// min timestamp
f(fmt.Sprintf("-%d", now.Unix()+1))
f(fmt.Sprintf("now-%d", now.Unix()+1))
// min year
f("1969Z")
f("1970+14:00")
@@ -403,24 +362,6 @@ func TestParseTimeAtOutsideLimits(t *testing.T) {
f("2262-04-11T11:47:17-12:00")
}
func TestParseTimeAtOutsideLimits_Nanos(t *testing.T) {
now := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
f := func(s string) {
t.Helper()
got, err := ParseTimeAt(s, now.UnixNano())
if err == nil {
t.Fatalf("expected error but got %d", got)
}
if !strings.Contains(err.Error(), "cannot parse numeric timestamp") {
t.Fatalf("expected error: %v", err)
}
}
// max unix nano
f(fmt.Sprintf("%d", uint64(math.MaxInt64+1)))
}
func TestParseTimeMsecFailure(t *testing.T) {
f := func(s string) {
t.Helper()