diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml
index 790090c8c7483..6d936027e1faa 100644
--- a/.github/workflows/claude-code-review.yml
+++ b/.github/workflows/claude-code-review.yml
@@ -103,6 +103,19 @@ jobs:
- Verify README updates for new features
- Check API documentation accuracy
+ 6. **Comments**
+ - An explanatory comment is **3 lines max**. Flag a longer one and ask for the
+ load-bearing sentence, unless the reason genuinely cannot be stated shorter
+ - A comment earns its place only when its absence would let a later edit
+ reintroduce a bug. Flag one a reader would lose nothing by deleting: a
+ restatement of the code under it, a banner over self-describing code,
+ narration of the change or of the review round that produced it, or a JSDoc
+ block whose tags only re-spell already-typed names
+ - Prefer making the code carry the meaning — a named constant, a named
+ intermediate value, an extracted function whose name states the intent
+ - Never raise this to ask for a comment to be **added**, and skip generated
+ files and comments another rule or tool requires
+
Provide detailed feedback using inline comments for specific issues.
Use top-level comments for general observations or praise.
diff --git a/docs-mintlify/reference/configuration/config.mdx b/docs-mintlify/reference/configuration/config.mdx
index db729738c9849..b9b5ec079b755 100644
--- a/docs-mintlify/reference/configuration/config.mdx
+++ b/docs-mintlify/reference/configuration/config.mdx
@@ -216,6 +216,7 @@ module.exports = {
Maximum number of compiled data models to persist with in-memory cache. Defaults
to 250, but optimum value will depend on deployed environment. When the max is
reached, will start dropping the least recently used data models from the cache.
+Must be a positive integer; the cache can not be disabled.
@@ -233,6 +234,11 @@ module.exports = {
+This configuration option can also be set using the [`CUBEJS_COMPILER_CACHE_SIZE`](/reference/configuration/environment-variables#cubejs_compiler_cache_size)
+environment variable, and takes precedence over it. Note that the environment
+variable also sizes the SQL API's compiler cache, which this option does not
+affect.
+
### `max_compiler_cache_keep_alive`
Maximum length of time in ms to keep compiled data models in memory. Default
diff --git a/docs-mintlify/reference/configuration/environment-variables.mdx b/docs-mintlify/reference/configuration/environment-variables.mdx
index a6146ae6efe5a..52b0c77abad68 100644
--- a/docs-mintlify/reference/configuration/environment-variables.mdx
+++ b/docs-mintlify/reference/configuration/environment-variables.mdx
@@ -131,6 +131,33 @@ The cache and queue driver to use for the Cube deployment.
It can be also set using the [`cache_and_queue_driver` configuration
option](/reference/configuration/config#cache_and_queue_driver).
+## `CUBEJS_COMPILER_CACHE_SIZE`
+
+The maximum number of compiled data models to keep in the in-memory compiler
+cache. When the maximum is reached, the least recently used data models are
+dropped from the cache. The optimum value depends on the deployed environment.
+
+This variable sizes two separate caches: the data model compiler cache and the
+[SQL API][ref-sql-api]'s own compiler cache. Setting it applies the same maximum
+to both. When it is unset, each falls back to its own default, so the effective
+default differs between them.
+
+| Cache | Possible Values | Default in Development | Default in Production |
+| ------------------------- | ----------------- | ---------------------- | --------------------- |
+| Data model compiler cache | A positive number | `250` | `250` |
+| SQL API compiler cache | A positive number | `100` | `100` |
+
+
+Neither cache can be disabled, so `0` is rejected rather than accepted and
+ignored. The same applies to the configuration option.
+
+
+The data model compiler cache can be also sized using the [`compiler_cache_size`
+configuration option](/reference/configuration/config#compiler_cache_size),
+which takes precedence over this environment variable. The SQL API compiler
+cache reads this environment variable only, so the configuration option does not
+affect it.
+
## `CUBEJS_CONCURRENCY`
The number of concurrent connections each query queue has to the database.
diff --git a/packages/cubejs-backend-native/src/node_export.rs b/packages/cubejs-backend-native/src/node_export.rs
index 4dbf02348df21..4edd373f9157e 100644
--- a/packages/cubejs-backend-native/src/node_export.rs
+++ b/packages/cubejs-backend-native/src/node_export.rs
@@ -628,7 +628,13 @@ async fn handle_sql_query(
// the promise it was awaiting is abandoned rather than
// cancelled, and only reports if it later rejects - which is
// why that one does log.
- if !err.message.eq_ignore_ascii_case("continue wait") {
+ // Matched on the error's cause, falling back to the message split
+ // into its `:`- and newline-delimited parts: a continue wait that
+ // came back through a `RepartitionExec` has been flattened to a
+ // string and reads `Execution error: Continue wait`, so the
+ // equality check this replaces let it through and reported the
+ // queue signal as a failed request in query history.
+ if !err.is_continue_wait() {
session_clone
.session_manager
.server
diff --git a/packages/cubejs-backend-native/src/transport.rs b/packages/cubejs-backend-native/src/transport.rs
index e497d6a62c738..d073ce6c54ec5 100644
--- a/packages/cubejs-backend-native/src/transport.rs
+++ b/packages/cubejs-backend-native/src/transport.rs
@@ -463,7 +463,7 @@ impl TransportService for NodeBridgeTransport {
.await;
if let Err(e) = &result {
- if e.message.to_lowercase().contains("continue wait") {
+ if e.is_continue_wait() {
if throw_continue_wait {
return Err(CubeError::continue_wait());
}
@@ -609,7 +609,7 @@ impl TransportService for NodeBridgeTransport {
.await;
if let Err(e) = &res {
- if e.message.to_lowercase().contains("continue wait") {
+ if e.is_continue_wait() {
if throw_continue_wait {
return Err(CubeError::continue_wait());
}
diff --git a/packages/cubejs-backend-shared/src/env.ts b/packages/cubejs-backend-shared/src/env.ts
index abf8a55c96416..6a8aaf4c42aff 100644
--- a/packages/cubejs-backend-shared/src/env.ts
+++ b/packages/cubejs-backend-shared/src/env.ts
@@ -312,6 +312,27 @@ const variables: Record any> = {
scheduledRefreshBatchSize: () => get('CUBEJS_SCHEDULED_REFRESH_BATCH_SIZE')
.default('1')
.asInt(),
+ /**
+ * Maximum number of compiled data models to keep in the in-memory compiler cache.
+ */
+ compilerCacheSize: () => {
+ const size = get('CUBEJS_COMPILER_CACHE_SIZE')
+ .default('250')
+ .asIntPositive();
+
+ // env-var's asIntPositive() lets 0 through, but every consumer of this option
+ // falls back to the default on a falsy value, so 0 would quietly mean 250
+ // instead of doing what it looks like it does.
+ if (size === 0) {
+ throw new InvalidConfiguration(
+ 'CUBEJS_COMPILER_CACHE_SIZE',
+ size,
+ 'Must be a positive integer. The compiler cache can not be disabled.',
+ );
+ }
+
+ return size;
+ },
nativeSqlPlanner: () => {
const explicitlySet = process.env.CUBEJS_TESSERACT_SQL_PLANNER !== undefined;
const enabled = get('CUBEJS_TESSERACT_SQL_PLANNER').default('true').asBool();
diff --git a/packages/cubejs-backend-shared/test/env.test.ts b/packages/cubejs-backend-shared/test/env.test.ts
index bd4fcb8e3b579..5a5d1effaebfe 100644
--- a/packages/cubejs-backend-shared/test/env.test.ts
+++ b/packages/cubejs-backend-shared/test/env.test.ts
@@ -285,3 +285,36 @@ describe('getEnv(defaultTimezone / scheduledRefreshTimezones)', () => {
);
});
});
+
+describe('getEnv(compilerCacheSize)', () => {
+ afterEach(() => {
+ delete process.env.CUBEJS_COMPILER_CACHE_SIZE;
+ });
+
+ test('defaults to 250', () => {
+ expect(getEnv('compilerCacheSize')).toBe(250);
+ });
+
+ test('reads CUBEJS_COMPILER_CACHE_SIZE', () => {
+ process.env.CUBEJS_COMPILER_CACHE_SIZE = '1000';
+ expect(getEnv('compilerCacheSize')).toBe(1000);
+ });
+
+ test('throws on zero, so it is never silently coerced to the default', () => {
+ process.env.CUBEJS_COMPILER_CACHE_SIZE = '0';
+ expect(() => getEnv('compilerCacheSize')).toThrowError(
+ 'Value "0" is not valid for CUBEJS_COMPILER_CACHE_SIZE. Must be a positive integer. The compiler cache can not be disabled.'
+ );
+ });
+
+ test.each([
+ '-1',
+ 'abc',
+ '1.5',
+ ])('throws on the negative or non-integer value %j', (value) => {
+ process.env.CUBEJS_COMPILER_CACHE_SIZE = value;
+ expect(() => getEnv('compilerCacheSize')).toThrowError(
+ /CUBEJS_COMPILER_CACHE_SIZE/
+ );
+ });
+});
diff --git a/packages/cubejs-server-core/src/core/OptsHandler.ts b/packages/cubejs-server-core/src/core/OptsHandler.ts
index a8cdb25995517..b6f3e25b7046c 100644
--- a/packages/cubejs-server-core/src/core/OptsHandler.ts
+++ b/packages/cubejs-server-core/src/core/OptsHandler.ts
@@ -407,6 +407,7 @@ export class OptsHandler {
dashboardAppPort: 3000,
scheduledRefreshConcurrency: getEnv('scheduledRefreshQueriesPerAppId'),
scheduledRefreshBatchSize: getEnv('scheduledRefreshBatchSize'),
+ compilerCacheSize: getEnv('compilerCacheSize'),
preAggregationsSchema:
getEnv('preAggregationsSchema') ||
(this.isDevMode()
diff --git a/packages/cubejs-server-core/src/core/optionsValidate.ts b/packages/cubejs-server-core/src/core/optionsValidate.ts
index 8b398d1ee5361..2824253895f29 100644
--- a/packages/cubejs-server-core/src/core/optionsValidate.ts
+++ b/packages/cubejs-server-core/src/core/optionsValidate.ts
@@ -113,7 +113,7 @@ const schemaOptions = Joi.object().keys({
scheduledRefreshConcurrency: Joi.number().min(1).integer(),
scheduledRefreshBatchSize: Joi.number().min(1).integer(),
// Compiler cache
- compilerCacheSize: Joi.number().min(0).integer(),
+ compilerCacheSize: Joi.number().min(1).integer(),
updateCompilerCacheKeepAlive: Joi.boolean(),
maxCompilerCacheKeepAlive: Joi.number().min(0).integer(),
telemetry: Joi.boolean(),
diff --git a/packages/cubejs-server-core/test/unit/OptsHandler.test.ts b/packages/cubejs-server-core/test/unit/OptsHandler.test.ts
index f3fdb456e937b..46e039071f808 100644
--- a/packages/cubejs-server-core/test/unit/OptsHandler.test.ts
+++ b/packages/cubejs-server-core/test/unit/OptsHandler.test.ts
@@ -1252,3 +1252,45 @@ describe('OptsHandler timezone env validation', () => {
expect(core.options.scheduledRefreshTimeZones).toEqual(['UTC', 'America/New_York']);
});
});
+
+describe('OptsHandler compilerCacheSize', () => {
+ beforeEach(() => {
+ process.env.CUBEJS_DB_TYPE = 'postgres';
+ });
+
+ afterEach(() => {
+ delete process.env.CUBEJS_COMPILER_CACHE_SIZE;
+ });
+
+ test('must default to 250 when neither the option nor the env variable is set', () => {
+ const core = new CubejsServerCoreExposed(conf);
+
+ expect(core.options.compilerCacheSize).toBe(250);
+ });
+
+ test('must take the value from CUBEJS_COMPILER_CACHE_SIZE', () => {
+ process.env.CUBEJS_COMPILER_CACHE_SIZE = '42';
+
+ const core = new CubejsServerCoreExposed(conf);
+
+ expect(core.options.compilerCacheSize).toBe(42);
+ });
+
+ test('must prefer CreateOptions.compilerCacheSize over the env variable', () => {
+ process.env.CUBEJS_COMPILER_CACHE_SIZE = '42';
+
+ const core = new CubejsServerCoreExposed({
+ ...conf,
+ compilerCacheSize: 7,
+ });
+
+ expect(core.options.compilerCacheSize).toBe(7);
+ });
+
+ test('must throw at construction if CUBEJS_COMPILER_CACHE_SIZE is not a valid size', () => {
+ process.env.CUBEJS_COMPILER_CACHE_SIZE = 'abc';
+
+ expect(() => new CubejsServerCoreExposed(conf))
+ .toThrow(/CUBEJS_COMPILER_CACHE_SIZE/);
+ });
+});
diff --git a/packages/cubejs-server-core/test/unit/index.test.ts b/packages/cubejs-server-core/test/unit/index.test.ts
index 4bdc5d8be3460..af3d728607d2d 100644
--- a/packages/cubejs-server-core/test/unit/index.test.ts
+++ b/packages/cubejs-server-core/test/unit/index.test.ts
@@ -122,7 +122,20 @@ describe('index.test', () => {
};
expect(() => new CubejsServerCore(options))
- .toThrowError(/"compilerCacheSize" must be greater than or equal to 0/);
+ .toThrowError(/"compilerCacheSize" must be greater than or equal to 1/);
+ });
+
+ // 0 used to validate and then get silently replaced by the default through the
+ // `|| 250` guards, which reads like a way to disable the compiler cache but isn't.
+ test('Should throw error, compilerCacheSize of 0', () => {
+ const options = {
+ externalDbType: 'mysql',
+ devServer: true,
+ compilerCacheSize: 0,
+ };
+
+ expect(() => new CubejsServerCore(options))
+ .toThrowError(/"compilerCacheSize" must be greater than or equal to 1/);
});
test('Should create instance of CubejsServerCore, orchestratorOptions as func', () => {
diff --git a/rust/cubesql/cubesql/src/compile/engine/df/scan.rs b/rust/cubesql/cubesql/src/compile/engine/df/scan.rs
index 3f2dfd434d5d6..4078df64ef6b2 100644
--- a/rust/cubesql/cubesql/src/compile/engine/df/scan.rs
+++ b/rust/cubesql/cubesql/src/compile/engine/df/scan.rs
@@ -692,7 +692,15 @@ impl CubeScanMemoryStream {
} else {
err.message
};
- if !err.message.eq_ignore_ascii_case("continue wait") {
+ // A continue wait also gets the `ContinueWait` cause here, the way
+ // `load_data` sets it below, so consumers can match on the cause
+ // rather than on the message. The other branch still only
+ // prefixes the message: unlike `load_data` this path leaves the
+ // incoming cause alone, and re-classifying it would change which
+ // Postgres error code a streaming failure reports.
+ if err.is_continue_wait() {
+ err.cause = CubeErrorCauseType::ContinueWait;
+ } else {
err.message = format!("Database Execution Error: {}", err.message);
}
Some(Err(ArrowError::ExternalError(Box::new(err))))
@@ -895,7 +903,7 @@ async fn load_data(
err.message
};
- if err.message.eq_ignore_ascii_case("continue wait") {
+ if err.is_continue_wait() {
err.cause = CubeErrorCauseType::ContinueWait;
} else {
err.cause = CubeErrorCauseType::DatabaseExecution(err.cause.meta().cloned());
diff --git a/rust/cubesql/cubesql/src/error.rs b/rust/cubesql/cubesql/src/error.rs
index 63f4518a88c70..2dc050d81b936 100644
--- a/rust/cubesql/cubesql/src/error.rs
+++ b/rust/cubesql/cubesql/src/error.rs
@@ -12,6 +12,11 @@ use std::{
};
use tokio::{sync::mpsc::error::SendError, time::error::Elapsed};
+/// Canonical spelling of the queue's "not finished yet" signal. It is the wire
+/// value the api-gateway sends and the event name query history reads, so it is
+/// also what a `ContinueWait` error normalizes back to.
+pub const CONTINUE_WAIT_MESSAGE: &str = "Continue wait";
+
#[derive(thiserror::Error, Debug)]
pub struct CubeError {
pub message: String,
@@ -127,7 +132,7 @@ impl CubeError {
pub fn continue_wait() -> Self {
Self {
- message: "Continue wait".to_string(),
+ message: CONTINUE_WAIT_MESSAGE.to_string(),
cause: CubeErrorCauseType::ContinueWait,
backtrace: None,
}
@@ -149,6 +154,101 @@ impl CubeError {
}
impl CubeError {
+ /// Whether this error is the queue's `Continue wait` signal rather than a
+ /// failure. Nothing user-visible may be reported for it - see
+ /// `handle_sql_query` in `cubejs-backend-native`, which must not log a
+ /// `Cube SQL Error` load event for one.
+ ///
+ /// The cause is the reliable half; the message check is the fallback for an
+ /// error that lost its cause on the way here. That happens: DataFusion's
+ /// `RepartitionExec` has to hand one error to every output partition and a
+ /// boxed error is not `Clone`, so `wait_for_task` flattens it to its
+ /// `Display` string and re-wraps it as `DataFusionError::Execution`. The
+ /// typed `CubeError` is gone at that point and the message has grown a
+ /// prefix (`Execution error: Continue wait`), which is why the message check
+ /// is not an equality one - and why every prefix would otherwise compound
+ /// through the next wrapping layer.
+ pub fn is_continue_wait(&self) -> bool {
+ matches!(self.cause, CubeErrorCauseType::ContinueWait)
+ || Self::is_continue_wait_message(&self.message)
+ }
+
+ /// `is_continue_wait` for a bare message, when no cause is available - a
+ /// message carried over the JS bridge, or one already flattened to a string.
+ ///
+ /// These messages have a structure, and the check follows it rather than
+ /// scanning for the phrase anywhere in the text. Each wrapping layer prepends
+ /// its own label and a colon (`Execution error: `, `Database Execution Error: `,
+ /// and these compound), and a message that arrived over the JS bridge can have
+ /// a stack appended, which puts the message on the first line and the frames
+ /// after it (`errorString` in `js/index.ts` falls back to `err.stack`). So the
+ /// phrase always lands as a whole `:`- or newline-delimited part, however many
+ /// layers wrapped it - and splitting on both separators matches every one of
+ /// those shapes without enumerating them.
+ ///
+ /// Matching parts rather than a substring is what keeps a real failure intact.
+ /// A false positive is expensive here, more so than at the original site where
+ /// it only meant one more retry. Two consumers are new to this predicate.
+ /// `normalize_continue_wait` runs on every DataFusion and Arrow conversion and
+ /// *replaces* the message, so the original text is gone before anything
+ /// downstream sees it. And `load_data` (`scan.rs`) held an equality check
+ /// until this change: a real database error misread here is minted with the
+ /// `ContinueWait` cause locally, so - unlike a genuine continue wait, which
+ /// the transport retries and never surfaces on the Postgres path - nothing
+ /// re-classifies it, and `sql/postgres/error.rs` answers the client
+ /// `SqlStatementNotYetComplete` (`03000`) instead of their failure. These
+ /// messages interpolate user-controlled SQL, so the phrase turns up inside
+ /// one as an identifier or a literal (`No field named 'continue wait'`,
+ /// `status = Utf8("continue wait")`); as part of a larger part it is not the
+ /// signal, and a substring test could not tell the two apart.
+ ///
+ /// The trade runs the other way for a wrapper that appends instead of
+ /// prepending: `Continue wait.` or `Continue wait (retrying)` is one part and
+ /// does not match. Exactly one such wrapper exists, and it is in this file -
+ /// the `Rewrite` arm of `Display` renders
+ /// `Rewrite Error: {}. Please check logs for additional information`, where
+ /// every other arm is `