forge-sql-orm 2.1.3 → 2.1.4
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 +205 -0
- package/dist/ForgeSQLORM.js +598 -2
- package/dist/ForgeSQLORM.js.map +1 -1
- package/dist/ForgeSQLORM.mjs +601 -5
- package/dist/ForgeSQLORM.mjs.map +1 -1
- package/dist/core/SystemTables.d.ts +5039 -0
- package/dist/core/SystemTables.d.ts.map +1 -1
- package/dist/webtriggers/applyMigrationsWebTrigger.d.ts +1 -1
- package/dist/webtriggers/applyMigrationsWebTrigger.d.ts.map +1 -1
- package/dist/webtriggers/index.d.ts +1 -0
- package/dist/webtriggers/index.d.ts.map +1 -1
- package/dist/webtriggers/topSlowestStatementLastHourTrigger.d.ts +72 -0
- package/dist/webtriggers/topSlowestStatementLastHourTrigger.d.ts.map +1 -0
- package/package.json +4 -3
- package/src/core/SystemTables.ts +313 -1
- package/src/webtriggers/applyMigrationsWebTrigger.ts +5 -3
- package/src/webtriggers/index.ts +1 -0
- package/src/webtriggers/topSlowestStatementLastHourTrigger.ts +305 -0
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import { ForgeSqlOperation } from "../core/ForgeSQLQueryBuilder";
|
|
2
|
+
import { clusterStatementsSummary, clusterStatementsSummaryHistory } from "../core/SystemTables";
|
|
3
|
+
import { desc, gte, sql } from "drizzle-orm";
|
|
4
|
+
import { unionAll } from "drizzle-orm/mysql-core";
|
|
5
|
+
import { formatLimitOffset } from "../utils/sqlUtils";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Scheduler trigger: log and return the single slowest statement from the last hour, filtered by latency OR memory usage.
|
|
9
|
+
*
|
|
10
|
+
* When scheduled (e.g. hourly), this trigger queries
|
|
11
|
+
* INFORMATION_SCHEMA.CLUSTER_STATEMENTS_SUMMARY_HISTORY for the last hour
|
|
12
|
+
* and prints the TOP 1 entry (by AVG_LATENCY) if it exceeds either threshold.
|
|
13
|
+
*
|
|
14
|
+
* **OR Logic**: Statements are included if they exceed EITHER threshold:
|
|
15
|
+
* - avgLatencyMs > warnThresholdMs OR
|
|
16
|
+
* - avgMemBytes > memoryThresholdBytes
|
|
17
|
+
*
|
|
18
|
+
* **Pro Tips:**
|
|
19
|
+
* - Memory-only monitoring: Set warnThresholdMs to 10000ms (effectively disabled)
|
|
20
|
+
* - Latency-only monitoring: Set memoryThresholdBytes to 16MB (16 * 1024 * 1024) (effectively disabled)
|
|
21
|
+
* - Combined monitoring: Use both thresholds for comprehensive monitoring
|
|
22
|
+
*
|
|
23
|
+
* Excludes statements with empty `digestText`, empty `digest`, or service statements (`Use`, `Set`, `Show`).
|
|
24
|
+
*
|
|
25
|
+
* Logging rule:
|
|
26
|
+
* - Query exceeds warnThresholdMs OR memoryThresholdBytes → console.warn (logged)
|
|
27
|
+
* - otherwise → not logged
|
|
28
|
+
*
|
|
29
|
+
* @param orm ForgeSQL ORM instance (required)
|
|
30
|
+
* @param warnThresholdMs Milliseconds threshold for logging and filtering (default: 300ms)
|
|
31
|
+
* @param memoryThresholdBytes Bytes threshold for average memory usage (default: 8MB)
|
|
32
|
+
* @returns HTTP response with a JSON payload containing the filtered rows
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* ```ts
|
|
36
|
+
* import ForgeSQL, { topSlowestStatementLastHourTrigger } from "forge-sql-orm";
|
|
37
|
+
*
|
|
38
|
+
* const FORGE_SQL_ORM = new ForgeSQL();
|
|
39
|
+
*
|
|
40
|
+
* // Default thresholds: 300ms latency OR 8MB memory
|
|
41
|
+
* export const topSlowQueryTrigger = () =>
|
|
42
|
+
* topSlowestStatementLastHourTrigger(FORGE_SQL_ORM);
|
|
43
|
+
*
|
|
44
|
+
* // Only latency monitoring: 500ms threshold (memory effectively disabled)
|
|
45
|
+
* export const latencyOnlyTrigger = () =>
|
|
46
|
+
* topSlowestStatementLastHourTrigger(FORGE_SQL_ORM, 500, 16 * 1024 * 1024);
|
|
47
|
+
*
|
|
48
|
+
* // Only memory monitoring: 4MB threshold (latency effectively disabled)
|
|
49
|
+
* export const memoryOnlyTrigger = () =>
|
|
50
|
+
* topSlowestStatementLastHourTrigger(FORGE_SQL_ORM, 10000, 4 * 1024 * 1024);
|
|
51
|
+
*
|
|
52
|
+
* // Both thresholds: 500ms latency OR 8MB memory
|
|
53
|
+
* export const bothThresholdsTrigger = () =>
|
|
54
|
+
* topSlowestStatementLastHourTrigger(FORGE_SQL_ORM, 500, 8 * 1024 * 1024);
|
|
55
|
+
* ```
|
|
56
|
+
*
|
|
57
|
+
* @example
|
|
58
|
+
* ```yaml
|
|
59
|
+
* scheduledTrigger:
|
|
60
|
+
* - key: top-slow-query-trigger
|
|
61
|
+
* function: topSlowQueryTrigger
|
|
62
|
+
* interval: hour
|
|
63
|
+
*
|
|
64
|
+
* function:
|
|
65
|
+
* - key: topSlowQueryTrigger
|
|
66
|
+
* handler: index.topSlowQueryTrigger
|
|
67
|
+
* ```
|
|
68
|
+
*/
|
|
69
|
+
// Main scheduler trigger function to log the single slowest SQL statement from the last hour.
|
|
70
|
+
export const topSlowestStatementLastHourTrigger = async (
|
|
71
|
+
orm: ForgeSqlOperation,
|
|
72
|
+
// warnThresholdMs: Only log queries whose average latency (ms) exceeds this threshold (default: 300ms)
|
|
73
|
+
warnThresholdMs: number = 300,
|
|
74
|
+
// memoryThresholdBytes: Also include queries whose avg memory usage exceeds this threshold (default: 8MB)
|
|
75
|
+
memoryThresholdBytes: number = 8 * 1024 * 1024,
|
|
76
|
+
) => {
|
|
77
|
+
// Helper: Convert nanoseconds to milliseconds (for latency fields)
|
|
78
|
+
const nsToMs = (v: unknown) => {
|
|
79
|
+
const n = Number(v);
|
|
80
|
+
return Number.isFinite(n) ? n / 1e6 : NaN;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// Helper: Convert bytes to megabytes (for memory fields)
|
|
84
|
+
const bytesToMB = (v: unknown) => {
|
|
85
|
+
const n = Number(v);
|
|
86
|
+
return Number.isFinite(n) ? n / (1024 * 1024) : NaN;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// Helper: JSON.stringify replacer to handle BigInt values (so BigInt serializes as string)
|
|
90
|
+
const jsonSafeStringify = (value: unknown) =>
|
|
91
|
+
JSON.stringify(value, (_k, v) => (typeof v === "bigint" ? v.toString() : v));
|
|
92
|
+
|
|
93
|
+
// Number of top slow queries to fetch
|
|
94
|
+
const TOP_N = 1;
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
// Get references to system summary tables
|
|
98
|
+
const summaryHistory = clusterStatementsSummaryHistory;
|
|
99
|
+
const summary = clusterStatementsSummary;
|
|
100
|
+
// Helper to define the selected fields (selection shape) for both tables
|
|
101
|
+
const selectShape = (t: typeof summaryHistory | typeof summary) => ({
|
|
102
|
+
digest: t.digest,
|
|
103
|
+
stmtType: t.stmtType,
|
|
104
|
+
schemaName: t.schemaName,
|
|
105
|
+
execCount: t.execCount,
|
|
106
|
+
|
|
107
|
+
avgLatencyNs: t.avgLatency,
|
|
108
|
+
maxLatencyNs: t.maxLatency,
|
|
109
|
+
minLatencyNs: t.minLatency,
|
|
110
|
+
|
|
111
|
+
avgProcessTimeNs: t.avgProcessTime,
|
|
112
|
+
avgWaitTimeNs: t.avgWaitTime,
|
|
113
|
+
avgBackoffTimeNs: t.avgBackoffTime,
|
|
114
|
+
|
|
115
|
+
avgTotalKeys: t.avgTotalKeys,
|
|
116
|
+
firstSeen: t.firstSeen,
|
|
117
|
+
lastSeen: t.lastSeen,
|
|
118
|
+
planInCache: t.planInCache,
|
|
119
|
+
planCacheHits: t.planCacheHits,
|
|
120
|
+
digestText: t.digestText,
|
|
121
|
+
plan: t.plan,
|
|
122
|
+
avgMemBytes: (t as any).avgMem,
|
|
123
|
+
maxMemBytes: (t as any).maxMem,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// Filters: Only include rows from the last hour for each table
|
|
127
|
+
const lastHourFilterHistory = gte(
|
|
128
|
+
summaryHistory.summaryEndTime,
|
|
129
|
+
sql`DATE_SUB(NOW(), INTERVAL 1 HOUR)`,
|
|
130
|
+
);
|
|
131
|
+
const lastHourFilterSummary = gte(
|
|
132
|
+
summary.summaryEndTime,
|
|
133
|
+
sql`DATE_SUB(NOW(), INTERVAL 1 HOUR)`,
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
// Query for summary history table (last hour)
|
|
137
|
+
const qHistory = orm
|
|
138
|
+
.getDrizzleQueryBuilder()
|
|
139
|
+
.select(selectShape(summaryHistory))
|
|
140
|
+
.from(summaryHistory)
|
|
141
|
+
.where(lastHourFilterHistory);
|
|
142
|
+
|
|
143
|
+
// Query for summary table (last hour)
|
|
144
|
+
const qSummary = orm
|
|
145
|
+
.getDrizzleQueryBuilder()
|
|
146
|
+
.select(selectShape(summary))
|
|
147
|
+
.from(summary)
|
|
148
|
+
.where(lastHourFilterSummary);
|
|
149
|
+
|
|
150
|
+
// Use UNION ALL to combine results from both tables (avoids duplicates, keeps all rows)
|
|
151
|
+
// This is necessary because some statements may only be present in one of the tables.
|
|
152
|
+
const combined = unionAll(qHistory, qSummary).as("combined");
|
|
153
|
+
|
|
154
|
+
// Threshold in nanoseconds (warnThresholdMs → ns)
|
|
155
|
+
const thresholdNs = Math.floor(warnThresholdMs * 1e6);
|
|
156
|
+
// memoryThresholdBytes is already provided in bytes (default 8MB)
|
|
157
|
+
|
|
158
|
+
// Group duplicates by digest+stmtType+schemaName and aggregate metrics
|
|
159
|
+
const grouped = orm
|
|
160
|
+
.getDrizzleQueryBuilder()
|
|
161
|
+
.select({
|
|
162
|
+
digest: combined.digest,
|
|
163
|
+
stmtType: combined.stmtType,
|
|
164
|
+
schemaName: combined.schemaName,
|
|
165
|
+
execCount: sql<number>`SUM(${combined.execCount})`.as("execCount"),
|
|
166
|
+
|
|
167
|
+
avgLatencyNs: sql<number>`MAX(${combined.avgLatencyNs})`.as("avgLatencyNs"),
|
|
168
|
+
maxLatencyNs: sql<number>`MAX(${combined.maxLatencyNs})`.as("maxLatencyNs"),
|
|
169
|
+
minLatencyNs: sql<number>`MIN(${combined.minLatencyNs})`.as("minLatencyNs"),
|
|
170
|
+
|
|
171
|
+
avgProcessTimeNs: sql<number>`MAX(${combined.avgProcessTimeNs})`.as("avgProcessTimeNs"),
|
|
172
|
+
avgWaitTimeNs: sql<number>`MAX(${combined.avgWaitTimeNs})`.as("avgWaitTimeNs"),
|
|
173
|
+
avgBackoffTimeNs: sql<number>`MAX(${combined.avgBackoffTimeNs})`.as("avgBackoffTimeNs"),
|
|
174
|
+
|
|
175
|
+
avgMemBytes: sql<number>`MAX(${combined.avgMemBytes})`.as("avgMemBytes"),
|
|
176
|
+
maxMemBytes: sql<number>`MAX(${combined.maxMemBytes})`.as("maxMemBytes"),
|
|
177
|
+
|
|
178
|
+
avgTotalKeys: sql<number>`MAX(${combined.avgTotalKeys})`.as("avgTotalKeys"),
|
|
179
|
+
firstSeen: sql<string>`MIN(${combined.firstSeen})`.as("firstSeen"),
|
|
180
|
+
lastSeen: sql<string>`MAX(${combined.lastSeen})`.as("lastSeen"),
|
|
181
|
+
planInCache: sql<boolean>`MAX(${combined.planInCache})`.as("planInCache"),
|
|
182
|
+
planCacheHits: sql<number>`SUM(${combined.planCacheHits})`.as("planCacheHits"),
|
|
183
|
+
// Prefer a non-empty sample text/plan via MAX; acceptable for de-dup
|
|
184
|
+
digestText: sql<string>`MAX(${combined.digestText})`.as("digestText"),
|
|
185
|
+
plan: sql<string>`MAX(${combined.plan})`.as("plan"),
|
|
186
|
+
})
|
|
187
|
+
.from(combined)
|
|
188
|
+
.where(
|
|
189
|
+
sql`COALESCE(${combined.digest}, '') <> '' AND COALESCE(${combined.digestText}, '') <> '' AND COALESCE(${combined.stmtType}, '') NOT IN ('Use','Set','Show')`,
|
|
190
|
+
)
|
|
191
|
+
.groupBy(combined.digest, combined.stmtType, combined.schemaName)
|
|
192
|
+
.as("grouped");
|
|
193
|
+
|
|
194
|
+
// Final selection: filter by threshold, sort by avg latency desc, limit TOP_N
|
|
195
|
+
const rows = await orm
|
|
196
|
+
.getDrizzleQueryBuilder()
|
|
197
|
+
.select({
|
|
198
|
+
digest: grouped.digest,
|
|
199
|
+
stmtType: grouped.stmtType,
|
|
200
|
+
schemaName: grouped.schemaName,
|
|
201
|
+
execCount: grouped.execCount,
|
|
202
|
+
|
|
203
|
+
avgLatencyNs: grouped.avgLatencyNs,
|
|
204
|
+
maxLatencyNs: grouped.maxLatencyNs,
|
|
205
|
+
minLatencyNs: grouped.minLatencyNs,
|
|
206
|
+
|
|
207
|
+
avgProcessTimeNs: grouped.avgProcessTimeNs,
|
|
208
|
+
avgWaitTimeNs: grouped.avgWaitTimeNs,
|
|
209
|
+
avgBackoffTimeNs: grouped.avgBackoffTimeNs,
|
|
210
|
+
|
|
211
|
+
avgMemBytes: grouped.avgMemBytes,
|
|
212
|
+
maxMemBytes: grouped.maxMemBytes,
|
|
213
|
+
|
|
214
|
+
avgTotalKeys: grouped.avgTotalKeys,
|
|
215
|
+
firstSeen: grouped.firstSeen,
|
|
216
|
+
lastSeen: grouped.lastSeen,
|
|
217
|
+
planInCache: grouped.planInCache,
|
|
218
|
+
planCacheHits: grouped.planCacheHits,
|
|
219
|
+
digestText: grouped.digestText,
|
|
220
|
+
plan: grouped.plan,
|
|
221
|
+
})
|
|
222
|
+
.from(grouped)
|
|
223
|
+
.where(
|
|
224
|
+
sql`${grouped.avgLatencyNs} > ${thresholdNs} OR ${grouped.avgMemBytes} > ${memoryThresholdBytes}`,
|
|
225
|
+
)
|
|
226
|
+
.orderBy(desc(grouped.avgLatencyNs))
|
|
227
|
+
.limit(formatLimitOffset(TOP_N));
|
|
228
|
+
|
|
229
|
+
// Map each row into a formatted object with ms and rank, for easier consumption/logging
|
|
230
|
+
const formatted = rows.map((r, i) => ({
|
|
231
|
+
rank: i + 1, // 1-based rank in the top N
|
|
232
|
+
digest: r.digest,
|
|
233
|
+
stmtType: r.stmtType,
|
|
234
|
+
schemaName: r.schemaName,
|
|
235
|
+
execCount: r.execCount,
|
|
236
|
+
avgLatencyMs: nsToMs(r.avgLatencyNs), // Convert ns to ms for readability
|
|
237
|
+
maxLatencyMs: nsToMs(r.maxLatencyNs),
|
|
238
|
+
minLatencyMs: nsToMs(r.minLatencyNs),
|
|
239
|
+
avgProcessTimeMs: nsToMs(r.avgProcessTimeNs),
|
|
240
|
+
avgWaitTimeMs: nsToMs(r.avgWaitTimeNs),
|
|
241
|
+
avgBackoffTimeMs: nsToMs(r.avgBackoffTimeNs),
|
|
242
|
+
avgMemMB: bytesToMB(r.avgMemBytes),
|
|
243
|
+
maxMemMB: bytesToMB(r.maxMemBytes),
|
|
244
|
+
avgMemBytes: r.avgMemBytes,
|
|
245
|
+
maxMemBytes: r.maxMemBytes,
|
|
246
|
+
avgTotalKeys: r.avgTotalKeys,
|
|
247
|
+
firstSeen: r.firstSeen,
|
|
248
|
+
lastSeen: r.lastSeen,
|
|
249
|
+
planInCache: r.planInCache,
|
|
250
|
+
planCacheHits: r.planCacheHits,
|
|
251
|
+
digestText: r.digestText,
|
|
252
|
+
plan: r.plan,
|
|
253
|
+
}));
|
|
254
|
+
|
|
255
|
+
// Log each entry (SQL already filtered by threshold)
|
|
256
|
+
for (const f of formatted) {
|
|
257
|
+
// eslint-disable-next-line no-console
|
|
258
|
+
console.warn(
|
|
259
|
+
`${f.rank}. ${f.stmtType} avg=${f.avgLatencyMs?.toFixed?.(2)}ms max=${f.maxLatencyMs?.toFixed?.(2)}ms mem≈${f.avgMemMB?.toFixed?.(2)}MB(max ${f.maxMemMB?.toFixed?.(2)}MB) exec=${f.execCount} \n` +
|
|
260
|
+
` digest=${f.digest}\n` +
|
|
261
|
+
` sql=${(f.digestText || "").slice(0, 300)}${f.digestText && f.digestText.length > 300 ? "…" : ""}`,
|
|
262
|
+
);
|
|
263
|
+
if (f.plan) {
|
|
264
|
+
// print full plan separately (not truncated)
|
|
265
|
+
// eslint-disable-next-line no-console
|
|
266
|
+
console.warn(` full plan:\n${f.plan}`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Return HTTP response with JSON payload of the results
|
|
271
|
+
return {
|
|
272
|
+
headers: { "Content-Type": ["application/json"] },
|
|
273
|
+
statusCode: 200,
|
|
274
|
+
statusText: "OK",
|
|
275
|
+
body: jsonSafeStringify({
|
|
276
|
+
success: true,
|
|
277
|
+
window: "last_1h",
|
|
278
|
+
top: TOP_N,
|
|
279
|
+
warnThresholdMs,
|
|
280
|
+
memoryThresholdBytes,
|
|
281
|
+
rows: formatted,
|
|
282
|
+
generatedAt: new Date().toISOString(),
|
|
283
|
+
}),
|
|
284
|
+
};
|
|
285
|
+
} catch (error: any) {
|
|
286
|
+
// Catch any error (DB, logic, etc) and log with details for debugging
|
|
287
|
+
// This ensures the scheduler never crashes and always returns a response.
|
|
288
|
+
// eslint-disable-next-line no-console
|
|
289
|
+
console.error(
|
|
290
|
+
"Error in topSlowestStatementLastHourTrigger:",
|
|
291
|
+
error?.cause?.context?.debug?.sqlMessage ?? error?.cause ?? error,
|
|
292
|
+
);
|
|
293
|
+
return {
|
|
294
|
+
headers: { "Content-Type": ["application/json"] },
|
|
295
|
+
statusCode: 500,
|
|
296
|
+
statusText: "Internal Server Error",
|
|
297
|
+
body: jsonSafeStringify({
|
|
298
|
+
success: false,
|
|
299
|
+
message: "Failed to fetch or log slow queries",
|
|
300
|
+
error: error?.cause?.context?.debug?.sqlMessage ?? error?.cause?.message,
|
|
301
|
+
timestamp: new Date().toISOString(),
|
|
302
|
+
}),
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
};
|