diff --git a/.changeset/bounded-map-key-discovery.md b/.changeset/bounded-map-key-discovery.md new file mode 100644 index 0000000000..841f6d4504 --- /dev/null +++ b/.changeset/bounded-map-key-discovery.md @@ -0,0 +1,15 @@ +--- +'@hyperdx/common-utils': patch +'@hyperdx/app': patch +'@hyperdx/api': patch +--- + +fix: don't scan the whole table to discover Map keys + +`getMapKeys` only applied a time predicate when the caller passed both a date +range and a timestamp expression; otherwise the raw `sampledKeys` scan ran with +no `WHERE` and touched every part of the table. It now defaults a missing date +range to the last 24 hours and skips the raw scan entirely when there is no +timestamp expression to filter on. The chart, alert and dashboard-filter +editors pass the source and date range they already have so Map keys keep +autocompleting there. diff --git a/packages/api/src/controllers/ai.ts b/packages/api/src/controllers/ai.ts index 2aa951279d..f7b2a6f5a4 100644 --- a/packages/api/src/controllers/ai.ts +++ b/packages/api/src/controllers/ai.ts @@ -3,6 +3,7 @@ import { createOpenAI } from '@ai-sdk/openai'; import { getMetadata, TableMetadata, + tcFromSource, } from '@hyperdx/common-utils/dist/core/metadata'; import { AILineTableResponse, @@ -128,6 +129,8 @@ export async function getAIMetadata(source: ISource) { databaseName, tableName, connectionId, + metadataMVs: tcFromSource(source).metadataMVs, + timestampValueExpression: source.timestampValueExpression, }); // TODO: Dedup with DBSearchPageFilters.tsx logic diff --git a/packages/app/src/DBSearchPageAlertModal.tsx b/packages/app/src/DBSearchPageAlertModal.tsx index 516e20f730..ab748a0312 100644 --- a/packages/app/src/DBSearchPageAlertModal.tsx +++ b/packages/app/src/DBSearchPageAlertModal.tsx @@ -314,6 +314,7 @@ const AlertForm = ({ ; handleSubmit: UseFormHandleSubmit; tableConnection: TableConnection; + sourceId?: string; + dateRange?: [Date, Date]; activeTab: string; isRawSqlInput: boolean; dashboardId?: string; @@ -98,6 +100,8 @@ export function ChartActionBar({ control, handleSubmit, tableConnection, + sourceId, + dateRange, activeTab, isRawSqlInput, dashboardId, @@ -187,6 +191,8 @@ export function ChartActionBar({ void; connection: TableConnection; + sourceId?: string; + dateRange?: [Date, Date]; parentRef?: HTMLElement | null; defaultValues: HeatmapSettingsValues; onSubmit: (v: HeatmapSettingsValues) => void; @@ -92,6 +96,8 @@ export default function HeatmapSettingsDrawer({ { diff --git a/packages/app/src/components/alerts/EditAlertModal.tsx b/packages/app/src/components/alerts/EditAlertModal.tsx index 3f599e3a5a..2d670aa2cd 100644 --- a/packages/app/src/components/alerts/EditAlertModal.tsx +++ b/packages/app/src/components/alerts/EditAlertModal.tsx @@ -427,6 +427,7 @@ export function EditAlertModal({ { expect(result.current.data).toEqual(fieldsA); }); + it('falls back to each connection timestampValueExpression when none is passed', async () => { + const getAllFields = jest + .spyOn(mockMetadata, 'getAllFields') + .mockResolvedValue(fieldsA); + + const { result } = renderHook( + () => + useMultipleAllFields([ + { ...tcA, timestampValueExpression: 'TimestampA' }, + tcB, + ]), + { wrapper }, + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(getAllFields).toHaveBeenCalledWith( + expect.objectContaining({ timestampValueExpression: 'TimestampA' }), + ); + expect(getAllFields).toHaveBeenCalledWith( + expect.objectContaining({ + tableName: 'table_b', + timestampValueExpression: undefined, + }), + ); + }); + it('should deduplicate fields across successful connections', async () => { jest .spyOn(mockMetadata, 'getAllFields') diff --git a/packages/app/src/hooks/useMetadata.tsx b/packages/app/src/hooks/useMetadata.tsx index 11229094ee..e9030abff3 100644 --- a/packages/app/src/hooks/useMetadata.tsx +++ b/packages/app/src/hooks/useMetadata.tsx @@ -261,7 +261,12 @@ export function useMultipleAllFields( const promiseResults = await Promise.allSettled( tableConnections.map(tc => - metadata.getAllFields({ ...tc, dateRange, timestampValueExpression }), + metadata.getAllFields({ + ...tc, + dateRange, + timestampValueExpression: + timestampValueExpression ?? tc.timestampValueExpression, + }), ), ); diff --git a/packages/common-utils/src/__tests__/metadata.int.test.ts b/packages/common-utils/src/__tests__/metadata.int.test.ts index 1e7b25588e..ef33934124 100644 --- a/packages/common-utils/src/__tests__/metadata.int.test.ts +++ b/packages/common-utils/src/__tests__/metadata.int.test.ts @@ -1306,4 +1306,109 @@ describe('Metadata Integration Tests', () => { querySpy.mockRestore(); }); }); + + describe('getMapKeys - unbounded scan guard (#3037)', () => { + const tableName = 'test_map_keys_scan'; + let metadata: Metadata; + + beforeAll(async () => { + await client.command({ + query: `DROP TABLE IF EXISTS default.${tableName}`, + }); + // Map(LowCardinality(String), String) hits a separate, pre-existing bug on this CH version. + await client.command({ + query: `CREATE TABLE default.${tableName} ( + Timestamp DateTime64(9), + LogAttributes Map(String, String) + ) + ENGINE = MergeTree() + PARTITION BY toDate(Timestamp) + ORDER BY Timestamp + `, + }); + + // One row inside the default 24h lookback, one row 5 days back in its own partition. + await client.insert({ + table: `default.${tableName}`, + values: [ + { + Timestamp: new Date(Date.now() - 60 * 60 * 1000) + .toISOString() + .replace('T', ' ') + .replace('Z', ''), + LogAttributes: { 'http.method': 'GET' }, + }, + { + Timestamp: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000) + .toISOString() + .replace('T', ' ') + .replace('Z', ''), + LogAttributes: { 'legacy.key': 'value' }, + }, + ], + format: 'JSONEachRow', + }); + }); + + afterAll(async () => { + await client.command({ + query: `DROP TABLE IF EXISTS default.${tableName}`, + }); + }); + + beforeEach(() => { + metadata = new Metadata(hdxClient, new MetadataCache()); + }); + + it('falls back to an unbounded scan when no timestampValueExpression is given', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + const keys = await metadata.getMapKeys({ + databaseName: 'default', + tableName, + column: 'LogAttributes', + connectionId: 'test_connection', + dateRange: [new Date(Date.now() - 60 * 60 * 1000), new Date()], + }); + + // dateRange is ignored without an expression to apply it to. + expect(keys).toEqual( + expect.arrayContaining(['http.method', 'legacy.key']), + ); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('Unbounded Map key scan'), + ); + + warnSpy.mockRestore(); + }); + + it('defaults to the last 24h and excludes older partitions', async () => { + const keys = await metadata.getMapKeys({ + databaseName: 'default', + tableName, + column: 'LogAttributes', + connectionId: 'test_connection', + timestampValueExpression: 'Timestamp', + }); + + expect(keys).toContain('http.method'); + expect(keys).not.toContain('legacy.key'); + }); + + it('reaches older partitions when the caller asks for that window explicitly', async () => { + const keys = await metadata.getMapKeys({ + databaseName: 'default', + tableName, + column: 'LogAttributes', + connectionId: 'test_connection', + timestampValueExpression: 'Timestamp', + dateRange: [ + new Date(Date.now() - 6 * 24 * 60 * 60 * 1000), + new Date(Date.now() - 4 * 24 * 60 * 60 * 1000), + ], + }); + + expect(keys).toEqual(['legacy.key']); + }); + }); }); diff --git a/packages/common-utils/src/__tests__/metadata.test.ts b/packages/common-utils/src/__tests__/metadata.test.ts index 85587d712c..18fed88c21 100644 --- a/packages/common-utils/src/__tests__/metadata.test.ts +++ b/packages/common-utils/src/__tests__/metadata.test.ts @@ -10,6 +10,7 @@ import { import * as renderChartConfigModule from '@/core/renderChartConfig'; import { timeFilterExpr } from '@/core/renderChartConfig'; import { isBuilderChartConfig } from '@/guards'; +import { TextIndexInfoLookup } from '@/queryParser'; import { BuilderChartConfigWithDateRange, SourceKind, TSource } from '@/types'; // Mock ClickhouseClient @@ -1953,33 +1954,82 @@ describe('Metadata', () => { }); }); - it('emits a sampledKeys SQL with no time-filter or source-filter clause when neither is provided', async () => { - const md = buildMetadata(); - + it('skips the mergeTreeTextIndex path and falls back to the raw scan when a text index exists but there is no timestampValueExpression', async () => { + const md = new Metadata(mockClickhouseClient, new MetadataCache()); + jest.spyOn(md, 'getServerVersion').mockResolvedValue([26, 3, 0, 0]); + const textIndexLookup: TextIndexInfoLookup = new Map([ + [ + 'LogAttributes', + { + key: { indexName: 'idx_log_attr_keys', mapColumn: 'LogAttributes' }, + }, + ], + ]); + jest + .spyOn(md, 'getMapColumnTextIndexes') + .mockResolvedValue(textIndexLookup); + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); (mockClickhouseClient.query as jest.Mock) .mockResolvedValueOnce({ - // DESCRIBE TABLE json: () => Promise.resolve({ data: [lowCardinalityMapColumn] }), }) .mockResolvedValueOnce({ - // sampledKeys query - json: () => Promise.resolve({ data: [] }), + json: () => Promise.resolve({ data: [{ key: 'http.method' }] }), }); - await md.getMapKeys({ + const keys = await md.getMapKeys({ databaseName: 'otel', tableName: 'generic_logs', column: 'LogAttributes', connectionId: 'conn-1', + dateRange: [ + new Date('2026-05-11T16:00:00Z'), + new Date('2026-05-11T17:00:00Z'), + ], }); - expect(timeFilterExpr).not.toHaveBeenCalled(); + expect(keys).toEqual(['http.method']); + // Must not run `WHERE 1` across every part of the text index without a timestamp expression. #3037 + const queries = (mockClickhouseClient.query as jest.Mock).mock.calls.map( + ([opts]) => opts.query, + ); + expect(queries.some(q => q.includes('mergeTreeTextIndex'))).toBe(false); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('Unbounded Map key scan'), + ); + warn.mockRestore(); + }); + + it('scans the last 24h when the caller supplies no dateRange', async () => { + const md = buildMetadata(); + (mockClickhouseClient.query as jest.Mock) + .mockResolvedValueOnce({ + json: () => Promise.resolve({ data: [lowCardinalityMapColumn] }), + }) + .mockResolvedValueOnce({ + json: () => Promise.resolve({ data: [{ key: 'http.method' }] }), + }); + + const keys = await md.getMapKeys({ + databaseName: 'otel', + tableName: 'generic_logs', + column: 'LogAttributes', + connectionId: 'conn-1', + timestampValueExpression: 'EventTime, EventDate', + }); - // Find the sampledKeys query (the second call, after DESCRIBE) + expect(keys).toEqual(['http.method']); + const { dateRange } = (timeFilterExpr as jest.Mock).mock.calls[0][0]; + const HOUR = 60 * 60 * 1000; + expect( + dateRange[1].getTime() - dateRange[0].getTime(), + ).toBeGreaterThanOrEqual(24 * HOUR); + expect(dateRange[0].getTime() % HOUR).toBe(0); + expect(dateRange[1].getTime() % HOUR).toBe(0); const sampledKeysCall = (mockClickhouseClient.query as jest.Mock).mock .calls[1][0]; - expect(sampledKeysCall.query).not.toContain('WHERE'); - expect(sampledKeysCall.query).not.toContain('__TIME_FILTER__'); + expect(sampledKeysCall.query).toContain('WHERE'); + expect(sampledKeysCall.query).toContain('__TIME_FILTER__'); }); it('injects the time filter into the sampledKeys WHERE clause when dateRange and timestampValueExpression are provided', async () => { @@ -2045,6 +2095,7 @@ describe('Metadata', () => { tableName: 'generic_logs', column: 'LogAttributes', connectionId: 'conn-1', + timestampValueExpression: 'EventTime, EventDate', }); const sampledKeysCall = (mockClickhouseClient.query as jest.Mock).mock @@ -2083,6 +2134,7 @@ describe('Metadata', () => { tableName: 'generic_logs', column: 'LogAttributes', connectionId: 'conn-1', + timestampValueExpression: 'EventTime, EventDate', }); const sampledKeysCall = (mockClickhouseClient.query as jest.Mock).mock @@ -2306,6 +2358,119 @@ describe('Metadata', () => { expect(keysB).toEqual(['second']); expect(mockClickhouseClient.query).toHaveBeenCalledTimes(2); }); + + // The shipped OTel rollups only index NativeColumn, so Map columns always + // come back empty from the rollup and must reach the bounded main-table scan. + it('falls back to the bounded main-table scan when the rollup is empty', async () => { + const md = buildMetadata(); + const emptyTextIndexLookup: TextIndexInfoLookup = new Map(); + jest + .spyOn(md, 'getMapColumnTextIndexes') + .mockResolvedValue(emptyTextIndexLookup); + const columnMeta: ColumnMeta = { + name: 'LogAttributes', + type: 'Map(String, String)', + default_type: '', + default_expression: '', + comment: '', + codec_expression: '', + ttl_expression: '', + }; + jest.spyOn(md, 'getColumn').mockResolvedValue(columnMeta); + (mockClickhouseClient.query as jest.Mock) + .mockResolvedValueOnce({ json: () => Promise.resolve({ data: [] }) }) + .mockResolvedValueOnce({ + json: () => Promise.resolve({ data: [{ keysArr: ['raw.key'] }] }), + }); + + const keys = await md.getMapKeys({ ...baseArgs }); + + expect(keys).toEqual(['raw.key']); + expect(mockClickhouseClient.query).toHaveBeenCalledTimes(2); + const scanCall = (mockClickhouseClient.query as jest.Mock).mock + .calls[1][0]; + expect(scanCall.query).toContain('WHERE'); + expect(scanCall.query).toContain('__TIME_FILTER__'); + }); + + it('falls back to an unbounded scan (with a warning) when timestampValueExpression is missing', async () => { + const md = buildMetadata(); + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + jest + .spyOn(md, 'getMapColumnTextIndexes') + .mockResolvedValue(new Map() as TextIndexInfoLookup); + const columnMeta: ColumnMeta = { + name: 'LogAttributes', + type: 'Map(String, String)', + default_type: '', + default_expression: '', + comment: '', + codec_expression: '', + ttl_expression: '', + }; + jest.spyOn(md, 'getColumn').mockResolvedValue(columnMeta); + // Rollup (empty), then the unbounded scan. + (mockClickhouseClient.query as jest.Mock) + .mockResolvedValueOnce({ json: () => Promise.resolve({ data: [] }) }) + .mockResolvedValueOnce({ + json: () => Promise.resolve({ data: [{ keysArr: ['raw.key'] }] }), + }); + + const { + timestampValueExpression: _timestampValueExpression, + ...noTsArgs + } = baseArgs; + expect(await md.getMapKeys(noTsArgs)).toEqual(['raw.key']); + + const scanCall = (mockClickhouseClient.query as jest.Mock).mock + .calls[1][0]; + expect(scanCall.query).not.toContain('WHERE'); + expect(scanCall.query).not.toContain('__TIME_FILTER__'); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('Unbounded Map key scan'), + ); + }); + + it('reuses the cached unbounded scan across dateRanges when timestampValueExpression is missing', async () => { + const md = buildMetadata(); + jest.spyOn(console, 'warn').mockImplementation(() => {}); + jest + .spyOn(md, 'getMapColumnTextIndexes') + .mockResolvedValue(new Map() as TextIndexInfoLookup); + jest.spyOn(md, 'getColumn').mockResolvedValue({ + name: 'LogAttributes', + type: 'Map(String, String)', + default_type: '', + default_expression: '', + comment: '', + codec_expression: '', + ttl_expression: '', + }); + // Rollup (empty) then scan for the first call; the second call with a + // different dateRange must be served from cache. + (mockClickhouseClient.query as jest.Mock) + .mockResolvedValueOnce({ json: () => Promise.resolve({ data: [] }) }) + .mockResolvedValueOnce({ + json: () => Promise.resolve({ data: [{ keysArr: ['raw.key'] }] }), + }); + + const { + timestampValueExpression: _timestampValueExpression, + ...noTsArgs + } = baseArgs; + expect(await md.getMapKeys(noTsArgs)).toEqual(['raw.key']); + expect( + await md.getMapKeys({ + ...noTsArgs, + dateRange: [ + new Date('2026-05-12T10:00:00Z'), + new Date('2026-05-12T11:00:00Z'), + ], + }), + ).toEqual(['raw.key']); + + expect(mockClickhouseClient.query).toHaveBeenCalledTimes(2); + }); }); describe('getMapValues', () => { @@ -2560,6 +2725,7 @@ describe('Metadata', () => { databaseName: 'otel', tableName: 'test_logs', connectionId: 'conn-1', + timestampValueExpression: 'EventTime, EventDate', }); // The Map column itself should be present @@ -2643,6 +2809,7 @@ describe('Metadata', () => { databaseName: 'otel', tableName: 'test_logs', connectionId: 'conn-1', + timestampValueExpression: 'EventTime, EventDate', }); // Sub-fields for LogAttributes @@ -3342,6 +3509,7 @@ describe('parametric aggregate arguments are inlined as literals', () => { column: 'LogAttributes', connectionId: 'conn-1', maxKeys: 500, + timestampValueExpression: 'EventTime, EventDate', }); const sampledKeysCall = (mockClickhouseClient.query as jest.Mock).mock diff --git a/packages/common-utils/src/core/metadata.ts b/packages/common-utils/src/core/metadata.ts index 80f5c3ebcd..871deeb773 100644 --- a/packages/common-utils/src/core/metadata.ts +++ b/packages/common-utils/src/core/metadata.ts @@ -322,6 +322,17 @@ export type SkipIndexMetadata = { granularity: number; }; +const FIELD_METADATA_LOOKBACK_MS = 24 * 60 * 60 * 1000; + +// Hour-aligned so a range-keyed cache rotates hourly instead of every call. +export function defaultFieldMetadataDateRange(): [Date, Date] { + const now = new Date(); + return getAlignedDateRange( + [new Date(now.getTime() - FIELD_METADATA_LOOKBACK_MS), now], + '1 hour', + ); +} + export class Metadata { private readonly clickhouseClient: BaseClickhouseClient; private readonly cache: MetadataCache; @@ -926,7 +937,7 @@ export class Metadata { connectionId, metricName, metadataMVs, - dateRange, + dateRange: callerDateRange, timestampValueExpression, signal, }: { @@ -943,20 +954,28 @@ export class Metadata { }) { inlineNonNegativeInt(maxKeys, 'maxKeys'); + // Default a missing range instead of scanning unbounded. #3037 + const dateRange = callerDateRange ?? defaultFieldMetadataDateRange(); + // Align date range to rollup granularity for consistent cache keys - const alignedDateRange = - metadataMVs && dateRange - ? getAlignedDateRange(dateRange, metadataMVs.granularity) - : undefined; + const alignedDateRange = metadataMVs + ? getAlignedDateRange(dateRange, metadataMVs.granularity) + : undefined; - const dateRangeCacheSuffix = - dateRange && timestampValueExpression - ? `${dateRange[0].getTime()}-${dateRange[1].getTime()}-${timestampValueExpression}` + // Without timestampValueExpression the scan has no time filter, so its + // result doesn't depend on dateRange; keying on it would re-scan every + // hour as the default range rolls and pile up entries in the TTL-less cache. + const dateRangeCacheSuffix = timestampValueExpression + ? `${dateRange[0].getTime()}-${dateRange[1].getTime()}-${timestampValueExpression}` + : ''; + const alignedCacheSuffix = + alignedDateRange && timestampValueExpression + ? `${alignedDateRange[0].getTime()}.${alignedDateRange[1].getTime()}.${timestampValueExpression}` : ''; const cacheKey = metricName ? `${connectionId}.${databaseName}.${tableName}.${column}.${metricName}.${dateRangeCacheSuffix}.keys` - : metadataMVs && alignedDateRange - ? `${connectionId}.${databaseName}.${tableName}.${column}.${alignedDateRange[0].getTime()}.${alignedDateRange[1].getTime()}.keys` + : alignedDateRange + ? `${connectionId}.${databaseName}.${tableName}.${column}.${alignedCacheSuffix}.keys` : `${connectionId}.${databaseName}.${tableName}.${column}.${dateRangeCacheSuffix}.keys`; const cachedKeys = this.cache.get(cacheKey); @@ -975,7 +994,12 @@ export class Metadata { supportsMergeTreeTextIndex(clickhouseVersion); // Text Index path: query the key rollup index const textIndexInfo = textIndexInfoLookup.get(column); - if (textIndexInfo?.key?.indexName && canQueryMergeTreeTextIndex) { + // Without timestampValueExpression, partsFilter can't bound this either. #3037 + if ( + textIndexInfo?.key?.indexName && + canQueryMergeTreeTextIndex && + timestampValueExpression + ) { const partsFilter = await this.partsOverlapFilter({ databaseName, tableName, @@ -1010,7 +1034,11 @@ export class Metadata { ); return []; } - } else if (textIndexInfo?.kv?.indexName && canQueryMergeTreeTextIndex) { + } else if ( + textIndexInfo?.kv?.indexName && + canQueryMergeTreeTextIndex && + timestampValueExpression + ) { const partsFilter = await this.partsOverlapFilter({ databaseName, tableName, @@ -1050,8 +1078,13 @@ export class Metadata { // Rollup path: query the key rollup table filtered by ColumnIdentifier and date range if (metadataMVs && alignedDateRange) { + // Own cache key: the shipped OTel rollups only index NativeColumn, so + // Map columns come back empty here and must fall through to the bounded + // scan below. Caching [] under cacheKey would block that fallback. + // The rollup filters on Timestamp even without timestampValueExpression, + // so its key always carries the aligned range. const rollupKeys = await this.cache.getOrFetch( - cacheKey, + `${cacheKey}.${alignedDateRange[0].getTime()}.${alignedDateRange[1].getTime()}.rollup`, async () => { try { const startExpr = renderStartOfBucketExpr( @@ -1111,7 +1144,14 @@ export class Metadata { if (rollupKeys.length > 0) return rollupKeys; } - // Original path: scan main table + // Original path: scan main table. Without a timestamp expression there's + // nothing to filter on, so the scan is only bounded by the row LIMIT. #3037 + if (!timestampValueExpression) { + console.warn( + `Unbounded Map key scan for ${databaseName}.${tableName}.${column}: no timestampValueExpression to bound the scan`, + ); + } + const colMeta = await this.getColumn({ databaseName, tableName, @@ -1131,26 +1171,27 @@ export class Metadata { strategy = 'lowCardinalityKeys'; } - const timeFilterCondition = - dateRange && timestampValueExpression - ? await timeFilterExpr({ - connectionId, - databaseName, - tableName, - dateRange, - dateRangeStartInclusive: true, - dateRangeEndInclusive: true, - timestampValueExpression, - metadata: this, - }) - : null; const whereConditions: ChSql[] = [ ...(metricName ? [chSql`MetricName=${{ String: metricName }}`] : []), - ...(timeFilterCondition ? [timeFilterCondition] : []), + ...(timestampValueExpression + ? [ + await timeFilterExpr({ + connectionId, + databaseName, + tableName, + dateRange, + dateRangeStartInclusive: true, + dateRangeEndInclusive: true, + timestampValueExpression, + metadata: this, + }), + ] + : []), ]; - const where = whereConditions.length - ? chSql`WHERE ${concatChSql(' AND ', ...whereConditions)}` - : ''; + const where = + whereConditions.length > 0 + ? chSql`WHERE ${concatChSql(' AND ', ...whereConditions)}` + : chSql``; // NOTE: getSubcolumn(col, 'keys') is used instead of the `col.keys` dot // form because, on a multi-shard Distributed read of a Map subcolumn, some @@ -3129,6 +3170,8 @@ export type TableConnection = { connectionId: string; metricName?: string; metadataMVs?: MetadataMaterializedViews; + // Bounds Map key discovery; without it Map keys are skipped. #3037 + timestampValueExpression?: string; }; export type TableConnectionChoice = @@ -3160,6 +3203,7 @@ export function tcFromSource(source?: TSource): TableConnection { source && (isLogSource(source) || isTraceSource(source)) ? source.metadataMaterializedViews : undefined, + timestampValueExpression: source?.timestampValueExpression, }; }