forge-sql-orm 2.1.10 → 2.1.12

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.
Files changed (36) hide show
  1. package/README.md +356 -263
  2. package/dist/ForgeSQLORM.js +3263 -3226
  3. package/dist/ForgeSQLORM.js.map +1 -1
  4. package/dist/ForgeSQLORM.mjs +3261 -3224
  5. package/dist/ForgeSQLORM.mjs.map +1 -1
  6. package/dist/core/ForgeSQLORM.d.ts +66 -12
  7. package/dist/core/ForgeSQLORM.d.ts.map +1 -1
  8. package/dist/core/ForgeSQLQueryBuilder.d.ts +66 -11
  9. package/dist/core/ForgeSQLQueryBuilder.d.ts.map +1 -1
  10. package/dist/core/SystemTables.d.ts +82 -82
  11. package/dist/utils/cacheUtils.d.ts.map +1 -1
  12. package/dist/utils/forgeDriver.d.ts.map +1 -1
  13. package/dist/utils/forgeDriverProxy.d.ts +6 -2
  14. package/dist/utils/forgeDriverProxy.d.ts.map +1 -1
  15. package/dist/utils/metadataContextUtils.d.ts +5 -2
  16. package/dist/utils/metadataContextUtils.d.ts.map +1 -1
  17. package/dist/utils/sqlUtils.d.ts +72 -1
  18. package/dist/utils/sqlUtils.d.ts.map +1 -1
  19. package/dist/webtriggers/index.d.ts +1 -1
  20. package/dist/webtriggers/index.d.ts.map +1 -1
  21. package/dist/webtriggers/slowQuerySchedulerTrigger.d.ts +67 -0
  22. package/dist/webtriggers/slowQuerySchedulerTrigger.d.ts.map +1 -0
  23. package/package.json +12 -12
  24. package/src/core/ForgeSQLORM.ts +166 -29
  25. package/src/core/ForgeSQLQueryBuilder.ts +65 -11
  26. package/src/core/SystemTables.ts +1 -1
  27. package/src/utils/cacheUtils.ts +26 -3
  28. package/src/utils/forgeDriver.ts +15 -34
  29. package/src/utils/forgeDriverProxy.ts +58 -6
  30. package/src/utils/metadataContextUtils.ts +18 -6
  31. package/src/utils/sqlUtils.ts +241 -2
  32. package/src/webtriggers/index.ts +1 -1
  33. package/src/webtriggers/slowQuerySchedulerTrigger.ts +89 -0
  34. package/dist/webtriggers/topSlowestStatementLastHourTrigger.d.ts +0 -114
  35. package/dist/webtriggers/topSlowestStatementLastHourTrigger.d.ts.map +0 -1
  36. package/src/webtriggers/topSlowestStatementLastHourTrigger.ts +0 -563
@@ -29,7 +29,6 @@ import {
29
29
  DeleteAndEvictCacheType,
30
30
  ExecuteQuery,
31
31
  ExecuteQueryCacheable,
32
- ForgeSQLMetadata,
33
32
  InsertAndEvictCacheType,
34
33
  SelectAliasedCacheableType,
35
34
  SelectAliasedDistinctCacheableType,
@@ -544,32 +543,87 @@ export interface QueryBuilderForgeSql {
544
543
  executeWithLocalCacheContextAndReturnValue<T>(cacheContext: () => Promise<T>): Promise<T>;
545
544
 
546
545
  /**
547
- * Executes a query and provides access to execution metadata.
546
+ * Executes a query and provides access to execution metadata with performance monitoring.
548
547
  * This method allows you to capture detailed information about query execution
549
- * including database execution time, response size, and Forge SQL metadata.
548
+ * including database execution time, response size, and query analysis capabilities.
549
+ *
550
+ * The method aggregates metrics across all database operations within the query function,
551
+ * making it ideal for monitoring resolver performance and detecting performance issues.
550
552
  *
551
553
  * @template T - The return type of the query
552
- * @param query - A function that returns a Promise with the query result
553
- * @param onMetadata - Callback function that receives execution metadata
554
+ * @param query - A function that returns a Promise with the query result. Can contain multiple database operations.
555
+ * @param onMetadata - Callback function that receives aggregated execution metadata
556
+ * @param onMetadata.totalDbExecutionTime - Total database execution time across all operations in the query function (in milliseconds)
557
+ * @param onMetadata.totalResponseSize - Total response size across all operations (in bytes)
558
+ * @param onMetadata.printQueries - Function to analyze and print query execution plans from CLUSTER_STATEMENTS_SUMMARY
554
559
  * @returns Promise with the query result
560
+ *
555
561
  * @example
556
562
  * ```typescript
563
+ * // Basic usage with performance monitoring
557
564
  * const result = await forgeSQL.executeWithMetadata(
558
- * async () => await forgeSQL.select().from(users).where(eq(users.id, 1)),
559
- * (dbTime, responseSize, metadata) => {
560
- * console.log(`DB execution time: ${dbTime}ms`);
561
- * console.log(`Response size: ${responseSize} bytes`);
562
- * console.log('Forge metadata:', metadata);
565
+ * async () => {
566
+ * const users = await forgeSQL.selectFrom(usersTable);
567
+ * const orders = await forgeSQL.selectFrom(ordersTable).where(eq(ordersTable.userId, usersTable.id));
568
+ * return { users, orders };
569
+ * },
570
+ * (totalDbExecutionTime, totalResponseSize, printQueries) => {
571
+ * const threshold = 500; // ms baseline for this resolver
572
+ *
573
+ * if (totalDbExecutionTime > threshold * 1.5) {
574
+ * console.warn(`[Performance Warning] Resolver exceeded DB time: ${totalDbExecutionTime} ms`);
575
+ * await printQueries(); // Analyze and print query execution plans
576
+ * } else if (totalDbExecutionTime > threshold) {
577
+ * console.debug(`[Performance Debug] High DB time: ${totalDbExecutionTime} ms`);
578
+ * }
579
+ *
580
+ * console.log(`DB response size: ${totalResponseSize} bytes`);
563
581
  * }
564
582
  * );
565
583
  * ```
584
+ *
585
+ * @example
586
+ * ```typescript
587
+ * // Resolver with performance monitoring
588
+ * resolver.define("fetch", async (req: Request) => {
589
+ * try {
590
+ * return await forgeSQL.executeWithMetadata(
591
+ * async () => {
592
+ * // Resolver logic with multiple queries
593
+ * const users = await forgeSQL.selectFrom(demoUsers);
594
+ * const orders = await forgeSQL.selectFrom(demoOrders)
595
+ * .where(eq(demoOrders.userId, demoUsers.id));
596
+ * return { users, orders };
597
+ * },
598
+ * async (totalDbExecutionTime, totalResponseSize, printQueries) => {
599
+ * const threshold = 500; // ms baseline for this resolver
600
+ *
601
+ * if (totalDbExecutionTime > threshold * 1.5) {
602
+ * console.warn(`[Performance Warning fetch] Resolver exceeded DB time: ${totalDbExecutionTime} ms`);
603
+ * await printQueries(); // Optionally log or capture diagnostics for further analysis
604
+ * } else if (totalDbExecutionTime > threshold) {
605
+ * console.debug(`[Performance Debug] High DB time: ${totalDbExecutionTime} ms`);
606
+ * }
607
+ *
608
+ * console.log(`DB response size: ${totalResponseSize} bytes`);
609
+ * }
610
+ * );
611
+ * } catch (e) {
612
+ * const error = e?.cause?.debug?.sqlMessage ?? e?.cause;
613
+ * console.error(error, e);
614
+ * throw error;
615
+ * }
616
+ * });
617
+ * ```
618
+ *
619
+ * @note **Important**: When multiple resolvers are running concurrently, their query data may also appear in `printQueries()` analysis, as it queries the global `CLUSTER_STATEMENTS_SUMMARY` table.
566
620
  */
567
621
  executeWithMetadata<T>(
568
622
  query: () => Promise<T>,
569
623
  onMetadata: (
570
624
  totalDbExecutionTime: number,
571
625
  totalResponseSize: number,
572
- forgeMetadata: ForgeSQLMetadata,
626
+ printQueriesWithPlan: () => Promise<void>,
573
627
  ) => Promise<void> | void,
574
628
  ): Promise<T>;
575
629
  /**
@@ -21,7 +21,7 @@ export const migrations = mysqlTable("__migrations", {
21
21
 
22
22
  const informationSchema = mysqlSchema("information_schema");
23
23
 
24
- export const slowQuery = informationSchema.table("SLOW_QUERY", {
24
+ export const slowQuery = informationSchema.table("CLUSTER_SLOW_QUERY", {
25
25
  time: timestamp("Time", { fsp: 6, mode: "string" }).notNull(), // Timestamp when the slow query was recorded
26
26
 
27
27
  txnStartTs: bigint("Txn_start_ts", { mode: "bigint", unsigned: true }), // Transaction start timestamp (TSO)
@@ -47,6 +47,27 @@ function nowPlusSeconds(secondsToAdd: number): number {
47
47
  return Math.floor(dt.toSeconds());
48
48
  }
49
49
 
50
+ /**
51
+ * Extracts all table/column names between backticks from SQL query and returns them as comma-separated string.
52
+ *
53
+ * @param sql - SQL query string
54
+ * @returns Comma-separated string of unique backticked values
55
+ */
56
+ function extractBacktickedValues(sql: string): string {
57
+ const regex = /`([^`]+)`/g;
58
+ const matches = new Set<string>();
59
+ let match;
60
+
61
+ while ((match = regex.exec(sql.toLowerCase())) !== null) {
62
+ if (!match[1].startsWith("a_")) {
63
+ matches.add(`\`${match[1]}\``);
64
+ }
65
+ }
66
+
67
+ // Sort to ensure consistent order for the same input
68
+ return Array.from(matches).sort().join(",");
69
+ }
70
+
50
71
  /**
51
72
  * Generates a hash key for a query based on its SQL and parameters.
52
73
  *
@@ -332,12 +353,14 @@ export async function getFromCache<T>(
332
353
  }
333
354
 
334
355
  try {
335
- const cacheResult = await kvs.entity<CacheEntity>(options.cacheEntityName).get(key);
356
+ const cacheResult = (await kvs.entity<CacheEntity>(options.cacheEntityName).get(key)) as
357
+ | CacheEntity
358
+ | undefined;
336
359
 
337
360
  if (
338
361
  cacheResult &&
339
362
  (cacheResult[expirationName] as number) >= getCurrentTime() &&
340
- sqlQuery.sql.toLowerCase() === cacheResult[entityQueryName]
363
+ extractBacktickedValues(sqlQuery.sql) === cacheResult[entityQueryName]
341
364
  ) {
342
365
  if (options.logCache) {
343
366
  // eslint-disable-next-line no-console
@@ -398,7 +421,7 @@ export async function setCacheResult(
398
421
  .set(
399
422
  key,
400
423
  {
401
- [entityQueryName]: sqlQuery.sql.toLowerCase(),
424
+ [entityQueryName]: extractBacktickedValues(sqlQuery.sql),
402
425
  [expirationName]: nowPlusSeconds(cacheTtl),
403
426
  [dataName]: JSON.stringify(results),
404
427
  },
@@ -1,6 +1,10 @@
1
1
  import { sql, UpdateQueryResponse } from "@forge/sql";
2
2
  import { saveMetaDataToContext } from "./metadataContextUtils";
3
3
  import { getOperationType } from "./requestTypeContextUtils";
4
+ import { withTimeout } from "./sqlUtils";
5
+
6
+ const timeoutMs = 10000;
7
+ const timeoutMessage = `Atlassian @forge/sql did not return a response within ${timeoutMs}ms (${timeoutMs / 1000} seconds), so the request is blocked. Possible causes: slow query, network issues, or exceeding Forge SQL limits.`;
4
8
 
5
9
  /**
6
10
  * Metadata structure for Forge SQL query results.
@@ -62,36 +66,6 @@ export function isUpdateQueryResponse(obj: unknown): obj is UpdateQueryResponse
62
66
  );
63
67
  }
64
68
 
65
- /**
66
- * Executes a promise with a timeout.
67
- *
68
- * @param promise - The promise to execute
69
- * @param timeoutMs - Timeout in milliseconds (default: 10000ms)
70
- * @returns Promise that resolves with the result or rejects on timeout
71
- * @throws {Error} When the operation times out
72
- */
73
- async function withTimeout<T>(promise: Promise<T>, timeoutMs: number = 10000): Promise<T> {
74
- let timeoutId: ReturnType<typeof setTimeout> | undefined;
75
-
76
- const timeoutPromise = new Promise<never>((_, reject) => {
77
- timeoutId = setTimeout(() => {
78
- reject(
79
- new Error(
80
- `Atlassian @forge/sql did not return a response within ${timeoutMs}ms (${timeoutMs / 1000} seconds), so the request is blocked. Possible causes: slow query, network issues, or exceeding Forge SQL limits.`,
81
- ),
82
- );
83
- }, timeoutMs);
84
- });
85
-
86
- try {
87
- return await Promise.race([promise, timeoutPromise]);
88
- } finally {
89
- if (timeoutId) {
90
- clearTimeout(timeoutId);
91
- }
92
- }
93
- }
94
-
95
69
  function inlineParams(sql: string, params: unknown[]): string {
96
70
  let i = 0;
97
71
  return sql.replace(/\?/g, () => {
@@ -148,7 +122,7 @@ async function processExecuteMethod(query: string, params: unknown[]): Promise<F
148
122
  sqlStatement.bindParams(...params);
149
123
  }
150
124
 
151
- const result = await withTimeout(sqlStatement.execute());
125
+ const result = await withTimeout(sqlStatement.execute(), timeoutMessage, timeoutMs);
152
126
  await saveMetaDataToContext(result.metadata as ForgeSQLMetadata);
153
127
  if (!result.rows) {
154
128
  return { rows: [[]] };
@@ -171,7 +145,11 @@ async function processAllMethod(query: string, params: unknown[]): Promise<Forge
171
145
  await sqlStatement.bindParams(...params);
172
146
  }
173
147
 
174
- const result = (await withTimeout(sqlStatement.execute())) as ForgeSQLResult;
148
+ const result = (await withTimeout(
149
+ sqlStatement.execute(),
150
+ timeoutMessage,
151
+ timeoutMs,
152
+ )) as ForgeSQLResult;
175
153
  await saveMetaDataToContext(result.metadata);
176
154
 
177
155
  if (!result.rows) {
@@ -212,10 +190,13 @@ export const forgeDriver = async (
212
190
  method: QueryMethod,
213
191
  ): Promise<ForgeDriverResult> => {
214
192
  const operationType = await getOperationType();
215
-
216
193
  // Handle DDL operations
217
194
  if (operationType === "DDL") {
218
- const result = await withTimeout(sql.executeDDL(inlineParams(query, params)));
195
+ const result = await withTimeout(
196
+ sql.executeDDL(inlineParams(query, params)),
197
+ timeoutMessage,
198
+ timeoutMs,
199
+ );
219
200
  return await processDDLResult(method, result);
220
201
  }
221
202
 
@@ -1,11 +1,33 @@
1
1
  import { forgeDriver } from "./forgeDriver";
2
2
  import { injectSqlHints, SqlHints } from "./sqlHints";
3
+ import { ForgeSqlOperation } from "../core/ForgeSQLQueryBuilder";
4
+ import { printQueriesWithPlan } from "./sqlUtils";
3
5
 
4
6
  /**
5
- * Creates a proxy for the forgeDriver that injects SQL hints
7
+ * Error codes and constants for query analysis
8
+ */
9
+ const QUERY_ERROR_CODES = {
10
+ TIMEOUT: "SQL_QUERY_TIMEOUT",
11
+ OUT_OF_MEMORY_ERRNO: 8175,
12
+ } as const;
13
+
14
+ /**
15
+ * Delay to wait for CLUSTER_STATEMENTS_SUMMARY to be populated
16
+ */
17
+ const STATEMENTS_SUMMARY_DELAY_MS = 200;
18
+
19
+ /**
20
+ * Creates a proxy for the forgeDriver that injects SQL hints and handles query analysis
21
+ * @param forgeSqlOperation - The ForgeSQL operation instance
22
+ * @param options - SQL hints to inject
23
+ * @param logRawSqlQuery - Whether to log raw SQL queries
6
24
  * @returns A proxied version of the forgeDriver
7
25
  */
8
- export function createForgeDriverProxy(options?: SqlHints, logRawSqlQuery?: boolean) {
26
+ export function createForgeDriverProxy(
27
+ forgeSqlOperation: ForgeSqlOperation,
28
+ options?: SqlHints,
29
+ logRawSqlQuery?: boolean,
30
+ ) {
9
31
  return async (
10
32
  query: string,
11
33
  params: any[],
@@ -20,16 +42,46 @@ export function createForgeDriverProxy(options?: SqlHints, logRawSqlQuery?: bool
20
42
 
21
43
  if (options && logRawSqlQuery && modifiedQuery !== query) {
22
44
  // eslint-disable-next-line no-console
23
- console.debug("injected Hints: " + modifiedQuery);
45
+ console.debug(`SQL Hints injected: ${modifiedQuery}`);
24
46
  }
47
+
48
+ const queryStartTime = Date.now();
49
+
25
50
  try {
26
- // Call the original forgeDriver with the modified query
51
+ // Execute the query with injected hints
27
52
  return await forgeDriver(modifiedQuery, params, method);
28
- } catch (error) {
53
+ } catch (error: any) {
54
+ // Check if this is a timeout or out-of-memory error that we want to analyze
55
+ const isTimeoutError = error.code === QUERY_ERROR_CODES.TIMEOUT;
56
+ const isOutOfMemoryError =
57
+ error?.context?.debug?.errno === QUERY_ERROR_CODES.OUT_OF_MEMORY_ERRNO;
58
+
59
+ if (isTimeoutError || isOutOfMemoryError) {
60
+ if (isTimeoutError) {
61
+ // eslint-disable-next-line no-console
62
+ console.error(` TIMEOUT detected - Query exceeded time limit`);
63
+ } else {
64
+ // eslint-disable-next-line no-console
65
+ console.error(`OUT OF MEMORY detected - Query exceeded memory limit`);
66
+ }
67
+
68
+ // Wait for CLUSTER_STATEMENTS_SUMMARY to be populated with our failed query data
69
+ await new Promise((resolve) => setTimeout(resolve, STATEMENTS_SUMMARY_DELAY_MS));
70
+
71
+ const queryEndTime = Date.now();
72
+ const queryDuration = queryEndTime - queryStartTime;
73
+
74
+ // Analyze the failed query using CLUSTER_STATEMENTS_SUMMARY
75
+ await printQueriesWithPlan(forgeSqlOperation, queryDuration);
76
+ }
77
+
78
+ // Log SQL error details if requested
29
79
  if (logRawSqlQuery) {
30
80
  // eslint-disable-next-line no-console
31
- console.debug("SQL Error:", JSON.stringify(error));
81
+ console.debug(`SQL Error Details:`, JSON.stringify(error, null, 2));
32
82
  }
83
+
84
+ // Re-throw the original error
33
85
  throw error;
34
86
  }
35
87
  };
@@ -1,19 +1,31 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
2
  import { ForgeSQLMetadata } from "./forgeDriver";
3
+ import { ForgeSqlOperation } from "../core/ForgeSQLQueryBuilder";
4
+ import { printQueriesWithPlan } from "./sqlUtils";
3
5
 
4
6
  export type MetadataQueryContext = {
5
7
  totalDbExecutionTime: number;
6
8
  totalResponseSize: number;
7
- lastMetadata?: ForgeSQLMetadata;
9
+ beginTime: Date;
10
+ printQueriesWithPlan: () => Promise<void>;
11
+ forgeSQLORM: ForgeSqlOperation;
8
12
  };
9
13
  export const metadataQueryContext = new AsyncLocalStorage<MetadataQueryContext>();
10
14
 
11
- export async function saveMetaDataToContext(metadata: ForgeSQLMetadata): Promise<void> {
15
+ export async function saveMetaDataToContext(metadata?: ForgeSQLMetadata): Promise<void> {
12
16
  const context = metadataQueryContext.getStore();
13
- if (context && metadata) {
14
- context.totalResponseSize += metadata.responseSize;
15
- context.totalDbExecutionTime += metadata.dbExecutionTime;
16
- context.lastMetadata = metadata;
17
+ if (context) {
18
+ context.printQueriesWithPlan = async () => {
19
+ if (process.env.NODE_ENV !== "test") {
20
+ await new Promise((r) => setTimeout(r, 200));
21
+ }
22
+ await printQueriesWithPlan(context.forgeSQLORM, Date.now() - context.beginTime.getTime());
23
+ };
24
+ if (metadata) {
25
+ context.totalResponseSize += metadata.responseSize;
26
+ context.totalDbExecutionTime += metadata.dbExecutionTime;
27
+ }
28
+ // Log the results to console
17
29
  }
18
30
  }
19
31
 
@@ -1,4 +1,18 @@
1
- import { AnyColumn, Column, isTable, SQL, sql, StringChunk } from "drizzle-orm";
1
+ import {
2
+ and,
3
+ AnyColumn,
4
+ Column,
5
+ gte,
6
+ ilike,
7
+ isNotNull,
8
+ isTable,
9
+ ne,
10
+ not,
11
+ notInArray,
12
+ SQL,
13
+ sql,
14
+ StringChunk,
15
+ } from "drizzle-orm";
2
16
  import { AnyMySqlTable, MySqlCustomColumn } from "drizzle-orm/mysql-core/index";
3
17
  import { DateTime } from "luxon";
4
18
  import { PrimaryKeyBuilder } from "drizzle-orm/mysql-core/primary-keys";
@@ -6,9 +20,14 @@ import { AnyIndexBuilder } from "drizzle-orm/mysql-core/indexes";
6
20
  import { CheckBuilder } from "drizzle-orm/mysql-core/checks";
7
21
  import { ForeignKeyBuilder } from "drizzle-orm/mysql-core/foreign-keys";
8
22
  import { UniqueConstraintBuilder } from "drizzle-orm/mysql-core/unique-constraint";
9
- import type { SelectedFields } from "drizzle-orm/mysql-core/query-builders/select.types";
23
+ import { SelectedFields } from "drizzle-orm/mysql-core/query-builders/select.types";
10
24
  import { MySqlTable } from "drizzle-orm/mysql-core";
11
25
  import { isSQLWrapper } from "drizzle-orm/sql/sql";
26
+ import { clusterStatementsSummary, slowQuery } from "../core/SystemTables";
27
+ import { ForgeSqlOperation } from "../core/ForgeSQLQueryBuilder";
28
+ import { ColumnDataType } from "drizzle-orm/column-builder";
29
+ import { AnyMySqlColumn } from "drizzle-orm/mysql-core/columns/common";
30
+ import type { ColumnBaseConfig } from "drizzle-orm/column";
12
31
 
13
32
  /**
14
33
  * Interface representing table metadata information
@@ -536,3 +555,223 @@ export function formatLimitOffset(limitOrOffset: number): number {
536
555
  export function nextVal(sequenceName: string): number {
537
556
  return sql.raw(`NEXTVAL(${sequenceName})`) as unknown as number;
538
557
  }
558
+
559
+ /**
560
+ * Analyzes and prints query performance data from CLUSTER_STATEMENTS_SUMMARY table.
561
+ *
562
+ * This function queries the CLUSTER_STATEMENTS_SUMMARY table to find queries that were executed
563
+ * within the specified time window and prints detailed performance information including:
564
+ * - SQL query text
565
+ * - Memory usage (average and max in MB)
566
+ * - Execution time (average in ms)
567
+ * - Number of executions
568
+ * - Execution plan
569
+ *
570
+ * @param forgeSQLORM - The ForgeSQL operation instance for database access
571
+ * @param timeDiffMs - Time window in milliseconds to look back for queries (e.g., 1500 for last 1.5 seconds)
572
+ * @param timeout - Optional timeout in milliseconds for the query execution (defaults to 1500ms)
573
+ *
574
+ * @example
575
+ * ```typescript
576
+ * // Analyze queries from the last 2 seconds
577
+ * await printQueriesWithPlan(forgeSQLORM, 2000);
578
+ *
579
+ * // Analyze queries with custom timeout
580
+ * await printQueriesWithPlan(forgeSQLORM, 1000, 3000);
581
+ * ```
582
+ *
583
+ * @throws Does not throw - errors are logged to console.debug instead
584
+ */
585
+ export async function printQueriesWithPlan(
586
+ forgeSQLORM: ForgeSqlOperation,
587
+ timeDiffMs: number,
588
+ timeout?: number,
589
+ ) {
590
+ try {
591
+ const statementsTable = clusterStatementsSummary;
592
+ const timeoutMs = timeout ?? 3000;
593
+ const results = await withTimeout(
594
+ forgeSQLORM
595
+ .getDrizzleQueryBuilder()
596
+ .select({
597
+ digestText: withTidbHint(statementsTable.digestText),
598
+ avgLatency: statementsTable.avgLatency,
599
+ avgMem: statementsTable.avgMem,
600
+ execCount: statementsTable.execCount,
601
+ plan: statementsTable.plan,
602
+ stmtType: statementsTable.stmtType,
603
+ })
604
+ .from(statementsTable)
605
+ .where(
606
+ and(
607
+ isNotNull(statementsTable.digest),
608
+ not(ilike(statementsTable.digestText, "%information_schema%")),
609
+ notInArray(statementsTable.stmtType, [
610
+ "Use",
611
+ "Set",
612
+ "Show",
613
+ "Commit",
614
+ "Rollback",
615
+ "Begin",
616
+ ]),
617
+ gte(
618
+ statementsTable.lastSeen,
619
+ sql`DATE_SUB
620
+ (NOW(), INTERVAL
621
+ ${timeDiffMs * 1000}
622
+ MICROSECOND
623
+ )`,
624
+ ),
625
+ ),
626
+ ),
627
+ `Timeout ${timeoutMs}ms in printQueriesWithPlan - transient timeouts are usually fine; repeated timeouts mean this diagnostic query is consistently slow and should be investigated`,
628
+ timeoutMs + 200,
629
+ );
630
+
631
+ results.forEach((result) => {
632
+ // Average execution time (convert from nanoseconds to milliseconds)
633
+ const avgTimeMs = Number(result.avgLatency) / 1_000_000;
634
+ const avgMemMB = Number(result.avgMem) / 1_000_000;
635
+
636
+ // 1. Query info: SQL, memory, time, executions
637
+ // eslint-disable-next-line no-console
638
+ console.warn(
639
+ `SQL: ${result.digestText} | Memory: ${avgMemMB.toFixed(2)} MB | Time: ${avgTimeMs.toFixed(2)} ms | stmtType: ${result.stmtType} | Executions: ${result.execCount}\n Plan:${result.plan}`,
640
+ );
641
+ });
642
+ } catch (error) {
643
+ // eslint-disable-next-line no-console
644
+ console.debug(
645
+ `Error occurred while retrieving query execution plan: ${error instanceof Error ? error.message : "Unknown error"}. Try again after some time`,
646
+ error,
647
+ );
648
+ }
649
+ }
650
+
651
+ const SESSION_ALIAS_NAME_ORM = "orm";
652
+
653
+ /**
654
+ * Analyzes and logs slow queries from the last specified number of hours.
655
+ *
656
+ * This function queries the slow query system table to find queries that were executed
657
+ * within the specified time window and logs detailed performance information including:
658
+ * - SQL query text
659
+ * - Maximum memory usage (in MB)
660
+ * - Query execution time (in ms)
661
+ * - Execution count
662
+ * - Execution plan
663
+ *
664
+ * @param forgeSQLORM - The ForgeSQL operation instance for database access
665
+ * @param hours - Number of hours to look back for slow queries (e.g., 1 for last hour, 24 for last day)
666
+ * @param timeout - Optional timeout in milliseconds for the query execution (defaults to 1500ms)
667
+ *
668
+ * @example
669
+ * ```typescript
670
+ * // Analyze slow queries from the last hour
671
+ * await slowQueryPerHours(forgeSQLORM, 1);
672
+ *
673
+ * // Analyze slow queries from the last 24 hours with custom timeout
674
+ * await slowQueryPerHours(forgeSQLORM, 24, 3000);
675
+ *
676
+ * // Analyze slow queries from the last 6 hours
677
+ * await slowQueryPerHours(forgeSQLORM, 6);
678
+ * ```
679
+ *
680
+ * @throws Does not throw - errors are logged to console.debug instead
681
+ */
682
+ export async function slowQueryPerHours(
683
+ forgeSQLORM: ForgeSqlOperation,
684
+ hours: number,
685
+ timeout?: number,
686
+ ) {
687
+ try {
688
+ const timeoutMs = timeout ?? 1500;
689
+ const results = await withTimeout(
690
+ forgeSQLORM
691
+ .getDrizzleQueryBuilder()
692
+ .select({
693
+ query: withTidbHint(slowQuery.query),
694
+ queryTime: slowQuery.queryTime,
695
+ memMax: slowQuery.memMax,
696
+ plan: slowQuery.plan,
697
+ })
698
+ .from(slowQuery)
699
+ .where(
700
+ and(
701
+ isNotNull(slowQuery.digest),
702
+ ne(slowQuery.sessionAlias, SESSION_ALIAS_NAME_ORM),
703
+ gte(
704
+ slowQuery.time,
705
+ sql`DATE_SUB
706
+ (NOW(), INTERVAL
707
+ ${hours}
708
+ HOUR
709
+ )`,
710
+ ),
711
+ ),
712
+ ),
713
+ `Timeout ${timeoutMs}ms in slowQueryPerHours - transient timeouts are usually fine; repeated timeouts mean this diagnostic query is consistently slow and should be investigated`,
714
+ timeoutMs,
715
+ );
716
+ const response: string[] = [];
717
+ results.forEach((result) => {
718
+ // Convert memory from bytes to MB and handle null values
719
+ const memMaxMB = result.memMax ? Number(result.memMax) / 1_000_000 : 0;
720
+
721
+ const message = `Found SlowQuery SQL: ${result.query} | Memory: ${memMaxMB.toFixed(2)} MB | Time: ${result.queryTime} ms\n Plan:${result.plan}`;
722
+ response.push(message);
723
+ // 1. Query info: SQL, memory, time, executions
724
+ // eslint-disable-next-line no-console
725
+ console.warn(message);
726
+ });
727
+ return response;
728
+ } catch (error) {
729
+ // eslint-disable-next-line no-console
730
+ console.debug(
731
+ `Error occurred while retrieving query execution plan: ${error instanceof Error ? error.message : "Unknown error"}. Try again after some time`,
732
+ error,
733
+ );
734
+ return [
735
+ `Error occurred while retrieving query execution plan: ${error instanceof Error ? error.message : "Unknown error"}`,
736
+ ];
737
+ }
738
+ }
739
+
740
+ /**
741
+ * Executes a promise with a timeout.
742
+ *
743
+ * @param promise - The promise to execute
744
+ * @param timeoutMs - Timeout in milliseconds
745
+ * @returns Promise that resolves with the result or rejects on timeout
746
+ * @throws {Error} When the operation times out
747
+ */
748
+ export async function withTimeout<T>(
749
+ promise: Promise<T>,
750
+ message: string,
751
+ timeoutMs: number,
752
+ ): Promise<T> {
753
+ let timeoutId: ReturnType<typeof setTimeout> | undefined;
754
+
755
+ const timeoutPromise = new Promise<never>((_, reject) => {
756
+ timeoutId = setTimeout(() => {
757
+ reject(new Error(message));
758
+ }, timeoutMs);
759
+ });
760
+
761
+ try {
762
+ return await Promise.race([promise, timeoutPromise]);
763
+ } finally {
764
+ if (timeoutId) {
765
+ clearTimeout(timeoutId);
766
+ }
767
+ }
768
+ }
769
+
770
+ export function withTidbHint<
771
+ TDataType extends ColumnDataType,
772
+ TPartial extends Partial<ColumnBaseConfig<TDataType, string>>,
773
+ >(column: AnyMySqlColumn<TPartial>): AnyMySqlColumn<TPartial> {
774
+ // We lie a bit to TypeScript here: at runtime this is a new SQL fragment,
775
+ // but returning TExpr keeps the column type info in downstream inference.
776
+ return sql`/*+ SET_VAR(tidb_session_alias=${sql.raw(`${SESSION_ALIAS_NAME_ORM}`)}) */ ${column}` as unknown as AnyMySqlColumn<TPartial>;
777
+ }
@@ -3,7 +3,7 @@ export * from "./applyMigrationsWebTrigger";
3
3
  export * from "./fetchSchemaWebTrigger";
4
4
  export * from "./dropTablesMigrationWebTrigger";
5
5
  export * from "./clearCacheSchedulerTrigger";
6
- export * from "./topSlowestStatementLastHourTrigger";
6
+ export * from "./slowQuerySchedulerTrigger";
7
7
 
8
8
  export interface TriggerResponse<BODY> {
9
9
  body?: BODY;