Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -266,8 +266,17 @@ export class PreAggregationPartitionRangeLoader {
if (this.preAggregation.rollupLambdaId) {
if (this.lambdaQuery && loadResults.length > 0) {
const { buildRangeEnd, targetTableName } = loadResults[loadResults.length - 1];
const lambdaTypes = await this.loadCache.getTableColumnTypes(this.preAggregation, targetTableName);
lambdaTable = await this.downloadLambdaTable(buildRangeEnd, lambdaTypes);
if (this.lambdaSourceDataCovered(buildRangeEnd)) {
this.logger('Skipping lambda source query', {
preAggregationId: this.preAggregation.preAggregationId,
requestId: this.requestId,
buildRangeEnd,
matchedTimeDimensionDateRange: this.preAggregation.matchedTimeDimensionDateRange,
});
} else {
const lambdaTypes = await this.loadCache.getTableColumnTypes(this.preAggregation, targetTableName);
lambdaTable = await this.downloadLambdaTable(buildRangeEnd, lambdaTypes);
}
}
const rollupLambdaResults = this.preAggregationsTablesToTempTables.filter(tempTableResult => tempTableResult[1].rollupLambdaId === this.preAggregation.rollupLambdaId);
const filteredResults = loadResults.filter(
Expand Down Expand Up @@ -368,6 +377,20 @@ export class PreAggregationPartitionRangeLoader {
}
}

/**
* The lambda query is lower bounded by `buildRangeEnd` (exclusive, via
* `afterDate FROM_PARTITION_RANGE`), so it can only contribute rows strictly after it.
* @see https://github.com/cube-js/cube/issues/11682
*/
private lambdaSourceDataCovered(buildRangeEnd?: string): boolean {
const matchedRangeEnd = this.preAggregation.matchedTimeDimensionDateRange?.[1];
if (!matchedRangeEnd || !buildRangeEnd) {
return false;
}
// buildRangeEnd comes back from the DB and may carry a `Z` suffix.
return reformatInIsoLocal(matchedRangeEnd) <= reformatInIsoLocal(buildRangeEnd);
}
Comment on lines +385 to +392

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The skip decision and the SQL bound can disagree in the multi-usage case, and the disagreement is in the unsafe direction.

lambdaSourceDateRange() bounds the SQL by the union of every usage range for the rollupLambda, but this check reads this.preAggregation.matchedTimeDimensionDateRange, which comes from one description. With several usage infos, preAggregationDescriptionsForUsageInfos() produces one description per usage (each with its own merged range) and then preAggregationsDescription() collapses them with R.uniqBy(desc => desc.tableName) (schema-compiler/src/adapter/PreAggregations.ts:145) — the first one wins.

So if usage A ends inside the built range and usage B extends past it, the surviving description carries A's range, lambdaSourceDataCovered() returns true, and the fresh tail that B's SQL bound was explicitly widened to fetch is never downloaded. Before this change the download always ran.

Given the PR already notes that multi-usage descriptions don't add the lambda table to their per-usage unions, this may be masked today — but the two computations should be fed from the same value rather than relying on that. Consider having the schema compiler stamp the effective source range onto LambdaQuery (next to maxSourceRowLimit) and comparing against that here, so the skip can never be wider than the bound actually rendered into the SQL.


/**
* Downloads the lambda table from the source DB.
*/
Expand Down Expand Up @@ -399,8 +422,9 @@ export class PreAggregationPartitionRangeLoader {
lambdaTypes,
}
);
if (data.rowCount === this.options.maxSourceRowLimit) {
throw new Error(`The maximum number of source rows ${this.options.maxSourceRowLimit} was reached for ${this.preAggregation.preAggregationId}`);
const appliedRowLimit = this.lambdaQuery.maxSourceRowLimit ?? this.options.maxSourceRowLimit;
if (data.rowCount === appliedRowLimit) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor, and the PR already flags it as out of scope: now that the limit is inlined into the SQL by the compiler rather than substituted at runtime, an === check is more fragile than before — any divergence between the two values (see the config-option issue on BaseQuery.js) turns the guard from "throw" into "silently return truncated data". >= costs nothing here and fails closed.

Suggested change
if (data.rowCount === appliedRowLimit) {
if (data.rowCount >= appliedRowLimit) {

throw new Error(`The maximum number of source rows ${appliedRowLimit} was reached for ${this.preAggregation.preAggregationId}`);
}
return {
name: `${LAMBDA_TABLE_PREFIX}_${this.preAggregation.tableName.replace('.', '_')}`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,11 @@ export type LambdaOptions = {
export type LambdaQuery = {
sqlAndParams: QueryWithParams,
cacheKeyQueries: any[],
/**
* Limit actually rendered into `sqlAndParams`. Undefined for SQL that instead carries the
* MAX_SOURCE_ROW_LIMIT placeholder as a param.
*/
maxSourceRowLimit?: number,
};

export type PreAggregationDescription = {
Expand Down
105 changes: 105 additions & 0 deletions packages/cubejs-query-orchestrator/test/unit/PreAggregations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -796,6 +796,111 @@ describe('PreAggregations', () => {
});
});

// @link https://github.com/cube-js/cube/issues/11682
describe('lambda source query loading', () => {
const buildRangeEnd = '2024-01-02T23:59:59.999';

// The PreAggregationLoader.prototype spy below would otherwise leak into later tests.
afterEach(() => {
jest.restoreAllMocks();
});

const createLambdaLoader = (matchedTimeDimensionDateRange?: [string, string]) => {
const loader = new PreAggregationPartitionRangeLoader(
{} as any, // driverFactory
// eslint-disable-next-line @typescript-eslint/no-empty-function
() => {}, // logger
{ options: {} } as any, // queryCache
{} as any, // preAggregations
mockPreAggregation({
preAggregationId: 'Orders.d',
rollupLambdaId: 'Orders.d_lambda',
lastRollupLambda: true,
unionWithSourceData: true,
matchedTimeDimensionDateRange,
}) as any,
[], // preAggregationsTablesToTempTables
{ getTableColumnTypes: jest.fn().mockResolvedValue([{ name: 'ts', type: 'timestamp' }]) } as any,
{
lambdaQuery: {
sqlAndParams: ['SELECT * FROM public.orders WHERE ts > ?', [FROM_PARTITION_RANGE]],
cacheKeyQueries: [],
},
} as any,
);

jest.spyOn(loader as any, 'partitionRanges').mockResolvedValue({
buildRange: ['2024-01-01T00:00:00.000', buildRangeEnd],
partitionRanges: [['2024-01-02T00:00:00.000', buildRangeEnd]],
});
jest.spyOn(PreAggregationLoader.prototype, 'loadPreAggregation').mockResolvedValue({
targetTableName: 'stb_pre_aggregations.orders_d20240102_abc_def',
refreshKeyValues: [],
lastUpdatedAt: 1,
buildRangeEnd,
} as any);
const downloadLambdaTable = jest.spyOn(loader as any, 'downloadLambdaTable').mockResolvedValue({
name: 'lambda_stb_pre_aggregations_orders_d',
columns: [],
csvRows: '',
});

return { loader, downloadLambdaTable };
};

test('skips the source query when the requested range is inside the built range', async () => {
const { loader, downloadLambdaTable } = createLambdaLoader(['2024-01-01T00:00:00.000', buildRangeEnd]);

const result: any = await loader.loadPreAggregations();

expect(downloadLambdaTable).not.toHaveBeenCalled();
expect(result.lambdaTable).toBeUndefined();
expect(result.targetTableName).toEqual('stb_pre_aggregations.orders_d20240102_abc_def');
});

test('runs the source query when the requested range extends past the built range', async () => {
const { loader, downloadLambdaTable } = createLambdaLoader(['2024-01-01T00:00:00.000', '2024-01-05T23:59:59.999']);

const result: any = await loader.loadPreAggregations();

expect(downloadLambdaTable).toHaveBeenCalledWith(buildRangeEnd, [{ name: 'ts', type: 'timestamp' }]);
expect(result.lambdaTable?.name).toEqual('lambda_stb_pre_aggregations_orders_d');
expect(result.targetTableName).toMatch(/UNION ALL SELECT \* FROM lambda_stb_pre_aggregations_orders_d/);
});

test('runs the source query when no date range was requested', async () => {
const { loader, downloadLambdaTable } = createLambdaLoader(undefined);

await loader.loadPreAggregations();

expect(downloadLambdaTable).toHaveBeenCalled();
});
});

describe('lambdaSourceDataCovered', () => {
const covered = (matchedTimeDimensionDateRange: any, buildRangeEnd: any) => (
createLoader({ matchedTimeDimensionDateRange }) as any
).lambdaSourceDataCovered(buildRangeEnd);

test('covered when the requested range ends within the built range', () => {
expect(covered(['2024-01-01T00:00:00.000', '2024-01-02T23:59:59.999'], '2024-01-02T23:59:59.999')).toBe(true);
expect(covered(['2024-01-01T00:00:00.000', '2024-01-02T23:59:59.999'], '2024-01-03T23:59:59.999')).toBe(true);
});

test('not covered when the requested range extends past the built range', () => {
expect(covered(['2024-01-01T00:00:00.000', '2024-01-05T23:59:59.999'], '2024-01-03T23:59:59.999')).toBe(false);
});

test('normalizes a buildRangeEnd read back from the DB with a Z suffix', () => {
expect(covered(['2024-01-01T00:00:00.000', '2024-01-02T23:59:59.999'], '2024-01-02T23:59:59.999Z')).toBe(true);
});

test('keeps the source query when either bound is unknown', () => {
expect(covered(undefined, '2024-01-02T23:59:59.999')).toBe(false);
expect(covered(['2024-01-01T00:00:00.000', '2024-01-02T23:59:59.999'], undefined)).toBe(false);
});
});

describe('partitionTableName', () => {
test('should generate correct table names for different granularities', () => {
const testDateRange: [string, string] = ['2024-01-05T12:34:56.789', '2024-01-05T23:59:59.999'];
Expand Down
37 changes: 28 additions & 9 deletions packages/cubejs-schema-compiler/src/adapter/BaseQuery.js
Original file line number Diff line number Diff line change
Expand Up @@ -1093,26 +1093,41 @@ export class BaseQuery {
const lambdaPreAgg = preAggForQuery.referencedPreAggregations[preAggForQuery.referencedPreAggregations.length - 1];
// TODO(cristipp) Use source query instead of preaggregation references.
const references = this.cubeEvaluator.evaluatePreAggregationReferences(lambdaPreAgg.cube, lambdaPreAgg.preAggregation);
const [timeDimension] = references.timeDimensions;
// @see https://github.com/cube-js/cube/issues/11682
const sourceDateRange = timeDimension &&
this.preAggregations.lambdaSourceDateRange(lambdaPreAgg, preAggForQuery);
// Tesseract renders LIMIT as an inline number and can't carry the MAX_SOURCE_ROW_LIMIT
// placeholder (it parses to None, emitting no LIMIT at all), so resolve it here.
const resolvedRowLimit = this.options.maxSourceRowLimit ?? getEnv('maxSourceRowLimit');
const maxSourceRowLimit = typeof resolvedRowLimit === 'number' && resolvedRowLimit > 0
? resolvedRowLimit
Comment on lines +1100 to +1104

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Baking the limit here silently drops support for the preAggregationsOptions.maxSourceRowLimit config option.

Two things line up badly:

  • this.options.maxSourceRowLimit is not a schema-compiler query option — nothing in the repo ever puts it there (grep maxSourceRowLimit only finds preAggregationsOptions in OptsHandler.ts:634, LambdaOptions in the orchestrator, and the env getter). So this always falls through to getEnv('maxSourceRowLimit') (default 200000).
  • The runtime value that used to win is the orchestrator's this.options.maxSourceRowLimit, which OptsHandler fills from preAggregationsOptions.maxSourceRowLimit or env. That is what the MAX_SOURCE_ROW_LIMIT placeholder was substituted with in downloadLambdaTable.

So for a user with preAggregationsOptions: { maxSourceRowLimit: 5000 } in cube.js and no CUBEJS_MAX_SOURCE_ROW_LIMIT, after this change the source SQL gets LIMIT 200000, and because PreAggregationPartitionRangeLoader:425 prefers this.lambdaQuery.maxSourceRowLimit ?? this.options.maxSourceRowLimit, the guard also checks against 200000 — the configured 5000 becomes completely inert (previously it was honored on the legacy planner).

Either plumb preAggregationsOptions.maxSourceRowLimit into the compiler query options (so this.options.maxSourceRowLimit is real), or keep the placeholder when it hasn't been resolved from an authoritative source and only bake the number on the native path.

Fix this →

: undefined;
const lambdaQuery = this.newSubQuery(
{
measures: references.measures,
dimensions: references.dimensions,
timeDimensions: references.timeDimensions,
filters: [
...this.options.filters ?? [],
references.timeDimensions.length > 0
? {
member: references.timeDimensions[0].dimension,
operator: 'afterDate',
values: [FROM_PARTITION_RANGE]
}
: [],
...(timeDimension ? [{
member: timeDimension.dimension,
operator: 'afterDate',
values: [FROM_PARTITION_RANGE]
}] : []),
// Kept separate from the afterDate filter on purpose: inDateRange's lower bound is
// inclusive and would double count rows sitting exactly at the partition end.
...(sourceDateRange ? [{
member: timeDimension.dimension,
operator: 'inDateRange',
values: sourceDateRange
}] : []),
],
segments: this.options.segments,
order: [],
limit: undefined,
offset: undefined,
rowLimit: MAX_SOURCE_ROW_LIMIT,
rowLimit: maxSourceRowLimit ?? MAX_SOURCE_ROW_LIMIT,
preAggregationQuery: true,
}
);
Expand All @@ -1121,7 +1136,11 @@ export class BaseQuery {
() => this.cacheKeyQueries(),
{ preAggregationQuery: true }
);
result[this.preAggregations.preAggregationId(lambdaPreAgg)] = { sqlAndParams, cacheKeyQueries };
result[this.preAggregations.preAggregationId(lambdaPreAgg)] = {
sqlAndParams,
cacheKeyQueries,
maxSourceRowLimit,
};
}
return result;
}
Expand Down
105 changes: 79 additions & 26 deletions packages/cubejs-schema-compiler/src/adapter/PreAggregations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,20 +313,24 @@ export class PreAggregations {
return [];
}

private preAggregationDescriptionFor(cube: string, foundPreAggregation: PreAggregationForQuery): FullPreAggregationDescription {
const { preAggregationName, preAggregation, references } = foundPreAggregation;
/**
* Requested time dimension range this pre-aggregation was matched by, as local
* (timezone naked) ISO strings, e.g. ['2024-02-01T00:00:00.000', '2024-02-29T23:59:59.999'].
* Drives partition selection, and bounds the rollupLambda source query so both halves of
* the union agree.
*/
public matchedTimeDimensionDateRangeFor(foundPreAggregation: PreAggregationForQuery): [string, string] | undefined {
const { preAggregation } = foundPreAggregation;

const tableName = this.preAggregationTableName(cube, preAggregationName, preAggregation);
const invalidateKeyQueries = this.query.preAggregationInvalidateKeyQueries(cube, preAggregation, preAggregationName);
const queryForSqlEvaluation = this.query.preAggregationQueryForSqlEvaluation(cube, preAggregation);
// Atm this is only defined in KsqlQuery but without it partitions are recreated on every refresh
const partitionInvalidateKeyQueries = queryForSqlEvaluation.partitionInvalidateKeyQueries?.(cube, preAggregation);
if (!preAggregation.partitionGranularity) {
return undefined;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description says "so nothing is recomputed", but extracting this into a public method actually adds a computation: matchedTimeDimensionDateRangeFor() is now called once per description from preAggregationDescriptionFor() and once more from lambdaSourceDateRange() (via buildLambdaQuery). Each call re-runs this.query.allBackAliasMembers(), which is not memoized (BaseQuery.js:5472backAliasMembers(flattenAllMembers()), which traverses symbols and evaluates member SQL — see the warning comment at BaseQuery.js:5406).

Worth memoizing either allBackAliasMembers() or this method per foundPreAggregation, since the whole point of the change is to make lambda requests cheaper.


const allBackAliasMembers = this.query.allBackAliasMembers();

let matchedTimeDimension: BaseTimeDimension | undefined;

if (preAggregation.partitionGranularity && !this.hasCumulativeMeasures()) {
if (!this.hasCumulativeMeasures()) {
matchedTimeDimension = this.query.timeDimensions.find(td => {
if (!td.dateRange) {
return false;
Expand All @@ -349,24 +353,77 @@ export class PreAggregations {
});
}

let filters: BaseFilter[] | undefined;
const filters = this.query.filters?.filter((td): td is BaseFilter => {
// TODO support all date operators
if (td.isDateOperator() && 'camelizeOperator' in td && td.camelizeOperator === 'inDateRange') {
if (td.dimension === foundPreAggregation.references.timeDimensions[0].dimension) {
return true;
}

if (preAggregation.partitionGranularity) {
filters = this.query.filters?.filter((td): td is BaseFilter => {
// TODO support all date operators
if (td.isDateOperator() && 'camelizeOperator' in td && td.camelizeOperator === 'inDateRange') {
if (td.dimension === foundPreAggregation.references.timeDimensions[0].dimension) {
return true;
}
// Handling for views
return td.dimension === allBackAliasMembers[foundPreAggregation.references.timeDimensions[0].dimension];
}

// Handling for views
return td.dimension === allBackAliasMembers[foundPreAggregation.references.timeDimensions[0].dimension];
}
return false;
});

return false;
});
return matchedTimeDimension?.boundaryDateRangeFormatted() ||
filters?.[0]?.formattedDateRange() || // TODO intersect all date ranges
undefined;
}

/**
* Mirrors the merge done in preAggregationDescriptionsForUsageInfos(), otherwise a forward
* shifted usage would be bounded tighter than the partitions it unions with and lose
* source rows.
*/
public lambdaSourceDateRange(
lambdaPreAggregation: PreAggregationForQuery,
rollupLambda: PreAggregationForQuery
): [string, string] | undefined {
const matchedDateRange = this.matchedTimeDimensionDateRangeFor(lambdaPreAggregation);

if (!matchedDateRange) {
return undefined;
}

const usageInfos = (this.preAggregationUsageInfos || []).filter(
usageInfo => usageInfo.cubeName === rollupLambda.cube &&
usageInfo.preAggregationName === rollupLambda.preAggregationName
);

if (usageInfos.length === 0) {
return matchedDateRange;
}

let merged: [string, string] | undefined;

for (const usageInfo of usageInfos) {
const usageDateRange = PreAggregations.mergeUsageDateRanges(usageInfo.usages);
if (!usageDateRange) {
// An unknown usage range may need anything, so bound nothing.
return undefined;
}
merged = merged
? [
usageDateRange[0] < merged[0] ? usageDateRange[0] : merged[0],
usageDateRange[1] > merged[1] ? usageDateRange[1] : merged[1],
]
: usageDateRange;
}

return merged;
}

private preAggregationDescriptionFor(cube: string, foundPreAggregation: PreAggregationForQuery): FullPreAggregationDescription {
const { preAggregationName, preAggregation, references } = foundPreAggregation;

const tableName = this.preAggregationTableName(cube, preAggregationName, preAggregation);
const invalidateKeyQueries = this.query.preAggregationInvalidateKeyQueries(cube, preAggregation, preAggregationName);
const queryForSqlEvaluation = this.query.preAggregationQueryForSqlEvaluation(cube, preAggregation);
// Atm this is only defined in KsqlQuery but without it partitions are recreated on every refresh
const partitionInvalidateKeyQueries = queryForSqlEvaluation.partitionInvalidateKeyQueries?.(cube, preAggregation);

const uniqueKeyColumnsDefault = () => null;
const uniqueKeyColumns = ({
rollup: () => queryForSqlEvaluation.preAggregationUniqueKeyColumns(cube, preAggregation),
Expand Down Expand Up @@ -403,11 +460,7 @@ export class PreAggregations {
preAggregationStartEndQueries:
(preAggregation.partitionGranularity || references.timeDimensions[0]?.granularity) &&
this.refreshRangeQuery(cube).preAggregationStartEndQueries(cube, preAggregation),
matchedTimeDimensionDateRange:
preAggregation.partitionGranularity && (
matchedTimeDimension?.boundaryDateRangeFormatted() ||
filters?.[0]?.formattedDateRange() // TODO intersect all date ranges
),
matchedTimeDimensionDateRange: this.matchedTimeDimensionDateRangeFor(foundPreAggregation),
indexesSql: Object.keys(preAggregation.indexes || {})
.map(
index => {
Expand Down
Loading
Loading