diff --git a/docs/providers/mysql.md b/docs/providers/mysql.md index 32e99cff..f1af6eab 100644 --- a/docs/providers/mysql.md +++ b/docs/providers/mysql.md @@ -598,6 +598,12 @@ outright on Doris for a filter the statement does not need ([#573](https://githu and the narrowest fix changes only what a grammar refuses. `getOverview()` also costs one round trip fewer than before, reading uptime and connections out of the same result set. +**Database size is absent, never zeroed, when it is not measured.** `getOverview()` sizes the +database with `SUM(DATA_LENGTH + INDEX_LENGTH)` over `information_schema.tables`. A missing result +row, or a row without the `size_bytes` column, is no measurement at all: `databaseSizeBytes` is +omitted and `databaseSize` stays `"N/A"`. Only a returned SQL `NULL` — an empty database — is a +measured zero, and that reading is published as `0`/`"0 B"`. + **Graceful degradation — note the *different* failure modes:** - `getHealth()` slow-queries: the digest rows, or **an empty list** — never a placeholder row, and on this path **the reason is dropped**. It used to answer a single fabricated row diff --git a/docs/providers/postgres.md b/docs/providers/postgres.md index 5afe0956..2ccbb7ba 100644 --- a/docs/providers/postgres.md +++ b/docs/providers/postgres.md @@ -713,6 +713,12 @@ base) fans these out in parallel. `getTableStats()` / `getIndexStats()` accept an optional `{ schema }` filter; with none they cover all user schemas. +**Database size is absent, never zeroed, when it is not measured.** `getOverview()` sizes the +database with `pg_database_size`; a missing result row, or a row without `database_size_bytes`, is +no measurement at all, so `databaseSizeBytes` is omitted and `databaseSize` stays `"N/A"`. Only a +returned SQL `NULL` — an empty database — is a measured zero, and that reading is published as +`0`/`"0 B"`. + ### 7.1 When the cache hit ratio is not measurable The ratio comes from `pg_statio_user_tables`, and there are two ordinary states in which that view diff --git a/src/lib/db/providers/sql/mysql.ts b/src/lib/db/providers/sql/mysql.ts index 743449be..758824ce 100644 --- a/src/lib/db/providers/sql/mysql.ts +++ b/src/lib/db/providers/sql/mysql.ts @@ -29,6 +29,7 @@ import { } from "../../types"; import { DatabaseConfigError, ConnectionError, QueryError, mapDatabaseError } from "../../errors"; import { formatBytes } from "../../utils/pool-manager"; +import { measuredNullableAggregate } from "../../utils/measured-aggregate"; import { CACHE_HIT_RATIO_UNAVAILABLE, formatCacheHitRatio, measuredNumber } from "@/lib/monitoring-cache-ratio"; /** @@ -1292,7 +1293,8 @@ export class MySQLProvider extends SQLBaseProvider { // Get database size const [sizeRows] = await runStatement(conn, OVERVIEW_DATABASE_SIZE_SQL, [this.config.database]); - const databaseSizeBytes = parseInt(sizeRows[0]?.size_bytes || "0"); + const databaseSizeBytes = measuredNullableAggregate(sizeRows[0], "size_bytes"); + const databaseSize = databaseSizeBytes === undefined ? "N/A" : formatBytes(databaseSizeBytes); // Get table and index count const [countRows] = await runStatement(conn, OVERVIEW_OBJECT_COUNTS_SQL, [this.config.database]); @@ -1308,8 +1310,8 @@ export class MySQLProvider extends SQLBaseProvider { ...(uptimeSeconds === undefined ? {} : { startTime: new Date(Date.now() - uptimeSeconds * 1000) }), ...(activeConnections === undefined ? {} : { activeConnections }), maxConnections, - databaseSize: formatBytes(databaseSizeBytes), - databaseSizeBytes, + databaseSize, + ...(databaseSizeBytes === undefined ? {} : { databaseSizeBytes }), tableCount: parseInt(tableCountRows[0]?.cnt || "0"), indexCount: parseInt(countRows[0]?.index_count || "0"), }; diff --git a/src/lib/db/providers/sql/postgres.ts b/src/lib/db/providers/sql/postgres.ts index 50b39ff1..794d42f7 100644 --- a/src/lib/db/providers/sql/postgres.ts +++ b/src/lib/db/providers/sql/postgres.ts @@ -39,6 +39,7 @@ import { import { assertReadOnlyBudget, measureResultBytes } from "./read-only-budget"; import { postgresColumnTypes } from "./column-types"; import { formatBytes } from "../../utils/pool-manager"; +import { measuredNullableAggregate } from "../../utils/measured-aggregate"; import { CACHE_HIT_RATIO_UNAVAILABLE, formatCacheHitRatio, measuredNumber } from "@/lib/monitoring-cache-ratio"; // ============================================================================ @@ -1813,8 +1814,8 @@ export class PostgresProvider extends SQLBaseProvider { let databaseSizeBytes: number | undefined; try { const sizeRes = await client.query(OVERVIEW_SIZE_SQL, [this.config.database]); - databaseSize = sizeRes.rows[0].database_size || "0 bytes"; - databaseSizeBytes = parseInt(sizeRes.rows[0].database_size_bytes || "0"); + databaseSizeBytes = measuredNullableAggregate(sizeRes.rows[0], "database_size_bytes"); + if (databaseSizeBytes !== undefined) databaseSize = formatBytes(databaseSizeBytes); } catch { databaseSize = "N/A"; databaseSizeBytes = undefined; @@ -1842,7 +1843,7 @@ export class PostgresProvider extends SQLBaseProvider { activeConnections, maxConnections, databaseSize, - databaseSizeBytes, + ...(databaseSizeBytes === undefined ? {} : { databaseSizeBytes }), tableCount, indexCount, }; diff --git a/tests/integration/db/mysql-provider.test.ts b/tests/integration/db/mysql-provider.test.ts index 46246eab..5994f9eb 100644 --- a/tests/integration/db/mysql-provider.test.ts +++ b/tests/integration/db/mysql-provider.test.ts @@ -1560,6 +1560,78 @@ describe("MySQLProvider", () => { expect(overview.startTime).toBeInstanceOf(Date); }); + test("a size result without the expected column leaves overview size absent", async () => { + mockExecuteFn = (sql: string) => { + const lower = sql.toLowerCase(); + if (lower.includes("information_schema.tables") && lower.includes("sum(data_length") && !lower.includes("table_name")) { + return Promise.resolve([[{ size_mb: "12.50", name: "testdb" }], []]); + } + return defaultMockExecute(sql); + }; + + provider = new MySQLProvider(makeMySQLConfig()); + await provider.connect(); + const overview = await provider.getOverview(); + + expect("databaseSizeBytes" in overview).toBe(false); + expect(overview.databaseSize).toBe("N/A"); + }); + + test("a size read with no result row leaves overview size absent", async () => { + mockExecuteFn = (sql: string) => { + const lower = sql.toLowerCase(); + if (lower.includes("information_schema.tables") && lower.includes("sum(data_length") && !lower.includes("table_name")) { + return Promise.resolve([[], []]); + } + return defaultMockExecute(sql); + }; + + provider = new MySQLProvider(makeMySQLConfig()); + await provider.connect(); + const overview = await provider.getOverview(); + + expect("databaseSizeBytes" in overview).toBe(false); + expect(overview.databaseSize).toBe("N/A"); + }); + + test("a non-finite size leaves overview size absent", async () => { + mockExecuteFn = (sql: string) => { + const lower = sql.toLowerCase(); + if (lower.includes("information_schema.tables") && lower.includes("sum(data_length") && !lower.includes("table_name")) { + return Promise.resolve([[{ size_mb: "12.50", size_bytes: Number.POSITIVE_INFINITY, name: "testdb" }], []]); + } + return defaultMockExecute(sql); + }; + + provider = new MySQLProvider(makeMySQLConfig()); + await provider.connect(); + const overview = await provider.getOverview(); + + expect("databaseSizeBytes" in overview).toBe(false); + expect(overview.databaseSize).toBe("N/A"); + }); + + test("a database that measures zero bytes keeps its measured zero size", async () => { + // The anti-vacuity twin of the tests above: `SUM(DATA_LENGTH + INDEX_LENGTH)` + // returns NULL over an empty schema, and that returned null aggregate is a + // measured zero the provider must keep publishing - never an absence. + mockExecuteFn = (sql: string) => { + const lower = sql.toLowerCase(); + if (lower.includes("information_schema.tables") && lower.includes("sum(data_length") && !lower.includes("table_name")) { + return Promise.resolve([[{ size_mb: "0.00", size_bytes: null, name: "testdb" }], []]); + } + return defaultMockExecute(sql); + }; + + provider = new MySQLProvider(makeMySQLConfig()); + await provider.connect(); + const overview = await provider.getOverview(); + + expect("databaseSizeBytes" in overview).toBe(true); + expect(overview.databaseSizeBytes).toBe(0); + expect(overview.databaseSize).toBe("0 B"); + }); + test("does not call a MariaDB server MySQL", async () => { mockExecuteFn = mariaDBMockExecute; diff --git a/tests/integration/db/postgres-provider.test.ts b/tests/integration/db/postgres-provider.test.ts index d54b5bef..bc22bf7b 100644 --- a/tests/integration/db/postgres-provider.test.ts +++ b/tests/integration/db/postgres-provider.test.ts @@ -2349,6 +2349,86 @@ describe("PostgresProvider", () => { // 90061 seconds = 1d 1h 1m expect(overview.uptime).toBe("1d 1h 1m"); }); + + test("a size result without the expected column leaves overview size absent", async () => { + mockQueryFn = async (sql: string, params?: unknown[]) => { + const normalized = sql.trim().toLowerCase(); + if (normalized.includes("pg_database_size") && normalized.includes("database_size_bytes")) { + return Promise.resolve({ rows: [{ database_size: "512 MB" }], fields: [], rowCount: 1 }); + } + return defaultMockQuery(sql, params); + }; + + provider = new PostgresProvider(makePgConfig()); + await provider.connect(); + const overview = await provider.getOverview(); + + expect("databaseSizeBytes" in overview).toBe(false); + expect(overview.databaseSize).toBe("N/A"); + }); + + test("a size read with no result row leaves overview size absent", async () => { + mockQueryFn = async (sql: string, params?: unknown[]) => { + const normalized = sql.trim().toLowerCase(); + if (normalized.includes("pg_database_size") && normalized.includes("database_size_bytes")) { + return Promise.resolve({ rows: [], fields: [], rowCount: 0 }); + } + return defaultMockQuery(sql, params); + }; + + provider = new PostgresProvider(makePgConfig()); + await provider.connect(); + const overview = await provider.getOverview(); + + expect("databaseSizeBytes" in overview).toBe(false); + expect(overview.databaseSize).toBe("N/A"); + }); + + test("a non-finite size leaves overview size absent", async () => { + mockQueryFn = async (sql: string, params?: unknown[]) => { + const normalized = sql.trim().toLowerCase(); + if (normalized.includes("pg_database_size") && normalized.includes("database_size_bytes")) { + return Promise.resolve({ + rows: [{ database_size: "512 MB", database_size_bytes: Number.POSITIVE_INFINITY }], + fields: [], + rowCount: 1, + }); + } + return defaultMockQuery(sql, params); + }; + + provider = new PostgresProvider(makePgConfig()); + await provider.connect(); + const overview = await provider.getOverview(); + + expect("databaseSizeBytes" in overview).toBe(false); + expect(overview.databaseSize).toBe("N/A"); + }); + + test("a database that measures zero bytes keeps its measured zero size", async () => { + // The anti-vacuity twin of the tests above: `pg_database_size($1)` answers NULL + // when the aggregate has nothing to measure, and that returned null aggregate is + // a measured zero the provider must keep publishing - never an absence. + mockQueryFn = async (sql: string, params?: unknown[]) => { + const normalized = sql.trim().toLowerCase(); + if (normalized.includes("pg_database_size") && normalized.includes("database_size_bytes")) { + return Promise.resolve({ + rows: [{ database_size: null, database_size_bytes: null }], + fields: [], + rowCount: 1, + }); + } + return defaultMockQuery(sql, params); + }; + + provider = new PostgresProvider(makePgConfig()); + await provider.connect(); + const overview = await provider.getOverview(); + + expect("databaseSizeBytes" in overview).toBe(true); + expect(overview.databaseSizeBytes).toBe(0); + expect(overview.databaseSize).toBe("0 B"); + }); }); // --------------------------------------------------------------------------