mirror of
https://github.com/VictoriaMetrics/VictoriaMetrics.git
synced 2026-05-22 19:26:35 +03:00
Compare commits
6 Commits
vmalert-up
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d82ad68f60 | ||
|
|
dcbd8ef721 | ||
|
|
886c7762eb | ||
|
|
d3006b25e6 | ||
|
|
c41e967ee1 | ||
|
|
0c9a011e0a |
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 GraphiteQL expr")
|
||||
f([]string{"testdata/rules/rules1-bad.rules"}, "bad graphite 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 MetricsQL expr")
|
||||
}, true, "bad prometheus expr")
|
||||
|
||||
f(&Group{
|
||||
Name: "test graphite expr",
|
||||
@@ -293,7 +293,7 @@ func TestGroupValidate_Failure(t *testing.T) {
|
||||
"description": "some-description",
|
||||
}},
|
||||
},
|
||||
}, true, "bad GraphiteQL expr")
|
||||
}, true, "bad graphite 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 GraphiteQL expr")
|
||||
}, true, "bad graphite 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 MetricsQL expr")
|
||||
}, true, "bad prometheus expr")
|
||||
}
|
||||
|
||||
func TestGroupValidate_Success(t *testing.T) {
|
||||
|
||||
@@ -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 GraphiteQL expr: %q, err: %w", expr, err)
|
||||
return fmt.Errorf("bad graphite expr: %q, err: %w", expr, err)
|
||||
}
|
||||
case "prometheus":
|
||||
if _, err := metricsql.Parse(expr); err != nil {
|
||||
return fmt.Errorf("bad MetricsQL expr: %q, err: %w", expr, err)
|
||||
return fmt.Errorf("bad prometheus expr: %q, err: %w", expr, err)
|
||||
}
|
||||
case "vlogs":
|
||||
q, err := logstorage.ParseStatsQuery(expr, 0)
|
||||
|
||||
@@ -20,6 +20,9 @@ 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) {
|
||||
@@ -77,9 +80,6 @@ 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))
|
||||
|
||||
|
||||
@@ -990,9 +990,6 @@ 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
|
||||
@@ -1098,9 +1095,6 @@ 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
|
||||
@@ -1127,9 +1121,6 @@ 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
|
||||
|
||||
1
app/vmselect/vmui/assets/index-BL7jEFBa.css
Normal file
1
app/vmselect/vmui/assets/index-BL7jEFBa.css
Normal file
File diff suppressed because one or more lines are too long
197
app/vmselect/vmui/assets/index-BjJ7fDL7.js
Normal file
197
app/vmselect/vmui/assets/index-BjJ7fDL7.js
Normal file
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
@@ -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-C7gvW_Zn.js"></script>
|
||||
<script type="module" crossorigin src="./assets/index-BjJ7fDL7.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-D2OEy8Ra.css">
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-BL7jEFBa.css">
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
|
||||
@@ -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. "+
|
||||
"When set, then /api/v1/query_range would return '503 Service Unavailable' error for queries with 'from' value outside -retentionPeriod. "+
|
||||
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. "+
|
||||
"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,21 +103,6 @@ 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 {
|
||||
@@ -151,14 +136,15 @@ func Init(resetCacheIfNeeded func(mrs []storage.MetricRow)) {
|
||||
startTime := time.Now()
|
||||
WG = syncwg.WaitGroup{}
|
||||
opts := storage.OpenOptions{
|
||||
Retention: retentionPeriod.Duration(),
|
||||
FutureRetention: futureRetention.Duration(),
|
||||
MaxHourlySeries: getMaxHourlySeries(),
|
||||
MaxDailySeries: getMaxDailySeries(),
|
||||
DisablePerDayIndex: *disablePerDayIndex,
|
||||
TrackMetricNamesStats: *trackMetricNamesStats,
|
||||
IDBPrefillStart: *idbPrefillStart,
|
||||
LogNewSeries: *logNewSeries,
|
||||
Retention: retentionPeriod.Duration(),
|
||||
FutureRetention: futureRetention.Duration(),
|
||||
DenyQueriesOutsideRetention: *denyQueriesOutsideRetention,
|
||||
MaxHourlySeries: getMaxHourlySeries(),
|
||||
MaxDailySeries: getMaxDailySeries(),
|
||||
DisablePerDayIndex: *disablePerDayIndex,
|
||||
TrackMetricNamesStats: *trackMetricNamesStats,
|
||||
IDBPrefillStart: *idbPrefillStart,
|
||||
LogNewSeries: *logNewSeries,
|
||||
}
|
||||
strg := storage.MustOpenStorage(*DataPath, opts)
|
||||
Storage = strg
|
||||
|
||||
@@ -3,6 +3,7 @@ export interface MetricBase {
|
||||
metric: {
|
||||
[key: string]: string;
|
||||
};
|
||||
nullTimestamps?: number[];
|
||||
}
|
||||
|
||||
export interface MetricResult extends MetricBase {
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface ChartTooltipProps {
|
||||
point: { top: number, left: number };
|
||||
unit?: string;
|
||||
statsFormatted?: SeriesItemStatsFormatted;
|
||||
description?: ReactNode;
|
||||
isSticky?: boolean;
|
||||
info?: ReactNode;
|
||||
marker?: string;
|
||||
@@ -34,6 +35,7 @@ const ChartTooltip: FC<ChartTooltipProps> = ({
|
||||
unit = "",
|
||||
info,
|
||||
statsFormatted,
|
||||
description,
|
||||
isSticky,
|
||||
marker,
|
||||
duplicateCount = 0,
|
||||
@@ -173,6 +175,7 @@ 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);
|
||||
|
||||
@@ -143,4 +143,10 @@ $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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,19 +15,65 @@ 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)) => {
|
||||
@@ -35,7 +81,36 @@ 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;
|
||||
@@ -84,7 +159,7 @@ const useLineTooltip = ({ u, metrics, series, unit }: LineTooltipHook) => {
|
||||
marker: `${seriesItem?.stroke}`,
|
||||
duplicateCount,
|
||||
};
|
||||
}, [u, tooltipIdx, metrics, series, unit]);
|
||||
}, [u, tooltipIdx, metrics, series, unit, nullTooltip]);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (!showTooltip) return;
|
||||
@@ -99,8 +174,9 @@ const useLineTooltip = ({ u, metrics, series, unit }: LineTooltipHook) => {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setShowTooltip(tooltipIdx.dataIdx !== -1 && tooltipIdx.seriesIdx !== -1);
|
||||
}, [tooltipIdx]);
|
||||
const normalHit = tooltipIdx.dataIdx !== -1 && tooltipIdx.seriesIdx !== -1;
|
||||
setShowTooltip(normalHit || nullTooltip !== null);
|
||||
}, [tooltipIdx, nullTooltip]);
|
||||
|
||||
useEventListener("click", handleClick);
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ describe("convertMetricsDataToCSV", () => {
|
||||
},
|
||||
];
|
||||
const result = convertMetricsDataToCSV(data);
|
||||
expect(result).toBe("header1,header2\n123,value2");
|
||||
expect(result).toBe("header1,header2,__timestamp__,__value__\n123,value2,1623945600,123");
|
||||
});
|
||||
|
||||
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\n123,value2\n456,value4");
|
||||
expect(result).toBe("header1,header2,__timestamp__,__value__\n123,value2,1623945600,123\n456,value4,1623949200,456");
|
||||
});
|
||||
|
||||
it("should handle metric entries with multiple values field", () => {
|
||||
@@ -58,7 +58,7 @@ describe("convertMetricsDataToCSV", () => {
|
||||
},
|
||||
];
|
||||
const result = convertMetricsDataToCSV(data);
|
||||
expect(result).toBe("header1,header2\n123-456,values");
|
||||
expect(result).toBe("header1,header2,__timestamp__,__value__\n123-456,values,-,-");
|
||||
});
|
||||
|
||||
it("should handle a combination of metric entries with value and values", () => {
|
||||
@@ -81,6 +81,19 @@ describe("convertMetricsDataToCSV", () => {
|
||||
},
|
||||
];
|
||||
const result = convertMetricsDataToCSV(data);
|
||||
expect(result).toBe("header1,header2\n123,first\n456-789,second");
|
||||
expect(result).toBe("header1,header2,__timestamp__,__value__\n123,first,1623945600,123\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");
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -3,16 +3,22 @@ import { getColumns, MetricCategory } from "../../hooks/useSortedCategories";
|
||||
import { formatValueToCSV } from "../../utils/csv";
|
||||
|
||||
const getHeaders = (data: InstantMetricResult[]): string => {
|
||||
return getColumns(data).map(({ key }) => key).join(",");
|
||||
const metricHeaders = getColumns(data).map(({ key }) => key);
|
||||
return [...metricHeaders, "__timestamp__", "__value__"].join(",");
|
||||
};
|
||||
|
||||
const getRows = (data: InstantMetricResult[], headers: MetricCategory[]) => {
|
||||
return data?.map(d => headers.map(c => formatValueToCSV(d.metric[c.key] || "-")).join(","));
|
||||
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(",");
|
||||
});
|
||||
};
|
||||
|
||||
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");
|
||||
};
|
||||
|
||||
@@ -149,15 +149,21 @@ export const useFetchExport = ({ hideQuery, showAllSeries }: FetchQueryParams):
|
||||
const pointsToTake = shouldDownsample ? maxPointsPerSeries : totalPoints;
|
||||
const step = shouldDownsample ? totalPoints / maxPointsPerSeries : 1;
|
||||
|
||||
const values: [number, number][] = Array.from({ length: pointsToTake }, (_, i) => {
|
||||
const values: [number, number][] = new Array(pointsToTake);
|
||||
const nullTimestamps: number[] = [];
|
||||
for (let i = 0; i < pointsToTake; i++) {
|
||||
const idx = shouldDownsample ? Math.floor(i * step) : i;
|
||||
return [rawTimestamps[idx] / 1000, rawValues[idx]];
|
||||
});
|
||||
const ts = rawTimestamps[idx] / 1000;
|
||||
const raw = rawValues[idx];
|
||||
if (raw === null) nullTimestamps.push(ts);
|
||||
values[i] = [ts, raw as number];
|
||||
}
|
||||
|
||||
tempData.push({
|
||||
group: counter,
|
||||
metric: jsonLine.metric,
|
||||
values,
|
||||
nullTimestamps,
|
||||
} as MetricBase);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface SeriesItem extends Series {
|
||||
statsFormatted: SeriesItemStatsFormatted;
|
||||
median: number;
|
||||
hasAlias?: boolean;
|
||||
nullTimestamps?: number[];
|
||||
}
|
||||
|
||||
export interface HideSeriesArgs {
|
||||
|
||||
@@ -103,6 +103,28 @@ 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);
|
||||
|
||||
@@ -38,6 +38,7 @@ export const getSeriesItemContext = (data: MetricResult[], hideSeries: string[],
|
||||
show: !includesHideSeries(label, hideSeries),
|
||||
scale: "1",
|
||||
paths: isRawQuery ? drawPoints : undefined,
|
||||
nullTimestamps: d.nullTimestamps,
|
||||
...getSeriesStatistics(d),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -26,6 +26,10 @@ 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).
|
||||
@@ -34,6 +38,7 @@ See also [LTS releases](https://docs.victoriametrics.com/victoriametrics/lts-rel
|
||||
* 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).
|
||||
@@ -47,6 +52,9 @@ See also [LTS releases](https://docs.victoriametrics.com/victoriametrics/lts-rel
|
||||
* 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)
|
||||
|
||||
|
||||
@@ -51,8 +51,6 @@ 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)
|
||||
}
|
||||
|
||||
@@ -85,4 +83,8 @@ 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")
|
||||
}
|
||||
|
||||
@@ -228,6 +228,7 @@ 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
|
||||
@@ -241,7 +242,6 @@ 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
|
||||
|
||||
@@ -61,10 +61,11 @@ type Storage struct {
|
||||
// indexdb rotation.
|
||||
legacyNextRotationTimestamp atomic.Int64
|
||||
|
||||
path string
|
||||
cachePath string
|
||||
retentionMsecs int64
|
||||
futureRetentionMsecs int64
|
||||
path string
|
||||
cachePath string
|
||||
retentionMsecs int64
|
||||
futureRetentionMsecs int64
|
||||
denyQueriesOutsideRetention bool
|
||||
|
||||
// lock file for exclusive access to the storage on the given path.
|
||||
flockF *os.File
|
||||
@@ -161,14 +162,15 @@ type Storage struct {
|
||||
|
||||
// OpenOptions optional args for MustOpenStorage
|
||||
type OpenOptions struct {
|
||||
Retention time.Duration
|
||||
FutureRetention time.Duration
|
||||
MaxHourlySeries int
|
||||
MaxDailySeries int
|
||||
DisablePerDayIndex bool
|
||||
TrackMetricNamesStats bool
|
||||
IDBPrefillStart time.Duration
|
||||
LogNewSeries bool
|
||||
Retention time.Duration
|
||||
FutureRetention time.Duration
|
||||
DenyQueriesOutsideRetention bool
|
||||
MaxHourlySeries int
|
||||
MaxDailySeries int
|
||||
DisablePerDayIndex bool
|
||||
TrackMetricNamesStats bool
|
||||
IDBPrefillStart time.Duration
|
||||
LogNewSeries bool
|
||||
}
|
||||
|
||||
// MustOpenStorage opens storage on the given path with the given retentionMsecs.
|
||||
@@ -190,12 +192,13 @@ 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(),
|
||||
stopCh: make(chan struct{}),
|
||||
idbPrefillStartSeconds: idbPrefillStart.Milliseconds() / 1000,
|
||||
path: path,
|
||||
cachePath: filepath.Join(path, cacheDirname),
|
||||
retentionMsecs: retention.Milliseconds(),
|
||||
futureRetentionMsecs: futureRetention.Milliseconds(),
|
||||
denyQueriesOutsideRetention: opts.DenyQueriesOutsideRetention,
|
||||
stopCh: make(chan struct{}),
|
||||
idbPrefillStartSeconds: idbPrefillStart.Milliseconds() / 1000,
|
||||
}
|
||||
s.logNewSeries.Store(opts.LogNewSeries)
|
||||
|
||||
@@ -1225,6 +1228,25 @@ 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.
|
||||
//
|
||||
@@ -1236,6 +1258,10 @@ 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)
|
||||
}
|
||||
@@ -1271,6 +1297,9 @@ 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)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
"time"
|
||||
@@ -1144,3 +1145,268 @@ 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)
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package timeutil
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -27,3 +28,126 @@ 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))
|
||||
}
|
||||
|
||||
@@ -74,7 +74,11 @@ func ParseTimeAt(s string, currentTimestamp int64) (int64, error) {
|
||||
if d > 0 {
|
||||
d = -d
|
||||
}
|
||||
return currentTimestamp + int64(d), nil
|
||||
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
|
||||
}
|
||||
if len(s) == 4 {
|
||||
// Parse YYYY
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package timeutil
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -204,10 +206,11 @@ 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()
|
||||
currentTimestamp := time.Now().UnixNano()
|
||||
got, err := ParseTimeAt(s, currentTimestamp)
|
||||
got, err := ParseTimeAt(s, now.UnixNano())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -226,6 +229,14 @@ 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))
|
||||
@@ -286,13 +297,39 @@ 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()
|
||||
currentTimestamp := time.Now().UnixNano()
|
||||
got, err := ParseTimeAt(s, currentTimestamp)
|
||||
got, err := ParseTimeAt(s, now.UnixNano())
|
||||
if err == nil {
|
||||
t.Fatalf("expected error but got %d", got)
|
||||
}
|
||||
@@ -301,6 +338,10 @@ 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")
|
||||
@@ -362,6 +403,24 @@ 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()
|
||||
|
||||
Reference in New Issue
Block a user