Skip to content
Merged
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
153 changes: 153 additions & 0 deletions docs-mintlify/admin/deployment/maintenance-window.mdx
Original file line number Diff line number Diff line change
@@ -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
---

<Note>

Available on the [Enterprise plan](https://cube.dev/pricing) with the
[Single-tenant infrastructure](/admin/deployment/infrastructure#dedicated-infrastructure)
add-on.

</Note>

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.

<Info>

You do not choose specific versions. The system manages version progression
automatically — the maintenance window only controls **when** the update
happens.

</Info>

## 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**:

<Frame>
<img
src="https://ucarecdn.com/e6db33a7-0d5e-4e28-8494-554393eaf2cb/"
alt="Maintenance window settings"
/>
</Frame>

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.

<Warning>

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.

</Warning>

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.

<Warning>

Only snapshots from the last 7 days are available. If you need to revert to an
older version, contact [support](/admin/account-billing/support).

</Warning>

## 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.

<Info>

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.

</Info>

## 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.
14 changes: 8 additions & 6 deletions docs-mintlify/docs/explore-analyze/charts/chart-types/pie.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Frame><img src="..." /></Frame> 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 <Frame><img src="..." /></Frame> 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 <Frame><img src="..." /></Frame> 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 <Frame><img src="..." /></Frame> when image is ready) */}

## Color and slice ordering

Expand Down
2 changes: 1 addition & 1 deletion docs/redirects-new-docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
},
{
Expand Down
110 changes: 106 additions & 4 deletions packages/cubejs-backend-native/src/node_export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<Session>,
span_id: &Option<Arc<SpanId>>,
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<NodeCubeServices>,
native_auth_ctx: Arc<NativeSQLAuthContext>,
Expand All @@ -243,7 +314,7 @@ async fn handle_sql_query(
timezone: Option<String>,
throw_continue_wait: bool,
request_id: Option<String>,
) -> Result<(), CubeError> {
) -> Result<SqlQueryOutcome, CubeError> {
let span_id = Some(Arc::new(SpanId::new(
request_id.unwrap_or_else(|| Uuid::new_v4().to_string()),
serde_json::json!({ "sql": sql_query }),
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -668,6 +767,9 @@ fn exec_sql(mut cx: FunctionContext) -> JsResult<JsValue> {
};

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();
Expand Down
Loading
Loading