From f98d60f935404d394ad3da3b3163889675c7930c Mon Sep 17 00:00:00 2001 From: Pavel Tiunov Date: Tue, 25 Aug 2026 14:14:48 -0700 Subject: [PATCH 1/3] docs: restore the maintenance window page on the Mintlify docs site (#11650) --- .../admin/deployment/maintenance-window.mdx | 153 ++++++++++++++++++ docs/redirects-new-docs.json | 2 +- 2 files changed, 154 insertions(+), 1 deletion(-) create mode 100644 docs-mintlify/admin/deployment/maintenance-window.mdx diff --git a/docs-mintlify/admin/deployment/maintenance-window.mdx b/docs-mintlify/admin/deployment/maintenance-window.mdx new file mode 100644 index 0000000000000..f91bc90de3976 --- /dev/null +++ b/docs-mintlify/admin/deployment/maintenance-window.mdx @@ -0,0 +1,153 @@ +--- +title: Maintenance window +description: Apply platform updates during a scheduled weekly time slot instead of immediately as they are released. +hidden: true +--- + + + +Available on the [Enterprise plan](https://cube.dev/pricing) with the +[Single-tenant infrastructure](/admin/deployment/infrastructure#dedicated-infrastructure) +add-on. + + + +Cube can apply platform updates to your infrastructure during a scheduled weekly +time slot instead of immediately as they are released. This gives you control +over **when** updates happen, reducing the risk of unexpected changes during +peak hours. + +## How it works + +Cube automatically takes daily snapshots of all current service versions. Each +snapshot captures a consistent, known-good combination of service versions at +that point in time. + +A snapshot must be at least **24 hours old** before it becomes eligible for +deployment. This ensures that only versions confirmed stable in production are +promoted to tenants with a maintenance window enabled. + +During the configured maintenance window, the system automatically advances your +deployment to the latest eligible snapshot. Outside of the window, no updates +are applied. + + + +You do not choose specific versions. The system manages version progression +automatically — the maintenance window only controls **when** the update +happens. + + + +## What gets updated + +The maintenance window applies to all services for your deployment, including: + +- **Control-plane services** — the infrastructure serving the Cube UI, API, and + orchestration. +- **Data-plane services** — the infrastructure responsible for query execution, + builds, and worker lifecycle in single-tenant regions. + +Both control-plane and data-plane services are updated to the same snapshot +during the window. + +## Configuration + +Go to **Admin → Settings → Maintenance Window**: + + + Maintenance window settings + + +1. Toggle **Enable Scheduled Maintenance Window** to on. +2. Select the **Day of week** (e.g., Sunday). +3. Select the **Time (UTC)** — the hour when the update window opens (e.g., + 02:00 UTC). + +Settings are saved automatically. + + + +The maintenance window spans **one hour** starting from the selected time. For +example, selecting 02:00 means updates may be applied between 02:00 and 02:59 +UTC on the selected day. + + + +When you enable the maintenance window for the first time, the day defaults to +**Sunday** and the time defaults to **02:00 UTC**. Your deployment is +immediately assigned the latest eligible snapshot so that it starts from a +known, stable version. + +## Active version + +When the maintenance window is enabled and your deployment has been updated at +least once, an **Active Version** section appears below the schedule settings. + +The **Active Version** dropdown shows all snapshots that were active for your +deployment within the last 7 days. The currently active snapshot is pre-selected +and marked with `(current)`. To switch to a different version, select it from +the dropdown. The change takes effect on the next reconciliation cycle. + + + +Only snapshots from the last 7 days are available. If you need to revert to an +older version, contact [support](/admin/account-billing/support). + + + +## Upgrade now + +If a critical fix has been released and you don't want to wait for the next +scheduled maintenance window, you can upgrade to the current production versions +immediately. + +When the maintenance window is enabled, an **Upgrade Now** section appears at +the bottom of the settings page. Click **Upgrade Now** to create a snapshot from +the service versions currently running in production and switch your deployment +to it right away. The button is disabled while the upgrade is in progress. Once +complete, the new version will appear in the **Active Version** dropdown. + + + +Unlike the scheduled maintenance window, **Upgrade Now** does not require a +24-hour eligibility buffer — it always uses the versions running in production +at the time you click the button. If your deployment is already running those +versions, the button will return an error. + + + +## FAQ + +### What happens if I disable the maintenance window? + +Your deployment returns to the default behavior — receiving updates immediately +as they are released. + +### What if no new snapshot is available during my maintenance window? + +Nothing happens. The system only advances your deployment if a newer eligible +snapshot exists. If you are already on the latest eligible snapshot, the +maintenance window is a no-op. + +### How far behind the latest release will my services be? + +At most, your services will be behind by the interval between your maintenance +windows (typically one week) plus the 24-hour eligibility buffer. For example, +with a Sunday 02:00 UTC window, your services could be up to ~8 days behind the +latest release in the worst case. + +### Can I roll back to a previous version? + +Yes. When the maintenance window is enabled, the settings page shows an **Active +Version** dropdown listing all snapshots from the last 7 days. Select any of +them to switch back. See [Active version](#active-version) above for details. + +### Can I trigger an immediate upgrade without waiting for the window? + +Yes. Use the **Upgrade Now** button on the settings page to upgrade to the +current production versions immediately, without waiting for the 24-hour +eligibility buffer. See [Upgrade now](#upgrade-now) above for details. diff --git a/docs/redirects-new-docs.json b/docs/redirects-new-docs.json index 09c8ad01a38eb..2d41bdb23b3fa 100644 --- a/docs/redirects-new-docs.json +++ b/docs/redirects-new-docs.json @@ -2621,7 +2621,7 @@ }, { "source": "/product/administration/workspace/maintenance-window", - "destination": "https://docs.cube.dev/admin", + "destination": "https://docs.cube.dev/admin/deployment/maintenance-window", "permanent": true }, { From e803c6cb58610c5da1a0710664a99d3047a6b3de Mon Sep 17 00:00:00 2001 From: Pavel Tiunov Date: Tue, 25 Aug 2026 16:26:00 -0700 Subject: [PATCH 2/3] fix(backend-native): treat a client disconnect on /v1/cubesql as a graceful end (#11649) --- .../cubejs-backend-native/src/node_export.rs | 110 ++++++++++++++++- .../cubejs-backend-native/test/sql.test.ts | 112 ++++++++++++++++++ 2 files changed, 218 insertions(+), 4 deletions(-) diff --git a/packages/cubejs-backend-native/src/node_export.rs b/packages/cubejs-backend-native/src/node_export.rs index f5f6d4af34e03..1ea0a36f0bc11 100644 --- a/packages/cubejs-backend-native/src/node_export.rs +++ b/packages/cubejs-backend-native/src/node_export.rs @@ -3,6 +3,7 @@ use cubesql::compile::{convert_statement_to_cube_query, get_df_batches}; use cubesql::config::processing_loop::ShutdownMode; use cubesql::sql::dataframe::arrow_to_column_type; use cubesql::sql::ColumnType; +use cubesql::sql::Session; use cubesql::transport::{SpanId, TransportService}; use futures::StreamExt; @@ -233,6 +234,76 @@ async fn write_jsonl_message( .await } +/// How a `/v1/cubesql` request ended when nothing actually failed. +enum SqlQueryOutcome { + /// The whole result set was streamed to the client. + Completed, + /// The client closed the response stream before the result set was fully + /// written, so this attempt delivered nothing. It is reported as a + /// `Continue wait`: the event query history already reads for an attempt + /// that produced no result. `Load Request` is logged when the attempt + /// starts, so without this the attempt usually has no end at all and the + /// time it spent cannot be attributed - dropping the future abandons the JS + /// load rather than cancelling it, and an abandoned load that resolves, or + /// never settles, reports nothing. + /// + /// The exception is an abandoned load that goes on to *reject*: it still + /// runs in its own promise (`sql-server.ts`), and the gateway routes the + /// rejection into `handleError`, which logs its own `Continue wait`. A + /// disconnect racing the continue-wait boundary therefore double-logs - + /// CUB-4099's disconnects cluster around 61s against a ~60s boundary, so + /// this is not rare. Two rows saying the same thing beat none, so the call + /// is not gated on it, but that is where a duplicate comes from. + /// + /// Whether the client comes back is not something this end of the stream + /// can know, and the reporting deliberately does not depend on it: under + /// `throwContinueWait` it polls with the same request id and the queued + /// query stays alive while it keeps doing so, and without the flag this is + /// the end of the road. Either way the attempt is over having produced + /// nothing, which is all the event claims. What it must not claim is that + /// the query failed. + ClientDisconnected, +} + +/// Records a `Continue wait` load event for a `/v1/cubesql` attempt that ended +/// without delivering a result. +/// +/// `Load Request` is logged when the attempt starts, so an attempt that reports +/// nothing back leaves no way to attribute the time it spent. `Continue wait` +/// is the event the query history consumer already reads for "this attempt +/// produced no result", which is what happened. It does not by itself close the +/// request - a polling client opens further attempts under the same request id, +/// and CUB-4099 has one that ran 44 minutes over six of them - but it does give +/// this attempt an end. +async fn log_continue_wait( + session: &Arc, + span_id: &Option>, + sql_query: &str, +) -> Result<(), CubeError> { + let Some(auth_context) = session.state.auth_context() else { + return Ok(()); + }; + + session + .session_manager + .server + .transport + .log_load_state( + span_id.clone(), + auth_context, + session.state.get_load_request_meta("sql"), + "Continue wait".to_string(), + serde_json::json!({ + "query": { + "sql": sql_query, + }, + "apiType": "sql", + "duration": span_id.as_ref().map(|span_id| span_id.duration()), + }), + ) + .await +} + async fn handle_sql_query( services: Arc, native_auth_ctx: Arc, @@ -243,7 +314,7 @@ async fn handle_sql_query( timezone: Option, throw_continue_wait: bool, request_id: Option, -) -> Result<(), CubeError> { +) -> Result { let span_id = Some(Arc::new(SpanId::new( request_id.unwrap_or_else(|| Uuid::new_v4().to_string()), serde_json::json!({ "sql": sql_query }), @@ -477,14 +548,32 @@ async fn handle_sql_query( }; let result = tokio::select! { + // Dropping the `execute()` future here cancels the query stream, + // which is exactly what we want: there is no consumer left for it. _ = close_rx => { - Err(CubeError::internal("Client disconnected".to_string())) + Ok(SqlQueryOutcome::ClientDisconnected) } - res = execute() => res + res = execute() => res.map(|_| SqlQueryOutcome::Completed), }; match &result { - Ok(_) => { + Ok(SqlQueryOutcome::ClientDisconnected) => { + log::debug!( + "Client disconnected before the result was fully written, span id: {}", + span_id.as_ref().map(|s| s.span_id.as_str()).unwrap_or("-") + ); + + // Usually nothing else reports this outcome, so without this + // the attempt ends unrecorded and the time it spent cannot be + // attributed. `Continue wait` is the name query history already + // reads for an attempt that produced no result, rather than a + // new one it would log and drop. See + // `SqlQueryOutcome::ClientDisconnected` for why it is not gated + // on `throw_continue_wait`, and the `Err` arm below for why a + // real continue wait deliberately does not log here. + log_continue_wait(&session_clone, &span_id, sql_query).await?; + } + Ok(SqlQueryOutcome::Completed) => { session_clone .session_manager .server @@ -507,6 +596,16 @@ async fn handle_sql_query( .await?; } Err(err) => { + // A `Continue wait` that reaches this arm was produced by the + // JS side, which already reports it: `OrchestratorApi` logs it + // on `ContinueWaitError` and the gateway's `handleError` logs + // it again, both into the sink `logLoadEvent` writes to. #10649 + // stopped this arm reporting it as a `Cube SQL Error`; logging + // it as anything from here would just be a third copy. The + // disconnect arm usually has no such JS-side counterpart - + // 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") { session_clone .session_manager @@ -668,6 +767,9 @@ fn exec_sql(mut cx: FunctionContext) -> JsResult { }; let args = match result { + // Includes `SqlQueryOutcome::ClientDisconnected`: the stream is + // already gone, so there is nobody to hand an error payload to, + // and a disconnect is not an error to report in the first place. Ok(_) => vec![], Err(err) => { let mut error_response = Map::new(); diff --git a/packages/cubejs-backend-native/test/sql.test.ts b/packages/cubejs-backend-native/test/sql.test.ts index e79f2966033c6..78c6812b75874 100644 --- a/packages/cubejs-backend-native/test/sql.test.ts +++ b/packages/cubejs-backend-native/test/sql.test.ts @@ -424,6 +424,118 @@ describe('SQLInterface', () => { await native.shutdownInterface(instance, 'fast'); }); + // Both `throwContinueWait` states, because the flag is an opt-in that only + // `@cubejs-client/core` sets: with it the client polls and comes back for + // another attempt, without it this disconnect is the end of the road. The + // reporting is the same either way — the attempt delivered no result and has + // to be closed out — so pin both rather than leave the off case incidental. + test.each([ + ['throwContinueWait off', undefined], + ['throwContinueWait on', true], + ])( + 'client disconnect ends the /cubesql stream gracefully, not as an error (%s)', + async (_name, throwContinueWait) => { + // A client that closes the response stream before the result set has + // been fully written ends the attempt without delivering anything. It + // must not be reported as `Cube SQL Error` — that event feeds error rates + // and query history — and no error payload should be pushed into the + // stream that is already gone. + const loadEvents: string[] = []; + const methods = { + ...interfaceMethods(), + // Return data in both stream and non-stream mode: `CUBESQL_STREAM_MODE` + // only picks the streaming branch for limits above + // `non_streaming_query_max_row_limit`, and this test must behave the + // same either way. + sqlApiLoad: jest.fn(async ({ streaming, query }: any) => { + if (streaming) { + return { stream: new FakeRowStream(query) }; + } + return { + results: [ + { + annotation: { + measures: {}, + dimensions: {}, + segments: {}, + timeDimensions: {}, + }, + data: { + members: ['KibanaSampleDataEcommerce.order_date'], + columns: [['2024-01-01T00:00:00.000']], + }, + }, + ], + }; + }), + logLoadEvent: ({ event }: { event: string; properties: any }) => { + loadEvents.push(event); + }, + }; + + const instance = await native.registerInterface({ + ...methods, + canSwitchUserForSession: (_payload: any) => true, + }); + + const chunks: string[] = []; + const cubeSqlStream = new Writable({ + write(chunk, _enc, callback) { + chunks.push(chunk.toString('utf-8')); + callback(); + // Simulate the client going away right after the JSONL schema header. + this.destroy(); + }, + }); + // The native side holds a reference to the stream and only learns it is + // gone through the `close` event, so the writes already in flight (and the + // final `end()`) hit a destroyed stream and emit ERR_STREAM_DESTROYED. + // Swallow them: an unhandled 'error' would fail the test. + cubeSqlStream.on('error', jest.fn()); + // `exec_sql` delivers a failure as the *argument* to the stream's `end`, + // not through `write`, so the spy has to be in place before `execSql` + // roots the function. + const endSpy = jest.spyOn(cubeSqlStream, 'end'); + + try { + await native.execSql( + instance, + 'SELECT order_date FROM KibanaSampleDataEcommerce ORDER BY order_date DESC LIMIT 100000;', + cubeSqlStream, + null, + 'stale-if-slow', + undefined, + throwContinueWait + ); + + expect(loadEvents).toContain('Load Request'); + expect(loadEvents).not.toContain('Cube SQL Error'); + // `Load Request` is logged when the attempt starts, so the disconnect + // owes it a terminal event — otherwise the request dangles and query + // history cannot account for its running time. `Continue wait` is the + // event that closes an attempt which produced no result, which is what + // happened here whether or not the client intends to poll again. + // Asserting `Load Request Success` is absent also keeps the test honest: + // it would pass vacuously had the query simply finished before the + // `close` event arrived. + expect(loadEvents).toContain('Continue wait'); + expect(loadEvents).not.toContain('Load Request Success'); + // The stream is closed with no error payload. This is the half of the + // fix that `loadEvents` does not cover, and it has to be asserted on + // `end` rather than on the written chunks: `exec_sql` passes the + // `{"error": ...}` JSONL line to `end` as an argument. + expect(endSpy).toHaveBeenCalled(); + expect(endSpy.mock.calls[0]).toHaveLength(0); + // Only the JSONL schema header made it out — everything after it hit a + // stream that was already destroyed. + expect(chunks).toHaveLength(1); + expect(JSON.parse(chunks[0].trim()).schema).toBeDefined(); + } finally { + await native.shutdownInterface(instance, 'fast'); + } + } + ); + test('external flag is surfaced in /cubesql JSONL schema header when set to true', async () => { // End-to-end coverage of the cubesql -> backend-native -> JSONL path: // the non-streaming `load` returns a V1LoadResponseColumnar with From 512aefec94cf57de76b1615541a9cde5a52aec57 Mon Sep 17 00:00:00 2001 From: Igor Lukanin Date: Wed, 26 Aug 2026 02:47:48 +0200 Subject: [PATCH 3/3] docs: correct the donut shape control and document the center total (#11520) * docs: correct the donut shape control and document the center total * docs: trim the center total section per review * docs: reflect the donut centre total's default-on toggle, shared label font and 100% line * docs: make the percentage label reference self-contained on the pie page --------- Co-authored-by: igorlukanin <3852894+igorlukanin@users.noreply.github.com> --- .../explore-analyze/charts/chart-types/pie.mdx | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/docs-mintlify/docs/explore-analyze/charts/chart-types/pie.mdx b/docs-mintlify/docs/explore-analyze/charts/chart-types/pie.mdx index 675c3c6f62d14..e6f4693d8f8cf 100644 --- a/docs-mintlify/docs/explore-analyze/charts/chart-types/pie.mdx +++ b/docs-mintlify/docs/explore-analyze/charts/chart-types/pie.mdx @@ -15,17 +15,19 @@ Standard filled circle. Each slice's arc length is proportional to its value. ### Donut -A pie with a hollow center. Increase the **Inner radius** value in the Style tab to any non-zero value to switch to a donut. The hollow center can be used to surface a summary value via a [KPI](/docs/explore-analyze/charts/chart-types/kpi) tile on a dashboard, or simply to reduce visual density. +A pie with a hollow center. Select **Donut** under **Shape** in the Style tab to switch; select **Pie** to switch back. -By default, a donut also shows the measure's total in the hole, formatted with the measure's own number format. Toggle it with **Show total**, next to the **Data labels** controls in the Style tab. +{/* Screenshot: donut chart — same data as the pie variant. Place directly below this heading, half-width centered or side-by-side with pie. (hidden — replace this comment with when image is ready) */} -{/* Screenshot: donut chart — same data as the pie variant, with inner radius applied. Place directly below this heading, half-width centered or side-by-side with pie. (hidden — replace this comment with when image is ready) */} +## Center total -## Inner radius +A donut shows the measure's grand total in its hole by default, using the measure's number format. Use the **Show total** button in the Data labels section of the Style tab to turn it off or on again. -Drag the **Inner radius** slider in the Style tab or enter a pixel value. Setting it to `0` returns to a full pie. +The button appears only for a donut, since a pie has no hollow center to fill. -{/* Screenshot: Style tab with the Inner radius control highlighted. Place inline, 50% width, right-aligned. (hidden — replace this comment with when image is ready) */} +The total uses the font size set in the Data labels section, the same as the slice labels. If you turn on the **Percentage** label, the total also shows `100%` on a second line, since the whole is the entire circle. + +{/* Screenshot: donut chart with the center total visible, Style tab showing the Show total button. Place inline, 50% width, right-aligned. (hidden — replace this comment with when image is ready) */} ## Color and slice ordering