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**:
+
+
+
+
+
+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-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
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
},
{
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