Skip to content
Closed
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 @@ -225,12 +225,12 @@ describe('transformMetaExtended helpers', () => {
{
name: 'PlaygroundUsers',
relationship: 'belongsTo',
sql: () => `{CUBE}.id = {PlaygroundUsers.anonymous}`,
sql: () => '{CUBE}.id = {PlaygroundUsers.anonymous}',
},
{
name: 'IpEnrich',
relationship: 'belongsTo',
sql: () => `{CUBE.email} = {IpEnrich.email}`,
sql: () => '{CUBE.email} = {IpEnrich.email}',
},
];

Expand Down
2 changes: 1 addition & 1 deletion packages/cubejs-cubestore-driver/src/CubeStoreDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ export class CubeStoreDriver extends BaseDriver implements DriverInterface {
withEntries.push(`delimiter = '${options.delimiter}'`);
}
if (options.disableQuoting) {
withEntries.push(`disable_quoting = true`);
withEntries.push('disable_quoting = true');
}
if (options.buildRangeEnd) {
withEntries.push(`build_range_end = '${options.buildRangeEnd}'`);
Expand Down
2 changes: 1 addition & 1 deletion packages/cubejs-schema-compiler/src/adapter/BaseQuery.js
Original file line number Diff line number Diff line change
Expand Up @@ -2847,7 +2847,7 @@ export class BaseQuery {
R.map(s => (
(cache || this.compilerCache).cache(
['collectFrom'].concat(methodCacheKey).concat(
s.path() ? [s.path().join('.')] : [s.cube().name, s.expression?.toString() || s.expressionName || s.definition().sql]
s.path() ? [s.path().join('.')] : [s.cube().name, s.expression?.toString() || s.expressionName || s.definition().sql.toString()]
),
() => fn(() => this.traverseSymbol(s))
)
Expand Down
70 changes: 55 additions & 15 deletions packages/cubejs-schema-compiler/src/adapter/QueryCache.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,66 @@
export class QueryCache {
private readonly storage: {};
export interface QueryCacheInterface {
cache(key: any[], fn: Function): any;
}

/**
* Uses string concatenation (+=) instead of JSON.stringify for performance:
* - V8 optimizes += with ConsString (tree of string segments)
* - JSON.stringify builds a new flat string from scratch each time
* - For strings/numbers (common case), ~3x faster than JSON.stringify
* - Only falls back to JSON.stringify for objects/arrays
*/
export function fastComputeCacheKey(key: unknown): string {
if (Array.isArray(key)) {
let result = '';

for (let i = 0; i < key.length; i++) {
if (i > 0) {
result += ':';
}

if (typeof key[i] === 'string' || typeof key[i] === 'number') {
result += key[i];
} else if (key[i] === undefined) {
result += 'undefined';
} else if (key[i] === null) {
result += 'null';
} else if (typeof key[i] === 'function') {
throw new TypeError(`Function is not allowed as subkey, passed as ${key[i]}`);
} else {
result += JSON.stringify(key[i]);
}
}

return result;
}

if (typeof key === 'string') {
return key;
}

return JSON.stringify(key);
}

export class QueryCache implements QueryCacheInterface {
private readonly storage: Map<string, any>;

public constructor() {
this.storage = {};
this.storage = new Map();
}

/**
* @returns Returns the result of executing a function (Either call a function or take a value from the cache)
*/
public cache(key: any[], fn: Function): any {
let keyHolder = this.storage;
Copy link
Copy Markdown
Member Author

@ovr ovr Dec 15, 2025

Choose a reason for hiding this comment

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

  // For key ['a', 'b', 'c']:
  // storage['a'] = {}
  // storage['a']['b'] = {}
  // storage['a']['b']['c'] = fn()

I don't know the reason (I assume, string interning) for writing such a nested cache, but I am certain that it's better to rewrite it. I've done fastComputeCacheKey and tested that it's using ConsString

const { length } = key;
for (let i = 0; i < length - 1; i++) {
if (!keyHolder[key[i]]) {
keyHolder[key[i]] = {};
}
keyHolder = keyHolder[key[i]];
}
const lastKey = key[length - 1];
if (!keyHolder[lastKey]) {
keyHolder[lastKey] = fn();
const keyString = fastComputeCacheKey(key);

if (this.storage.has(keyString)) {
return this.storage.get(keyString);
}
return keyHolder[lastKey];

const result = fn();
this.storage.set(keyString, result);

return result;
}
}
31 changes: 25 additions & 6 deletions packages/cubejs-schema-compiler/src/compiler/CompilerCache.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { LRUCache } from 'lru-cache';
import { QueryCache } from '../adapter/QueryCache';
import { QueryCacheInterface, QueryCache, fastComputeCacheKey } from '../adapter/QueryCache';

export class CompilerCache extends QueryCache {
export class CompilerCache implements QueryCacheInterface {
protected readonly queryCache: LRUCache<string, QueryCache>;

protected readonly rbacCache: LRUCache<string, any>;

public constructor({ maxQueryCacheSize, maxQueryCacheAge }) {
super();
protected readonly cacheStorage: LRUCache<string, any>;

public constructor({ maxQueryCacheSize, maxQueryCacheAge }) {
this.queryCache = new LRUCache({
max: maxQueryCacheSize || 10000,
ttl: (maxQueryCacheAge * 1000) || 1000 * 60 * 10,
Expand All @@ -19,14 +19,33 @@ export class CompilerCache extends QueryCache {
max: 10000,
ttl: 1000 * 60 * 5, // 5 minutes
});

this.cacheStorage = new LRUCache({
max: maxQueryCacheSize || 10000,
ttl: (maxQueryCacheAge * 1000) || 1000 * 60 * 10,
updateAgeOnGet: true
});
}

public cache(key: any[], fn: Function): any {
const keyString = fastComputeCacheKey(key);

if (this.cacheStorage.has(keyString)) {
return this.cacheStorage.get(keyString);
}

const result = fn();
this.cacheStorage.set(keyString, result);

return result;
}

public getRbacCacheInstance(): LRUCache<string, any> {
return this.rbacCache;
}

public getQueryCache(key: unknown): QueryCache {
const keyString = JSON.stringify(key);
public getQueryCache(key: unknown): QueryCacheInterface {
const keyString = fastComputeCacheKey(key);

const exist = this.queryCache.get(keyString);
if (exist) {
Expand Down
56 changes: 56 additions & 0 deletions packages/cubejs-schema-compiler/test/unit/query-cache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { fastComputeCacheKey, QueryCache } from '../../src/adapter/QueryCache';

describe('QueryCache', () => {
let cache: QueryCache;

beforeEach(() => {
cache = new QueryCache();
});

it('caches function result', () => {
let callCount = 0;
const fn = () => {
callCount++;
return 'result';
};

const result1 = cache.cache(['key1'], fn);
const result2 = cache.cache(['key1'], fn);

expect(result1).toBe('result');
expect(result2).toBe('result');
expect(callCount).toBe(1);
});

it('differentiates between different keys', () => {
let callCount = 0;
const fn = () => {
callCount++;
return `result-${callCount}`;
};

const result1 = cache.cache(['key1'], fn);
const result2 = cache.cache(['key2'], fn);

expect(result1).toBe('result-1');
expect(result2).toBe('result-2');
expect(callCount).toBe(2);
});

it('fastComputeCacheKey', () => {
expect(fastComputeCacheKey([])).toBe('');
expect(fastComputeCacheKey(['hello'])).toBe('hello');
expect(fastComputeCacheKey(['hello', 'world'])).toBe('hello:world');
expect(fastComputeCacheKey(['key', 123, 'value', 456])).toBe('key:123:value:456');
expect(fastComputeCacheKey([{ a: 1 }])).toBe('{"a":1}');
expect(fastComputeCacheKey([
'string',
42,
null,
undefined,
{ obj: 'value' },
[1, 2],
true
])).toBe('string:42:null:u:{"obj":"value"}:[1,2]:true');
});
});
Loading