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
35 changes: 35 additions & 0 deletions console/src/api/materialize/cluster/replicaUtilization.test.sql.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// Copyright Materialize, Inc. and contributors. All rights reserved.
//
// Use of this software is governed by the Business Source License
// included in the LICENSE file.
//
// As of the Change Date specified in that file, in accordance with
// the Business Source License, use of this software will be governed
// by the Apache License, Version 2.0.

import { NUMBER_OR_NULL } from "~/test/sql/matchers";
import { executeSqlHttp } from "~/test/sql/materializeSqlClient";

import { buildReplicaUtilizationQuery } from "./replicaUtilization";

describe("buildReplicaUtilizationQuery", () => {
it("runs against the maintained view", async () => {
const query = buildReplicaUtilizationQuery().compile();
const result = await executeSqlHttp(query);

// The environment may have no metric samples inside the window yet, so the
// assertion is on the shape rather than on a reading. What this proves is
// that the query plans and executes: in particular that `mz_now()` is
// accepted in the `WHERE` of a one-shot SELECT, and that every column named
// here exists on `mz_console_cluster_utilization_overview_3h`.
for (const row of result.rows) {
expect(row).toEqual({
replicaId: expect.any(String),
cpuPercent: NUMBER_OR_NULL,
memoryPercent: NUMBER_OR_NULL,
diskPercent: NUMBER_OR_NULL,
heapPercent: NUMBER_OR_NULL,
});
}
});
});
59 changes: 59 additions & 0 deletions console/src/api/materialize/cluster/replicaUtilization.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// Copyright Materialize, Inc. and contributors. All rights reserved.
//
// Use of this software is governed by the Business Source License
// included in the LICENSE file.
//
// As of the Change Date specified in that file, in accordance with
// the Business Source License, use of this software will be governed
// by the Apache License, Version 2.0.

import { QueryKey } from "@tanstack/react-query";
import { InferResult, sql } from "kysely";

import { executeSqlV2, queryBuilder } from "~/api/materialize";

/**
* Peak utilization per replica over the last hour.
*
* Reads the maintained `_3h` view that already backs the cluster detail page's
* utilization charts, so the list and the charts agree and no new arrangement
* is built for the list.
*
* NOTE: the view reports fractions of the replica's allocation, not
* percentages, matching the rest of the utilization-history path. The column
* names carry `percent` because the view's own columns do.
*/
export function buildReplicaUtilizationQuery() {
return (
queryBuilder
.selectFrom("mz_console_cluster_utilization_overview_3h")
// The view retains three hours; the list reports the last hour of it. The
// `mz_now()` form is what lets the filter bound the read rather than
// filtering after the fact.
.where(sql<boolean>`mz_now() <= occurred_at + INTERVAL '1 hour'`)
.groupBy("replica_id")
.select([
"replica_id as replicaId",
sql<number | null>`MAX(cpu_percent)`.as("cpuPercent"),
sql<number | null>`MAX(memory_percent)`.as("memoryPercent"),
sql<number | null>`MAX(disk_percent)`.as("diskPercent"),
sql<number | null>`MAX(heap_percent)`.as("heapPercent"),
])
);
}

export type ReplicaUtilization = InferResult<
ReturnType<typeof buildReplicaUtilizationQuery>
>[0];

/** Fetches last-hour peak utilization for every replica in the environment. */
export async function fetchReplicaUtilization({
queryKey,
requestOptions,
}: {
queryKey: QueryKey;
requestOptions?: RequestInit;
}) {
const compiledQuery = buildReplicaUtilizationQuery().compile();
return executeSqlV2({ queries: compiledQuery, queryKey, requestOptions });
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,95 @@
// the Business Source License, use of this software will be governed
// by the Apache License, Version 2.0.

import { shouldSurfaceOom } from "./useLatestOfflineReplica";
import { renderHook } from "@testing-library/react";

import useLatestOfflineReplica, {
shouldSurfaceOom,
} from "./useLatestOfflineReplica";

// The hook reduces the subscribe's per-process rows into one entry per replica.
// Feeding rows directly is the only way to reach that reducer.
const { subscribeRows } = vi.hoisted(() => ({
subscribeRows: [] as {
data: {
replicaId: string | null;
processId: string;
clusterId: string;
lastOfflineAt: Date | null;
isOom: boolean;
};
}[],
}));

vi.mock("../useSubscribe", () => ({
useSubscribe: () => ({
data: subscribeRows,
snapshotComplete: true,
resubscribing: false,
isError: false,
error: undefined,
disconnect: vi.fn(),
reset: vi.fn(),
}),
}));

const offlineRow = (
replicaId: string | null,
processId: string,
lastOfflineAt: Date | null,
isOom = true,
) => ({
data: { replicaId, processId, clusterId: "u1", lastOfflineAt, isOom },
});

const renderMap = () =>
renderHook(() => useLatestOfflineReplica()).result.current.data;

describe("useLatestOfflineReplica", () => {
beforeEach(() => {
subscribeRows.length = 0;
});

it("keys entries by replica, not by cluster", () => {
const now = new Date();
subscribeRows.push(
offlineRow("u10", "0", now),
offlineRow("u11", "0", now),
);

const map = renderMap();

// Both replicas belong to cluster u1. Keying by cluster would collapse
// them into a single entry and lose which replica was affected.
expect([...map.keys()].sort()).toEqual(["u10", "u11"]);
});

it("keeps the newest outage when a replica has several processes", () => {
const older = new Date("2024-03-01T08:00:00.000Z");
const newest = new Date("2024-03-07T09:00:00.000Z");
// Deliberately out of order: the subscribe does not sort its rows.
subscribeRows.push(
offlineRow("u10", "0", older),
offlineRow("u10", "2", newest),
offlineRow("u10", "1", new Date("2024-03-04T12:00:00.000Z")),
);

const map = renderMap();

expect(map.size).toBe(1);
expect(map.get("u10")?.lastOfflineAt).toEqual(newest);
});

it("skips rows with no replica id or no timestamp", () => {
const now = new Date();
subscribeRows.push(
offlineRow(null, "0", now),
offlineRow("u12", "0", null),
);

expect(renderMap().size).toBe(0);
});
});

describe("shouldSurfaceOom", () => {
it("should return true when lastOomAt is within 15 minutes", () => {
Expand Down
26 changes: 16 additions & 10 deletions console/src/api/materialize/cluster/useLatestOfflineReplica.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,14 @@ import React from "react";
import { queryBuilder } from "../db";
import { useSubscribe } from "../useSubscribe";

type ClusterId = string;
type ReplicaId = string;

export type LatestOfflineReplicaInfo = {
shouldSurfaceOom: boolean;
lastOfflineAt: Date;
};

export type LatestOfflineReplicaMap = Map<ClusterId, LatestOfflineReplicaInfo>;
export type LatestOfflineReplicaMap = Map<ReplicaId, LatestOfflineReplicaInfo>;

export type QueryPayload = {
mz_state: string;
Expand Down Expand Up @@ -95,16 +95,22 @@ const useLatestOfflineReplica = () => {
});

const result = React.useMemo(() => {
const map = new Map();
const map: LatestOfflineReplicaMap = new Map();
for (const row of data) {
const { lastOfflineAt: lastOfflineAt, isOom, clusterId } = row.data;
if (!lastOfflineAt) continue;

const newLatestOfflineReplicaInfo: LatestOfflineReplicaInfo = {
lastOfflineAt: lastOfflineAt,
const { lastOfflineAt, isOom, replicaId } = row.data;
// `replicaId` comes from a left join, so it is nullable in the type even
// though the status filter means a matched row always has one.
if (!lastOfflineAt || !replicaId) continue;

// The subscribe emits one row per process and does not order them, so
// compare timestamps rather than letting the last row win.
const existing = map.get(replicaId);
if (existing && existing.lastOfflineAt >= lastOfflineAt) continue;

map.set(replicaId, {
lastOfflineAt,
shouldSurfaceOom: isOom && shouldSurfaceOom(new Date(), lastOfflineAt),
};
map.set(clusterId, newLatestOfflineReplicaInfo);
});
}

return map;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`queries > buildStatementLoggingMaxSampleRateQuery produces the expected query 1`] = `
{
"parameters": [],
"sql": "SHOW statement_logging_max_sample_rate",
}
`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Copyright Materialize, Inc. and contributors. All rights reserved.
//
// Use of this software is governed by the Business Source License
// included in the LICENSE file.
//
// As of the Change Date specified in that file, in accordance with
// the Business Source License, use of this software will be governed
// by the Apache License, Version 2.0.

import { queryBuilder } from "~/api/materialize";

import { buildStatementLoggingMaxSampleRateQuery } from "./statementLoggingMaxSampleRate";

describe("queries", () => {
it("buildStatementLoggingMaxSampleRateQuery produces the expected query", () => {
const { sql, parameters } =
buildStatementLoggingMaxSampleRateQuery().compile(queryBuilder);
expect({ sql, parameters }).toMatchSnapshot();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// Copyright Materialize, Inc. and contributors. All rights reserved.
//
// Use of this software is governed by the Business Source License
// included in the LICENSE file.
//
// As of the Change Date specified in that file, in accordance with
// the Business Source License, use of this software will be governed
// by the Apache License, Version 2.0.

import { QueryKey } from "@tanstack/react-query";
import { sql } from "kysely";

import { executeSqlV2, queryBuilder } from "~/api/materialize";

export const buildStatementLoggingMaxSampleRateQuery = () => {
return sql<{
statement_logging_max_sample_rate: string;
}>`SHOW statement_logging_max_sample_rate`;
};

/**
* Fetches the system-wide cap on the statement logging sample rate. The effective rate is
* `min(session statement_logging_sample_rate, this)`, so a value of `0` means statement
* logging is off for everyone and query history can never have rows.
*
* Returns `null` if the variable could not be read as a number.
*/
export default async function fetchStatementLoggingMaxSampleRate({
queryKey,
requestOptions,
}: {
queryKey: QueryKey;
requestOptions: RequestInit;
}) {
const compiledQuery =
buildStatementLoggingMaxSampleRateQuery().compile(queryBuilder);

const response = await executeSqlV2({
queries: compiledQuery,
queryKey,
requestOptions,
});

const rate = parseFloat(response.rows[0]?.statement_logging_max_sample_rate);

return Number.isFinite(rate) ? rate : null;
}
2 changes: 1 addition & 1 deletion console/src/components/ClusterMetrics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ export const ClusterMetricsInner = ({
)}
{metrics.heapPercent && (
<RadialPercentageGraph percentage={metrics.heapPercent ?? 0}>
<Text textStyle="text-ui-med">Memory Utilization</Text>
<Text textStyle="text-ui-med">Heap Utilization</Text>
<Text
color={colors.foreground.secondary}
textAlign="center"
Expand Down
Loading
Loading