@mason-data/cli 0.7.0 → 0.9.0
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/README.md +101 -73
- package/dist/src/api/error.js +3 -0
- package/dist/src/api/postgres/catalog-worker.js +49 -2
- package/dist/src/api/postgres/catalog.js +368 -25
- package/dist/src/api/postgres/encode.js +3 -1
- package/dist/src/api/postgres/explain.js +3 -2
- package/dist/src/api/postgres/listener.js +11 -3
- package/dist/src/api/postgres/protocol.js +39 -23
- package/dist/src/api/postgres/proxy.js +283 -0
- package/dist/src/api/postgres/schema.js +6 -0
- package/dist/src/api/postgres/server.js +1617 -245
- package/dist/src/api/postgres/translate.js +127 -10
- package/dist/src/api/request-log.js +18 -5
- package/dist/src/cdc/poll.js +5 -5
- package/dist/src/cli/background.js +47 -25
- package/dist/src/cli/bridge-stdin.js +101 -0
- package/dist/src/cli/cancel.js +75 -0
- package/dist/src/cli/command.js +170 -11
- package/dist/src/cli/commands.js +804 -209
- package/dist/src/cli/control.js +59 -0
- package/dist/src/cli/credentials.js +182 -0
- package/dist/src/cli/demo.js +20 -11
- package/dist/src/cli/deploy.js +574 -0
- package/dist/src/cli/dev.js +491 -130
- package/dist/src/cli/doctor.js +131 -38
- package/dist/src/cli/generated-files.js +143 -0
- package/dist/src/cli/guides.js +175 -36
- package/dist/src/cli/hosted.js +850 -0
- package/dist/src/cli/init.js +162 -65
- package/dist/src/cli/login.js +289 -0
- package/dist/src/cli/machine.js +13 -12
- package/dist/src/cli/main.js +11 -5
- package/dist/src/cli/mason.cjs +17 -0
- package/dist/src/cli/mcp.js +31 -22
- package/dist/src/cli/metrics.js +79 -0
- package/dist/src/cli/output-gate.js +176 -0
- package/dist/src/cli/page-share.js +96 -0
- package/dist/src/cli/pg-tls.js +14 -4
- package/dist/src/cli/pg-url.js +4 -0
- package/dist/src/cli/pin.js +19 -14
- package/dist/src/cli/pool-size.cjs +85 -0
- package/dist/src/cli/ports.js +2 -0
- package/dist/src/cli/preflight.js +138 -46
- package/dist/src/cli/project.js +142 -4
- package/dist/src/cli/readiness.js +76 -6
- package/dist/src/cli/registry.js +65 -38
- package/dist/src/cli/replica-hosted.js +665 -0
- package/dist/src/cli/replica.js +76 -0
- package/dist/src/cli/replication-checks.js +7 -5
- package/dist/src/cli/replication-mode.js +105 -26
- package/dist/src/cli/request-filter.js +134 -0
- package/dist/src/cli/roles.js +585 -0
- package/dist/src/cli/run-server.js +637 -48
- package/dist/src/cli/runs.js +1 -1
- package/dist/src/cli/security.js +67 -18
- package/dist/src/cli/serve.js +204 -46
- package/dist/src/cli/share-run.js +329 -0
- package/dist/src/cli/share.js +286 -0
- package/dist/src/cli/source-edit.js +75 -17
- package/dist/src/cli/source-role.js +344 -0
- package/dist/src/cli/sources.js +306 -47
- package/dist/src/cli/style.js +2 -1
- package/dist/src/cli/supervise.js +27 -24
- package/dist/src/cli/sync.js +55 -25
- package/dist/src/cli/token-hosted.js +337 -0
- package/dist/src/cli/tunnel.js +200 -0
- package/dist/src/cli/ui-ssh.js +460 -0
- package/dist/src/cli/ui.js +124 -17
- package/dist/src/cli/usage.js +128 -0
- package/dist/src/cli/user-hosted.js +299 -0
- package/dist/src/cli/user.js +573 -65
- package/dist/src/cli/version.js +9 -7
- package/dist/src/demo/seed.js +29 -0
- package/dist/src/duck/analyze.js +36 -20
- package/dist/src/duck/cancel.js +10 -1
- package/dist/src/duck/collation.js +266 -0
- package/dist/src/duck/constant-key.js +94 -0
- package/dist/src/duck/distinct-key.js +283 -26
- package/dist/src/duck/events.js +165 -0
- package/dist/src/duck/executor-worker.js +20 -11
- package/dist/src/duck/executor.js +47 -33
- package/dist/src/duck/explain.js +60 -46
- package/dist/src/duck/extensions.js +160 -0
- package/dist/src/duck/functions.js +461 -63
- package/dist/src/duck/gaps.js +48 -21
- package/dist/src/duck/generated-plan.js +1070 -0
- package/dist/src/duck/generated.js +906 -0
- package/dist/src/duck/grants.js +176 -0
- package/dist/src/duck/held-types.js +64 -0
- package/dist/src/duck/history.js +127 -0
- package/dist/src/duck/jsonb-order.js +9 -12
- package/dist/src/duck/kept-route.js +6 -5
- package/dist/src/duck/kept.js +15 -13
- package/dist/src/duck/late.js +1469 -40
- package/dist/src/duck/libc-bound.js +341 -0
- package/dist/src/duck/libc-order.js +892 -126
- package/dist/src/duck/pg-eval-worker.js +7 -1
- package/dist/src/duck/pg-eval.js +6 -4
- package/dist/src/duck/pgtree.js +774 -23
- package/dist/src/duck/plan.js +369 -47
- package/dist/src/duck/publication.js +20 -6
- package/dist/src/duck/requests.js +269 -93
- package/dist/src/duck/resolved.js +117 -2
- package/dist/src/duck/rewrite.js +4584 -580
- package/dist/src/duck/row-filter.js +655 -0
- package/dist/src/duck/statement.js +19 -0
- package/dist/src/duck/store.js +439 -177
- package/dist/src/duck/stream.js +19 -10
- package/dist/src/duck/sync.js +1411 -368
- package/dist/src/duck/token.js +4 -1
- package/dist/src/duck/types.js +69 -12
- package/dist/src/duck/usage.js +326 -0
- package/dist/src/duck/users.js +69 -15
- package/dist/src/duck/verified.js +161 -1
- package/dist/src/duck/views.js +5 -5
- package/dist/src/lib/database-name.js +62 -0
- package/dist/src/lib/database-url.js +55 -10
- package/dist/src/lib/dotenv.js +17 -4
- package/dist/src/lib/instant.js +50 -0
- package/dist/src/lib/log-buffer.js +77 -0
- package/dist/src/lib/log-read.js +196 -0
- package/dist/src/lib/pg-numeric.js +390 -49
- package/dist/src/lib/project-version.js +23 -23
- package/dist/src/lib/secret-mask.js +142 -0
- package/dist/src/lib/table-sync.js +282 -0
- package/dist/src/web/app.css +706 -66
- package/dist/src/web/app.js +331 -409
- package/dist/src/web/charts.js +229 -0
- package/dist/src/web/index.html +82 -18
- package/dist/src/web/pages/queries.js +192 -0
- package/dist/src/web/pages/status.js +611 -0
- package/dist/src/web/pages/syncs.js +155 -0
- package/dist/src/web/pages/tables.js +439 -0
- package/dist/src/web/pages/usage.js +397 -0
- package/dist/src/web/pages/users.js +219 -0
- package/dist/src/web/shared.js +584 -0
- package/dist/src/web/vendor/README.md +25 -0
- package/dist/src/web/vendor/geist-1.7.2/Geist-Variable.woff2 +0 -0
- package/dist/src/web/vendor/geist-1.7.2/GeistMono-Variable.woff2 +0 -0
- package/dist/src/web/vendor/geist-1.7.2/LICENSE.txt +92 -0
- package/dist/src/web/vendor/lucide-1.56.0/LICENSE.txt +43 -0
- package/dist/src/web/vendor/lucide-1.56.0/chart-column.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/layout-dashboard.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/logs.mjs +34 -0
- package/dist/src/web/vendor/lucide-1.56.0/menu.mjs +28 -0
- package/dist/src/web/vendor/lucide-1.56.0/square-terminal.mjs +28 -0
- package/dist/src/web/vendor/lucide-1.56.0/table.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/users.mjs +29 -0
- package/dist/src/web/vendor/lucide-1.56.0/x.mjs +27 -0
- package/dist/src/web/vendor/uplot-1.6.32/LICENSE.txt +21 -0
- package/dist/src/web/vendor/uplot-1.6.32/uPlot.esm.js +6140 -0
- package/dist/src/web/vendor/uplot-1.6.32/uPlot.min.css +1 -0
- package/dist/src/web/vendor/zilla-slab-5.3.0/LICENSE.txt +93 -0
- package/dist/src/web/vendor/zilla-slab-5.3.0/zilla-slab-latin-700-normal.woff2 +0 -0
- package/package.json +12 -4
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
const requiredOf = (t) => t.columns.filter((c) => !c.generated).map((c) => c.name);
|
|
2
2
|
/** what follows every refusal: Mason waits for the owner, and when it looks again */
|
|
3
3
|
const NEVER_ALTERED = "Mason reads a publication it is given as it is, never altering it: once its owner has run that, the next mason start " +
|
|
4
|
-
"(or mason add) checks it again. Or
|
|
5
|
-
"
|
|
4
|
+
"(or mason add) checks it again. Or give the table's entry sync: poll <interval> in the sources file, which needs no publication; " +
|
|
5
|
+
"or write publication: null in it, where Mason makes its own, carrying the replicated tables alone (not on a read replica).";
|
|
6
6
|
/**
|
|
7
7
|
* The verdict over a described publication (pure: `described` is what `readNamedPublication` read). `identities` are
|
|
8
8
|
* the tables' `relreplident`: a table whose identity is not its key is expected WHOLE (a column list missing an
|
|
@@ -25,6 +25,20 @@ export function namedPublicationVerdict(described, tables, identities) {
|
|
|
25
25
|
}
|
|
26
26
|
return { refused: null, tables: out, noTruncate };
|
|
27
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* A TABLE'S GAP ON ONE LINE, for its line in `mason status` (src/duck/sync.ts `notStreamedReason`): what is wrong, the
|
|
30
|
+
* statements its owner runs (this module's own "Expected" block, joined), and the way that needs no publication. A gap
|
|
31
|
+
* written otherwise (one line) is its own first line.
|
|
32
|
+
*/
|
|
33
|
+
export function gapLine(gap) {
|
|
34
|
+
const lines = gap.split("\n");
|
|
35
|
+
const at = lines.findIndex((l) => l.startsWith("Expected ("));
|
|
36
|
+
if (at < 0)
|
|
37
|
+
return `${lines[0]} — or give its entry sync: poll <interval> in the sources file, which needs no publication`;
|
|
38
|
+
const end = lines.findIndex((l, i) => i > at && l.trim() === "");
|
|
39
|
+
const sql = lines.slice(at + 1, end < 0 ? undefined : end).map((l) => l.trim()).filter((l) => !l.startsWith("(")).join(" ");
|
|
40
|
+
return `${lines[0]} — the database's owner runs ${sql} — or give its entry sync: poll <interval> in the sources file, which needs no publication`;
|
|
41
|
+
}
|
|
28
42
|
// ------------------------------------------------------------------ the check
|
|
29
43
|
const q = (s) => /^[a-z_][a-z0-9_]*$/.test(s) ? s : `"${s.replace(/"/g, '""')}"`;
|
|
30
44
|
const qualified = (t) => t.split(".").map(q).join(".");
|
|
@@ -56,13 +70,13 @@ function checkPublication(required, described, whole) {
|
|
|
56
70
|
continue;
|
|
57
71
|
if (d.published === null) {
|
|
58
72
|
problems.push(`${table} is not published`);
|
|
59
|
-
missingLines.push(` missing: ${table} (read by Mason's
|
|
73
|
+
missingLines.push(` missing: ${table} (read by Mason's sync)`);
|
|
60
74
|
continue;
|
|
61
75
|
}
|
|
62
76
|
const lacking = inCatalogOrder(d, cols).filter((c) => !d.published.includes(c));
|
|
63
77
|
if (lacking.length) {
|
|
64
78
|
problems.push(`${table} columns ${lacking.join(", ")} are not published`);
|
|
65
|
-
missingLines.push(` missing: ${table} columns ${lacking.join(", ")} (read by Mason's
|
|
79
|
+
missingLines.push(` missing: ${table} columns ${lacking.join(", ")} (read by Mason's sync)`);
|
|
66
80
|
}
|
|
67
81
|
if (d.rowFilter !== null) {
|
|
68
82
|
problems.push(`${table} is published with a row filter`);
|
|
@@ -103,7 +117,7 @@ function checkPublication(required, described, whole) {
|
|
|
103
117
|
(found.size ? tableLines(found, columnLists).join(",\n") : " (no tables)")]
|
|
104
118
|
: [`Found: no publication named ${pub.name}. Publications on this database: ${pub.others.length ? pub.others.join(", ") : "none"}.`];
|
|
105
119
|
return [
|
|
106
|
-
`publication ${pub.name} does not carry what Mason's
|
|
120
|
+
`publication ${pub.name} does not carry what Mason's synced table holds.`,
|
|
107
121
|
"",
|
|
108
122
|
...(missingLines.length ? [...missingLines, ""] : []),
|
|
109
123
|
...(publishes.length && pub.exists ? [` it publishes ${pub.publish.join(", ") || "nothing"}; the change reader needs insert, update and delete`, ""] : []),
|
|
@@ -185,7 +199,7 @@ export function describedOf(answer, publication) {
|
|
|
185
199
|
}
|
|
186
200
|
/** The named publication and the copied tables as the live catalog describes them: read-only, two statements.
|
|
187
201
|
* (`_project`, the project's name, is unused: the tables are found by their own names.) */
|
|
188
|
-
export async function readNamedPublication(pg, publication, tables
|
|
202
|
+
export async function readNamedPublication(pg, publication, tables) {
|
|
189
203
|
const one = async (sql) => {
|
|
190
204
|
const v = (await pg.query(sql)).rows[0]?.[0];
|
|
191
205
|
if (typeof v !== "string")
|
|
@@ -1,28 +1,15 @@
|
|
|
1
|
-
import { outcomeOf } from "../api/request-log.js";
|
|
1
|
+
import { outcomeOf, outcomeSql, statusOf } from "../api/request-log.js";
|
|
2
2
|
import { MASON_SCHEMA } from "./store.js";
|
|
3
|
+
import { Batches, History, Streak } from "./history.js";
|
|
4
|
+
import { GAPS } from "./gaps.js";
|
|
5
|
+
import { quantileContOf } from "./usage.js";
|
|
3
6
|
export const REQUESTS_TABLE = `${MASON_SCHEMA}.requests`;
|
|
4
|
-
/** How many days of requests are kept unless MASON_REQUEST_LOG_DAYS says. */
|
|
5
|
-
export const DEFAULT_REQUEST_LOG_DAYS = 7;
|
|
6
7
|
const FLUSH_MS = 1000;
|
|
7
8
|
const BATCH_ROWS = 1000;
|
|
8
|
-
/** how often the rows past the retention are deleted (and once at open) */
|
|
9
|
-
const TRIM_EVERY_MS = 60 * 60 * 1000;
|
|
10
|
-
/**
|
|
11
|
-
* The days MASON_REQUEST_LOG_DAYS keeps, or why the text is not a number of days. Unset or empty: the default.
|
|
12
|
-
*/
|
|
13
|
-
export function requestLogDays(text) {
|
|
14
|
-
if (text === undefined || text.trim() === "")
|
|
15
|
-
return DEFAULT_REQUEST_LOG_DAYS;
|
|
16
|
-
const days = Number(text);
|
|
17
|
-
if (!Number.isInteger(days) || days < 1) {
|
|
18
|
-
return { problem: `MASON_REQUEST_LOG_DAYS=${JSON.stringify(text)} is not a number of days: write a whole number, 1 or more (unset, ${DEFAULT_REQUEST_LOG_DAYS})` };
|
|
19
|
-
}
|
|
20
|
-
return days;
|
|
21
|
-
}
|
|
22
9
|
/** A request's outcome in a word, from its SQLSTATE (src/api/request-log.ts: the listener's refusal line reads it too). */
|
|
23
10
|
export { outcomeOf };
|
|
24
11
|
const COLUMNS = ["request_id", "started_at", "session", "application_name", "token_name", "user_name", "method", "sqlstate", "message", "detail", "hint",
|
|
25
|
-
"rows", "duration_ms", "query", "query_truncated", "seq", "json_reads"];
|
|
12
|
+
"rows", "duration_ms", "query", "query_truncated", "seq", "json_reads", "bytes_sent", "gap", "role", "attributes", "client_addr"];
|
|
26
13
|
/** the ledger's DDL: one row per request, named for a developer who knows pg_stat_activity */
|
|
27
14
|
/** a ledger made before `mason user` gains its column; a column added last is named in every INSERT */
|
|
28
15
|
export const REQUESTS_USER_DDL = `ALTER TABLE ${REQUESTS_TABLE} ADD COLUMN IF NOT EXISTS user_name VARCHAR`;
|
|
@@ -40,68 +27,118 @@ export const REQUESTS_SEQ_DDL = `ALTER TABLE ${REQUESTS_TABLE} ADD COLUMN IF NOT
|
|
|
40
27
|
* `review`: read by several statements, or by a slow one, within the log's retention). A ledger made before gains it.
|
|
41
28
|
*/
|
|
42
29
|
export const REQUESTS_JSON_DDL = `ALTER TABLE ${REQUESTS_TABLE} ADD COLUMN IF NOT EXISTS json_reads VARCHAR`;
|
|
30
|
+
/** THE ROLE a session read as (src/duck/grants.ts): beside `user_name`, so a refusal (42501) says who was held to what;
|
|
31
|
+
* a ledger made before roles gains the column, its old rows NULL (read as '') */
|
|
32
|
+
export const REQUESTS_ROLE_DDL = `ALTER TABLE ${REQUESTS_TABLE} ADD COLUMN IF NOT EXISTS role VARCHAR`;
|
|
33
|
+
/** THE USER'S ATTRIBUTES beside its role (src/duck/row-filter.ts: the values its role's row filters read), as JSON text,
|
|
34
|
+
* so a statement's rows can be told apart by whose values filtered them; NULL for none, and in a ledger made before */
|
|
35
|
+
export const REQUESTS_ATTRIBUTES_DDL = `ALTER TABLE ${REQUESTS_TABLE} ADD COLUMN IF NOT EXISTS attributes VARCHAR`;
|
|
36
|
+
/** THE BYTES SENT for each statement (ApiLogRow.bytesSent): what the answers cost to send. A ledger made before gains it;
|
|
37
|
+
* its old rows are NULL, never 0. */
|
|
38
|
+
export const REQUESTS_BYTES_DDL = `ALTER TABLE ${REQUESTS_TABLE} ADD COLUMN IF NOT EXISTS bytes_sent BIGINT`;
|
|
39
|
+
/** THE REGISTRY ENTRY THAT REFUSED EACH STATEMENT (ApiLogRow.gap, src/duck/gaps.ts): NULL for an answer and for any
|
|
40
|
+
* other refusal; what `unsupported` groups by. A ledger made before gains it; its old rows are NULL. */
|
|
41
|
+
export const REQUESTS_GAP_DDL = `ALTER TABLE ${REQUESTS_TABLE} ADD COLUMN IF NOT EXISTS gap VARCHAR`;
|
|
42
|
+
/** THE CLIENT'S ADDRESS (ApiLogRow.clientAddress), named as pg_stat_activity names it: behind a TCP proxy the client its
|
|
43
|
+
* PROXY header carries, so a deployment behind one can tell who sent what. A ledger made before gains it; its old rows
|
|
44
|
+
* are NULL. */
|
|
45
|
+
export const REQUESTS_CLIENT_ADDR_DDL = `ALTER TABLE ${REQUESTS_TABLE} ADD COLUMN IF NOT EXISTS client_addr VARCHAR`;
|
|
43
46
|
export const REQUESTS_DDL = `CREATE TABLE IF NOT EXISTS ${REQUESTS_TABLE} (request_id VARCHAR, started_at TIMESTAMPTZ, session INTEGER, ` +
|
|
44
47
|
`application_name VARCHAR, token_name VARCHAR, user_name VARCHAR, method VARCHAR, sqlstate VARCHAR, message VARCHAR, detail VARCHAR, hint VARCHAR, ` +
|
|
45
|
-
`"rows" BIGINT, duration_ms INTEGER, query VARCHAR, query_truncated BOOLEAN, seq BIGINT, json_reads VARCHAR)`;
|
|
48
|
+
`"rows" BIGINT, duration_ms INTEGER, query VARCHAR, query_truncated BOOLEAN, seq BIGINT, json_reads VARCHAR, bytes_sent BIGINT, gap VARCHAR, role VARCHAR, attributes VARCHAR, client_addr VARCHAR)`;
|
|
46
49
|
/** an ApiLogRow's values in COLUMNS' order */
|
|
47
50
|
function valuesOf({ row: r, seq }) {
|
|
48
51
|
const app = (r.client ?? "").replace(/^postgres:/, "");
|
|
49
52
|
return [r.requestId, r.at.toISOString(), r.session ?? null, app === "unnamed" ? "" : app, r.tokenName ?? "", r.user ?? "", r.method,
|
|
50
53
|
r.sqlstate ?? null, r.error === undefined || r.error === "" ? null : r.error, r.detail ?? null, r.hint ?? null,
|
|
51
54
|
r.rows === undefined ? null : r.rows, Math.round(r.durationMs), r.payload ?? "", r.payloadTruncated === true, seq,
|
|
52
|
-
r.jsonReads === undefined || r.jsonReads.length === 0 ? null : JSON.stringify(r.jsonReads)
|
|
55
|
+
r.jsonReads === undefined || r.jsonReads.length === 0 ? null : JSON.stringify(r.jsonReads), r.bytesSent === undefined ? null : r.bytesSent, r.gap ?? null, r.role ?? "",
|
|
56
|
+
r.attributes === undefined || Object.keys(r.attributes).length === 0 ? null : JSON.stringify(r.attributes), r.clientAddress ?? null];
|
|
53
57
|
}
|
|
54
58
|
/** A ledger row as the commands read it. */
|
|
55
59
|
function requestOf(o) {
|
|
56
60
|
const text = (v) => v === null || v === undefined ? null : String(v);
|
|
57
61
|
const sqlstate = text(o.sqlstate);
|
|
62
|
+
const outcome = outcomeOf(sqlstate);
|
|
63
|
+
const startedAt = new Date(String(o.started_at));
|
|
58
64
|
return {
|
|
59
|
-
requestId: String(o.request_id), startedAt:
|
|
60
|
-
applicationName: String(o.application_name ?? ""), tokenName: String(o.token_name ?? ""), user: String(o.user_name ?? ""),
|
|
61
|
-
|
|
62
|
-
|
|
65
|
+
requestId: String(o.request_id), startedAt: startedAt.toISOString(), session: o.session === null ? null : Number(o.session),
|
|
66
|
+
applicationName: String(o.application_name ?? ""), clientAddress: typeof o.client_addr === "string" ? o.client_addr : null, tokenName: String(o.token_name ?? ""), user: String(o.user_name ?? ""), role: String(o.role ?? ""),
|
|
67
|
+
attributes: typeof o.attributes === "string" && o.attributes !== "" ? JSON.parse(o.attributes) : null, method: String(o.method),
|
|
68
|
+
outcome, status: statusOf(outcome), sqlstate, message: text(o.message), detail: text(o.detail), hint: text(o.hint),
|
|
69
|
+
rows: o.rows === null ? null : Number(o.rows), durationMs: Number(o.duration_ms),
|
|
70
|
+
endedAt: new Date(startedAt.getTime() + Number(o.duration_ms)).toISOString(), bytesSent: o.bytes_sent === null || o.bytes_sent === undefined ? null : Number(o.bytes_sent),
|
|
71
|
+
query: String(o.query ?? ""), queryTruncated: o.query_truncated === true,
|
|
63
72
|
};
|
|
64
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* A position in the request log's order, as a page hands it on: the row's start, ISO 8601, and its number (`seq`; -1
|
|
76
|
+
* for a row recorded before rows were numbered) — `2026-10-28T10:00:00.123Z~42`. A reader passes it back as it came.
|
|
77
|
+
*/
|
|
78
|
+
export const requestCursor = (at, seq) => `${at}~${seq}`;
|
|
79
|
+
/** A cursor read back (requestCursor), or null when the text is not one. */
|
|
80
|
+
export function parseRequestCursor(text) {
|
|
81
|
+
const m = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z)~(-?\d+)$/.exec(text);
|
|
82
|
+
if (m === null || Number.isNaN(Date.parse(m[1])))
|
|
83
|
+
return null;
|
|
84
|
+
return { at: m[1], seq: Number(m[2]) };
|
|
85
|
+
}
|
|
86
|
+
/** how much of a statement `unsupported` shows as its example */
|
|
87
|
+
export const UNSUPPORTED_EXAMPLE_CHARS = 1000;
|
|
88
|
+
/**
|
|
89
|
+
* A WINDOW FOLDED FROM THE GROUPS of a scan by SQLSTATE and a completed one's duration (`summary`, `window`): each
|
|
90
|
+
* outcome by `outcomeOf` (the one rule), the average from the durations' sum, the p50 / p95 from the durations'
|
|
91
|
+
* histogram as quantile_cont interpolates them (`quantileContOf`). ONE place adds a window up.
|
|
92
|
+
*/
|
|
93
|
+
function foldWindow(groups, days, keptDays) {
|
|
94
|
+
const w = { days, covered: Math.min(days, keptDays), statements: 0, completed: 0, refused: 0, failed: 0, cancelled: 0, rows: 0, bytesSent: 0, durationMs: null };
|
|
95
|
+
const histogram = [];
|
|
96
|
+
let duration = 0;
|
|
97
|
+
for (const g of groups) {
|
|
98
|
+
const n = Number(g.n);
|
|
99
|
+
w.statements += n;
|
|
100
|
+
w.rows += Number(g.sent_rows);
|
|
101
|
+
w.bytesSent += Number(g.sent_bytes);
|
|
102
|
+
const outcome = outcomeOf(g.sqlstate === null ? null : String(g.sqlstate));
|
|
103
|
+
if (outcome === "ok") {
|
|
104
|
+
w.completed += n;
|
|
105
|
+
duration += Number(g.duration);
|
|
106
|
+
histogram.push([Number(g.ms), n]);
|
|
107
|
+
}
|
|
108
|
+
else
|
|
109
|
+
w[outcome] += n;
|
|
110
|
+
}
|
|
111
|
+
histogram.sort((a, b) => a[0] - b[0]);
|
|
112
|
+
const p50 = quantileContOf(histogram, 0.5), p95 = quantileContOf(histogram, 0.95);
|
|
113
|
+
if (w.completed > 0 && p50 !== null && p95 !== null)
|
|
114
|
+
w.durationMs = { avg: Math.round(duration / w.completed), p50, p95 };
|
|
115
|
+
return w;
|
|
116
|
+
}
|
|
65
117
|
export class RequestLedger {
|
|
66
|
-
buffer = [];
|
|
67
118
|
/** the next row's number (REQUESTS_SEQ_DDL): one past the ledger's last at open, then one per request recorded */
|
|
68
119
|
next = 0;
|
|
69
|
-
timer = null;
|
|
70
|
-
trimTimer = null;
|
|
71
|
-
/** the flush in flight: one at a time, so batches commit in the order they were taken */
|
|
72
|
-
flushing = Promise.resolve();
|
|
73
120
|
/** rows lost since the last flush that succeeded: said once when the streak begins, and when it ends */
|
|
74
|
-
lost =
|
|
121
|
+
lost = new Streak();
|
|
75
122
|
closed = false;
|
|
76
|
-
|
|
77
|
-
|
|
123
|
+
history;
|
|
124
|
+
batches;
|
|
78
125
|
log;
|
|
79
126
|
options;
|
|
80
127
|
/** days kept */
|
|
81
128
|
days;
|
|
82
|
-
constructor(
|
|
83
|
-
this.
|
|
84
|
-
this.reader = reader;
|
|
129
|
+
constructor(history, log, options) {
|
|
130
|
+
this.history = history;
|
|
85
131
|
this.log = log;
|
|
86
132
|
this.options = options;
|
|
87
133
|
this.days = options.days;
|
|
134
|
+
this.batches = new Batches({ rows: BATCH_ROWS, ms: options.flushMs ?? FLUSH_MS, write: (rows) => this.write(rows) });
|
|
88
135
|
}
|
|
89
136
|
/** The ledger of a store: its table made, the rows past the retention deleted, and a trim every hour. */
|
|
90
137
|
static async open(store, log, options) {
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
await
|
|
94
|
-
await
|
|
95
|
-
await writer.run(REQUESTS_SEQ_DDL);
|
|
96
|
-
await writer.run(REQUESTS_JSON_DDL);
|
|
97
|
-
const reader = await store.instance.connect();
|
|
98
|
-
const ledger = new RequestLedger(writer, reader, log, options);
|
|
99
|
-
ledger.next = Number((await reader.runAndReadAll(`SELECT coalesce(max(seq), -1) + 1 FROM ${REQUESTS_TABLE}`)).getRowsJS()[0][0]);
|
|
100
|
-
await ledger.trim();
|
|
101
|
-
ledger.trimTimer = setInterval(() => {
|
|
102
|
-
ledger.trim().catch((e) => log(`request log: deleting the requests older than ${ledger.days} days failed: ${e.message}`));
|
|
103
|
-
}, TRIM_EVERY_MS);
|
|
104
|
-
ledger.trimTimer.unref();
|
|
138
|
+
const history = await History.open(store, [REQUESTS_DDL, REQUESTS_USER_DDL, REQUESTS_SEQ_DDL, REQUESTS_JSON_DDL, REQUESTS_BYTES_DDL, REQUESTS_GAP_DDL, REQUESTS_ROLE_DDL, REQUESTS_ATTRIBUTES_DDL, REQUESTS_CLIENT_ADDR_DDL]);
|
|
139
|
+
const ledger = new RequestLedger(history, log, options);
|
|
140
|
+
ledger.next = Number((await history.reader.runAndReadAll(`SELECT coalesce(max(seq), -1) + 1 FROM ${REQUESTS_TABLE}`)).getRowsJS()[0][0]);
|
|
141
|
+
await history.keepTrimmed(() => ledger.trim(), (e) => log(`request log: deleting the requests older than ${ledger.days} days failed: ${e.message}`));
|
|
105
142
|
return ledger;
|
|
106
143
|
}
|
|
107
144
|
/** One request, written with the next batch. Never throws: the statement it records was already answered. */
|
|
@@ -111,26 +148,10 @@ export class RequestLedger {
|
|
|
111
148
|
this.log(`request log: a request after the log closed, not recorded (${row.requestId})`);
|
|
112
149
|
return;
|
|
113
150
|
}
|
|
114
|
-
this.
|
|
115
|
-
if (this.buffer.length >= BATCH_ROWS) {
|
|
116
|
-
void this.flush();
|
|
117
|
-
return;
|
|
118
|
-
}
|
|
119
|
-
this.timer ??= setTimeout(() => { void this.flush(); }, this.options.flushMs ?? FLUSH_MS);
|
|
151
|
+
this.batches.push({ row, seq: this.next++ });
|
|
120
152
|
}
|
|
121
153
|
/** Writes what is waiting, after any flush in flight. Resolves whether it was written or not (a failure is said). */
|
|
122
|
-
flush() {
|
|
123
|
-
if (this.timer !== null) {
|
|
124
|
-
clearTimeout(this.timer);
|
|
125
|
-
this.timer = null;
|
|
126
|
-
}
|
|
127
|
-
const rows = this.buffer;
|
|
128
|
-
this.buffer = [];
|
|
129
|
-
if (rows.length === 0)
|
|
130
|
-
return this.flushing;
|
|
131
|
-
this.flushing = this.flushing.then(() => this.write(rows));
|
|
132
|
-
return this.flushing;
|
|
133
|
-
}
|
|
154
|
+
flush() { return this.batches.flush(); }
|
|
134
155
|
async write(rows) {
|
|
135
156
|
const width = COLUMNS.length;
|
|
136
157
|
const sql = `INSERT INTO ${REQUESTS_TABLE} (${COLUMNS.map((c) => c === "rows" ? `"rows"` : c).join(", ")}) VALUES ` +
|
|
@@ -140,45 +161,162 @@ export class RequestLedger {
|
|
|
140
161
|
if (this.options.insert !== undefined)
|
|
141
162
|
await this.options.insert(sql, values);
|
|
142
163
|
else
|
|
143
|
-
await this.writer.run(sql, values);
|
|
164
|
+
await this.history.writer.run(sql, values);
|
|
144
165
|
}
|
|
145
166
|
catch (e) {
|
|
146
167
|
// Tolerated, narrowly, as the ClickHouse writer tolerates it: the answers are sent, so there is no statement left
|
|
147
168
|
// to fail, and a ledger that cannot write must not take the listener down. SAID, once per streak with the rows it
|
|
148
169
|
// costs, so a ledger that stopped recording never reads as a quiet listener.
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
`${rows.length > 1 ? ` … ${rows[rows.length - 1].row.requestId}` : ""}): ${e.message}; said once until a write succeeds`);
|
|
152
|
-
this.lost += rows.length;
|
|
170
|
+
this.lost.failed(rows.length, () => this.log(`request log: could not record ${rows.length} request(s) in ${REQUESTS_TABLE} (${rows[0].row.requestId}` +
|
|
171
|
+
`${rows.length > 1 ? ` … ${rows[rows.length - 1].row.requestId}` : ""}): ${e.message}; said once until a write succeeds`));
|
|
153
172
|
return;
|
|
154
173
|
}
|
|
155
|
-
|
|
156
|
-
this.log(`request log: recording again; ${this.lost} request(s) were not recorded`);
|
|
157
|
-
this.lost = 0;
|
|
158
|
-
}
|
|
174
|
+
this.lost.succeeded((n) => this.log(`request log: recording again; ${n} request(s) were not recorded`));
|
|
159
175
|
}
|
|
160
176
|
/** Deletes the requests older than the retention; the count is said when there were any. */
|
|
161
177
|
async trim() {
|
|
162
|
-
const r = await this.writer.runAndReadAll(`DELETE FROM ${REQUESTS_TABLE} WHERE started_at < now() - INTERVAL (${this.days}) DAY`);
|
|
178
|
+
const r = await this.history.writer.runAndReadAll(`DELETE FROM ${REQUESTS_TABLE} WHERE started_at < now() - INTERVAL (${this.days}) DAY`);
|
|
163
179
|
const n = Number(r.getRowsJS()[0]?.[0] ?? 0);
|
|
164
180
|
if (n > 0)
|
|
165
|
-
this.log(`request log: deleted ${n} request(s) older than ${this.days} days (
|
|
181
|
+
this.log(`request log: deleted ${n} request(s) older than ${this.days} days (MASON_HISTORY_DAYS)`);
|
|
166
182
|
return n;
|
|
167
183
|
}
|
|
168
|
-
/**
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
184
|
+
/**
|
|
185
|
+
* The newest `limit` requests that pass the filters, newest LAST. `failed`: the ones not answered (refused, failed or
|
|
186
|
+
* cancelled); `outcomes`, `since` / `until` (started at or after / before), `user`, `client` (application_name) and
|
|
187
|
+
* `before` (strictly older than that position: a page's `before`) narrow it further. Committed rows only.
|
|
188
|
+
*/
|
|
189
|
+
async recent(q) {
|
|
190
|
+
return (await this.rows(q)).map((r) => requestOf(r));
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* ONE PAGE of the request log (`/api/requests`, `mason logs --requests`): `recent`, and `before`, the cursor of the
|
|
194
|
+
* page's oldest row when an older one passes the filters (null: this is the last page). Pages follow the ledger's
|
|
195
|
+
* order (started_at, then seq), so the page asked with `before` holds the rows older than the page before it, whatever
|
|
196
|
+
* was recorded since — but a statement still running when a page was read is recorded at its START, so it may land in
|
|
197
|
+
* a page already read.
|
|
198
|
+
*/
|
|
199
|
+
async page(q) {
|
|
200
|
+
const rows = await this.rows({ ...q, limit: q.limit + 1 });
|
|
201
|
+
const more = rows.length > q.limit;
|
|
202
|
+
const shown = more ? rows.slice(1) : rows;
|
|
203
|
+
const first = shown[0];
|
|
204
|
+
return { requests: shown.map((r) => requestOf(r)),
|
|
205
|
+
before: more && first !== undefined ? requestCursor(new Date(String(first.started_at)).toISOString(), first.seq === null || first.seq === undefined ? -1 : Number(first.seq)) : null };
|
|
206
|
+
}
|
|
207
|
+
async rows(q) {
|
|
208
|
+
const where = [];
|
|
209
|
+
const values = [];
|
|
210
|
+
const arg = (v, cast = "") => { values.push(v); return `$${values.length}${cast}`; };
|
|
211
|
+
if (q.failed === true)
|
|
212
|
+
where.push("sqlstate IS NOT NULL");
|
|
213
|
+
if (q.outcomes !== undefined && q.outcomes.length > 0)
|
|
214
|
+
where.push(`(${outcomeSql("sqlstate")}) IN (${q.outcomes.map((o) => arg(o)).join(", ")})`);
|
|
215
|
+
if (q.since !== undefined)
|
|
216
|
+
where.push(`started_at >= ${arg(q.since, "::TIMESTAMPTZ")}`);
|
|
217
|
+
if (q.until !== undefined)
|
|
218
|
+
where.push(`started_at < ${arg(q.until, "::TIMESTAMPTZ")}`);
|
|
219
|
+
if (q.user !== undefined)
|
|
220
|
+
where.push(`user_name = ${arg(q.user)}`);
|
|
221
|
+
if (q.client !== undefined)
|
|
222
|
+
where.push(`application_name = ${arg(q.client)}`);
|
|
223
|
+
if (q.minMs !== undefined)
|
|
224
|
+
where.push(`duration_ms >= ${arg(q.minMs)}`);
|
|
225
|
+
// a substring, any case: contains() takes the text as it is (no LIKE pattern to escape); a scan of the kept days, as every filter is
|
|
226
|
+
if (q.search !== undefined)
|
|
227
|
+
where.push(`contains(lower(coalesce(query, '')), lower(${arg(q.search)}))`);
|
|
228
|
+
if (q.before !== undefined) {
|
|
229
|
+
const at = arg(q.before.at, "::TIMESTAMPTZ");
|
|
230
|
+
where.push(`(started_at < ${at} OR (started_at = ${at} AND coalesce(seq, -1) < ${arg(q.before.seq)}))`);
|
|
231
|
+
}
|
|
232
|
+
const limit = arg(q.limit);
|
|
233
|
+
const rows = (await this.history.reader.runAndReadAll(`SELECT * FROM (SELECT * FROM ${REQUESTS_TABLE}${where.length ? ` WHERE ${where.join(" AND ")}` : ""} ` +
|
|
234
|
+
`ORDER BY started_at DESC, coalesce(seq, -1) DESC LIMIT ${limit}) ORDER BY started_at, coalesce(seq, -1)`, values)).getRowObjectsJson();
|
|
235
|
+
return rows;
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* THE QUERY SUMMARY of `mason status` (`/api/queries`): the statements of the last day and of the last 7 days — how
|
|
239
|
+
* many, how many completed, refused, failed and cancelled, how long the completed ones took (average, p50, p95), the
|
|
240
|
+
* rows and the bytes sent — in ONE scan of the ledger. A refused login (CONNECT) is not a statement. The log keeps
|
|
241
|
+
* `days` days, so a window longer than that is `covered` only as far as it keeps (said, never a short week passed off
|
|
242
|
+
* as a whole one).
|
|
243
|
+
*
|
|
244
|
+
* The scan is ONE GROUP BY — by the window (the last day or before it), the SQLSTATE, and a completed one's duration
|
|
245
|
+
* (whole ms) — whose few groups are added up here: each outcome by `outcomeOf` (the one rule), the average from the
|
|
246
|
+
* durations' sum, and the p50 / p95 from the durations' histogram as quantile_cont interpolates them
|
|
247
|
+
* (`quantileContOf`), exact. Measured over a week of 50 statements a second (30 M rows): four quantile_cont and
|
|
248
|
+
* sixteen FILTERed aggregates over the raw rows took 0.71–0.77 s, this ~0.12 s — and the Overview asks it every 30 s
|
|
249
|
+
* (docs/OBSERVABILITY.md "Architecture review").
|
|
250
|
+
*/
|
|
251
|
+
async summary() {
|
|
252
|
+
const groups = (await this.history.reader.runAndReadAll(`SELECT started_at > now() - INTERVAL 1 DAY AS in_day, sqlstate, ` +
|
|
253
|
+
`CASE WHEN ${outcomeSql("sqlstate")} = 'ok' THEN duration_ms END AS ms, count(*) AS n, sum(duration_ms) AS duration, ` +
|
|
254
|
+
`coalesce(sum("rows"), 0) AS sent_rows, coalesce(sum(bytes_sent), 0) AS sent_bytes ` +
|
|
255
|
+
`FROM ${REQUESTS_TABLE} WHERE started_at > now() - INTERVAL 7 DAY AND method <> 'CONNECT' GROUP BY ALL`)).getRowObjectsJson();
|
|
256
|
+
const windowOf = (days) => foldWindow(days === 1 ? groups.filter((g) => g.in_day === true) : groups, days, this.days);
|
|
257
|
+
return { keptDays: this.days, day: windowOf(1), week: windowOf(7) };
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* ONE WINDOW OF THE REQUEST LOG from `since` (ISO 8601) to now — what the Usage page's cards say for its time range:
|
|
261
|
+
* the statements, each outcome, the rows and bytes sent and the completed ones' durations, as `summary` folds a day,
|
|
262
|
+
* by the same GROUP BY (`foldWindow`). `days` is the window's length in days (a fraction under one), `covered` as far
|
|
263
|
+
* as the log keeps it.
|
|
264
|
+
*/
|
|
265
|
+
async window(since, now = Date.now()) {
|
|
266
|
+
const groups = (await this.history.reader.runAndReadAll(`SELECT sqlstate, CASE WHEN ${outcomeSql("sqlstate")} = 'ok' THEN duration_ms END AS ms, count(*) AS n, ` +
|
|
267
|
+
`sum(duration_ms) AS duration, coalesce(sum("rows"), 0) AS sent_rows, coalesce(sum(bytes_sent), 0) AS sent_bytes ` +
|
|
268
|
+
`FROM ${REQUESTS_TABLE} WHERE started_at >= $1::TIMESTAMPTZ AND method <> 'CONNECT' GROUP BY ALL`, [since])).getRowObjectsJson();
|
|
269
|
+
return foldWindow(groups, Math.max(0, (now - Date.parse(since)) / 86_400_000), this.days);
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* WHO CONNECTED from `since` on, by the statements they sent: a row per user and client (application_name) — its
|
|
273
|
+
* statements, the failed ones (refused or failed, as the Failed card counts them: a cancel is not), the sessions they came from and when the latest started —
|
|
274
|
+
* the most statements first, `limit` of them, and the totals over every one (`total`: how many clients, how many
|
|
275
|
+
* sessions — a card says them, so they are never the first `limit`'s). A session that sent only what the log does not list (select 1, SET, a
|
|
276
|
+
* catalog query) is not here; the live sessions are `/api/state`'s. Committed rows only.
|
|
277
|
+
*/
|
|
278
|
+
async clients(since, limit = 50) {
|
|
279
|
+
// the totals over EVERY client, before the LIMIT (a window over the groups): a card says them, never the first `limit`'s
|
|
280
|
+
const rows = (await this.history.reader.runAndReadAll(`SELECT user_name, token_name, application_name, count(*) AS statements, ` +
|
|
281
|
+
`count(*) OVER () AS total_clients, sum(count(DISTINCT session)) OVER () AS total_sessions, ` +
|
|
282
|
+
`count(*) FILTER (WHERE (${outcomeSql("sqlstate")}) IN ('refused', 'failed')) AS failed, count(DISTINCT session) AS sessions, max(started_at) AS last ` +
|
|
283
|
+
`FROM ${REQUESTS_TABLE} WHERE started_at >= $1::TIMESTAMPTZ AND method <> 'CONNECT' GROUP BY ALL ORDER BY statements DESC, last DESC LIMIT $2`, [since, limit])).getRowObjectsJson();
|
|
284
|
+
const first = rows[0];
|
|
285
|
+
return { total: { clients: Number(first?.total_clients ?? 0), sessions: Number(first?.total_sessions ?? 0) },
|
|
286
|
+
clients: rows.map((r) => ({ user: String(r.user_name ?? ""), tokenName: String(r.token_name ?? ""), client: String(r.application_name ?? ""),
|
|
287
|
+
statements: Number(r.statements), failed: Number(r.failed), sessions: Number(r.sessions), last: new Date(String(r.last)).toISOString() })) };
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* THE COMPLETED STATEMENTS' p50 AND p95 IN BUCKETS of `stepSeconds` from `since` until `until`, by when each started
|
|
291
|
+
* (quantile_cont, as `summary`'s): what a chart of more than a minute a point plots. A bucket's p95 is the p95 of its
|
|
292
|
+
* statements, never the highest of its minutes' p95s — one slow minute in an hour was the hour's "p95". By bucket
|
|
293
|
+
* start, epoch ms; a bucket with no completed statement is absent. Committed rows only.
|
|
294
|
+
*/
|
|
295
|
+
async durationsBy(since, until, stepSeconds) {
|
|
296
|
+
const rows = (await this.history.reader.runAndReadAll(`SELECT epoch_ms(time_bucket(to_seconds($3::BIGINT), started_at, $1::TIMESTAMPTZ)) AS at, ` +
|
|
297
|
+
`quantile_cont(duration_ms, 0.5) AS p50, quantile_cont(duration_ms, 0.95) AS p95 FROM ${REQUESTS_TABLE} ` +
|
|
298
|
+
`WHERE started_at >= $1::TIMESTAMPTZ AND started_at < $2::TIMESTAMPTZ AND method <> 'CONNECT' AND (${outcomeSql("sqlstate")}) = 'ok' GROUP BY 1`, [since, until, stepSeconds])).getRowsJS();
|
|
299
|
+
// rounded as the summary's and a sample's are (src/duck/usage.ts quantileCont): one ms, one definition
|
|
300
|
+
return new Map(rows.map(([at, p50, p95]) => [Number(at), { p50: Math.round(Number(p50)), p95: Math.round(Number(p95)) }]));
|
|
301
|
+
}
|
|
302
|
+
/** The slowest `limit` completed statements started from `since` on, slowest first: what ran longest in a time range
|
|
303
|
+
* that is past (the Usage page's analogue of what runs now). Committed rows only. */
|
|
304
|
+
async slowest(since, limit = 10) {
|
|
305
|
+
const rows = (await this.history.reader.runAndReadAll(`SELECT * FROM ${REQUESTS_TABLE} WHERE started_at >= $1::TIMESTAMPTZ AND sqlstate IS NULL AND method <> 'CONNECT' ` +
|
|
306
|
+
`ORDER BY duration_ms DESC, started_at DESC LIMIT $2`, [since, limit])).getRowObjectsJson();
|
|
172
307
|
return rows.map((r) => requestOf(r));
|
|
173
308
|
}
|
|
174
309
|
/**
|
|
175
310
|
* The last day's slow statements in `mason status`'s shape (src/cli/run-server.ts SlowQuery): each distinct statement
|
|
176
|
-
* whose LATEST answer still took `slowMs` or more, slowest first.
|
|
311
|
+
* whose LATEST answer still took `slowMs` or more, slowest first. Only a statement that took it at least once can,
|
|
312
|
+
* so those are found first and only their runs are grouped (a day of 50 statements a second, 4.3 M rows: 0.21 s
|
|
313
|
+
* grouping every statement's text, 0.07 s this way).
|
|
177
314
|
*/
|
|
178
315
|
async slow(slowMs, limit = 5) {
|
|
179
|
-
const
|
|
316
|
+
const day = `sqlstate IS NULL AND started_at > now() - INTERVAL 1 DAY AND query <> '' AND NOT query_truncated`;
|
|
317
|
+
const rows = (await this.history.reader.runAndReadAll(`SELECT query, count(*) AS runs, CAST(quantile_disc(duration_ms, 0.5) AS INTEGER) AS median_ms, ` +
|
|
180
318
|
`max(duration_ms) AS max_ms, arg_max(duration_ms, started_at) AS latest_ms, max(started_at) AS last FROM ${REQUESTS_TABLE} ` +
|
|
181
|
-
`WHERE
|
|
319
|
+
`WHERE ${day} AND query IN (SELECT query FROM ${REQUESTS_TABLE} WHERE ${day} AND duration_ms >= $1) ` +
|
|
182
320
|
`GROUP BY query HAVING arg_max(duration_ms, started_at) >= $1 ORDER BY latest_ms DESC LIMIT $2`, [slowMs, limit])).getRowObjectsJson();
|
|
183
321
|
return rows.map((r) => ({ route: "postgres", payload: String(r.query), runs: Number(r.runs), medianMs: Number(r.median_ms), maxMs: Number(r.max_ms),
|
|
184
322
|
latestMs: Number(r.latest_ms), last: new Date(String(r.last)).toISOString() }));
|
|
@@ -189,21 +327,59 @@ export class RequestLedger {
|
|
|
189
327
|
*/
|
|
190
328
|
async refused(limit = 8) {
|
|
191
329
|
const day = `sqlstate IS NOT NULL AND started_at > now() - INTERVAL 1 DAY`;
|
|
192
|
-
const rows = (await this.reader.runAndReadAll(`SELECT query, count(*) AS runs, max(started_at) AS last, arg_max(sqlstate, started_at) AS code, arg_max(message, started_at) AS message ` +
|
|
330
|
+
const rows = (await this.history.reader.runAndReadAll(`SELECT query, count(*) AS runs, max(started_at) AS last, arg_max(sqlstate, started_at) AS code, arg_max(message, started_at) AS message ` +
|
|
193
331
|
`FROM ${REQUESTS_TABLE} WHERE ${day} GROUP BY query ORDER BY last DESC LIMIT $1`, [limit])).getRowObjectsJson();
|
|
194
|
-
const total = Number((await this.reader.runAndReadAll(`SELECT count(*) FROM ${REQUESTS_TABLE} WHERE ${day}`)).getRowsJS()[0]?.[0] ?? 0);
|
|
332
|
+
const total = Number((await this.history.reader.runAndReadAll(`SELECT count(*) FROM ${REQUESTS_TABLE} WHERE ${day}`)).getRowsJS()[0]?.[0] ?? 0);
|
|
195
333
|
return { total, refused: rows.map((r) => ({ route: "postgres", payload: String(r.query), runs: Number(r.runs), last: new Date(String(r.last)).toISOString(),
|
|
196
334
|
code: String(r.code), message: String(r.message ?? "") })) };
|
|
197
335
|
}
|
|
336
|
+
/**
|
|
337
|
+
* THE REFUSED STATEMENTS GROUPED BY THE UNSUPPORTED SQL THEY USED (`/api/unsupported`, `mason status`): for each
|
|
338
|
+
* registry entry that refused a statement started at or after `since`, how many it refused, by whom (users and
|
|
339
|
+
* clients), when last, and the latest of them — the most refused first, `limit` entries, in ONE query. A statement
|
|
340
|
+
* refused for any other reason (a missing table, a syntax error) has no entry, so it is not here. Committed rows only.
|
|
341
|
+
*/
|
|
342
|
+
async unsupported(q) {
|
|
343
|
+
const rows = (await this.history.reader.runAndReadAll(`SELECT gap, count(*) AS runs, ` +
|
|
344
|
+
`coalesce(list_sort(list(DISTINCT user_name) FILTER (WHERE user_name <> '')), []) AS users, ` +
|
|
345
|
+
`coalesce(list_sort(list(DISTINCT application_name) FILTER (WHERE application_name <> '')), []) AS clients, ` +
|
|
346
|
+
`max(started_at) AS last, arg_max(query, started_at) AS example ` +
|
|
347
|
+
`FROM ${REQUESTS_TABLE} WHERE gap IS NOT NULL AND started_at >= $1::TIMESTAMPTZ GROUP BY gap ORDER BY runs DESC, last DESC, gap LIMIT $2`, [q.since, q.limit])).getRowObjectsJson();
|
|
348
|
+
return rows.map((r) => {
|
|
349
|
+
const gap = String(r.gap);
|
|
350
|
+
const query = String(r.example ?? "");
|
|
351
|
+
return { gap, title: Object.hasOwn(GAPS, gap) ? GAPS[gap].what : null, runs: Number(r.runs),
|
|
352
|
+
users: r.users.map(String), clients: r.clients.map(String), last: new Date(String(r.last)).toISOString(),
|
|
353
|
+
example: query.length > UNSUPPORTED_EXAMPLE_CHARS ? `${query.slice(0, UNSUPPORTED_EXAMPLE_CHARS)}…` : query };
|
|
354
|
+
});
|
|
355
|
+
}
|
|
198
356
|
/** Writes what is waiting and closes the ledger's connections (mason stop). */
|
|
199
357
|
async close() {
|
|
200
|
-
|
|
201
|
-
clearInterval(this.trimTimer);
|
|
202
|
-
this.trimTimer = null;
|
|
203
|
-
}
|
|
358
|
+
this.history.stopTrimming();
|
|
204
359
|
await this.flush();
|
|
205
360
|
this.closed = true;
|
|
206
|
-
this.
|
|
207
|
-
|
|
361
|
+
this.history.closeConnections();
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* When each `mason user` last ran a statement, within the log's retention: for each of `users` (a name and the window
|
|
365
|
+
* it was a user in: `since` its addedAt, `until` its removedAt or null), the time of its newest request in that
|
|
366
|
+
* window, ISO 8601, or null when the log has none. The window tells a user from an earlier one of the same name. A
|
|
367
|
+
* refused login (28P01, 57P03) or an ended session (28000) is no use, so it does not count. Committed rows only.
|
|
368
|
+
* ONE scan of the log for every user, JOINED to the users' windows (a hash join on the name, the window a residual),
|
|
369
|
+
* never one per user — nor a `max … FILTER` per user, which evaluated every user's filter on every row: on a week of
|
|
370
|
+
* 50 statements a second (30 M rows) 0.18 s for 6 users and 2.2–2.8 s for 66, where the join is ~0.15 s for any
|
|
371
|
+
* number; the Users page asks it every 30 s (docs/OBSERVABILITY.md "Architecture review").
|
|
372
|
+
*/
|
|
373
|
+
async lastUsed(users) {
|
|
374
|
+
if (users.length === 0)
|
|
375
|
+
return [];
|
|
376
|
+
const params = [];
|
|
377
|
+
const at = (v) => { params.push(v); return `$${params.length}`; };
|
|
378
|
+
const windows = users.map((u, i) => `(${at(i)}::INTEGER, ${at(u.name)}::VARCHAR, ${at(u.since)}::TIMESTAMPTZ, ${at(u.until)}::TIMESTAMPTZ)`).join(", ");
|
|
379
|
+
const found = (await this.history.reader.runAndReadAll(`SELECT u.i, epoch_ms(max(r.started_at)) AS at FROM (VALUES ${windows}) AS u(i, name, since, until) ` +
|
|
380
|
+
`JOIN ${REQUESTS_TABLE} r ON r.user_name = u.name AND r.started_at >= u.since AND (u.until IS NULL OR r.started_at <= u.until) ` +
|
|
381
|
+
`WHERE r.sqlstate IS NULL OR r.sqlstate NOT IN ('28P01', '57P03', '28000') GROUP BY u.i`, params)).getRowsJS();
|
|
382
|
+
const row = new Map(found.map(([i, ms]) => [Number(i), ms]));
|
|
383
|
+
return users.map((_, i) => { const ms = row.get(i); return ms === null || ms === undefined ? null : new Date(Number(ms)).toISOString(); });
|
|
208
384
|
}
|
|
209
385
|
}
|