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 `