Compare commits

..

1 Commits

Author SHA1 Message Date
srikanthccv
ce43780c78 fix(querybuilder): read an unknown body path in a column as a string path
A body. key that metadata does not report kept no data type in the logs
fallback, so a select, group by, order by, or aggregation on it failed with
404 "field not found" instead of the not-found warning. The fallback now types
it as bodyPath does for a bare key. Filters keep their SQL.

Assisted-by: Claude Opus 5.5
2026-11-05 03:46:58 +05:30
12 changed files with 211 additions and 230 deletions

View File

@@ -3,7 +3,6 @@ import { Tooltip } from 'antd';
import { QueryParams } from 'constants/query';
import useUrlQuery from 'hooks/useUrlQuery';
import { ArrowDownLeft, ArrowUpRight, Calendar } from '@signozhq/icons';
import { useAlertHistoryQueryParams } from 'pages/AlertDetails/hooks';
import { AlertRuleStats } from 'types/api/alerts/def';
import { calculateChange } from 'utils/calculateChange';
@@ -87,7 +86,7 @@ function StatsCard({
}: StatsCardProps): JSX.Element {
const urlQuery = useUrlQuery();
const { relativeTime } = useAlertHistoryQueryParams();
const relativeTime = urlQuery.get('relativeTime');
const { changePercentage, changeDirection } = calculateChange(
totalCurrentCount,

View File

@@ -1,8 +1,6 @@
import { useMemo } from 'react';
import {
useAlertHistoryQueryParams,
useGetAlertRuleDetailsTimelineGraphData,
} from 'pages/AlertDetails/hooks';
import useUrlQuery from 'hooks/useUrlQuery';
import { useGetAlertRuleDetailsTimelineGraphData } from 'pages/AlertDetails/hooks';
import DataStateRenderer from 'periscope/components/DataStateRenderer/DataStateRenderer';
import { AlertRuleTimelineGraphResponse } from 'types/api/alerts/def';
@@ -15,7 +13,9 @@ function GraphWrapper({
}: {
totalCurrentTriggers: number;
}): JSX.Element {
const { relativeTime } = useAlertHistoryQueryParams();
const urlQuery = useUrlQuery();
const relativeTime = urlQuery.get('relativeTime');
const { isLoading, isRefetching, isError, data, isValidRuleId, ruleId } =
useGetAlertRuleDetailsTimelineGraphData();

View File

@@ -1,25 +0,0 @@
import GetMinMax from './getMinMax';
const DAY_NS = 24 * 60 * 60 * 1000 * 1e6;
function rangeInDays(interval: string): number {
const { minTime, maxTime } = GetMinMax(interval);
return Math.round((maxTime - minTime) / DAY_NS);
}
describe('GetMinMax', () => {
it.each([
['1month', 30],
['2months', 60],
['3months', 90],
['6month', 180],
['10d', 10],
['3w', 21],
])('resolves %p to %p days', (interval, days) => {
expect(rangeInDays(interval)).toBe(days);
});
it('throws on an unknown interval', () => {
expect(() => GetMinMax('1y')).toThrow('invalid time type');
});
});

View File

@@ -14,7 +14,7 @@ export const isValidShortHandDateTimeFormat = (time: string): boolean =>
const extractTimeAndUnit = (time: string): { time: number; unit: string } => {
// Match the pattern
const match = validCustomTimeRegex.exec(time);
const match = /^(\d+)([mhdw])$/.exec(time);
if (match) {
return { time: parseInt(match[1], 10), unit: match[2] };
@@ -39,16 +39,13 @@ export const getMinTimeForRelativeTimes = (
return getMinAgo({ minutes: 24 * 60 * time }).getTime();
case 'w':
return getMinAgo({ minutes: 24 * 60 * 7 * time }).getTime();
case 'month':
case 'months':
return getMinAgo({ minutes: 24 * 60 * 30 * time }).getTime();
default:
return getMinAgo({ minutes: 1 }).getTime();
}
};
const GetMinMax = (
interval: string,
interval: Time | string,
dateTimeRange?: [number, number],
// eslint-disable-next-line sonarjs/cognitive-complexity
): GetMinMaxPayload => {

View File

@@ -21,8 +21,7 @@ import { fromRuleDTOToPostableRuleV2 } from 'types/api/alerts/convert';
import AlertHeader from './AlertHeader/AlertHeader';
import AlertNotFound from './AlertNotFound';
import { useGetAlertRuleDetails } from './hooks';
import { useRouteTabUtils } from './useRouteTabUtils';
import { useGetAlertRuleDetails, useRouteTabUtils } from './hooks';
import './AlertDetails.styles.scss';

View File

@@ -1,53 +0,0 @@
import { renderHook } from '@testing-library/react';
import useUrlQuery from 'hooks/useUrlQuery';
import { useAlertHistoryQueryParams } from '../hooks';
jest.mock('hooks/useUrlQuery', () => ({
__esModule: true,
default: jest.fn(),
}));
const THIRTY_MINUTES_MS = 30 * 60 * 1000;
function renderWithSearch(
search: string,
): ReturnType<typeof useAlertHistoryQueryParams> {
jest.mocked(useUrlQuery).mockReturnValue(new URLSearchParams(search));
return renderHook(() => useAlertHistoryQueryParams()).result.current;
}
describe('useAlertHistoryQueryParams', () => {
it.each(['1y', '90s', 'now-1h', 'custom', 'null', ''])(
'falls back to the default range for relativeTime=%p',
(param) => {
const { startTime, endTime, relativeTime } = renderWithSearch(
`ruleId=1&relativeTime=${param}`,
);
expect(endTime - startTime).toBeCloseTo(THIRTY_MINUTES_MS, -3);
expect(relativeTime).toBe('30m');
},
);
it('uses a valid relativeTime', () => {
const { startTime, endTime, relativeTime } = renderWithSearch(
'ruleId=1&relativeTime=6h',
);
expect(endTime - startTime).toBeCloseTo(6 * 60 * 60 * 1000, -3);
expect(relativeTime).toBe('6h');
});
it('prefers startTime and endTime over relativeTime', () => {
const { startTime, endTime, relativeTime } = renderWithSearch(
'ruleId=1&relativeTime=1y&startTime=1000&endTime=2000',
);
expect({ startTime, endTime, relativeTime }).toStrictEqual({
startTime: 1000,
endTime: 2000,
relativeTime: null,
});
});
});

View File

@@ -29,21 +29,27 @@ import {
type RuletypesPostableRuleDTO,
} from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import { TabRoutes } from 'components/RouteTab/types';
import { QueryParams } from 'constants/query';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import ROUTES from 'constants/routes';
import AlertHistory from 'container/AlertHistory';
import { TIMELINE_TABLE_PAGE_SIZE } from 'container/AlertHistory/constants';
import {
computeCursorForPage,
useTimelineTableOrder,
useTimelineTablePage,
} from 'container/AlertHistory/Timeline/Table/useTimelineTableCursor';
import { TimelineFilter } from 'container/AlertHistory/types';
import { AlertDetailsTab, TimelineFilter } from 'container/AlertHistory/types';
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
import { useNotifications } from 'hooks/useNotifications';
import useUrlQuery from 'hooks/useUrlQuery';
import GetMinMax, { isValidShortHandDateTimeFormat } from 'lib/getMinMax';
import GetMinMax from 'lib/getMinMax';
import { buildRoutePath } from 'lib/router/buildRoutePath';
import { navigate } from 'lib/router/navigation';
import { History, Table } from '@signozhq/icons';
import EditRules from 'pages/EditRules';
import BetaTag from 'periscope/components/BetaTag/BetaTag';
import { useAlertRule } from 'providers/Alert';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { toPostableRuleDTOFromAlertDef } from 'types/api/alerts/convert';
@@ -57,7 +63,6 @@ export const useAlertHistoryQueryParams = (): {
startTime: number;
endTime: number;
hasStartAndEndParams: boolean;
relativeTime: string | null;
params: URLSearchParams;
} => {
const params = useUrlQuery();
@@ -67,9 +72,8 @@ export const useAlertHistoryQueryParams = (): {
const relativeTimeParam = params.get(QueryParams.relativeTime);
const relativeTime =
relativeTimeParam && isValidShortHandDateTimeFormat(relativeTimeParam)
? relativeTimeParam
: DEFAULT_TIME_RANGE;
(relativeTimeParam === 'null' ? null : relativeTimeParam) ||
DEFAULT_TIME_RANGE;
const intStartTime = parseInt(startTime || '0', 10);
const intEndTime = parseInt(endTime || '0', 10);
@@ -89,10 +93,63 @@ export const useAlertHistoryQueryParams = (): {
startTime: Math.floor(nanoToMilli(minTime)),
endTime: Math.floor(nanoToMilli(maxTime)),
hasStartAndEndParams,
relativeTime: hasStartAndEndParams ? null : relativeTime,
params,
};
};
export const useRouteTabUtils = (): { routes: TabRoutes[] } => {
const urlQuery = useUrlQuery();
const getRouteUrl = (tab: AlertDetailsTab): string => {
let route = '';
let params = urlQuery.toString();
const ruleIdKey = QueryParams.ruleId;
const relativeTimeKey = QueryParams.relativeTime;
switch (tab) {
case AlertDetailsTab.OVERVIEW:
route = ROUTES.ALERT_OVERVIEW;
break;
case AlertDetailsTab.HISTORY:
params = `${ruleIdKey}=${urlQuery.get(
ruleIdKey,
)}&${relativeTimeKey}=${urlQuery.get(relativeTimeKey)}`;
route = ROUTES.ALERT_HISTORY;
break;
default:
return '';
}
return `${buildRoutePath(route)}?${params}`;
};
const routes = [
{
Component: EditRules,
name: (
<div className="tab-item" data-testid="alert-details-tab-overview">
<Table size={14} />
Overview
</div>
),
route: getRouteUrl(AlertDetailsTab.OVERVIEW),
key: ROUTES.ALERT_OVERVIEW,
},
{
Component: AlertHistory,
name: (
<div className="tab-item" data-testid="alert-details-tab-history">
<History size={14} />
History
<BetaTag />
</div>
),
route: getRouteUrl(AlertDetailsTab.HISTORY),
key: ROUTES.ALERT_HISTORY,
},
];
return { routes };
};
type Props = {
ruleId: string | null;
isValidRuleId: boolean;
@@ -299,7 +356,12 @@ export const useTimelineTable = ({
nextCursor?: string;
}): {
paginationConfig: TablePaginationConfig;
onChangeHandler: TableProps<AlertRuleTimelineTableResponse>['onChange'];
onChangeHandler: (
pagination: TablePaginationConfig,
sorter: any,
filters: any,
extra: any,
) => void;
handleNextPage: () => void;
handlePrevPage: () => void;
hasNextPage: boolean;
@@ -386,11 +448,8 @@ export const useAlertRuleStatusToggle = ({
{
onSuccess: (data) => {
setAlertRuleState(data.data.state);
void invalidateGetRuleByID(queryClient, { id: ruleId });
void queryClient.refetchQueries([
REACT_QUERY_KEY.ALERT_RULE_DETAILS,
ruleId,
]);
invalidateGetRuleByID(queryClient, { id: ruleId });
queryClient.refetchQueries([REACT_QUERY_KEY.ALERT_RULE_DETAILS, ruleId]);
notifications.success({
message: `Alert has been ${
data.data.state === 'disabled' ? 'disabled' : 'enabled'
@@ -398,11 +457,8 @@ export const useAlertRuleStatusToggle = ({
});
},
onError: (error) => {
void invalidateGetRuleByID(queryClient, { id: ruleId });
void queryClient.refetchQueries([
REACT_QUERY_KEY.ALERT_RULE_DETAILS,
ruleId,
]);
invalidateGetRuleByID(queryClient, { id: ruleId });
queryClient.refetchQueries([REACT_QUERY_KEY.ALERT_RULE_DETAILS, ruleId]);
showErrorModal(
convertToApiError(error as AxiosError<RenderErrorResponseDTO>) as APIError,
);

View File

@@ -1,65 +0,0 @@
import { TabRoutes } from 'components/RouteTab/types';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import AlertHistory from 'container/AlertHistory';
import { AlertDetailsTab } from 'container/AlertHistory/types';
import useUrlQuery from 'hooks/useUrlQuery';
import { buildRoutePath } from 'lib/router/buildRoutePath';
import { History, Table } from '@signozhq/icons';
import EditRules from 'pages/EditRules';
import BetaTag from 'periscope/components/BetaTag/BetaTag';
export const useRouteTabUtils = (): { routes: TabRoutes[] } => {
const urlQuery = useUrlQuery();
const getRouteUrl = (tab: AlertDetailsTab): string => {
let route = '';
let params = urlQuery.toString();
const ruleIdKey = QueryParams.ruleId;
const relativeTimeKey = QueryParams.relativeTime;
switch (tab) {
case AlertDetailsTab.OVERVIEW:
route = ROUTES.ALERT_OVERVIEW;
break;
case AlertDetailsTab.HISTORY:
params = `${ruleIdKey}=${urlQuery.get(
ruleIdKey,
)}&${relativeTimeKey}=${urlQuery.get(relativeTimeKey)}`;
route = ROUTES.ALERT_HISTORY;
break;
default:
return '';
}
return `${buildRoutePath(route)}?${params}`;
};
const routes = [
{
Component: EditRules,
name: (
<div className="tab-item" data-testid="alert-details-tab-overview">
<Table size={14} />
Overview
</div>
),
route: getRouteUrl(AlertDetailsTab.OVERVIEW),
key: ROUTES.ALERT_OVERVIEW,
},
{
Component: AlertHistory,
name: (
<div className="tab-item" data-testid="alert-details-tab-history">
<History size={14} />
History
<BetaTag />
</div>
),
route: getRouteUrl(AlertDetailsTab.HISTORY),
key: ROUTES.ALERT_HISTORY,
},
];
return { routes };
};

View File

@@ -0,0 +1,86 @@
package querybuilder_test
import (
"context"
"testing"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/telemetryschema/audittelemetryschema"
"github.com/SigNoz/signoz/pkg/telemetryschema/logstelemetryschema"
"github.com/SigNoz/signoz/pkg/telemetryschema/metricstelemetryschema"
"github.com/SigNoz/signoz/pkg/telemetryschema/tracestelemetryschema"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes/telemetrytypestest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
// A key metadata does not report warns and the query runs, so in a select
// field, group by, order by, or aggregation every storage's fallback must read.
// The only failure allowed is invalid input for a context the signal does not
// serve.
func TestResolveColumn_UnknownKey(t *testing.T) {
releaseTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)
start := uint64(releaseTime.Add(24 * time.Hour).UnixNano())
end := start + uint64(time.Hour.Nanoseconds())
schemaMetadataStore := telemetrytypestest.NewMockSchemaMetadataStore(t)
schemaMetadataStore.EXPECT().GetBaseColumnEvolutions(mock.Anything, telemetrytypes.SignalTraces, telemetrytypes.FieldContextAttribute).Return(tracestelemetryschema.MockAttributeEvolutionData(releaseTime), nil).Maybe()
testCases := []struct {
name string
storage qbtypes.Storage
query qbtypes.QueryInfo
}{
{
name: "Logs",
storage: logstelemetryschema.NewStorage(),
query: qbtypes.QueryInfo{Signal: telemetrytypes.SignalLogs, StartNs: start, EndNs: end},
},
{
name: "Logs_JSONBody",
storage: logstelemetryschema.NewStorage(),
query: qbtypes.QueryInfo{Signal: telemetrytypes.SignalLogs, StartNs: start, EndNs: end, BodyJSONOn: true},
},
{
name: "Traces",
storage: tracestelemetryschema.NewStorage(schemaMetadataStore),
query: qbtypes.QueryInfo{Signal: telemetrytypes.SignalTraces, StartNs: start, EndNs: end},
},
{
name: "Traces_JSONAttributes",
storage: tracestelemetryschema.NewStorage(schemaMetadataStore),
query: qbtypes.QueryInfo{Signal: telemetrytypes.SignalTraces, StartNs: start, EndNs: end, TraceAttrsJSONOn: true},
},
{
name: "Metrics",
storage: metricstelemetryschema.NewStorage(),
query: qbtypes.QueryInfo{Signal: telemetrytypes.SignalMetrics, StartNs: start, EndNs: end},
},
{
name: "Audit",
storage: audittelemetryschema.NewStorage(),
query: qbtypes.QueryInfo{Signal: telemetrytypes.SignalLogs, StartNs: start, EndNs: end},
},
}
for _, testCase := range testCases {
for _, value := range (telemetrytypes.FieldContext{}).Enum() {
fieldContext, ok := value.(telemetrytypes.FieldContext)
require.True(t, ok)
t.Run(testCase.name+"_"+fieldContext.StringValue(), func(t *testing.T) {
key := telemetrytypes.TelemetryFieldKey{Name: "unknown.key", FieldContext: fieldContext, Signal: testCase.query.Signal}
_, err := querybuilder.ResolveColumn(context.Background(), testCase.query, testCase.storage, &key, telemetrytypes.FieldDataTypeUnspecified, map[string][]*telemetrytypes.TelemetryFieldKey{})
if err != nil {
assert.True(t, errors.Ast(err, errors.TypeInvalidInput), "%v", err)
}
})
}
}
}

View File

@@ -505,7 +505,7 @@ func (m *storage) Fallback(_ context.Context, _ qbtypes.QueryInfo, key *telemetr
var keys []*telemetrytypes.TelemetryFieldKey
switch key.FieldContext {
case telemetrytypes.FieldContextBody:
keys = []*telemetrytypes.TelemetryFieldKey{telemetrytypes.NewTelemetryFieldKey(key.Name, key.FieldContext, key.FieldDataType)}
keys = []*telemetrytypes.TelemetryFieldKey{bodyPath(key.Name, key.FieldDataType, value)}
case telemetrytypes.FieldContextUnspecified:
if column, ok := logsV2Columns[key.Name]; ok {
keys = []*telemetrytypes.TelemetryFieldKey{{Name: key.Name, FieldContext: telemetrytypes.FieldContextLog, FieldDataType: querybuilder.ColumnDataType(column)}}

View File

@@ -240,12 +240,14 @@ def test_json_body_unknown_key_aggregation_over_body(
assert tuple(data[0]) == (6, 4), data[0]
@pytest.mark.parametrize("prefix", ["", "body."], ids=["bare", "body_prefix"])
def test_json_body_unknown_key_body_path_parity(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_logs: Callable[[list[Logs]], None],
export_json_types: Callable[[list[JSONPathType]], None],
prefix: str,
) -> None:
"""
Setup:
@@ -254,10 +256,11 @@ def test_json_body_unknown_key_body_path_parity(
(distributed_field_keys); unknown_field is left unregistered.
Tests:
Grouping/filtering by the registered body path (known_field, metadata-driven) and
by the unregistered one (unknown_field, synthesized body branch) return identical
results — the synthesize fallback reproduces exported-metadata resolution for body
paths, the body counterpart of the attribute parity in 13_unknown_key_collisions.py.
Grouping, counting distinct values, and selecting by the registered body path
(known_field, metadata-driven) and by the unregistered one (unknown_field,
fallback body path) return identical results, for the bare key and the
body.-prefixed key — the body counterpart of the attribute parity in
13_unknown_key_collisions.py.
"""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
logs = []
@@ -279,7 +282,10 @@ def test_json_body_unknown_key_body_path_parity(
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
counts: dict[str, dict[str, int]] = {}
distinct: dict[str, int] = {}
selected: dict[str, list[str]] = {}
for key in ("known_field", "unknown_field"):
field = f"{prefix}{key}"
response = make_scalar_query_request(
signoz,
token,
@@ -289,13 +295,42 @@ def test_json_body_unknown_key_body_path_parity(
"A",
"logs",
[build_aggregation("count()")],
group_by=[build_group_by_field(key, field_data_type="", field_context="")],
group_by=[build_group_by_field(field, field_data_type="", field_context="")],
filter_expression=f'service.name = "{SERVICE}"',
)
],
)
assert response.status_code == HTTPStatus.OK, f"{key}: {response.text}"
assert response.status_code == HTTPStatus.OK, f"{field}: {response.text}"
counts[key] = {row[0]: row[-1] for row in get_scalar_table_data(response.json())}
response = make_scalar_query_request(
signoz,
token,
now,
[
build_scalar_query(
"A",
"logs",
[build_aggregation(f"count_distinct({field})")],
filter_expression=f'service.name = "{SERVICE}"',
)
],
)
assert response.status_code == HTTPStatus.OK, f"{field}: {response.text}"
distinct[key] = get_scalar_table_data(response.json())[0][-1]
response = make_query_request(
signoz,
token,
start_ms=int((now - timedelta(minutes=5)).timestamp() * 1000),
end_ms=int(now.timestamp() * 1000),
request_type=RequestType.RAW,
queries=[build_raw_query("A", "logs", limit=100, filter_expression=f'service.name = "{SERVICE}"', select_fields=[{"name": field}])],
)
assert response.status_code == HTTPStatus.OK, f"{field}: {response.text}"
selected[key] = sorted(row["data"][key] for row in get_rows(response))
assert counts["known_field"] == counts["unknown_field"], f"registered {counts['known_field']} != synthesized {counts['unknown_field']}"
assert counts["known_field"] == {"alpha": 2, "beta": 1}, counts["known_field"]
assert distinct == {"known_field": 2, "unknown_field": 2}, distinct
assert selected == {"known_field": ["alpha", "alpha", "beta"], "unknown_field": ["alpha", "alpha", "beta"]}, selected

View File

@@ -442,51 +442,3 @@ def test_builder_refreshing_a_sliding_time_range(
assert uncached.status_code == HTTPStatus.OK, uncached.text
assert_results_equal(from_cache.json(), uncached.json(), "A", "the twentieth refresh")
def test_builder_rows_scanned_when_refreshing(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_metrics: Callable[[list[Metrics]], None],
) -> None:
metric_name = f"cache_rows_scanned_{uuid4().hex[:8]}"
# 90 minutes back so even the last refresh closes clear of the flux
# interval, which holds recent data out of the cache
start_time = datetime.fromtimestamp(int((datetime.now(tz=UTC) - timedelta(minutes=90)).timestamp()) // 60 * 60, tz=UTC)
start_time_ms = int(start_time.timestamp() * 1000)
query = [build_builder_query("A", metric_name, "max", "max")]
insert_metrics(
[
Metrics(
metric_name=metric_name,
labels={"service": "api"},
timestamp=start_time + timedelta(minutes=minute),
value=minute,
type_="Gauge",
is_monotonic=False,
)
for minute in range(70)
]
)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# a dashboard left open on a one hour range, re-running a minute later each
# time. rowsScanned is what answering the request cost, so a refresh served
# mostly out of the cache cannot report more than reading its range afresh
for refresh in range(10):
refresh_start_ms = start_time_ms + refresh * MINUTE_MS
from_cache = make_query_request(signoz, token, refresh_start_ms, refresh_start_ms + 60 * MINUTE_MS, query, no_cache=False)
assert from_cache.status_code == HTTPStatus.OK, from_cache.text
uncached = make_query_request(signoz, token, refresh_start_ms, refresh_start_ms + 60 * MINUTE_MS, query, no_cache=True)
assert uncached.status_code == HTTPStatus.OK, uncached.text
rows_scanned_from_cache = from_cache.json()["data"]["meta"]["rowsScanned"]
rows_scanned_uncached = uncached.json()["data"]["meta"]["rowsScanned"]
assert rows_scanned_uncached > 0, f"refresh {refresh}"
assert rows_scanned_from_cache <= rows_scanned_uncached, f"refresh {refresh}: {rows_scanned_from_cache} rows scanned when served from the cache, {rows_scanned_uncached} when read afresh"