Skip to content

Commit 6fe6b09

Browse files
authored
perf: Debounce information schema queries to Cube Store (1.8x) (#9879)
Introduces the @AsyncDebounce decorator to debounce promises for the getTablesQuery and getPrefixTablesQuery methods. This prevents redundant concurrent executions of the same operations, improving performance by avoiding duplicate database metadata queries. For the performance benchmark, I've used 1 cube with 1 pre-agg that has 10 partitions, partitioned by time dimension with year granularity. Previously, Cube would run 10 queries for the information schema while loading pre-aggregation. ### (average across runs) • Throughput almost doubled – from ~112 req/s (max 144) to ~206 req/s (max 229). • Average latency halved – from ~0.98 s to ~0.54 s. • p95 latency dropped from ~2.19 s to ~0.91 s. • Fast responses (<1 s) surged from ~57% to ~95%, with some runs hitting 100%. • Success rate improved from ~86% to ~98%, reaching 100% in the best runs. • Max latency fell from peaks of ~4.06 s to just ~2.05 s.
1 parent d2a3ae7 commit 6fe6b09

File tree

13 files changed

+128
-20
lines changed

13 files changed

+128
-20
lines changed
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## Package Overview
6+
7+
The `@cubejs-backend/shared` package contains shared utilities, types, and helper functions used across all Cube backend packages. This package provides core functionality like environment configuration, promise utilities, decorators, and common data types.
8+
9+
## Development Commands
10+
11+
**Note: This project uses Yarn as the package manager.**
12+
13+
```bash
14+
# Build the package
15+
yarn build
16+
17+
# Build with TypeScript compilation
18+
yarn tsc
19+
20+
# Watch mode for development
21+
yarn watch
22+
23+
# Run unit tests
24+
yarn unit
25+
26+
# Run linting
27+
yarn lint
28+
29+
# Fix linting issues
30+
yarn lint:fix
31+
```
32+
33+
## Architecture Overview
34+
35+
### Core Components
36+
37+
The shared package is organized into several key modules:
38+
39+
1. **Environment Configuration** (`src/env.ts`): Centralized environment variable management with type safety and validation
40+
2. **Promise Utilities** (`src/promises.ts`): Async helpers including debouncing, memoization, cancellation, and retry logic
41+
3. **Decorators** (`src/decorators.ts`): Method decorators for cross-cutting concerns like async debouncing
42+
4. **Type Helpers** (`src/type-helpers.ts`): Common TypeScript utility types used across packages
43+
5. **Time Utilities** (`src/time.ts`): Date/time manipulation and formatting functions
44+
6. **Process Utilities** (`src/process.ts`): Process management and platform detection

packages/cubejs-backend-shared/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
"env-var": "^6.3.0",
4444
"fs-extra": "^9.1.0",
4545
"https-proxy-agent": "^7.0.6",
46+
"lru-cache": "^11.1.0",
4647
"moment-range": "^4.0.2",
4748
"moment-timezone": "^0.5.47",
4849
"node-fetch": "^2.6.1",
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { asyncDebounceFn, AsyncDebounceOptions } from './promises';
2+
3+
export function AsyncDebounce(options: AsyncDebounceOptions = {}) {
4+
return (
5+
target: any,
6+
propertyKey: string | symbol,
7+
descriptor: PropertyDescriptor
8+
): PropertyDescriptor => {
9+
const originalMethod = descriptor.value;
10+
11+
return {
12+
configurable: true,
13+
get() {
14+
const debouncedMethod = asyncDebounceFn(originalMethod.bind(this), options);
15+
16+
Object.defineProperty(this, propertyKey, {
17+
value: debouncedMethod,
18+
configurable: true,
19+
writable: false
20+
});
21+
22+
return debouncedMethod;
23+
}
24+
};
25+
};
26+
}

packages/cubejs-backend-shared/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,4 @@ export * from './time';
2020
export * from './process';
2121
export * from './platform';
2222
export * from './FileRepository';
23+
export * from './decorators';

packages/cubejs-backend-shared/src/promises.ts

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/* eslint-disable arrow-body-style,no-restricted-syntax */
22
import crypto from 'crypto';
3+
import { LRUCache } from 'lru-cache';
34

45
import { Optional } from './type-helpers';
56

@@ -264,28 +265,35 @@ export const retryWithTimeout = <T>(
264265
timeout
265266
);
266267

267-
/**
268-
* Creates a debounced version of an asynchronous function.
269-
*/
270-
export const asyncDebounce = <Ret, Arguments>(
268+
export type AsyncDebounceOptions = {
269+
max?: number;
270+
ttl?: number;
271+
};
272+
273+
export const asyncDebounceFn = <Ret, Arguments>(
271274
fn: (...args: Arguments[]) => Promise<Ret>,
275+
options: AsyncDebounceOptions = {}
272276
) => {
273-
const cache = new Map<string, Promise<Ret>>();
277+
const { max = 100, ttl = 60 * 1000 } = options;
278+
279+
const cache = new LRUCache<string, Promise<Ret>>({
280+
max,
281+
ttl,
282+
});
274283

275284
return async (...args: Arguments[]) => {
276285
const key = crypto.createHash('md5')
277286
.update(args.map((v) => JSON.stringify(v)).join(','))
278287
.digest('hex');
279288

280-
if (cache.has(key)) {
281-
return <Promise<Ret>>cache.get(key);
289+
const existing = cache.get(key);
290+
if (existing) {
291+
return existing;
282292
}
283293

284294
try {
285295
const promise = fn(...args);
286-
287296
cache.set(key, promise);
288-
289297
return await promise;
290298
} finally {
291299
cache.delete(key);
@@ -309,7 +317,7 @@ export const asyncMemoize = <Ret, Arguments>(
309317
) => {
310318
const cache = new Map<string, MemoizeBucket<Ret>>();
311319

312-
const debouncedFn = asyncDebounce(fn);
320+
const debouncedFn = asyncDebounceFn(fn);
313321

314322
const call = async (...args: Arguments[]) => {
315323
const key = options.extractKey(...args);
@@ -372,7 +380,7 @@ export const asyncMemoizeBackground = <Ret, Arguments>(
372380
) => {
373381
const cache = new Map<string, BackgroundMemoizeBucket<Ret, Arguments>>();
374382

375-
const debouncedFn = asyncDebounce(fn);
383+
const debouncedFn = asyncDebounceFn(fn);
376384

377385
const refreshBucket = async (bucket: BackgroundMemoizeBucket<Ret, Arguments>) => {
378386
try {
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { AsyncDebounce } from '../src';
2+
3+
describe('AsyncDebounce decorator', () => {
4+
test('should preserve this context', async () => {
5+
class TestClass {
6+
private value = 'test-value';
7+
8+
@AsyncDebounce()
9+
public async getValue() {
10+
return this.value;
11+
}
12+
}
13+
14+
const instance = new TestClass();
15+
const result = await instance.getValue();
16+
17+
expect(result).toBe('test-value');
18+
});
19+
});

packages/cubejs-backend-shared/test/promises.test.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@ import {
55
retryWithTimeout,
66
withTimeout,
77
withTimeoutRace,
8-
asyncMemoize, asyncRetry, asyncDebounce, asyncMemoizeBackground,
8+
asyncMemoize,
9+
asyncRetry,
10+
asyncDebounceFn,
11+
asyncMemoizeBackground,
912
} from '../src';
1013

1114
test('createCancelablePromise', async () => {
@@ -453,7 +456,7 @@ describe('asyncDebounce', () => {
453456
test('multiple async calls to single', async () => {
454457
let called = 0;
455458

456-
const doOnce = asyncDebounce(
459+
const doOnce = asyncDebounceFn(
457460
async (arg1: string, arg2: string) => {
458461
called++;
459462

packages/cubejs-cubestore-driver/src/CubeStoreDriver.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
QueryOptions,
1515
ExternalDriverCompatibilities, TableStructure, TableColumnQueryResult,
1616
} from '@cubejs-backend/base-driver';
17-
import { getEnv } from '@cubejs-backend/shared';
17+
import { AsyncDebounce, getEnv } from '@cubejs-backend/shared';
1818
import { format as formatSql, escape } from 'sqlstring';
1919
import fetch from 'node-fetch';
2020

@@ -158,13 +158,15 @@ export class CubeStoreDriver extends BaseDriver implements DriverInterface {
158158
});
159159
}
160160

161+
@AsyncDebounce()
161162
public async getTablesQuery(schemaName) {
162163
return this.query(
163164
`SELECT table_name, build_range_end FROM information_schema.tables WHERE table_schema = ${this.param(0)}`,
164165
[schemaName]
165166
);
166167
}
167168

169+
@AsyncDebounce()
168170
public async getPrefixTablesQuery(schemaName, tablePrefixes) {
169171
const prefixWhere = tablePrefixes.map(_ => 'table_name LIKE CONCAT(?, \'%\')').join(' OR ');
170172
return this.query(

packages/cubejs-cubestore-driver/tsconfig.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,6 @@
99
"noImplicitAny": false,
1010
"outDir": "dist",
1111
"rootDir": ".",
12-
"baseUrl": ".",
12+
"baseUrl": "."
1313
}
1414
}

packages/cubejs-query-orchestrator/src/orchestrator/PreAggregationLoadCache.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,9 +97,11 @@ export class PreAggregationLoadCache {
9797
const client = preAggregation.external ?
9898
await this.externalDriverFactory() :
9999
await this.driverFactory();
100+
100101
if (this.tablePrefixes && client.getPrefixTablesQuery && this.preAggregations.options.skipExternalCacheAndQueue) {
101102
return client.getPrefixTablesQuery(preAggregation.preAggregationsSchema, this.tablePrefixes);
102103
}
104+
103105
return client.getTablesQuery(preAggregation.preAggregationsSchema);
104106
}
105107

0 commit comments

Comments
 (0)