@rocicorp/zero 1.11.0-canary.14 → 1.11.0-canary.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/out/replicache/src/bg-interval.d.ts +2 -1
- package/out/replicache/src/bg-interval.d.ts.map +1 -1
- package/out/replicache/src/bg-interval.js +13 -4
- package/out/replicache/src/bg-interval.js.map +1 -1
- package/out/replicache/src/dag/lazy-store.d.ts +17 -4
- package/out/replicache/src/dag/lazy-store.d.ts.map +1 -1
- package/out/replicache/src/dag/lazy-store.js +35 -9
- package/out/replicache/src/dag/lazy-store.js.map +1 -1
- package/out/replicache/src/dag/store-impl.d.ts.map +1 -1
- package/out/replicache/src/dag/store-impl.js +7 -1
- package/out/replicache/src/dag/store-impl.js.map +1 -1
- package/out/replicache/src/kv/sqlite-store.d.ts.map +1 -1
- package/out/replicache/src/kv/sqlite-store.js +4 -6
- package/out/replicache/src/kv/sqlite-store.js.map +1 -1
- package/out/replicache/src/persist/client-gc.d.ts +2 -1
- package/out/replicache/src/persist/client-gc.d.ts.map +1 -1
- package/out/replicache/src/persist/client-gc.js +2 -2
- package/out/replicache/src/persist/client-gc.js.map +1 -1
- package/out/replicache/src/persist/client-group-gc.d.ts +2 -1
- package/out/replicache/src/persist/client-group-gc.d.ts.map +1 -1
- package/out/replicache/src/persist/client-group-gc.js +2 -2
- package/out/replicache/src/persist/client-group-gc.js.map +1 -1
- package/out/replicache/src/persist/collect-idb-databases.d.ts +2 -1
- package/out/replicache/src/persist/collect-idb-databases.d.ts.map +1 -1
- package/out/replicache/src/persist/collect-idb-databases.js +2 -2
- package/out/replicache/src/persist/collect-idb-databases.js.map +1 -1
- package/out/replicache/src/persist/heartbeat.d.ts +2 -1
- package/out/replicache/src/persist/heartbeat.d.ts.map +1 -1
- package/out/replicache/src/persist/heartbeat.js +2 -2
- package/out/replicache/src/persist/heartbeat.js.map +1 -1
- package/out/replicache/src/replicache-impl.d.ts +34 -3
- package/out/replicache/src/replicache-impl.d.ts.map +1 -1
- package/out/replicache/src/replicache-impl.js +104 -27
- package/out/replicache/src/replicache-impl.js.map +1 -1
- package/out/zero/package.js +1 -1
- package/out/zero/package.js.map +1 -1
- package/out/zero-cache/src/config/normalize.d.ts.map +1 -1
- package/out/zero-cache/src/config/normalize.js +1 -1
- package/out/zero-cache/src/config/normalize.js.map +1 -1
- package/out/zero-cache/src/config/zero-config.d.ts +23 -0
- package/out/zero-cache/src/config/zero-config.d.ts.map +1 -1
- package/out/zero-cache/src/config/zero-config.js +67 -16
- package/out/zero-cache/src/config/zero-config.js.map +1 -1
- package/out/zero-cache/src/services/change-streamer/change-streamer-http.d.ts +1 -1
- package/out/zero-cache/src/services/change-streamer/change-streamer-http.d.ts.map +1 -1
- package/out/zero-cache/src/services/change-streamer/change-streamer-http.js +4 -1
- package/out/zero-cache/src/services/change-streamer/change-streamer-http.js.map +1 -1
- package/out/zero-cache/src/services/plannerz.d.ts +5 -1
- package/out/zero-cache/src/services/plannerz.d.ts.map +1 -1
- package/out/zero-cache/src/services/plannerz.js +8 -4
- package/out/zero-cache/src/services/plannerz.js.map +1 -1
- package/out/zero-cache/src/services/profz.d.ts +24 -1
- package/out/zero-cache/src/services/profz.d.ts.map +1 -1
- package/out/zero-cache/src/services/profz.js +43 -3
- package/out/zero-cache/src/services/profz.js.map +1 -1
- package/out/zero-cache/src/services/statz.d.ts +4 -0
- package/out/zero-cache/src/services/statz.d.ts.map +1 -1
- package/out/zero-cache/src/services/statz.js +22 -5
- package/out/zero-cache/src/services/statz.js.map +1 -1
- package/out/zero-cache/src/services/view-syncer/snapshotter.d.ts.map +1 -1
- package/out/zero-cache/src/services/view-syncer/snapshotter.js +21 -2
- package/out/zero-cache/src/services/view-syncer/snapshotter.js.map +1 -1
- package/out/zero-client/src/client/mutator-proxy.d.ts.map +1 -1
- package/out/zero-client/src/client/mutator-proxy.js +3 -1
- package/out/zero-client/src/client/mutator-proxy.js.map +1 -1
- package/out/zero-client/src/client/options.d.ts +2 -1
- package/out/zero-client/src/client/options.d.ts.map +1 -1
- package/out/zero-client/src/client/options.js.map +1 -1
- package/out/zero-client/src/client/version.js +1 -1
- package/out/zero-client/src/client/zero.d.ts.map +1 -1
- package/out/zero-client/src/client/zero.js +2 -1
- package/out/zero-client/src/client/zero.js.map +1 -1
- package/out/zero-server/src/logging.d.ts +2 -2
- package/out/zero-server/src/logging.d.ts.map +1 -1
- package/out/zero-server/src/logging.js +2 -2
- package/out/zero-server/src/logging.js.map +1 -1
- package/out/zero-server/src/process-mutations.d.ts +12 -3
- package/out/zero-server/src/process-mutations.d.ts.map +1 -1
- package/out/zero-server/src/process-mutations.js +13 -8
- package/out/zero-server/src/process-mutations.js.map +1 -1
- package/out/zero-server/src/push-processor.d.ts +8 -2
- package/out/zero-server/src/push-processor.d.ts.map +1 -1
- package/out/zero-server/src/push-processor.js +12 -4
- package/out/zero-server/src/push-processor.js.map +1 -1
- package/out/zql/src/ivm/cap.d.ts.map +1 -1
- package/out/zql/src/ivm/cap.js +1 -0
- package/out/zql/src/ivm/cap.js.map +1 -1
- package/out/zql/src/ivm/flipped-join.d.ts.map +1 -1
- package/out/zql/src/ivm/flipped-join.js +1 -0
- package/out/zql/src/ivm/flipped-join.js.map +1 -1
- package/out/zql/src/ivm/join-utils.d.ts +1 -0
- package/out/zql/src/ivm/join-utils.d.ts.map +1 -1
- package/out/zql/src/ivm/join-utils.js.map +1 -1
- package/out/zql/src/ivm/join.d.ts.map +1 -1
- package/out/zql/src/ivm/join.js +1 -0
- package/out/zql/src/ivm/join.js.map +1 -1
- package/out/zql/src/ivm/memory-storage.d.ts +1 -0
- package/out/zql/src/ivm/memory-storage.d.ts.map +1 -1
- package/out/zql/src/ivm/memory-storage.js +1 -0
- package/out/zql/src/ivm/memory-storage.js.map +1 -1
- package/out/zql/src/ivm/operator.d.ts +6 -0
- package/out/zql/src/ivm/operator.d.ts.map +1 -1
- package/out/zql/src/ivm/operator.js.map +1 -1
- package/out/zql/src/ivm/take.d.ts.map +1 -1
- package/out/zql/src/ivm/take.js +1 -0
- package/out/zql/src/ivm/take.js.map +1 -1
- package/out/zqlite/src/database-storage.d.ts.map +1 -1
- package/out/zqlite/src/database-storage.js +9 -1
- package/out/zqlite/src/database-storage.js.map +1 -1
- package/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"plannerz.d.ts","sourceRoot":"","sources":["../../../../../zero-cache/src/services/plannerz.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAC,UAAU,EAAC,MAAM,kBAAkB,CAAC;AAEjD,OAAO,KAAK,EAAC,YAAY,EAAE,cAAc,EAAC,MAAM,SAAS,CAAC;AAC1D,OAAO,EAAC,QAAQ,EAAC,MAAM,2BAA2B,CAAC;AACnD,OAAO,EAEL,KAAK,eAAe,EACrB,MAAM,4CAA4C,CAAC;AACpD,OAAO,KAAK,EAAC,oBAAoB,IAAI,UAAU,EAAC,MAAM,wBAAwB,CAAC;AAuB/E;;;;GAIG;AACH,QAAA,MAAM,KAAK;aACT,UAAU;aAIV,WAAW;aAMX,WAAW;aAOX,qBAAqB;aAUrB,gBAAgB,oBAGd,+CAA+C,UAG/C,wCAAwC;aAE1C,OAAO;
|
|
1
|
+
{"version":3,"file":"plannerz.d.ts","sourceRoot":"","sources":["../../../../../zero-cache/src/services/plannerz.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { getOperatorAccess, getServerVersion } from "../config/zero-config.js";
|
|
2
2
|
import { Database } from "../../../zqlite/src/db.js";
|
|
3
3
|
import { computeZqlSpecsFromLiteSpecs, listIndexes, listTables } from "../db/lite-tables.js";
|
|
4
4
|
import { decodeSampleKinds } from "../../../zqlite/src/sqlite-stat4-sample.js";
|
|
@@ -39,8 +39,7 @@ var ABOUT = {
|
|
|
39
39
|
"Row counts are estimates from the statistics, not COUNT(*). This endpoint never scans a table.",
|
|
40
40
|
"Statistics are as of the last time they were gathered, which SQLite does not record. A table whose size changed a lot since then can be far off.",
|
|
41
41
|
"Tables in `tables` with `syncable: false` cannot be queried by clients: they lack a primary key or unique index, or every column has a type ZQL does not support."
|
|
42
|
-
]
|
|
43
|
-
nextStep: "POST an AST to /plannerz/analyze to see the plan the planner picks for one query, with the SQLite query plans it generates."
|
|
42
|
+
]
|
|
44
43
|
};
|
|
45
44
|
/**
|
|
46
45
|
* Parses a `sqlite_stat1.stat` value.
|
|
@@ -236,12 +235,17 @@ function replicaWatermark(lc, db) {
|
|
|
236
235
|
*
|
|
237
236
|
* Row data is never returned. See `designs/003_replica_stats_endpoint.md`.
|
|
238
237
|
*
|
|
238
|
+
* This is available to the operator password as well as the admin password
|
|
239
|
+
* (see `--operator-password`). That does not extend to `/plannerz/analyze`,
|
|
240
|
+
* which runs caller-supplied queries against the replica and so must require
|
|
241
|
+
* the admin password (`isAdminPasswordValid`).
|
|
242
|
+
*
|
|
239
243
|
* HTTP query parameters:
|
|
240
244
|
* * `pretty`: indents the JSON
|
|
241
245
|
* * `stat4=full`: includes the full sqlite_stat4 histogram, which is large
|
|
242
246
|
*/
|
|
243
247
|
async function handlePlannerzRequest(lc, config, req, res) {
|
|
244
|
-
if (
|
|
248
|
+
if (getOperatorAccess(lc, config, auth(req)?.pass) === void 0) {
|
|
245
249
|
res.code(401).header("WWW-Authenticate", "Basic realm=\"Plannerz Protected Area\"").send("Unauthorized");
|
|
246
250
|
return;
|
|
247
251
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"plannerz.js","names":[],"sources":["../../../../../zero-cache/src/services/plannerz.ts"],"sourcesContent":["import type {LogContext} from '@rocicorp/logger';\nimport auth from 'basic-auth';\nimport type {FastifyReply, FastifyRequest} from 'fastify';\nimport {Database} from '../../../zqlite/src/db.ts';\nimport {\n decodeSampleKinds,\n type SampleValueKind,\n} from '../../../zqlite/src/sqlite-stat4-sample.ts';\nimport type {NormalizedZeroConfig as ZeroConfig} from '../config/normalize.ts';\nimport {getServerVersion, isAdminPasswordValid} from '../config/zero-config.ts';\nimport {\n computeZqlSpecsFromLiteSpecs,\n listIndexes,\n listTables,\n} from '../db/lite-tables.ts';\nimport {StatementRunner} from '../db/statements.ts';\nimport {getReplicationState} from './replicator/schema/replication-state.ts';\n\n/**\n * How the statistics in the replica were gathered. zero-cache only ever runs\n * `PRAGMA optimize`, and always under an `analysis_limit`, which samples rather\n * than scanning. A replica that someone ran `ANALYZE` on by hand has better\n * statistics, which is visible in `sqlite_stat4` having rows at all.\n */\nconst APPROXIMATE_METHOD =\n 'PRAGMA optimize under an analysis_limit. Row counts and per-key averages ' +\n 'are sampled estimates, and sqlite_stat4 is not collected.';\nconst FULL_METHOD =\n 'ANALYZE, with no analysis limit. sqlite_stat4 has rows, so a full ANALYZE ' +\n 'ran against this replica at some point.';\n\n/**\n * A fixed glossary, sent with every bundle. The audience is a language model\n * that has the user's codebase but has never seen these statistics, and would\n * otherwise read a sampled estimate as an exact count.\n */\nconst ABOUT = {\n whatThisIs:\n 'Schema, indexes and SQLite statistics for the SQLite replica that ' +\n 'zero-cache queries. Use it to decide how to rewrite ZQL queries and ' +\n 'which indexes to add. It contains no row data.',\n stat1Format:\n 'sqlite_stat1.stat is \"<rows> <avg rows per distinct 1-column prefix> ' +\n '<... per 2-column prefix> ...\", parsed here into `rows` and ' +\n '`avgRowsPerPrefix`. NULLs count as ordinary values, so a sparse column ' +\n 'looks less selective than it is. Trailing flags such as \"unordered\", ' +\n '\"noskipscan\" and \"sz=N\" are kept in `flags`.',\n stat4Format:\n 'sqlite_stat4 is a per-index histogram. For each sample, `nEq` is the ' +\n 'rows equal to it, `nLt` the rows less than it, and `nDLt` the distinct ' +\n 'values less than it, one entry per key column. A large `nEq` next to a ' +\n 'small `nDLt` means a skewed column, where the average in stat1 hides ' +\n 'the common values. Sample values are never returned: `sample` reports ' +\n 'only each key column’s type.',\n howThePlannerUsesThis: [\n 'Cost comes from SQLite scanstatus estimates for the SQL each source ' +\n 'connection would run, so the statistics here are what the planner sees.',\n 'Join fanout comes from sqlite_stat4, else sqlite_stat1, else a default ' +\n 'of 3. With no stat4 (the usual case) fanout on a nullable foreign key ' +\n 'is overestimated, because stat1 counts NULLs.',\n 'The planner chooses per join whether to run it as a semi-join or to flip ' +\n 'it so the child drives. An index that matches the child’s ' +\n 'constraint plus ordering is what makes a flip cheap.',\n ],\n whatYouCanChange: [\n 'The ZQL query: `whereExists` vs `related`, the order of conditions, and ' +\n 'ordering that matches an existing index.',\n 'A `limit`, which lets the planner stop early.',\n 'Indexes: add them in Postgres, not on the replica. The replica copies ' +\n 'upstream indexes, so a new index reaches it through replication.',\n 'The planner flags in `server.planner`.',\n ],\n caveats: [\n 'Row counts are estimates from the statistics, not COUNT(*). This ' +\n 'endpoint never scans a table.',\n 'Statistics are as of the last time they were gathered, which SQLite does ' +\n 'not record. A table whose size changed a lot since then can be far off.',\n 'Tables in `tables` with `syncable: false` cannot be queried by clients: ' +\n 'they lack a primary key or unique index, or every column has a type ' +\n 'ZQL does not support.',\n ],\n nextStep:\n 'POST an AST to /plannerz/analyze to see the plan the planner picks for ' +\n 'one query, with the SQLite query plans it generates.',\n} as const;\n\nexport type Stat1 = {\n /** The raw `sqlite_stat1.stat` text, in case a flag is not parsed here. */\n raw: string;\n /** Estimated rows in the table. */\n rows: number;\n /**\n * Average rows per distinct key prefix: the first entry is for a 1-column\n * prefix, the second for 2 columns, and so on.\n */\n avgRowsPerPrefix: number[];\n /** Non-numeric tokens, e.g. `unordered`, `noskipscan`, `sz=N`. */\n flags: string[];\n};\n\n/** A `sqlite_stat4` sample with its values replaced by their types. */\nexport type Stat4Sample = {\n nEq: number[];\n nLt: number[];\n nDLt: number[];\n /** One entry per key column, plus the trailing rowid. */\n sample: SampleValueKind[];\n};\n\n/**\n * A digest of an index's `sqlite_stat4` histogram, one entry per key prefix\n * depth. This is what the default response carries: a full histogram runs to\n * hundreds of samples per index, which is a lot of context to spend on data an\n * LLM would only summarize anyway.\n */\nexport type Stat4PrefixSummary = {\n /** Rows equal to the most common sampled key at this depth. */\n maxRowsPerKey: number;\n /** Median rows per sampled key at this depth. */\n medianRowsPerKey: number;\n /**\n * Lower bound on distinct keys at this depth, from the last sample's count of\n * distinct values less than it.\n */\n estimatedDistinctKeys: number;\n};\n\nexport type Stat4Summary = {\n samples: number;\n /** The type of each key column, and of the trailing rowid. */\n sampleKinds: SampleValueKind[];\n /** One entry per key prefix depth: 1 column, then 2, and so on. */\n perPrefix: Stat4PrefixSummary[];\n};\n\nexport type IndexStats = {\n name: string;\n columns: {name: string; dir: 'ASC' | 'DESC'}[];\n unique: boolean;\n partial: boolean;\n stat1?: Stat1 | undefined;\n stat4?: Stat4Summary | undefined;\n /** The full histogram, only with `?stat4=full`. */\n stat4Samples?: Stat4Sample[] | undefined;\n};\n\nexport type ColumnStats = {\n name: string;\n dataType: string;\n nullable: boolean;\n /** The ZQL type, or `null` for a column clients cannot see. */\n zqlType: string | null;\n backfilling?: true | undefined;\n};\n\nexport type TableStats = {\n name: string;\n columns: ColumnStats[];\n primaryKey: string[] | undefined;\n estimatedRows: number | undefined;\n syncable: boolean;\n indexes: IndexStats[];\n};\n\nexport type PlannerzBundle = {\n about: typeof ABOUT;\n server: {\n zeroVersion: string;\n sqliteVersion: string;\n replicaWatermark: string | undefined;\n generatedAt: string;\n planner: {\n enableQueryPlanner: boolean;\n enableCorrelatedPredicatePushdown: boolean;\n enablePlannerAwarePushdown: boolean;\n };\n };\n statsQuality: {\n stat1Present: boolean;\n stat4Rows: number;\n method: string;\n tablesWithoutStats: string[];\n };\n tables: TableStats[];\n};\n\n/**\n * Parses a `sqlite_stat1.stat` value.\n *\n * @visibleForTesting\n */\nexport function parseStat1(raw: string): Stat1 {\n const tokens = raw.trim().split(SEPARATOR).filter(Boolean);\n const numbers: number[] = [];\n const flags: string[] = [];\n for (const token of tokens) {\n // Only leading tokens are counts; anything else is a flag, even if it\n // parses as a number (e.g. a future \"sz=N\" spelled differently).\n if (flags.length === 0 && COUNT.test(token)) {\n numbers.push(parseInt(token, 10));\n } else {\n flags.push(token);\n }\n }\n return {\n raw,\n rows: numbers[0] ?? 0,\n avgRowsPerPrefix: numbers.slice(1),\n flags,\n };\n}\n\n/** Whitespace between the counts in a `sqlite_stat1` or `sqlite_stat4` value. */\nconst SEPARATOR = /\\s+/;\n/** A count, as opposed to a trailing flag such as `unordered` or `sz=N`. */\nconst COUNT = /^\\d+$/;\n\ntype Stat1Row = {tbl: string; idx: string | null; stat: string};\ntype Stat4Row = {\n tbl: string;\n idx: string;\n neq: string;\n nlt: string;\n ndlt: string;\n sample: Buffer;\n};\n\nfunction tableExists(db: Database, name: string): boolean {\n return (\n db\n .prepare(\n `SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`,\n )\n .all(name).length > 0\n );\n}\n\nfunction parseCounts(raw: string): number[] {\n return raw\n .trim()\n .split(SEPARATOR)\n .filter(Boolean)\n .map(n => parseInt(n, 10));\n}\n\n/**\n * Builds the `/plannerz` bundle from the replica's catalog, `sqlite_stat*`\n * tables and the server config. No user table is read, so the cost does not\n * depend on how much data the replica holds.\n *\n * @visibleForTesting\n */\nexport function buildPlannerzBundle(\n lc: LogContext,\n config: ZeroConfig,\n opts: BundleOptions = {},\n): PlannerzBundle {\n using db = new Database(lc, config.replica.file, {readonly: true});\n return buildPlannerzBundleFromDB(lc, db, config, opts);\n}\n\nexport type BundleOptions = {\n /** Include the full `sqlite_stat4` histogram, not just the digest. */\n fullStat4?: boolean | undefined;\n};\n\n/** @visibleForTesting */\nexport function buildPlannerzBundleFromDB(\n lc: LogContext,\n db: Database,\n config: ZeroConfig,\n opts: BundleOptions = {},\n): PlannerzBundle {\n const tables = listReplicaTables(lc, db);\n const indexes = listIndexes(db);\n const zqlSpecs = computeZqlSpecsFromLiteSpecs(\n tables,\n indexes,\n {includeBackfillingColumns: false},\n undefined,\n undefined,\n lc,\n );\n\n const stat1Present = tableExists(db, 'sqlite_stat1');\n const stat1ByTableAndIndex = new Map<string, Stat1>();\n const statsByTable = new Map<string, Stat1>();\n if (stat1Present) {\n for (const {tbl, idx, stat} of db\n .prepare(`SELECT tbl, idx, stat FROM sqlite_stat1`)\n .all() as Stat1Row[]) {\n const parsed = parseStat1(stat);\n if (idx !== null) {\n stat1ByTableAndIndex.set(`${tbl}.${idx}`, parsed);\n }\n // Every row for a table starts with the table's row count. A row with a\n // null `idx` is written for a table with no indexes, and is preferred\n // because it is about the table itself.\n if (idx === null || !statsByTable.has(tbl)) {\n statsByTable.set(tbl, parsed);\n }\n }\n }\n\n const stat4Present = tableExists(db, 'sqlite_stat4');\n const stat4ByTableAndIndex = new Map<string, Stat4Sample[]>();\n let stat4Rows = 0;\n if (stat4Present) {\n for (const {tbl, idx, neq, nlt, ndlt, sample} of db\n .prepare(`SELECT tbl, idx, neq, nlt, ndlt, sample FROM sqlite_stat4`)\n .all() as Stat4Row[]) {\n stat4Rows++;\n const samples = stat4ByTableAndIndex.get(`${tbl}.${idx}`) ?? [];\n samples.push({\n nEq: parseCounts(neq),\n nLt: parseCounts(nlt),\n nDLt: parseCounts(ndlt),\n // Values are deliberately dropped here: only the types are reported.\n sample: decodeSampleKinds(sample),\n });\n stat4ByTableAndIndex.set(`${tbl}.${idx}`, samples);\n }\n }\n\n const indexesByTable = new Map<string, IndexStats[]>();\n for (const index of indexes) {\n const key = `${index.tableName}.${index.name}`;\n const stat4 = stat4ByTableAndIndex.get(key);\n const forTable = indexesByTable.get(index.tableName) ?? [];\n forTable.push({\n name: index.name,\n columns: Object.entries(index.columns).map(([name, dir]) => ({\n name,\n dir,\n })),\n unique: index.unique,\n partial: index.partial === true,\n stat1: stat1ByTableAndIndex.get(key),\n stat4: stat4?.length ? summarizeStat4(stat4) : undefined,\n stat4Samples: opts.fullStat4 && stat4?.length ? stat4 : undefined,\n });\n indexesByTable.set(index.tableName, forTable);\n }\n\n const tablesWithoutStats: string[] = [];\n const tableStats = tables.map(table => {\n const zqlSpec = zqlSpecs.get(table.name)?.zqlSpec;\n if (!statsByTable.has(table.name)) {\n tablesWithoutStats.push(table.name);\n }\n const backfilling = new Set(table.backfilling ?? []);\n return {\n name: table.name,\n columns: Object.entries(table.columns).map(([name, spec]) => ({\n name,\n dataType: spec.dataType,\n nullable: !spec.notNull,\n zqlType: zqlSpec?.[name]?.type ?? null,\n ...(backfilling.has(name) ? {backfilling: true as const} : {}),\n })),\n primaryKey: table.primaryKey ? [...table.primaryKey] : undefined,\n estimatedRows: statsByTable.get(table.name)?.rows,\n syncable: zqlSpec !== undefined,\n indexes: indexesByTable.get(table.name) ?? [],\n };\n });\n\n return {\n about: ABOUT,\n server: {\n zeroVersion: getServerVersion(config),\n sqliteVersion: sqliteVersion(db),\n replicaWatermark: replicaWatermark(lc, db),\n generatedAt: new Date().toISOString(),\n planner: {\n enableQueryPlanner: config.enableQueryPlanner,\n // Both default to on, matching services/analyze.ts.\n enableCorrelatedPredicatePushdown:\n config.enableCorrelatedPredicatePushdown !== false,\n enablePlannerAwarePushdown: config.enablePlannerAwarePushdown !== false,\n },\n },\n statsQuality: {\n stat1Present,\n stat4Rows,\n method: stat4Rows > 0 ? FULL_METHOD : APPROXIMATE_METHOD,\n tablesWithoutStats,\n },\n tables: tableStats,\n };\n}\n\n/**\n * Lists the replica's tables, tolerating a replica that has no metadata tables\n * yet. `listTables` reads `_zero.tableMetadata` for backfill status, which does\n * not exist until initial sync has created it. Reporting the schema without\n * backfill status beats failing the whole request.\n */\nfunction listReplicaTables(lc: LogContext, db: Database) {\n try {\n return listTables(db);\n } catch (e) {\n lc.debug?.('reading table metadata failed; listing tables without it', e);\n return listTables(db, true, false);\n }\n}\n\n/**\n * Digests an index's stat4 samples.\n *\n * Note that `nLt` and `nDLt` are stored as text, so SQL cannot order the\n * samples by them numerically. This works off the whole set rather than\n * relying on the samples' order: the highest `nDLt` is a lower bound on the\n * distinct keys, since the sample holding it has that many distinct keys below\n * it plus itself.\n *\n * @visibleForTesting\n */\nexport function summarizeStat4(samples: Stat4Sample[]): Stat4Summary {\n const depth = Math.max(...samples.map(s => s.nEq.length));\n const perPrefix: Stat4PrefixSummary[] = [];\n for (let d = 0; d < depth; d++) {\n const counts = samples\n .map(s => s.nEq[d])\n .filter(n => n !== undefined)\n .sort((a, b) => a - b);\n const maxDistinctBelow = Math.max(\n ...samples.map(s => s.nDLt[d]).filter(n => n !== undefined),\n );\n perPrefix.push({\n maxRowsPerKey: counts.at(-1)!,\n medianRowsPerKey: counts[Math.floor((counts.length - 1) / 2)],\n estimatedDistinctKeys: maxDistinctBelow + 1,\n });\n }\n return {\n samples: samples.length,\n // Every sample of an index describes the same columns.\n sampleKinds: samples[0].sample,\n perPrefix,\n };\n}\n\nfunction sqliteVersion(db: Database): string {\n const [{version}] = db\n .prepare(`SELECT sqlite_version() AS version`)\n .all() as {version: string}[];\n return version;\n}\n\nfunction replicaWatermark(lc: LogContext, db: Database): string | undefined {\n try {\n return getReplicationState(new StatementRunner(db)).stateVersion;\n } catch (e) {\n // A replica that has not finished initial sync has no replication state.\n lc.debug?.('could not read replication state', e);\n return undefined;\n }\n}\n\n/**\n * Serves the replica's schema, indexes and SQLite statistics for consumption by\n * a language model, so that it can advise on query rewrites and indexes.\n *\n * Row data is never returned. See `designs/003_replica_stats_endpoint.md`.\n *\n * HTTP query parameters:\n * * `pretty`: indents the JSON\n * * `stat4=full`: includes the full sqlite_stat4 histogram, which is large\n */\nexport async function handlePlannerzRequest(\n lc: LogContext,\n config: ZeroConfig,\n req: FastifyRequest,\n res: FastifyReply,\n) {\n const credentials = auth(req);\n if (!isAdminPasswordValid(lc, config, credentials?.pass)) {\n void res\n .code(401)\n .header('WWW-Authenticate', 'Basic realm=\"Plannerz Protected Area\"')\n .send('Unauthorized');\n return;\n }\n\n const query = req.query as Record<string, unknown>;\n const indent = query.pretty !== undefined ? 2 : undefined;\n let bundle: PlannerzBundle;\n try {\n bundle = buildPlannerzBundle(lc, config, {\n fullStat4: query.stat4 === 'full',\n });\n } catch (e) {\n lc.error?.('error building plannerz bundle', e);\n await res.code(500).send({error: String(e)});\n return;\n }\n await res\n .header('Content-Type', 'application/json')\n .send(JSON.stringify(bundle, null, indent));\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"plannerz.js","names":[],"sources":["../../../../../zero-cache/src/services/plannerz.ts"],"sourcesContent":["import type {LogContext} from '@rocicorp/logger';\nimport auth from 'basic-auth';\nimport type {FastifyReply, FastifyRequest} from 'fastify';\nimport {Database} from '../../../zqlite/src/db.ts';\nimport {\n decodeSampleKinds,\n type SampleValueKind,\n} from '../../../zqlite/src/sqlite-stat4-sample.ts';\nimport type {NormalizedZeroConfig as ZeroConfig} from '../config/normalize.ts';\nimport {getOperatorAccess, getServerVersion} from '../config/zero-config.ts';\nimport {\n computeZqlSpecsFromLiteSpecs,\n listIndexes,\n listTables,\n} from '../db/lite-tables.ts';\nimport {StatementRunner} from '../db/statements.ts';\nimport {getReplicationState} from './replicator/schema/replication-state.ts';\n\n/**\n * How the statistics in the replica were gathered. zero-cache only ever runs\n * `PRAGMA optimize`, and always under an `analysis_limit`, which samples rather\n * than scanning. A replica that someone ran `ANALYZE` on by hand has better\n * statistics, which is visible in `sqlite_stat4` having rows at all.\n */\nconst APPROXIMATE_METHOD =\n 'PRAGMA optimize under an analysis_limit. Row counts and per-key averages ' +\n 'are sampled estimates, and sqlite_stat4 is not collected.';\nconst FULL_METHOD =\n 'ANALYZE, with no analysis limit. sqlite_stat4 has rows, so a full ANALYZE ' +\n 'ran against this replica at some point.';\n\n/**\n * A fixed glossary, sent with every bundle. The audience is a language model\n * that has the user's codebase but has never seen these statistics, and would\n * otherwise read a sampled estimate as an exact count.\n */\nconst ABOUT = {\n whatThisIs:\n 'Schema, indexes and SQLite statistics for the SQLite replica that ' +\n 'zero-cache queries. Use it to decide how to rewrite ZQL queries and ' +\n 'which indexes to add. It contains no row data.',\n stat1Format:\n 'sqlite_stat1.stat is \"<rows> <avg rows per distinct 1-column prefix> ' +\n '<... per 2-column prefix> ...\", parsed here into `rows` and ' +\n '`avgRowsPerPrefix`. NULLs count as ordinary values, so a sparse column ' +\n 'looks less selective than it is. Trailing flags such as \"unordered\", ' +\n '\"noskipscan\" and \"sz=N\" are kept in `flags`.',\n stat4Format:\n 'sqlite_stat4 is a per-index histogram. For each sample, `nEq` is the ' +\n 'rows equal to it, `nLt` the rows less than it, and `nDLt` the distinct ' +\n 'values less than it, one entry per key column. A large `nEq` next to a ' +\n 'small `nDLt` means a skewed column, where the average in stat1 hides ' +\n 'the common values. Sample values are never returned: `sample` reports ' +\n 'only each key column’s type.',\n howThePlannerUsesThis: [\n 'Cost comes from SQLite scanstatus estimates for the SQL each source ' +\n 'connection would run, so the statistics here are what the planner sees.',\n 'Join fanout comes from sqlite_stat4, else sqlite_stat1, else a default ' +\n 'of 3. With no stat4 (the usual case) fanout on a nullable foreign key ' +\n 'is overestimated, because stat1 counts NULLs.',\n 'The planner chooses per join whether to run it as a semi-join or to flip ' +\n 'it so the child drives. An index that matches the child’s ' +\n 'constraint plus ordering is what makes a flip cheap.',\n ],\n whatYouCanChange: [\n 'The ZQL query: `whereExists` vs `related`, the order of conditions, and ' +\n 'ordering that matches an existing index.',\n 'A `limit`, which lets the planner stop early.',\n 'Indexes: add them in Postgres, not on the replica. The replica copies ' +\n 'upstream indexes, so a new index reaches it through replication.',\n 'The planner flags in `server.planner`.',\n ],\n caveats: [\n 'Row counts are estimates from the statistics, not COUNT(*). This ' +\n 'endpoint never scans a table.',\n 'Statistics are as of the last time they were gathered, which SQLite does ' +\n 'not record. A table whose size changed a lot since then can be far off.',\n 'Tables in `tables` with `syncable: false` cannot be queried by clients: ' +\n 'they lack a primary key or unique index, or every column has a type ' +\n 'ZQL does not support.',\n ],\n} as const;\n\nexport type Stat1 = {\n /** The raw `sqlite_stat1.stat` text, in case a flag is not parsed here. */\n raw: string;\n /** Estimated rows in the table. */\n rows: number;\n /**\n * Average rows per distinct key prefix: the first entry is for a 1-column\n * prefix, the second for 2 columns, and so on.\n */\n avgRowsPerPrefix: number[];\n /** Non-numeric tokens, e.g. `unordered`, `noskipscan`, `sz=N`. */\n flags: string[];\n};\n\n/** A `sqlite_stat4` sample with its values replaced by their types. */\nexport type Stat4Sample = {\n nEq: number[];\n nLt: number[];\n nDLt: number[];\n /** One entry per key column, plus the trailing rowid. */\n sample: SampleValueKind[];\n};\n\n/**\n * A digest of an index's `sqlite_stat4` histogram, one entry per key prefix\n * depth. This is what the default response carries: a full histogram runs to\n * hundreds of samples per index, which is a lot of context to spend on data an\n * LLM would only summarize anyway.\n */\nexport type Stat4PrefixSummary = {\n /** Rows equal to the most common sampled key at this depth. */\n maxRowsPerKey: number;\n /** Median rows per sampled key at this depth. */\n medianRowsPerKey: number;\n /**\n * Lower bound on distinct keys at this depth, from the last sample's count of\n * distinct values less than it.\n */\n estimatedDistinctKeys: number;\n};\n\nexport type Stat4Summary = {\n samples: number;\n /** The type of each key column, and of the trailing rowid. */\n sampleKinds: SampleValueKind[];\n /** One entry per key prefix depth: 1 column, then 2, and so on. */\n perPrefix: Stat4PrefixSummary[];\n};\n\nexport type IndexStats = {\n name: string;\n columns: {name: string; dir: 'ASC' | 'DESC'}[];\n unique: boolean;\n partial: boolean;\n stat1?: Stat1 | undefined;\n stat4?: Stat4Summary | undefined;\n /** The full histogram, only with `?stat4=full`. */\n stat4Samples?: Stat4Sample[] | undefined;\n};\n\nexport type ColumnStats = {\n name: string;\n dataType: string;\n nullable: boolean;\n /** The ZQL type, or `null` for a column clients cannot see. */\n zqlType: string | null;\n backfilling?: true | undefined;\n};\n\nexport type TableStats = {\n name: string;\n columns: ColumnStats[];\n primaryKey: string[] | undefined;\n estimatedRows: number | undefined;\n syncable: boolean;\n indexes: IndexStats[];\n};\n\nexport type PlannerzBundle = {\n about: typeof ABOUT;\n server: {\n zeroVersion: string;\n sqliteVersion: string;\n replicaWatermark: string | undefined;\n generatedAt: string;\n planner: {\n enableQueryPlanner: boolean;\n enableCorrelatedPredicatePushdown: boolean;\n enablePlannerAwarePushdown: boolean;\n };\n };\n statsQuality: {\n stat1Present: boolean;\n stat4Rows: number;\n method: string;\n tablesWithoutStats: string[];\n };\n tables: TableStats[];\n};\n\n/**\n * Parses a `sqlite_stat1.stat` value.\n *\n * @visibleForTesting\n */\nexport function parseStat1(raw: string): Stat1 {\n const tokens = raw.trim().split(SEPARATOR).filter(Boolean);\n const numbers: number[] = [];\n const flags: string[] = [];\n for (const token of tokens) {\n // Only leading tokens are counts; anything else is a flag, even if it\n // parses as a number (e.g. a future \"sz=N\" spelled differently).\n if (flags.length === 0 && COUNT.test(token)) {\n numbers.push(parseInt(token, 10));\n } else {\n flags.push(token);\n }\n }\n return {\n raw,\n rows: numbers[0] ?? 0,\n avgRowsPerPrefix: numbers.slice(1),\n flags,\n };\n}\n\n/** Whitespace between the counts in a `sqlite_stat1` or `sqlite_stat4` value. */\nconst SEPARATOR = /\\s+/;\n/** A count, as opposed to a trailing flag such as `unordered` or `sz=N`. */\nconst COUNT = /^\\d+$/;\n\ntype Stat1Row = {tbl: string; idx: string | null; stat: string};\ntype Stat4Row = {\n tbl: string;\n idx: string;\n neq: string;\n nlt: string;\n ndlt: string;\n sample: Buffer;\n};\n\nfunction tableExists(db: Database, name: string): boolean {\n return (\n db\n .prepare(\n `SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`,\n )\n .all(name).length > 0\n );\n}\n\nfunction parseCounts(raw: string): number[] {\n return raw\n .trim()\n .split(SEPARATOR)\n .filter(Boolean)\n .map(n => parseInt(n, 10));\n}\n\n/**\n * Builds the `/plannerz` bundle from the replica's catalog, `sqlite_stat*`\n * tables and the server config. No user table is read, so the cost does not\n * depend on how much data the replica holds.\n *\n * @visibleForTesting\n */\nexport function buildPlannerzBundle(\n lc: LogContext,\n config: ZeroConfig,\n opts: BundleOptions = {},\n): PlannerzBundle {\n using db = new Database(lc, config.replica.file, {readonly: true});\n return buildPlannerzBundleFromDB(lc, db, config, opts);\n}\n\nexport type BundleOptions = {\n /** Include the full `sqlite_stat4` histogram, not just the digest. */\n fullStat4?: boolean | undefined;\n};\n\n/** @visibleForTesting */\nexport function buildPlannerzBundleFromDB(\n lc: LogContext,\n db: Database,\n config: ZeroConfig,\n opts: BundleOptions = {},\n): PlannerzBundle {\n const tables = listReplicaTables(lc, db);\n const indexes = listIndexes(db);\n const zqlSpecs = computeZqlSpecsFromLiteSpecs(\n tables,\n indexes,\n {includeBackfillingColumns: false},\n undefined,\n undefined,\n lc,\n );\n\n const stat1Present = tableExists(db, 'sqlite_stat1');\n const stat1ByTableAndIndex = new Map<string, Stat1>();\n const statsByTable = new Map<string, Stat1>();\n if (stat1Present) {\n for (const {tbl, idx, stat} of db\n .prepare(`SELECT tbl, idx, stat FROM sqlite_stat1`)\n .all() as Stat1Row[]) {\n const parsed = parseStat1(stat);\n if (idx !== null) {\n stat1ByTableAndIndex.set(`${tbl}.${idx}`, parsed);\n }\n // Every row for a table starts with the table's row count. A row with a\n // null `idx` is written for a table with no indexes, and is preferred\n // because it is about the table itself.\n if (idx === null || !statsByTable.has(tbl)) {\n statsByTable.set(tbl, parsed);\n }\n }\n }\n\n const stat4Present = tableExists(db, 'sqlite_stat4');\n const stat4ByTableAndIndex = new Map<string, Stat4Sample[]>();\n let stat4Rows = 0;\n if (stat4Present) {\n for (const {tbl, idx, neq, nlt, ndlt, sample} of db\n .prepare(`SELECT tbl, idx, neq, nlt, ndlt, sample FROM sqlite_stat4`)\n .all() as Stat4Row[]) {\n stat4Rows++;\n const samples = stat4ByTableAndIndex.get(`${tbl}.${idx}`) ?? [];\n samples.push({\n nEq: parseCounts(neq),\n nLt: parseCounts(nlt),\n nDLt: parseCounts(ndlt),\n // Values are deliberately dropped here: only the types are reported.\n sample: decodeSampleKinds(sample),\n });\n stat4ByTableAndIndex.set(`${tbl}.${idx}`, samples);\n }\n }\n\n const indexesByTable = new Map<string, IndexStats[]>();\n for (const index of indexes) {\n const key = `${index.tableName}.${index.name}`;\n const stat4 = stat4ByTableAndIndex.get(key);\n const forTable = indexesByTable.get(index.tableName) ?? [];\n forTable.push({\n name: index.name,\n columns: Object.entries(index.columns).map(([name, dir]) => ({\n name,\n dir,\n })),\n unique: index.unique,\n partial: index.partial === true,\n stat1: stat1ByTableAndIndex.get(key),\n stat4: stat4?.length ? summarizeStat4(stat4) : undefined,\n stat4Samples: opts.fullStat4 && stat4?.length ? stat4 : undefined,\n });\n indexesByTable.set(index.tableName, forTable);\n }\n\n const tablesWithoutStats: string[] = [];\n const tableStats = tables.map(table => {\n const zqlSpec = zqlSpecs.get(table.name)?.zqlSpec;\n if (!statsByTable.has(table.name)) {\n tablesWithoutStats.push(table.name);\n }\n const backfilling = new Set(table.backfilling ?? []);\n return {\n name: table.name,\n columns: Object.entries(table.columns).map(([name, spec]) => ({\n name,\n dataType: spec.dataType,\n nullable: !spec.notNull,\n zqlType: zqlSpec?.[name]?.type ?? null,\n ...(backfilling.has(name) ? {backfilling: true as const} : {}),\n })),\n primaryKey: table.primaryKey ? [...table.primaryKey] : undefined,\n estimatedRows: statsByTable.get(table.name)?.rows,\n syncable: zqlSpec !== undefined,\n indexes: indexesByTable.get(table.name) ?? [],\n };\n });\n\n return {\n about: ABOUT,\n server: {\n zeroVersion: getServerVersion(config),\n sqliteVersion: sqliteVersion(db),\n replicaWatermark: replicaWatermark(lc, db),\n generatedAt: new Date().toISOString(),\n planner: {\n enableQueryPlanner: config.enableQueryPlanner,\n // Both default to on, matching services/analyze.ts.\n enableCorrelatedPredicatePushdown:\n config.enableCorrelatedPredicatePushdown !== false,\n enablePlannerAwarePushdown: config.enablePlannerAwarePushdown !== false,\n },\n },\n statsQuality: {\n stat1Present,\n stat4Rows,\n method: stat4Rows > 0 ? FULL_METHOD : APPROXIMATE_METHOD,\n tablesWithoutStats,\n },\n tables: tableStats,\n };\n}\n\n/**\n * Lists the replica's tables, tolerating a replica that has no metadata tables\n * yet. `listTables` reads `_zero.tableMetadata` for backfill status, which does\n * not exist until initial sync has created it. Reporting the schema without\n * backfill status beats failing the whole request.\n */\nfunction listReplicaTables(lc: LogContext, db: Database) {\n try {\n return listTables(db);\n } catch (e) {\n lc.debug?.('reading table metadata failed; listing tables without it', e);\n return listTables(db, true, false);\n }\n}\n\n/**\n * Digests an index's stat4 samples.\n *\n * Note that `nLt` and `nDLt` are stored as text, so SQL cannot order the\n * samples by them numerically. This works off the whole set rather than\n * relying on the samples' order: the highest `nDLt` is a lower bound on the\n * distinct keys, since the sample holding it has that many distinct keys below\n * it plus itself.\n *\n * @visibleForTesting\n */\nexport function summarizeStat4(samples: Stat4Sample[]): Stat4Summary {\n const depth = Math.max(...samples.map(s => s.nEq.length));\n const perPrefix: Stat4PrefixSummary[] = [];\n for (let d = 0; d < depth; d++) {\n const counts = samples\n .map(s => s.nEq[d])\n .filter(n => n !== undefined)\n .sort((a, b) => a - b);\n const maxDistinctBelow = Math.max(\n ...samples.map(s => s.nDLt[d]).filter(n => n !== undefined),\n );\n perPrefix.push({\n maxRowsPerKey: counts.at(-1)!,\n medianRowsPerKey: counts[Math.floor((counts.length - 1) / 2)],\n estimatedDistinctKeys: maxDistinctBelow + 1,\n });\n }\n return {\n samples: samples.length,\n // Every sample of an index describes the same columns.\n sampleKinds: samples[0].sample,\n perPrefix,\n };\n}\n\nfunction sqliteVersion(db: Database): string {\n const [{version}] = db\n .prepare(`SELECT sqlite_version() AS version`)\n .all() as {version: string}[];\n return version;\n}\n\nfunction replicaWatermark(lc: LogContext, db: Database): string | undefined {\n try {\n return getReplicationState(new StatementRunner(db)).stateVersion;\n } catch (e) {\n // A replica that has not finished initial sync has no replication state.\n lc.debug?.('could not read replication state', e);\n return undefined;\n }\n}\n\n/**\n * Serves the replica's schema, indexes and SQLite statistics for consumption by\n * a language model, so that it can advise on query rewrites and indexes.\n *\n * Row data is never returned. See `designs/003_replica_stats_endpoint.md`.\n *\n * This is available to the operator password as well as the admin password\n * (see `--operator-password`). That does not extend to `/plannerz/analyze`,\n * which runs caller-supplied queries against the replica and so must require\n * the admin password (`isAdminPasswordValid`).\n *\n * HTTP query parameters:\n * * `pretty`: indents the JSON\n * * `stat4=full`: includes the full sqlite_stat4 histogram, which is large\n */\nexport async function handlePlannerzRequest(\n lc: LogContext,\n config: ZeroConfig,\n req: FastifyRequest,\n res: FastifyReply,\n) {\n const credentials = auth(req);\n if (getOperatorAccess(lc, config, credentials?.pass) === undefined) {\n void res\n .code(401)\n .header('WWW-Authenticate', 'Basic realm=\"Plannerz Protected Area\"')\n .send('Unauthorized');\n return;\n }\n\n const query = req.query as Record<string, unknown>;\n const indent = query.pretty !== undefined ? 2 : undefined;\n let bundle: PlannerzBundle;\n try {\n bundle = buildPlannerzBundle(lc, config, {\n fullStat4: query.stat4 === 'full',\n });\n } catch (e) {\n lc.error?.('error building plannerz bundle', e);\n await res.code(500).send({error: String(e)});\n return;\n }\n await res\n .header('Content-Type', 'application/json')\n .send(JSON.stringify(bundle, null, indent));\n}\n"],"mappings":"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"}
|
|
@@ -2,6 +2,29 @@ import type { LogContext } from '@rocicorp/logger';
|
|
|
2
2
|
import type { FastifyReply, FastifyRequest } from 'fastify';
|
|
3
3
|
import type { NormalizedZeroConfig } from '../config/normalize.ts';
|
|
4
4
|
import { type Worker } from '../types/processes.ts';
|
|
5
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Serves CPU profiles. This is available to the operator password as well as
|
|
7
|
+
* the admin password (see `--operator-password`), so it must never serve
|
|
8
|
+
* application data to `operator` access.
|
|
9
|
+
*
|
|
10
|
+
* A V8 CPU profile describes code, not data: function names, script URLs and
|
|
11
|
+
* positions, and sample timings. The exception is a regular expression, which
|
|
12
|
+
* V8 names after its source (`RegExp: <source>`). zero-cache compiles `LIKE`
|
|
13
|
+
* and `ILIKE` patterns from client queries into regular expressions, so their
|
|
14
|
+
* sources are redacted for `operator` access. See {@link redactProfile}.
|
|
15
|
+
*/
|
|
16
|
+
export declare function handleProfzRequest(lc: LogContext, config: Pick<NormalizedZeroConfig, 'adminPassword' | 'operatorPassword'>, req: FastifyRequest, res: FastifyReply, getWorker?: (() => Promise<Worker>) | undefined, forcedWorker?: string | undefined, localProcessName?: string): Promise<void>;
|
|
6
17
|
export declare function handleProfrmzRequest(lc: LogContext, config: NormalizedZeroConfig, req: FastifyRequest, res: FastifyReply, getWorker?: (() => Promise<Worker>) | undefined): Promise<void>;
|
|
18
|
+
/**
|
|
19
|
+
* Returns a copy of the V8 CPU `profile` with the source of every regular
|
|
20
|
+
* expression replaced, since a pattern built at runtime can contain
|
|
21
|
+
* application data (e.g. a `LIKE` pattern from a client query). The time spent
|
|
22
|
+
* in regular expressions is still attributed to them.
|
|
23
|
+
*
|
|
24
|
+
* A `profile` that is not in the expected format is withheld rather than
|
|
25
|
+
* served unredacted.
|
|
26
|
+
*
|
|
27
|
+
* @visibleForTesting
|
|
28
|
+
*/
|
|
29
|
+
export declare function redactProfile(profile: unknown): unknown;
|
|
7
30
|
//# sourceMappingURL=profz.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"profz.d.ts","sourceRoot":"","sources":["../../../../../zero-cache/src/services/profz.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"profz.d.ts","sourceRoot":"","sources":["../../../../../zero-cache/src/services/profz.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAC,UAAU,EAAC,MAAM,kBAAkB,CAAC;AAEjD,OAAO,KAAK,EAAC,YAAY,EAAE,cAAc,EAAC,MAAM,SAAS,CAAC;AAE1D,OAAO,KAAK,EAAC,oBAAoB,EAAC,MAAM,wBAAwB,CAAC;AAEjE,OAAO,EAKL,KAAK,MAAM,EACZ,MAAM,uBAAuB,CAAC;AAI/B;;;;;;;;;;GAUG;AACH,wBAAsB,kBAAkB,CACtC,EAAE,EAAE,UAAU,EACd,MAAM,EAAE,IAAI,CAAC,oBAAoB,EAAE,eAAe,GAAG,kBAAkB,CAAC,EACxE,GAAG,EAAE,cAAc,EACnB,GAAG,EAAE,YAAY,EACjB,SAAS,CAAC,EAAE,CAAC,MAAM,OAAO,CAAC,MAAM,CAAC,CAAC,GAAG,SAAS,EAC/C,YAAY,CAAC,EAAE,MAAM,GAAG,SAAS,EACjC,gBAAgB,GAAE,MAAqB,GACtC,OAAO,CAAC,IAAI,CAAC,CAuIf;AAED,wBAAsB,oBAAoB,CACxC,EAAE,EAAE,UAAU,EACd,MAAM,EAAE,oBAAoB,EAC5B,GAAG,EAAE,cAAc,EACnB,GAAG,EAAE,YAAY,EACjB,SAAS,CAAC,EAAE,CAAC,MAAM,OAAO,CAAC,MAAM,CAAC,CAAC,GAAG,SAAS,GAC9C,OAAO,CAAC,IAAI,CAAC,CAyDf;AAMD;;;;;;;;;;GAUG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAgBvD"}
|
|
@@ -1,13 +1,25 @@
|
|
|
1
1
|
import { sleep } from "../../../shared/src/sleep.js";
|
|
2
2
|
import { singleProcessMode, subscribeToMessageType } from "../types/processes.js";
|
|
3
|
-
import {
|
|
3
|
+
import { getOperatorAccess } from "../config/zero-config.js";
|
|
4
4
|
import { CpuProfiler } from "../types/profiler.js";
|
|
5
5
|
import { URLParams } from "../types/url-params.js";
|
|
6
6
|
import { randomUUID } from "node:crypto";
|
|
7
7
|
import auth from "basic-auth";
|
|
8
8
|
//#region ../zero-cache/src/services/profz.ts
|
|
9
|
+
/**
|
|
10
|
+
* Serves CPU profiles. This is available to the operator password as well as
|
|
11
|
+
* the admin password (see `--operator-password`), so it must never serve
|
|
12
|
+
* application data to `operator` access.
|
|
13
|
+
*
|
|
14
|
+
* A V8 CPU profile describes code, not data: function names, script URLs and
|
|
15
|
+
* positions, and sample timings. The exception is a regular expression, which
|
|
16
|
+
* V8 names after its source (`RegExp: <source>`). zero-cache compiles `LIKE`
|
|
17
|
+
* and `ILIKE` patterns from client queries into regular expressions, so their
|
|
18
|
+
* sources are redacted for `operator` access. See {@link redactProfile}.
|
|
19
|
+
*/
|
|
9
20
|
async function handleProfzRequest(lc, config, req, res, getWorker, forcedWorker, localProcessName = "dispatcher") {
|
|
10
|
-
|
|
21
|
+
const access = getOperatorAccess(lc, config, auth(req)?.pass);
|
|
22
|
+
if (access === void 0) {
|
|
11
23
|
res.code(401).header("WWW-Authenticate", "Basic realm=\"Profz Protected Area\"").send("Unauthorized");
|
|
12
24
|
return;
|
|
13
25
|
}
|
|
@@ -48,6 +60,7 @@ async function handleProfzRequest(lc, config, req, res, getWorker, forcedWorker,
|
|
|
48
60
|
const localName = targetWorker ?? (singleProcessMode() ? "zero-cache" : localProcessName);
|
|
49
61
|
responses.set(localName, localProfile);
|
|
50
62
|
}
|
|
63
|
+
if (access === "operator") for (const [name, prof] of responses) responses.set(name, redactProfile(prof));
|
|
51
64
|
if (targetWorker !== void 0 && targetWorker !== "all") {
|
|
52
65
|
let matchedProfile = responses.get(targetWorker);
|
|
53
66
|
if (!matchedProfile) {
|
|
@@ -68,7 +81,7 @@ async function handleProfzRequest(lc, config, req, res, getWorker, forcedWorker,
|
|
|
68
81
|
res.header("Content-Type", "application/json").header("Content-Disposition", `attachment; filename="zero-profile-bundle-${Date.now()}.json"`).send(bundle);
|
|
69
82
|
}
|
|
70
83
|
async function handleProfrmzRequest(lc, config, req, res, getWorker) {
|
|
71
|
-
if (
|
|
84
|
+
if (getOperatorAccess(lc, config, auth(req)?.pass) === void 0) {
|
|
72
85
|
res.code(401).header("WWW-Authenticate", "Basic realm=\"Profrmz Protected Area\"").send("Unauthorized");
|
|
73
86
|
return;
|
|
74
87
|
}
|
|
@@ -99,6 +112,33 @@ async function handleProfrmzRequest(lc, config, req, res, getWorker) {
|
|
|
99
112
|
}
|
|
100
113
|
return handleProfzRequest(lc, config, req, res, getWorker, "change-streamer");
|
|
101
114
|
}
|
|
115
|
+
var REGEXP_NAME_PREFIX = "RegExp:";
|
|
116
|
+
var REDACTED_REGEXP_NAME = "RegExp: <redacted>";
|
|
117
|
+
/**
|
|
118
|
+
* Returns a copy of the V8 CPU `profile` with the source of every regular
|
|
119
|
+
* expression replaced, since a pattern built at runtime can contain
|
|
120
|
+
* application data (e.g. a `LIKE` pattern from a client query). The time spent
|
|
121
|
+
* in regular expressions is still attributed to them.
|
|
122
|
+
*
|
|
123
|
+
* A `profile` that is not in the expected format is withheld rather than
|
|
124
|
+
* served unredacted.
|
|
125
|
+
*
|
|
126
|
+
* @visibleForTesting
|
|
127
|
+
*/
|
|
128
|
+
function redactProfile(profile) {
|
|
129
|
+
const { nodes } = profile ?? {};
|
|
130
|
+
if (!Array.isArray(nodes)) return { error: "Profile withheld: unrecognized format" };
|
|
131
|
+
return {
|
|
132
|
+
...profile,
|
|
133
|
+
nodes: nodes.map((node) => node.callFrame?.functionName?.startsWith(REGEXP_NAME_PREFIX) ? {
|
|
134
|
+
...node,
|
|
135
|
+
callFrame: {
|
|
136
|
+
...node.callFrame,
|
|
137
|
+
functionName: REDACTED_REGEXP_NAME
|
|
138
|
+
}
|
|
139
|
+
} : node)
|
|
140
|
+
};
|
|
141
|
+
}
|
|
102
142
|
//#endregion
|
|
103
143
|
export { handleProfrmzRequest, handleProfzRequest };
|
|
104
144
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"profz.js","names":[],"sources":["../../../../../zero-cache/src/services/profz.ts"],"sourcesContent":["import {randomUUID} from 'node:crypto';\nimport type {LogContext} from '@rocicorp/logger';\nimport auth from 'basic-auth';\nimport type {FastifyReply, FastifyRequest} from 'fastify';\nimport {sleep} from '../../../shared/src/sleep.ts';\nimport type {NormalizedZeroConfig} from '../config/normalize.ts';\nimport {isAdminPasswordValid} from '../config/zero-config.ts';\nimport {\n singleProcessMode,\n subscribeToMessageType,\n type ProfileResponse,\n type ProfileResponseMessage,\n type Worker,\n} from '../types/processes.ts';\nimport {CpuProfiler} from '../types/profiler.ts';\nimport {URLParams} from '../types/url-params.ts';\n\nexport async function handleProfzRequest(\n lc: LogContext,\n config: Pick<NormalizedZeroConfig, 'adminPassword'>,\n req: FastifyRequest,\n res: FastifyReply,\n getWorker?: (() => Promise<Worker>) | undefined,\n forcedWorker?: string | undefined,\n localProcessName: string = 'dispatcher',\n): Promise<void> {\n const credentials = auth(req);\n if (!isAdminPasswordValid(lc, config, credentials?.pass)) {\n void res\n .code(401)\n .header('WWW-Authenticate', 'Basic realm=\"Profz Protected Area\"')\n .send('Unauthorized');\n return;\n }\n\n const params = new URLParams(new URL(req.url, 'http://localhost'));\n const rawDuration =\n params.getInteger('duration', false) ??\n params.getInteger('seconds', false) ??\n 5;\n const durationSec = Math.max(1, Math.min(60, rawDuration));\n const durationMs = durationSec * 1000;\n const targetWorker = forcedWorker ?? params.get('worker', false) ?? undefined;\n const targetWorkerIndex = params.getInteger('index', false) ?? undefined;\n const id = randomUUID();\n\n const responses = new Map<string, unknown>();\n\n // Profile dispatcher / local process if requested, when profiling all processes,\n // or when no worker dispatcher is available (single process / standalone mode)\n const shouldProfileLocal =\n singleProcessMode() ||\n getWorker === undefined ||\n targetWorker === undefined ||\n targetWorker === localProcessName;\n\n const localProfilePromise = shouldProfileLocal\n ? CpuProfiler.profile(durationMs).catch(err => {\n lc.warn?.('Failed to capture local CPU profile:', err);\n return null;\n })\n : Promise.resolve(null);\n\n if (!singleProcessMode() && getWorker !== undefined) {\n try {\n const worker = await getWorker();\n const onMessage = (msg: ProfileResponse) => {\n if (msg.id === id && msg.profile) {\n responses.set(msg.name, msg.profile);\n } else if (msg.id === id && msg.error) {\n lc.warn?.(`Worker ${msg.name} profile error: ${msg.error}`);\n }\n };\n\n // The worker is long-lived (and shared across requests), so the\n // per-request handler must be removed once this request is done.\n const unsubscribe = subscribeToMessageType<ProfileResponseMessage>(\n worker,\n 'profileResponse',\n onMessage,\n );\n try {\n worker.send([\n 'profile',\n {\n id,\n durationMs,\n worker:\n targetWorker === localProcessName ? '__none__' : targetWorker,\n workerIndex: targetWorkerIndex,\n },\n ]);\n\n // Wait for duration plus grace period for IPC transfer\n await sleep(durationMs + 500);\n } finally {\n unsubscribe();\n }\n } catch (err) {\n lc.warn?.('Failed to dispatch profile request to child workers:', err);\n }\n }\n\n const localProfile = await localProfilePromise;\n if (localProfile) {\n const localName =\n targetWorker ?? (singleProcessMode() ? 'zero-cache' : localProcessName);\n responses.set(localName, localProfile);\n }\n\n // If a single specific worker was requested, return that profile directly\n if (targetWorker !== undefined && targetWorker !== 'all') {\n let matchedProfile = responses.get(targetWorker);\n if (!matchedProfile) {\n // Try matching by prefix or substring (e.g. 'syncer' matches 'syncer-0')\n for (const [name, prof] of responses.entries()) {\n if (name === targetWorker || name.startsWith(`${targetWorker}-`)) {\n matchedProfile = prof;\n break;\n }\n }\n }\n\n if (matchedProfile) {\n void res\n .header('Content-Type', 'application/json')\n .header(\n 'Content-Disposition',\n `attachment; filename=\"${targetWorker}.cpuprofile\"`,\n )\n .send(matchedProfile);\n return;\n }\n\n void res.code(404).send({\n error: `No profile captured for worker \"${targetWorker}\". Available: ${[...responses.keys()].join(', ')}`,\n });\n return;\n }\n\n // Default (Option A): Return multi-process profile bundle as a JSON map\n const bundle: Record<string, unknown> = {};\n for (const [name, prof] of responses.entries()) {\n bundle[name] = prof;\n }\n\n void res\n .header('Content-Type', 'application/json')\n .header(\n 'Content-Disposition',\n `attachment; filename=\"zero-profile-bundle-${Date.now()}.json\"`,\n )\n .send(bundle);\n}\n\nexport async function handleProfrmzRequest(\n lc: LogContext,\n config: NormalizedZeroConfig,\n req: FastifyRequest,\n res: FastifyReply,\n getWorker?: (() => Promise<Worker>) | undefined,\n): Promise<void> {\n const credentials = auth(req);\n if (!isAdminPasswordValid(lc, config, credentials?.pass)) {\n void res\n .code(401)\n .header('WWW-Authenticate', 'Basic realm=\"Profrmz Protected Area\"')\n .send('Unauthorized');\n return;\n }\n\n // In distributed mode, proxy to the upstream Replication Manager\n if (config.changeStreamer.uri) {\n const upstreamURL = new URL('/profz', config.changeStreamer.uri);\n if (upstreamURL.protocol === 'ws:') {\n upstreamURL.protocol = 'http:';\n } else if (upstreamURL.protocol === 'wss:') {\n upstreamURL.protocol = 'https:';\n }\n const incomingURL = new URL(req.url, 'http://localhost');\n for (const [key, value] of incomingURL.searchParams.entries()) {\n upstreamURL.searchParams.set(key, value);\n }\n\n try {\n const headers: Record<string, string> = {};\n if (req.headers.authorization) {\n headers.authorization = req.headers.authorization;\n }\n const response = await fetch(upstreamURL.toString(), {\n method: 'GET',\n headers,\n });\n\n res.code(response.status);\n const contentType = response.headers.get('content-type');\n if (contentType) {\n res.header('content-type', contentType);\n }\n const contentDisposition = response.headers.get('content-disposition');\n if (contentDisposition) {\n res.header('content-disposition', contentDisposition);\n }\n\n const body = await response.text();\n return res.send(body);\n } catch (err) {\n lc.error?.(`Failed to proxy /profrmz to RM at ${upstreamURL}:`, err);\n return res.code(502).send({\n error: `Failed to proxy profiling request to RM at ${upstreamURL}: ${String(err)}`,\n });\n }\n }\n\n // In single-node mode, profile local change-streamer / RM process\n return handleProfzRequest(lc, config, req, res, getWorker, 'change-streamer');\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"profz.js","names":[],"sources":["../../../../../zero-cache/src/services/profz.ts"],"sourcesContent":["import {randomUUID} from 'node:crypto';\nimport type {Profiler} from 'node:inspector';\nimport type {LogContext} from '@rocicorp/logger';\nimport auth from 'basic-auth';\nimport type {FastifyReply, FastifyRequest} from 'fastify';\nimport {sleep} from '../../../shared/src/sleep.ts';\nimport type {NormalizedZeroConfig} from '../config/normalize.ts';\nimport {getOperatorAccess} from '../config/zero-config.ts';\nimport {\n singleProcessMode,\n subscribeToMessageType,\n type ProfileResponse,\n type ProfileResponseMessage,\n type Worker,\n} from '../types/processes.ts';\nimport {CpuProfiler} from '../types/profiler.ts';\nimport {URLParams} from '../types/url-params.ts';\n\n/**\n * Serves CPU profiles. This is available to the operator password as well as\n * the admin password (see `--operator-password`), so it must never serve\n * application data to `operator` access.\n *\n * A V8 CPU profile describes code, not data: function names, script URLs and\n * positions, and sample timings. The exception is a regular expression, which\n * V8 names after its source (`RegExp: <source>`). zero-cache compiles `LIKE`\n * and `ILIKE` patterns from client queries into regular expressions, so their\n * sources are redacted for `operator` access. See {@link redactProfile}.\n */\nexport async function handleProfzRequest(\n lc: LogContext,\n config: Pick<NormalizedZeroConfig, 'adminPassword' | 'operatorPassword'>,\n req: FastifyRequest,\n res: FastifyReply,\n getWorker?: (() => Promise<Worker>) | undefined,\n forcedWorker?: string | undefined,\n localProcessName: string = 'dispatcher',\n): Promise<void> {\n const credentials = auth(req);\n const access = getOperatorAccess(lc, config, credentials?.pass);\n if (access === undefined) {\n void res\n .code(401)\n .header('WWW-Authenticate', 'Basic realm=\"Profz Protected Area\"')\n .send('Unauthorized');\n return;\n }\n\n const params = new URLParams(new URL(req.url, 'http://localhost'));\n const rawDuration =\n params.getInteger('duration', false) ??\n params.getInteger('seconds', false) ??\n 5;\n const durationSec = Math.max(1, Math.min(60, rawDuration));\n const durationMs = durationSec * 1000;\n const targetWorker = forcedWorker ?? params.get('worker', false) ?? undefined;\n const targetWorkerIndex = params.getInteger('index', false) ?? undefined;\n const id = randomUUID();\n\n const responses = new Map<string, unknown>();\n\n // Profile dispatcher / local process if requested, when profiling all processes,\n // or when no worker dispatcher is available (single process / standalone mode)\n const shouldProfileLocal =\n singleProcessMode() ||\n getWorker === undefined ||\n targetWorker === undefined ||\n targetWorker === localProcessName;\n\n const localProfilePromise = shouldProfileLocal\n ? CpuProfiler.profile(durationMs).catch(err => {\n lc.warn?.('Failed to capture local CPU profile:', err);\n return null;\n })\n : Promise.resolve(null);\n\n if (!singleProcessMode() && getWorker !== undefined) {\n try {\n const worker = await getWorker();\n const onMessage = (msg: ProfileResponse) => {\n if (msg.id === id && msg.profile) {\n responses.set(msg.name, msg.profile);\n } else if (msg.id === id && msg.error) {\n lc.warn?.(`Worker ${msg.name} profile error: ${msg.error}`);\n }\n };\n\n // The worker is long-lived (and shared across requests), so the\n // per-request handler must be removed once this request is done.\n const unsubscribe = subscribeToMessageType<ProfileResponseMessage>(\n worker,\n 'profileResponse',\n onMessage,\n );\n try {\n worker.send([\n 'profile',\n {\n id,\n durationMs,\n worker:\n targetWorker === localProcessName ? '__none__' : targetWorker,\n workerIndex: targetWorkerIndex,\n },\n ]);\n\n // Wait for duration plus grace period for IPC transfer\n await sleep(durationMs + 500);\n } finally {\n unsubscribe();\n }\n } catch (err) {\n lc.warn?.('Failed to dispatch profile request to child workers:', err);\n }\n }\n\n const localProfile = await localProfilePromise;\n if (localProfile) {\n const localName =\n targetWorker ?? (singleProcessMode() ? 'zero-cache' : localProcessName);\n responses.set(localName, localProfile);\n }\n\n if (access === 'operator') {\n for (const [name, prof] of responses) {\n responses.set(name, redactProfile(prof));\n }\n }\n\n // If a single specific worker was requested, return that profile directly\n if (targetWorker !== undefined && targetWorker !== 'all') {\n let matchedProfile = responses.get(targetWorker);\n if (!matchedProfile) {\n // Try matching by prefix or substring (e.g. 'syncer' matches 'syncer-0')\n for (const [name, prof] of responses.entries()) {\n if (name === targetWorker || name.startsWith(`${targetWorker}-`)) {\n matchedProfile = prof;\n break;\n }\n }\n }\n\n if (matchedProfile) {\n void res\n .header('Content-Type', 'application/json')\n .header(\n 'Content-Disposition',\n `attachment; filename=\"${targetWorker}.cpuprofile\"`,\n )\n .send(matchedProfile);\n return;\n }\n\n void res.code(404).send({\n error: `No profile captured for worker \"${targetWorker}\". Available: ${[...responses.keys()].join(', ')}`,\n });\n return;\n }\n\n // Default (Option A): Return multi-process profile bundle as a JSON map\n const bundle: Record<string, unknown> = {};\n for (const [name, prof] of responses.entries()) {\n bundle[name] = prof;\n }\n\n void res\n .header('Content-Type', 'application/json')\n .header(\n 'Content-Disposition',\n `attachment; filename=\"zero-profile-bundle-${Date.now()}.json\"`,\n )\n .send(bundle);\n}\n\nexport async function handleProfrmzRequest(\n lc: LogContext,\n config: NormalizedZeroConfig,\n req: FastifyRequest,\n res: FastifyReply,\n getWorker?: (() => Promise<Worker>) | undefined,\n): Promise<void> {\n const credentials = auth(req);\n if (getOperatorAccess(lc, config, credentials?.pass) === undefined) {\n void res\n .code(401)\n .header('WWW-Authenticate', 'Basic realm=\"Profrmz Protected Area\"')\n .send('Unauthorized');\n return;\n }\n\n // In distributed mode, proxy to the upstream Replication Manager. The\n // credentials are forwarded, and the Replication Manager's /profz decides\n // for itself what access they grant (and so what to redact).\n if (config.changeStreamer.uri) {\n const upstreamURL = new URL('/profz', config.changeStreamer.uri);\n if (upstreamURL.protocol === 'ws:') {\n upstreamURL.protocol = 'http:';\n } else if (upstreamURL.protocol === 'wss:') {\n upstreamURL.protocol = 'https:';\n }\n const incomingURL = new URL(req.url, 'http://localhost');\n for (const [key, value] of incomingURL.searchParams.entries()) {\n upstreamURL.searchParams.set(key, value);\n }\n\n try {\n const headers: Record<string, string> = {};\n if (req.headers.authorization) {\n headers.authorization = req.headers.authorization;\n }\n const response = await fetch(upstreamURL.toString(), {\n method: 'GET',\n headers,\n });\n\n res.code(response.status);\n const contentType = response.headers.get('content-type');\n if (contentType) {\n res.header('content-type', contentType);\n }\n const contentDisposition = response.headers.get('content-disposition');\n if (contentDisposition) {\n res.header('content-disposition', contentDisposition);\n }\n\n const body = await response.text();\n return res.send(body);\n } catch (err) {\n lc.error?.(`Failed to proxy /profrmz to RM at ${upstreamURL}:`, err);\n return res.code(502).send({\n error: `Failed to proxy profiling request to RM at ${upstreamURL}: ${String(err)}`,\n });\n }\n }\n\n // In single-node mode, profile local change-streamer / RM process\n return handleProfzRequest(lc, config, req, res, getWorker, 'change-streamer');\n}\n\n// V8 names the code for a regular expression `RegExp: <source>`.\nconst REGEXP_NAME_PREFIX = 'RegExp:';\nconst REDACTED_REGEXP_NAME = 'RegExp: <redacted>';\n\n/**\n * Returns a copy of the V8 CPU `profile` with the source of every regular\n * expression replaced, since a pattern built at runtime can contain\n * application data (e.g. a `LIKE` pattern from a client query). The time spent\n * in regular expressions is still attributed to them.\n *\n * A `profile` that is not in the expected format is withheld rather than\n * served unredacted.\n *\n * @visibleForTesting\n */\nexport function redactProfile(profile: unknown): unknown {\n const {nodes} = (profile ?? {}) as Partial<Profiler.Profile>;\n if (!Array.isArray(nodes)) {\n return {error: 'Profile withheld: unrecognized format'};\n }\n return {\n ...(profile as Profiler.Profile),\n nodes: nodes.map(node =>\n node.callFrame?.functionName?.startsWith(REGEXP_NAME_PREFIX)\n ? {\n ...node,\n callFrame: {...node.callFrame, functionName: REDACTED_REGEXP_NAME},\n }\n : node,\n ),\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;AA6BA,eAAsB,mBACpB,IACA,QACA,KACA,KACA,WACA,cACA,mBAA2B,cACZ;CAEf,MAAM,SAAS,kBAAkB,IAAI,QADjB,KAAK,GACoB,GAAa,IAAI;CAC9D,IAAI,WAAW,KAAA,GAAW;EACxB,IACG,KAAK,GAAG,EACR,OAAO,oBAAoB,sCAAoC,EAC/D,KAAK,cAAc;EACtB;CACF;CAEA,MAAM,SAAS,IAAI,UAAU,IAAI,IAAI,IAAI,KAAK,kBAAkB,CAAC;CACjE,MAAM,cACJ,OAAO,WAAW,YAAY,KAAK,KACnC,OAAO,WAAW,WAAW,KAAK,KAClC;CAEF,MAAM,aADc,KAAK,IAAI,GAAG,KAAK,IAAI,IAAI,WAAW,CACrC,IAAc;CACjC,MAAM,eAAe,gBAAgB,OAAO,IAAI,UAAU,KAAK,KAAK,KAAA;CACpE,MAAM,oBAAoB,OAAO,WAAW,SAAS,KAAK,KAAK,KAAA;CAC/D,MAAM,KAAK,WAAW;CAEtB,MAAM,4BAAY,IAAI,IAAqB;CAU3C,MAAM,sBALJ,kBAAkB,KAClB,cAAc,KAAA,KACd,iBAAiB,KAAA,KACjB,iBAAiB,mBAGf,YAAY,QAAQ,UAAU,EAAE,OAAM,QAAO;EAC3C,GAAG,OAAO,wCAAwC,GAAG;EACrD,OAAO;CACT,CAAC,IACD,QAAQ,QAAQ,IAAI;CAExB,IAAI,CAAC,kBAAkB,KAAK,cAAc,KAAA,GACxC,IAAI;EACF,MAAM,SAAS,MAAM,UAAU;EAC/B,MAAM,aAAa,QAAyB;GAC1C,IAAI,IAAI,OAAO,MAAM,IAAI,SACvB,UAAU,IAAI,IAAI,MAAM,IAAI,OAAO;QAC9B,IAAI,IAAI,OAAO,MAAM,IAAI,OAC9B,GAAG,OAAO,UAAU,IAAI,KAAK,kBAAkB,IAAI,OAAO;EAE9D;EAIA,MAAM,cAAc,uBAClB,QACA,mBACA,SACF;EACA,IAAI;GACF,OAAO,KAAK,CACV,WACA;IACE;IACA;IACA,QACE,iBAAiB,mBAAmB,aAAa;IACnD,aAAa;GACf,CACF,CAAC;GAGD,MAAM,MAAM,aAAa,GAAG;EAC9B,UAAU;GACR,YAAY;EACd;CACF,SAAS,KAAK;EACZ,GAAG,OAAO,wDAAwD,GAAG;CACvE;CAGF,MAAM,eAAe,MAAM;CAC3B,IAAI,cAAc;EAChB,MAAM,YACJ,iBAAiB,kBAAkB,IAAI,eAAe;EACxD,UAAU,IAAI,WAAW,YAAY;CACvC;CAEA,IAAI,WAAW,YACb,KAAK,MAAM,CAAC,MAAM,SAAS,WACzB,UAAU,IAAI,MAAM,cAAc,IAAI,CAAC;CAK3C,IAAI,iBAAiB,KAAA,KAAa,iBAAiB,OAAO;EACxD,IAAI,iBAAiB,UAAU,IAAI,YAAY;EAC/C,IAAI,CAAC;QAEE,MAAM,CAAC,MAAM,SAAS,UAAU,QAAQ,GAC3C,IAAI,SAAS,gBAAgB,KAAK,WAAW,GAAG,aAAa,EAAE,GAAG;IAChE,iBAAiB;IACjB;GACF;;EAIJ,IAAI,gBAAgB;GAClB,IACG,OAAO,gBAAgB,kBAAkB,EACzC,OACC,uBACA,yBAAyB,aAAa,aACxC,EACC,KAAK,cAAc;GACtB;EACF;EAEA,IAAS,KAAK,GAAG,EAAE,KAAK,EACtB,OAAO,mCAAmC,aAAa,gBAAgB,CAAC,GAAG,UAAU,KAAK,CAAC,EAAE,KAAK,IAAI,IACxG,CAAC;EACD;CACF;CAGA,MAAM,SAAkC,CAAC;CACzC,KAAK,MAAM,CAAC,MAAM,SAAS,UAAU,QAAQ,GAC3C,OAAO,QAAQ;CAGjB,IACG,OAAO,gBAAgB,kBAAkB,EACzC,OACC,uBACA,6CAA6C,KAAK,IAAI,EAAE,OAC1D,EACC,KAAK,MAAM;AAChB;AAEA,eAAsB,qBACpB,IACA,QACA,KACA,KACA,WACe;CAEf,IAAI,kBAAkB,IAAI,QADN,KAAK,GACS,GAAa,IAAI,MAAM,KAAA,GAAW;EAClE,IACG,KAAK,GAAG,EACR,OAAO,oBAAoB,wCAAsC,EACjE,KAAK,cAAc;EACtB;CACF;CAKA,IAAI,OAAO,eAAe,KAAK;EAC7B,MAAM,cAAc,IAAI,IAAI,UAAU,OAAO,eAAe,GAAG;EAC/D,IAAI,YAAY,aAAa,OAC3B,YAAY,WAAW;OAClB,IAAI,YAAY,aAAa,QAClC,YAAY,WAAW;EAEzB,MAAM,cAAc,IAAI,IAAI,IAAI,KAAK,kBAAkB;EACvD,KAAK,MAAM,CAAC,KAAK,UAAU,YAAY,aAAa,QAAQ,GAC1D,YAAY,aAAa,IAAI,KAAK,KAAK;EAGzC,IAAI;GACF,MAAM,UAAkC,CAAC;GACzC,IAAI,IAAI,QAAQ,eACd,QAAQ,gBAAgB,IAAI,QAAQ;GAEtC,MAAM,WAAW,MAAM,MAAM,YAAY,SAAS,GAAG;IACnD,QAAQ;IACR;GACF,CAAC;GAED,IAAI,KAAK,SAAS,MAAM;GACxB,MAAM,cAAc,SAAS,QAAQ,IAAI,cAAc;GACvD,IAAI,aACF,IAAI,OAAO,gBAAgB,WAAW;GAExC,MAAM,qBAAqB,SAAS,QAAQ,IAAI,qBAAqB;GACrE,IAAI,oBACF,IAAI,OAAO,uBAAuB,kBAAkB;GAGtD,MAAM,OAAO,MAAM,SAAS,KAAK;GACjC,OAAO,IAAI,KAAK,IAAI;EACtB,SAAS,KAAK;GACZ,GAAG,QAAQ,qCAAqC,YAAY,IAAI,GAAG;GACnE,OAAO,IAAI,KAAK,GAAG,EAAE,KAAK,EACxB,OAAO,8CAA8C,YAAY,IAAI,OAAO,GAAG,IACjF,CAAC;EACH;CACF;CAGA,OAAO,mBAAmB,IAAI,QAAQ,KAAK,KAAK,WAAW,iBAAiB;AAC9E;AAGA,IAAM,qBAAqB;AAC3B,IAAM,uBAAuB;;;;;;;;;;;;AAa7B,SAAgB,cAAc,SAA2B;CACvD,MAAM,EAAC,UAAU,WAAW,CAAC;CAC7B,IAAI,CAAC,MAAM,QAAQ,KAAK,GACtB,OAAO,EAAC,OAAO,wCAAuC;CAExD,OAAO;EACL,GAAI;EACJ,OAAO,MAAM,KAAI,SACf,KAAK,WAAW,cAAc,WAAW,kBAAkB,IACvD;GACE,GAAG;GACH,WAAW;IAAC,GAAG,KAAK;IAAW,cAAc;GAAoB;EACnE,IACA,IACN;CACF;AACF"}
|
|
@@ -2,6 +2,10 @@ import type { LogContext } from '@rocicorp/logger';
|
|
|
2
2
|
import type { FastifyReply, FastifyRequest } from 'fastify';
|
|
3
3
|
import type { NormalizedZeroConfig as ZeroConfig } from '../config/normalize.ts';
|
|
4
4
|
/**
|
|
5
|
+
* Serves counts and sizes. This is available to the operator password as well
|
|
6
|
+
* as the admin password (see `--operator-password`), so it must never return
|
|
7
|
+
* application data: no row values, query ASTs or query arguments.
|
|
8
|
+
*
|
|
5
9
|
* HTTP query parameters:
|
|
6
10
|
* * `group`: restricts the groups for which stats are computed
|
|
7
11
|
* * `format=json`: returns the stats as a JSON object
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"statz.d.ts","sourceRoot":"","sources":["../../../../../zero-cache/src/services/statz.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAC,UAAU,EAAC,MAAM,kBAAkB,CAAC;AAEjD,OAAO,KAAK,EAAC,YAAY,EAAE,cAAc,EAAC,MAAM,SAAS,CAAC;AAG1D,OAAO,KAAK,EAAC,oBAAoB,IAAI,UAAU,EAAC,MAAM,wBAAwB,CAAC;
|
|
1
|
+
{"version":3,"file":"statz.d.ts","sourceRoot":"","sources":["../../../../../zero-cache/src/services/statz.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAC,UAAU,EAAC,MAAM,kBAAkB,CAAC;AAEjD,OAAO,KAAK,EAAC,YAAY,EAAE,cAAc,EAAC,MAAM,SAAS,CAAC;AAG1D,OAAO,KAAK,EAAC,oBAAoB,IAAI,UAAU,EAAC,MAAM,wBAAwB,CAAC;AAiT/E;;;;;;;;;GASG;AACH,wBAAsB,kBAAkB,CACtC,EAAE,EAAE,UAAU,EACd,MAAM,EAAE,UAAU,EAClB,GAAG,EAAE,cAAc,EACnB,GAAG,EAAE,YAAY,iBAuDlB"}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { BigIntJSON } from "../../../shared/src/bigint-json.js";
|
|
2
2
|
import { getShardID, upstreamSchema } from "../types/shards.js";
|
|
3
|
-
import {
|
|
3
|
+
import { getOperatorAccess } from "../config/zero-config.js";
|
|
4
4
|
import { Database } from "../../../zqlite/src/db.js";
|
|
5
5
|
import { getReplicationState } from "./replicator/schema/replication-state.js";
|
|
6
6
|
import { StatementRunner } from "../db/statements.js";
|
|
@@ -144,10 +144,10 @@ async function changeLogStats(lc, config) {
|
|
|
144
144
|
}
|
|
145
145
|
}
|
|
146
146
|
function replicaStats(lc, config) {
|
|
147
|
-
const db = new Database(lc, config.replica.file);
|
|
147
|
+
const db = new Database(lc, config.replica.file, { readonly: true });
|
|
148
148
|
try {
|
|
149
149
|
return Object.fromEntries([
|
|
150
|
-
["wal
|
|
150
|
+
["wal file sizes", walFileSizes(config.replica.file)],
|
|
151
151
|
["page count", pick(first(db.pragma("PAGE_COUNT")))],
|
|
152
152
|
["page size", pick(first(db.pragma("PAGE_SIZE")))],
|
|
153
153
|
["journal mode", pick(first(db.pragma("JOURNAL_MODE")))],
|
|
@@ -163,13 +163,26 @@ function replicaStats(lc, config) {
|
|
|
163
163
|
}
|
|
164
164
|
}
|
|
165
165
|
function replicationStats(lc, config) {
|
|
166
|
-
const db = new Database(lc, config.replica.file);
|
|
166
|
+
const db = new Database(lc, config.replica.file, { readonly: true });
|
|
167
167
|
try {
|
|
168
168
|
return getReplicationStats(db);
|
|
169
169
|
} finally {
|
|
170
170
|
db.close();
|
|
171
171
|
}
|
|
172
172
|
}
|
|
173
|
+
/**
|
|
174
|
+
* The sizes of the replica's WAL files, by suffix, read from the filesystem.
|
|
175
|
+
* `PRAGMA wal_checkpoint` would report the WAL's frame counts, but it runs a
|
|
176
|
+
* checkpoint to do so, and fails on a read-only connection in WAL mode.
|
|
177
|
+
*/
|
|
178
|
+
function walFileSizes(file) {
|
|
179
|
+
const sizes = {};
|
|
180
|
+
for (const suffix of ["-wal", "-wal2"]) {
|
|
181
|
+
const stats = fs.statSync(file + suffix, { throwIfNoEntry: false });
|
|
182
|
+
if (stats) sizes[suffix] = stats.size;
|
|
183
|
+
}
|
|
184
|
+
return sizes;
|
|
185
|
+
}
|
|
173
186
|
function getReplicationStats(db) {
|
|
174
187
|
const { stateVersion } = getReplicationState(new StatementRunner(db));
|
|
175
188
|
return { lsn: fromStateVersionString(stateVersion) };
|
|
@@ -198,13 +211,17 @@ function printStats(group, statsObject) {
|
|
|
198
211
|
return lines.join("");
|
|
199
212
|
}
|
|
200
213
|
/**
|
|
214
|
+
* Serves counts and sizes. This is available to the operator password as well
|
|
215
|
+
* as the admin password (see `--operator-password`), so it must never return
|
|
216
|
+
* application data: no row values, query ASTs or query arguments.
|
|
217
|
+
*
|
|
201
218
|
* HTTP query parameters:
|
|
202
219
|
* * `group`: restricts the groups for which stats are computed
|
|
203
220
|
* * `format=json`: returns the stats as a JSON object
|
|
204
221
|
* * `pretty`: formats the JSON object with indentation
|
|
205
222
|
*/
|
|
206
223
|
async function handleStatzRequest(lc, config, req, res) {
|
|
207
|
-
if (
|
|
224
|
+
if (getOperatorAccess(lc, config, auth(req)?.pass) === void 0) {
|
|
208
225
|
res.code(401).header("WWW-Authenticate", "Basic realm=\"Statz Protected Area\"").send("Unauthorized");
|
|
209
226
|
return;
|
|
210
227
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"statz.js","names":[],"sources":["../../../../../zero-cache/src/services/statz.ts"],"sourcesContent":["import fs from 'fs';\nimport os from 'os';\nimport type {LogContext} from '@rocicorp/logger';\nimport auth from 'basic-auth';\nimport type {FastifyReply, FastifyRequest} from 'fastify';\nimport {BigIntJSON} from '../../../shared/src/bigint-json.ts';\nimport {Database} from '../../../zqlite/src/db.ts';\nimport type {NormalizedZeroConfig as ZeroConfig} from '../config/normalize.ts';\nimport {isAdminPasswordValid} from '../config/zero-config.ts';\nimport {StatementRunner} from '../db/statements.ts';\nimport {pgClient} from '../types/pg.ts';\nimport {getShardID, upstreamSchema} from '../types/shards.ts';\nimport {fromStateVersionString} from './change-source/pg/lsn.ts';\nimport {getReplicationState} from './replicator/schema/replication-state.ts';\n\nasync function upstreamStats(lc: LogContext, config: ZeroConfig) {\n const schema = upstreamSchema(getShardID(config));\n const sql = pgClient(lc, config.upstream.db, 'statz-upstream');\n try {\n return await getPgStats([\n [\n 'numReplicas',\n sql`SELECT COUNT(*) as \"c\" FROM ${sql(schema)}.\"replicas\"`,\n ],\n [\n 'numClientsWithMutations',\n sql`SELECT COUNT(*) as \"c\" FROM ${sql(schema)}.\"clients\"`,\n ],\n [\n 'numMutationsProcessed',\n sql`SELECT SUM(\"lastMutationID\") as \"c\" FROM ${sql(schema)}.\"clients\"`,\n ],\n ]);\n } finally {\n await sql.end();\n }\n}\n\nasync function cvrStats(lc: LogContext, config: ZeroConfig) {\n const schema = upstreamSchema(getShardID(config)) + '/cvr';\n const sql = pgClient(lc, config.cvr.db, 'statz-cvr');\n\n function numQueriesPerClientGroup(\n active: boolean,\n ): ReturnType<ReturnType<typeof pgClient>> {\n const filter = active\n ? sql`WHERE \"inactivatedAt\" IS NULL AND deleted = false`\n : sql`WHERE \"inactivatedAt\" IS NOT NULL AND (\"inactivatedAt\" + \"ttl\") > NOW()`;\n return sql`WITH\n group_counts AS (\n SELECT\n \"clientGroupID\",\n COUNT(*) AS num_queries\n FROM ${sql(schema)}.\"desires\"\n ${filter}\n GROUP BY \"clientGroupID\"\n ),\n -- Count distinct clientIDs per clientGroupID\n client_per_group_counts AS (\n SELECT\n \"clientGroupID\",\n COUNT(DISTINCT \"clientID\") AS num_clients\n FROM ${sql(schema)}.\"desires\"\n ${filter}\n GROUP BY \"clientGroupID\"\n )\n -- Combine all the information\n SELECT\n g.\"clientGroupID\",\n cpg.num_clients,\n g.num_queries\n FROM group_counts g\n JOIN client_per_group_counts cpg ON g.\"clientGroupID\" = cpg.\"clientGroupID\"\n ORDER BY g.num_queries DESC;`;\n }\n\n try {\n return await getPgStats([\n [\n 'totalNumQueries',\n sql`SELECT COUNT(*) as \"c\" FROM ${sql(schema)}.\"desires\"`,\n ],\n [\n 'numUniqueQueryHashes',\n sql`SELECT COUNT(DISTINCT \"queryHash\") as \"c\" FROM ${sql(\n schema,\n )}.\"desires\"`,\n ],\n [\n 'numActiveQueries',\n sql`SELECT COUNT(*) as \"c\" FROM ${sql(schema)}.\"desires\" WHERE \"inactivatedAt\" IS NULL AND \"deleted\" = false`,\n ],\n [\n 'numInactiveQueries',\n sql`SELECT COUNT(*) as \"c\" FROM ${sql(schema)}.\"desires\" WHERE \"inactivatedAt\" IS NOT NULL AND (\"inactivatedAt\" + \"ttl\") > NOW()`,\n ],\n [\n 'numDeletedQueries',\n sql`SELECT COUNT(*) as \"c\" FROM ${sql(schema)}.\"desires\" WHERE \"deleted\" = true`,\n ],\n [\n 'freshQueriesPercentiles',\n sql`WITH client_group_counts AS (\n -- Count inactive desires per clientGroupID\n SELECT\n \"clientGroupID\",\n COUNT(*) AS fresh_count\n FROM ${sql(schema)}.\"desires\"\n WHERE\n (\"inactivatedAt\" IS NOT NULL\n AND (\"inactivatedAt\" + \"ttl\") > NOW()) OR (\"inactivatedAt\" IS NULL\n AND deleted = false)\n GROUP BY \"clientGroupID\"\n )\n\n SELECT\n percentile_cont(0.50) WITHIN GROUP (ORDER BY fresh_count) AS \"p50\",\n percentile_cont(0.75) WITHIN GROUP (ORDER BY fresh_count) AS \"p75\",\n percentile_cont(0.90) WITHIN GROUP (ORDER BY fresh_count) AS \"p90\",\n percentile_cont(0.95) WITHIN GROUP (ORDER BY fresh_count) AS \"p95\",\n percentile_cont(0.99) WITHIN GROUP (ORDER BY fresh_count) AS \"p99\",\n MIN(fresh_count) AS \"min\",\n MAX(fresh_count) AS \"max\",\n AVG(fresh_count) AS \"avg\"\n FROM client_group_counts;`,\n ],\n [\n 'rowsPerClientGroupPercentiles',\n sql`WITH client_group_counts AS (\n -- Count inactive desires per clientGroupID\n SELECT\n \"clientGroupID\",\n COUNT(*) AS row_count\n FROM ${sql(schema)}.\"rows\"\n GROUP BY \"clientGroupID\"\n )\n SELECT\n percentile_cont(0.50) WITHIN GROUP (ORDER BY row_count) AS \"p50\",\n percentile_cont(0.75) WITHIN GROUP (ORDER BY row_count) AS \"p75\",\n percentile_cont(0.90) WITHIN GROUP (ORDER BY row_count) AS \"p90\",\n percentile_cont(0.95) WITHIN GROUP (ORDER BY row_count) AS \"p95\",\n percentile_cont(0.99) WITHIN GROUP (ORDER BY row_count) AS \"p99\",\n MIN(row_count) AS \"min\",\n MAX(row_count) AS \"max\",\n AVG(row_count) AS \"avg\"\n FROM client_group_counts;`,\n ],\n [\n // check for AST blowup due to DNF conversion.\n 'astSizes',\n sql`SELECT\n percentile_cont(0.25) WITHIN GROUP (ORDER BY length(\"clientAST\"::text)) AS \"25th_percentile\",\n percentile_cont(0.5) WITHIN GROUP (ORDER BY length(\"clientAST\"::text)) AS \"50th_percentile\",\n percentile_cont(0.75) WITHIN GROUP (ORDER BY length(\"clientAST\"::text)) AS \"75th_percentile\",\n percentile_cont(0.9) WITHIN GROUP (ORDER BY length(\"clientAST\"::text)) AS \"90th_percentile\",\n percentile_cont(0.95) WITHIN GROUP (ORDER BY length(\"clientAST\"::text)) AS \"95th_percentile\",\n percentile_cont(0.99) WITHIN GROUP (ORDER BY length(\"clientAST\"::text)) AS \"99th_percentile\",\n MIN(length(\"clientAST\"::text)) AS \"minimum_length\",\n MAX(length(\"clientAST\"::text)) AS \"maximum_length\",\n AVG(length(\"clientAST\"::text))::integer AS \"average_length\",\n COUNT(*) AS \"total_records\"\n FROM ${sql(schema)}.\"queries\";`,\n ],\n [\n // output the hash of the largest AST\n 'biggestAstHash',\n sql`SELECT \"queryHash\", length(\"clientAST\"::text) AS \"ast_length\"\n FROM ${sql(schema)}.\"queries\"\n ORDER BY length(\"clientAST\"::text) DESC\n LIMIT 1;`,\n ],\n [\n 'totalActiveQueriesPerClientAndClientGroup',\n numQueriesPerClientGroup(true),\n ],\n [\n 'totalInactiveQueriesPerClientAndClientGroup',\n numQueriesPerClientGroup(false),\n ],\n [\n 'totalRowsPerClientGroup',\n sql`SELECT \"clientGroupID\", COUNT(*) as \"c\" FROM ${sql(\n schema,\n )}.\"rows\" GROUP BY \"clientGroupID\" ORDER BY \"c\" DESC`,\n ],\n [\n 'numRowsPerQuery',\n sql`SELECT\n k.key AS \"queryHash\",\n COUNT(*) AS row_count\n FROM ${sql(schema)}.\"rows\" r,\n LATERAL jsonb_each(r.\"refCounts\") k\n GROUP BY k.key\n ORDER BY row_count DESC;`,\n ],\n ] satisfies [\n name: string,\n query: ReturnType<ReturnType<typeof pgClient>>,\n ][]);\n } finally {\n await sql.end();\n }\n}\n\nasync function changeLogStats(lc: LogContext, config: ZeroConfig) {\n const schema = upstreamSchema(getShardID(config)) + '/cdc';\n const sql = pgClient(lc, config.change.db, 'statz-change');\n\n try {\n return await getPgStats([\n [\n 'changeLogSize',\n sql`SELECT COUNT(*) as \"change_log_size\" FROM ${sql(schema)}.\"changeLog\"`,\n ],\n ]);\n } finally {\n await sql.end();\n }\n}\n\nfunction replicaStats(lc: LogContext, config: ZeroConfig) {\n const db = new Database(lc, config.replica.file);\n try {\n return Object.fromEntries([\n ['wal checkpoint', pick(first(db.pragma('WAL_CHECKPOINT')))],\n ['page count', pick(first(db.pragma('PAGE_COUNT')))],\n ['page size', pick(first(db.pragma('PAGE_SIZE')))],\n ['journal mode', pick(first(db.pragma('JOURNAL_MODE')))],\n ['synchronous', pick(first(db.pragma('SYNCHRONOUS')))],\n ['cache size', pick(first(db.pragma('CACHE_SIZE')))],\n ['auto vacuum', pick(first(db.pragma('AUTO_VACUUM')))],\n ['freelist count', pick(first(db.pragma('FREELIST_COUNT')))],\n ['wal autocheckpoint', pick(first(db.pragma('WAL_AUTOCHECKPOINT')))],\n ['db file stats', fs.statSync(config.replica.file)],\n ] as const);\n } finally {\n db.close();\n }\n}\n\nfunction replicationStats(lc: LogContext, config: ZeroConfig) {\n const db = new Database(lc, config.replica.file);\n try {\n return getReplicationStats(db);\n } finally {\n db.close();\n }\n}\n\nfunction getReplicationStats(db: Database) {\n const {stateVersion} = getReplicationState(new StatementRunner(db));\n const lsn = fromStateVersionString(stateVersion);\n return {lsn};\n}\n\nfunction osStats() {\n return Object.fromEntries([\n ['load avg', os.loadavg()],\n ['uptime', os.uptime()],\n ['total mem', os.totalmem()],\n ['free mem', os.freemem()],\n ['cpus', os.cpus().length],\n ['available parallelism', os.availableParallelism()],\n ['platform', os.platform()],\n ['arch', os.arch()],\n ['release', os.release()],\n ] as const);\n}\n\nasync function getPgStats(\n pendingQueries: [\n name: string,\n query: ReturnType<ReturnType<typeof pgClient>>,\n ][],\n) {\n const results = await Promise.all(\n pendingQueries.map(async ([name, query]) => [name, await query] as const),\n );\n return Object.fromEntries(results);\n}\n\ntype StatsObject = Record<string, unknown>;\n\nfunction printStats(group: string, statsObject: StatsObject): string {\n const lines: string[] = ['\\n' + header(group)];\n for (const [name, result] of Object.entries(statsObject)) {\n lines.push('\\n' + name + ': ' + BigIntJSON.stringify(result, null, 2));\n }\n lines.push('\\n');\n return lines.join('');\n}\n\n/**\n * HTTP query parameters:\n * * `group`: restricts the groups for which stats are computed\n * * `format=json`: returns the stats as a JSON object\n * * `pretty`: formats the JSON object with indentation\n */\nexport async function handleStatzRequest(\n lc: LogContext,\n config: ZeroConfig,\n req: FastifyRequest,\n res: FastifyReply,\n) {\n const credentials = auth(req);\n if (!isAdminPasswordValid(lc, config, credentials?.pass)) {\n void res\n .code(401)\n .header('WWW-Authenticate', 'Basic realm=\"Statz Protected Area\"')\n .send('Unauthorized');\n return;\n }\n\n const statsFns: Record<string, () => Promise<StatsObject> | StatsObject> = {\n upstream: () => upstreamStats(lc, config),\n cvr: () => cvrStats(lc, config),\n changeLog: () => changeLogStats(lc, config),\n replica: () => replicaStats(lc, config),\n replication: () => replicationStats(lc, config),\n os: () => osStats(),\n };\n\n async function computeStats(group: string): Promise<[string, StatsObject]> {\n try {\n return [group, await statsFns[group]()];\n } catch (e) {\n lc.error?.(`error computing ${group} stats`, e);\n return [group, {error: String(e)}];\n }\n }\n\n const query = req.query as Record<string, unknown>;\n const groups =\n typeof query.group === 'string'\n ? query.group.split(',')\n : Array.isArray(query.group)\n ? query.group\n : undefined;\n\n const stats = await Promise.all(\n groups\n ? groups.filter(g => g in statsFns).map(computeStats)\n : Object.keys(statsFns).map(computeStats),\n );\n\n if (query.format === 'json') {\n const indent = query.pretty !== undefined ? 2 : undefined;\n await res\n .header('Content-Type', 'application/json')\n .send(BigIntJSON.stringify(Object.fromEntries(stats), null, indent));\n return;\n } else {\n const body = stats\n .map(([group, statsObject]) => printStats(group, statsObject))\n .join('');\n await res.header('Content-Type', 'text/plain; charset=utf-8').send(body);\n }\n}\n\nfunction first(x: object[]): object {\n return x[0];\n}\n\nfunction pick(x: object): unknown {\n return Object.values(x)[0];\n}\n\nfunction header(name: string): string {\n return `=== ${name} ===\\n`;\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"statz.js","names":[],"sources":["../../../../../zero-cache/src/services/statz.ts"],"sourcesContent":["import fs from 'fs';\nimport os from 'os';\nimport type {LogContext} from '@rocicorp/logger';\nimport auth from 'basic-auth';\nimport type {FastifyReply, FastifyRequest} from 'fastify';\nimport {BigIntJSON} from '../../../shared/src/bigint-json.ts';\nimport {Database} from '../../../zqlite/src/db.ts';\nimport type {NormalizedZeroConfig as ZeroConfig} from '../config/normalize.ts';\nimport {getOperatorAccess} from '../config/zero-config.ts';\nimport {StatementRunner} from '../db/statements.ts';\nimport {pgClient} from '../types/pg.ts';\nimport {getShardID, upstreamSchema} from '../types/shards.ts';\nimport {fromStateVersionString} from './change-source/pg/lsn.ts';\nimport {getReplicationState} from './replicator/schema/replication-state.ts';\n\nasync function upstreamStats(lc: LogContext, config: ZeroConfig) {\n const schema = upstreamSchema(getShardID(config));\n const sql = pgClient(lc, config.upstream.db, 'statz-upstream');\n try {\n return await getPgStats([\n [\n 'numReplicas',\n sql`SELECT COUNT(*) as \"c\" FROM ${sql(schema)}.\"replicas\"`,\n ],\n [\n 'numClientsWithMutations',\n sql`SELECT COUNT(*) as \"c\" FROM ${sql(schema)}.\"clients\"`,\n ],\n [\n 'numMutationsProcessed',\n sql`SELECT SUM(\"lastMutationID\") as \"c\" FROM ${sql(schema)}.\"clients\"`,\n ],\n ]);\n } finally {\n await sql.end();\n }\n}\n\nasync function cvrStats(lc: LogContext, config: ZeroConfig) {\n const schema = upstreamSchema(getShardID(config)) + '/cvr';\n const sql = pgClient(lc, config.cvr.db, 'statz-cvr');\n\n function numQueriesPerClientGroup(\n active: boolean,\n ): ReturnType<ReturnType<typeof pgClient>> {\n const filter = active\n ? sql`WHERE \"inactivatedAt\" IS NULL AND deleted = false`\n : sql`WHERE \"inactivatedAt\" IS NOT NULL AND (\"inactivatedAt\" + \"ttl\") > NOW()`;\n return sql`WITH\n group_counts AS (\n SELECT\n \"clientGroupID\",\n COUNT(*) AS num_queries\n FROM ${sql(schema)}.\"desires\"\n ${filter}\n GROUP BY \"clientGroupID\"\n ),\n -- Count distinct clientIDs per clientGroupID\n client_per_group_counts AS (\n SELECT\n \"clientGroupID\",\n COUNT(DISTINCT \"clientID\") AS num_clients\n FROM ${sql(schema)}.\"desires\"\n ${filter}\n GROUP BY \"clientGroupID\"\n )\n -- Combine all the information\n SELECT\n g.\"clientGroupID\",\n cpg.num_clients,\n g.num_queries\n FROM group_counts g\n JOIN client_per_group_counts cpg ON g.\"clientGroupID\" = cpg.\"clientGroupID\"\n ORDER BY g.num_queries DESC;`;\n }\n\n try {\n return await getPgStats([\n [\n 'totalNumQueries',\n sql`SELECT COUNT(*) as \"c\" FROM ${sql(schema)}.\"desires\"`,\n ],\n [\n 'numUniqueQueryHashes',\n sql`SELECT COUNT(DISTINCT \"queryHash\") as \"c\" FROM ${sql(\n schema,\n )}.\"desires\"`,\n ],\n [\n 'numActiveQueries',\n sql`SELECT COUNT(*) as \"c\" FROM ${sql(schema)}.\"desires\" WHERE \"inactivatedAt\" IS NULL AND \"deleted\" = false`,\n ],\n [\n 'numInactiveQueries',\n sql`SELECT COUNT(*) as \"c\" FROM ${sql(schema)}.\"desires\" WHERE \"inactivatedAt\" IS NOT NULL AND (\"inactivatedAt\" + \"ttl\") > NOW()`,\n ],\n [\n 'numDeletedQueries',\n sql`SELECT COUNT(*) as \"c\" FROM ${sql(schema)}.\"desires\" WHERE \"deleted\" = true`,\n ],\n [\n 'freshQueriesPercentiles',\n sql`WITH client_group_counts AS (\n -- Count inactive desires per clientGroupID\n SELECT\n \"clientGroupID\",\n COUNT(*) AS fresh_count\n FROM ${sql(schema)}.\"desires\"\n WHERE\n (\"inactivatedAt\" IS NOT NULL\n AND (\"inactivatedAt\" + \"ttl\") > NOW()) OR (\"inactivatedAt\" IS NULL\n AND deleted = false)\n GROUP BY \"clientGroupID\"\n )\n\n SELECT\n percentile_cont(0.50) WITHIN GROUP (ORDER BY fresh_count) AS \"p50\",\n percentile_cont(0.75) WITHIN GROUP (ORDER BY fresh_count) AS \"p75\",\n percentile_cont(0.90) WITHIN GROUP (ORDER BY fresh_count) AS \"p90\",\n percentile_cont(0.95) WITHIN GROUP (ORDER BY fresh_count) AS \"p95\",\n percentile_cont(0.99) WITHIN GROUP (ORDER BY fresh_count) AS \"p99\",\n MIN(fresh_count) AS \"min\",\n MAX(fresh_count) AS \"max\",\n AVG(fresh_count) AS \"avg\"\n FROM client_group_counts;`,\n ],\n [\n 'rowsPerClientGroupPercentiles',\n sql`WITH client_group_counts AS (\n -- Count inactive desires per clientGroupID\n SELECT\n \"clientGroupID\",\n COUNT(*) AS row_count\n FROM ${sql(schema)}.\"rows\"\n GROUP BY \"clientGroupID\"\n )\n SELECT\n percentile_cont(0.50) WITHIN GROUP (ORDER BY row_count) AS \"p50\",\n percentile_cont(0.75) WITHIN GROUP (ORDER BY row_count) AS \"p75\",\n percentile_cont(0.90) WITHIN GROUP (ORDER BY row_count) AS \"p90\",\n percentile_cont(0.95) WITHIN GROUP (ORDER BY row_count) AS \"p95\",\n percentile_cont(0.99) WITHIN GROUP (ORDER BY row_count) AS \"p99\",\n MIN(row_count) AS \"min\",\n MAX(row_count) AS \"max\",\n AVG(row_count) AS \"avg\"\n FROM client_group_counts;`,\n ],\n [\n // check for AST blowup due to DNF conversion.\n 'astSizes',\n sql`SELECT\n percentile_cont(0.25) WITHIN GROUP (ORDER BY length(\"clientAST\"::text)) AS \"25th_percentile\",\n percentile_cont(0.5) WITHIN GROUP (ORDER BY length(\"clientAST\"::text)) AS \"50th_percentile\",\n percentile_cont(0.75) WITHIN GROUP (ORDER BY length(\"clientAST\"::text)) AS \"75th_percentile\",\n percentile_cont(0.9) WITHIN GROUP (ORDER BY length(\"clientAST\"::text)) AS \"90th_percentile\",\n percentile_cont(0.95) WITHIN GROUP (ORDER BY length(\"clientAST\"::text)) AS \"95th_percentile\",\n percentile_cont(0.99) WITHIN GROUP (ORDER BY length(\"clientAST\"::text)) AS \"99th_percentile\",\n MIN(length(\"clientAST\"::text)) AS \"minimum_length\",\n MAX(length(\"clientAST\"::text)) AS \"maximum_length\",\n AVG(length(\"clientAST\"::text))::integer AS \"average_length\",\n COUNT(*) AS \"total_records\"\n FROM ${sql(schema)}.\"queries\";`,\n ],\n [\n // output the hash of the largest AST\n 'biggestAstHash',\n sql`SELECT \"queryHash\", length(\"clientAST\"::text) AS \"ast_length\"\n FROM ${sql(schema)}.\"queries\"\n ORDER BY length(\"clientAST\"::text) DESC\n LIMIT 1;`,\n ],\n [\n 'totalActiveQueriesPerClientAndClientGroup',\n numQueriesPerClientGroup(true),\n ],\n [\n 'totalInactiveQueriesPerClientAndClientGroup',\n numQueriesPerClientGroup(false),\n ],\n [\n 'totalRowsPerClientGroup',\n sql`SELECT \"clientGroupID\", COUNT(*) as \"c\" FROM ${sql(\n schema,\n )}.\"rows\" GROUP BY \"clientGroupID\" ORDER BY \"c\" DESC`,\n ],\n [\n 'numRowsPerQuery',\n sql`SELECT\n k.key AS \"queryHash\",\n COUNT(*) AS row_count\n FROM ${sql(schema)}.\"rows\" r,\n LATERAL jsonb_each(r.\"refCounts\") k\n GROUP BY k.key\n ORDER BY row_count DESC;`,\n ],\n ] satisfies [\n name: string,\n query: ReturnType<ReturnType<typeof pgClient>>,\n ][]);\n } finally {\n await sql.end();\n }\n}\n\nasync function changeLogStats(lc: LogContext, config: ZeroConfig) {\n const schema = upstreamSchema(getShardID(config)) + '/cdc';\n const sql = pgClient(lc, config.change.db, 'statz-change');\n\n try {\n return await getPgStats([\n [\n 'changeLogSize',\n sql`SELECT COUNT(*) as \"change_log_size\" FROM ${sql(schema)}.\"changeLog\"`,\n ],\n ]);\n } finally {\n await sql.end();\n }\n}\n\n// The replica is opened read-only: /statz is available to the operator\n// password, and must not change the replica. Among other things, a writable\n// connection would run `PRAGMA optimize` when closed.\nfunction replicaStats(lc: LogContext, config: ZeroConfig) {\n const db = new Database(lc, config.replica.file, {readonly: true});\n try {\n return Object.fromEntries([\n ['wal file sizes', walFileSizes(config.replica.file)],\n ['page count', pick(first(db.pragma('PAGE_COUNT')))],\n ['page size', pick(first(db.pragma('PAGE_SIZE')))],\n ['journal mode', pick(first(db.pragma('JOURNAL_MODE')))],\n ['synchronous', pick(first(db.pragma('SYNCHRONOUS')))],\n ['cache size', pick(first(db.pragma('CACHE_SIZE')))],\n ['auto vacuum', pick(first(db.pragma('AUTO_VACUUM')))],\n ['freelist count', pick(first(db.pragma('FREELIST_COUNT')))],\n ['wal autocheckpoint', pick(first(db.pragma('WAL_AUTOCHECKPOINT')))],\n ['db file stats', fs.statSync(config.replica.file)],\n ] as const);\n } finally {\n db.close();\n }\n}\n\nfunction replicationStats(lc: LogContext, config: ZeroConfig) {\n const db = new Database(lc, config.replica.file, {readonly: true});\n try {\n return getReplicationStats(db);\n } finally {\n db.close();\n }\n}\n\n/**\n * The sizes of the replica's WAL files, by suffix, read from the filesystem.\n * `PRAGMA wal_checkpoint` would report the WAL's frame counts, but it runs a\n * checkpoint to do so, and fails on a read-only connection in WAL mode.\n */\nfunction walFileSizes(file: string): Record<string, number> {\n const sizes: Record<string, number> = {};\n // `-wal` is used in WAL mode, and both files in WAL2 mode.\n for (const suffix of ['-wal', '-wal2']) {\n const stats = fs.statSync(file + suffix, {throwIfNoEntry: false});\n if (stats) {\n sizes[suffix] = stats.size;\n }\n }\n return sizes;\n}\n\nfunction getReplicationStats(db: Database) {\n const {stateVersion} = getReplicationState(new StatementRunner(db));\n const lsn = fromStateVersionString(stateVersion);\n return {lsn};\n}\n\nfunction osStats() {\n return Object.fromEntries([\n ['load avg', os.loadavg()],\n ['uptime', os.uptime()],\n ['total mem', os.totalmem()],\n ['free mem', os.freemem()],\n ['cpus', os.cpus().length],\n ['available parallelism', os.availableParallelism()],\n ['platform', os.platform()],\n ['arch', os.arch()],\n ['release', os.release()],\n ] as const);\n}\n\nasync function getPgStats(\n pendingQueries: [\n name: string,\n query: ReturnType<ReturnType<typeof pgClient>>,\n ][],\n) {\n const results = await Promise.all(\n pendingQueries.map(async ([name, query]) => [name, await query] as const),\n );\n return Object.fromEntries(results);\n}\n\ntype StatsObject = Record<string, unknown>;\n\nfunction printStats(group: string, statsObject: StatsObject): string {\n const lines: string[] = ['\\n' + header(group)];\n for (const [name, result] of Object.entries(statsObject)) {\n lines.push('\\n' + name + ': ' + BigIntJSON.stringify(result, null, 2));\n }\n lines.push('\\n');\n return lines.join('');\n}\n\n/**\n * Serves counts and sizes. This is available to the operator password as well\n * as the admin password (see `--operator-password`), so it must never return\n * application data: no row values, query ASTs or query arguments.\n *\n * HTTP query parameters:\n * * `group`: restricts the groups for which stats are computed\n * * `format=json`: returns the stats as a JSON object\n * * `pretty`: formats the JSON object with indentation\n */\nexport async function handleStatzRequest(\n lc: LogContext,\n config: ZeroConfig,\n req: FastifyRequest,\n res: FastifyReply,\n) {\n const credentials = auth(req);\n if (getOperatorAccess(lc, config, credentials?.pass) === undefined) {\n void res\n .code(401)\n .header('WWW-Authenticate', 'Basic realm=\"Statz Protected Area\"')\n .send('Unauthorized');\n return;\n }\n\n const statsFns: Record<string, () => Promise<StatsObject> | StatsObject> = {\n upstream: () => upstreamStats(lc, config),\n cvr: () => cvrStats(lc, config),\n changeLog: () => changeLogStats(lc, config),\n replica: () => replicaStats(lc, config),\n replication: () => replicationStats(lc, config),\n os: () => osStats(),\n };\n\n async function computeStats(group: string): Promise<[string, StatsObject]> {\n try {\n return [group, await statsFns[group]()];\n } catch (e) {\n lc.error?.(`error computing ${group} stats`, e);\n return [group, {error: String(e)}];\n }\n }\n\n const query = req.query as Record<string, unknown>;\n const groups =\n typeof query.group === 'string'\n ? query.group.split(',')\n : Array.isArray(query.group)\n ? query.group\n : undefined;\n\n const stats = await Promise.all(\n groups\n ? groups.filter(g => g in statsFns).map(computeStats)\n : Object.keys(statsFns).map(computeStats),\n );\n\n if (query.format === 'json') {\n const indent = query.pretty !== undefined ? 2 : undefined;\n await res\n .header('Content-Type', 'application/json')\n .send(BigIntJSON.stringify(Object.fromEntries(stats), null, indent));\n return;\n } else {\n const body = stats\n .map(([group, statsObject]) => printStats(group, statsObject))\n .join('');\n await res.header('Content-Type', 'text/plain; charset=utf-8').send(body);\n }\n}\n\nfunction first(x: object[]): object {\n return x[0];\n}\n\nfunction pick(x: object): unknown {\n return Object.values(x)[0];\n}\n\nfunction header(name: string): string {\n return `=== ${name} ===\\n`;\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"snapshotter.d.ts","sourceRoot":"","sources":["../../../../../../zero-cache/src/services/view-syncer/snapshotter.ts"],"names":[],"mappings":"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
|
|
1
|
+
{"version":3,"file":"snapshotter.d.ts","sourceRoot":"","sources":["../../../../../../zero-cache/src/services/view-syncer/snapshotter.ts"],"names":[],"mappings":"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"}
|
|
@@ -192,6 +192,23 @@ function readsFor(table) {
|
|
|
192
192
|
}));
|
|
193
193
|
}
|
|
194
194
|
var MAX_MEMOIZED_UNIQUE_KEYS = 30;
|
|
195
|
+
/**
|
|
196
|
+
* The index that SQLite creates for the `PRIMARY KEY("stateVersion", "pos")`
|
|
197
|
+
* of `_zero.changeLog2` (the first constraint of its CREATE TABLE statement,
|
|
198
|
+
* hence the `_1` suffix).
|
|
199
|
+
*
|
|
200
|
+
* For a `stateVersion > ?` query that reads a column outside of this index
|
|
201
|
+
* (e.g. `op`) and has no ORDER BY, the query planner chooses between a range
|
|
202
|
+
* search of the index and a full scan of the table based on `sqlite_stat1`.
|
|
203
|
+
* Statistics recorded while the change log held only a few rows make the full
|
|
204
|
+
* scan look cheaper, and they persist as the table grows to millions of rows:
|
|
205
|
+
* the change log of a replication-manager's replica only receives entries
|
|
206
|
+
* during column backfills, and view-syncers inherit its statistics when they
|
|
207
|
+
* restore its backup. Such queries therefore pin this index with INDEXED BY,
|
|
208
|
+
* which also makes them fail to prepare (rather than silently scan) if the
|
|
209
|
+
* index is ever renamed.
|
|
210
|
+
*/
|
|
211
|
+
var CHANGE_LOG_STATE_VERSION_INDEX = "sqlite_autoindex__zero.changeLog2_1";
|
|
195
212
|
var Snapshot = class Snapshot {
|
|
196
213
|
static create(lc, dbFile, appID, pageCacheSizeKib) {
|
|
197
214
|
const conn = new Database(lc, dbFile);
|
|
@@ -222,7 +239,8 @@ var Snapshot = class Snapshot {
|
|
|
222
239
|
* stale with respect to this snapshot if so.
|
|
223
240
|
*/
|
|
224
241
|
schemaChangedSince(prevVersion) {
|
|
225
|
-
return this.db.get(
|
|
242
|
+
return this.db.get(`SELECT 1 FROM "_zero.changeLog2" INDEXED BY "${CHANGE_LOG_STATE_VERSION_INDEX}"
|
|
243
|
+
WHERE stateVersion > ? AND op = ? LIMIT 1`, prevVersion, "r") !== void 0;
|
|
226
244
|
}
|
|
227
245
|
/**
|
|
228
246
|
* Whether any table-wide op (i.e. a RESET or TRUNCATE) was logged after
|
|
@@ -230,7 +248,8 @@ var Snapshot = class Snapshot {
|
|
|
230
248
|
* abort with a {@link ResetPipelinesSignal} if so.
|
|
231
249
|
*/
|
|
232
250
|
hasTableWideOpSince(prevVersion) {
|
|
233
|
-
return this.db.get(
|
|
251
|
+
return this.db.get(`SELECT 1 FROM "_zero.changeLog2" INDEXED BY "${CHANGE_LOG_STATE_VERSION_INDEX}"
|
|
252
|
+
WHERE stateVersion > ? AND op IN (?, ?) LIMIT 1`, prevVersion, "r", "t") !== void 0;
|
|
234
253
|
}
|
|
235
254
|
changesSince(prevVersion) {
|
|
236
255
|
const cached = this.db.statementCache.get(`SELECT "stateVersion", "table", "rowKey", "op" FROM "_zero.changeLog2"
|