diff --git a/docs/providers/mysql.md b/docs/providers/mysql.md index 811c7dad..abba9e3c 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 466caf29..6629f5d9 100644 --- a/docs/providers/postgres.md +++ b/docs/providers/postgres.md @@ -722,6 +722,16 @@ 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($1)` and reads the byte figure only, the shape `mssql.ts` uses: +`databaseSize` is `formatBytes()` over that number, so no `pg_size_pretty()` column is selected. 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"`. A returned SQL `NULL` is a measured +zero and is still published as `0`/`"0 B"`, which is the shared helper's contract rather than a +state this engine produces: `pg_database_size()` is a function, not an aggregate, and measured on +PostgreSQL 18 a freshly created database answers 7774735 bytes, never `NULL` and never zero. MySQL's +`SUM()` over an empty schema is where that null row is real. + ### 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..8eea8314 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"; // ============================================================================ @@ -574,7 +575,6 @@ const OVERVIEW_CONNECTIONS_SQL = ` // getOverview: database size, pretty-printed and raw bytes ($1 = database). const OVERVIEW_SIZE_SQL = ` SELECT - pg_size_pretty(pg_database_size($1)) as database_size, pg_database_size($1) as database_size_bytes `; @@ -1813,8 +1813,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 +1842,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..2787f877 100644 --- a/tests/integration/db/mysql-provider.test.ts +++ b/tests/integration/db/mysql-provider.test.ts @@ -1560,6 +1560,94 @@ 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..49bf8d3f 100644 --- a/tests/integration/db/postgres-provider.test.ts +++ b/tests/integration/db/postgres-provider.test.ts @@ -432,10 +432,10 @@ function defaultMockQuery(sql: string): Promise<{ rows: unknown[]; fields?: { na }); } - // getOverview: database size (pg_database_size with pretty + bytes) + // getOverview: database size, the byte figure only - `databaseSize` is formatBytes() over it. if (normalized.includes("pg_database_size") && normalized.includes("database_size_bytes")) { return Promise.resolve({ - rows: [{ database_size: "512 MB", database_size_bytes: "536870912" }], + rows: [{ database_size_bytes: "536870912" }], fields: [], rowCount: 1, }); @@ -2349,6 +2349,109 @@ 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) => { + const normalized = sql.trim().toLowerCase(); + if (normalized.includes("pg_database_size") && normalized.includes("database_size_bytes")) { + return Promise.resolve({ rows: [{ unexpected_column: "512 MB" }], fields: [], rowCount: 1 }); + } + return defaultMockQuery(sql); + }; + + 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) => { + 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); + }; + + 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) => { + const normalized = sql.trim().toLowerCase(); + if (normalized.includes("pg_database_size") && normalized.includes("database_size_bytes")) { + return Promise.resolve({ + rows: [{ database_size_bytes: Number.POSITIVE_INFINITY }], + fields: [], + rowCount: 1, + }); + } + return defaultMockQuery(sql); + }; + + 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: a null aggregate is a MEASURED zero the + // provider must keep publishing, never an absence. It pins the shared helper's + // contract rather than a state this engine produces - `pg_database_size()` is a + // function, not an aggregate, and measured on PostgreSQL 18 a freshly created + // database answers 7774735 bytes, never NULL and never zero. MySQL's `SUM()` over an + // empty schema is where the null row is real. + mockQueryFn = async (sql: string) => { + const normalized = sql.trim().toLowerCase(); + if (normalized.includes("pg_database_size") && normalized.includes("database_size_bytes")) { + return Promise.resolve({ + rows: [{ database_size_bytes: null }], + fields: [], + rowCount: 1, + }); + } + return defaultMockQuery(sql); + }; + + 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"); + }); + + test("the overview size read asks for the byte figure only", async () => { + const seen: string[] = []; + mockQueryFn = async (sql: string) => { + seen.push(sql); + return defaultMockQuery(sql); + }; + + provider = new PostgresProvider(makePgConfig()); + await provider.connect(); + await provider.getOverview(); + + const sizeRead = seen.find((sql) => sql.includes("pg_database_size")); + expect(sizeRead).toBeDefined(); + // `databaseSize` is `formatBytes(databaseSizeBytes)` now, the shape `mssql.ts` + // uses, so a selected `pg_size_pretty()` would be a column nothing reads - and + // one whose value disagreed with the published string on every rounding boundary. + expect(sizeRead).not.toContain("pg_size_pretty"); + expect(sizeRead).toContain("database_size_bytes"); + }); }); // --------------------------------------------------------------------------