-
Notifications
You must be signed in to change notification settings - Fork 3.7k
fix(db): bound log stats/export and KB cleanup queries for 60s statement_timeout #5435
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: staging
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,7 +1,7 @@ | ||||||||||||||||||||
| import { dbReplica } from '@sim/db' | ||||||||||||||||||||
| import { workflow, workflowExecutionLogs } from '@sim/db/schema' | ||||||||||||||||||||
| import { createLogger } from '@sim/logger' | ||||||||||||||||||||
| import { and, eq, sql } from 'drizzle-orm' | ||||||||||||||||||||
| import { and, eq, gte, lte, sql } from 'drizzle-orm' | ||||||||||||||||||||
| import { type NextRequest, NextResponse } from 'next/server' | ||||||||||||||||||||
| import { | ||||||||||||||||||||
| type DashboardStatsResponse, | ||||||||||||||||||||
|
|
@@ -19,6 +19,13 @@ import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' | |||||||||||||||||||
|
|
||||||||||||||||||||
| const logger = createLogger('LogsStatsAPI') | ||||||||||||||||||||
|
|
||||||||||||||||||||
| /** | ||||||||||||||||||||
| * Hard default window for open-ended stats requests. Production roles run with | ||||||||||||||||||||
| * a 60s statement_timeout, so the aggregations must never scan a workspace's | ||||||||||||||||||||
| * entire log history. | ||||||||||||||||||||
| */ | ||||||||||||||||||||
| const DEFAULT_STATS_WINDOW_MS = 30 * 24 * 60 * 60 * 1000 | ||||||||||||||||||||
|
|
||||||||||||||||||||
| export const revalidate = 0 | ||||||||||||||||||||
|
|
||||||||||||||||||||
| export const GET = withRouteHandler(async (request: NextRequest) => { | ||||||||||||||||||||
|
|
@@ -63,19 +70,37 @@ export const GET = withRouteHandler(async (request: NextRequest) => { | |||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| const commonFilters = buildFilterConditions(params, { useSimpleLevelFilter: true }) | ||||||||||||||||||||
| const whereCondition = commonFilters ? and(workspaceFilter, commonFilters) : workspaceFilter | ||||||||||||||||||||
|
|
||||||||||||||||||||
| const boundsQuery = await dbReplica | ||||||||||||||||||||
| .select({ | ||||||||||||||||||||
| minTime: sql<string>`MIN(${workflowExecutionLogs.startedAt})`, | ||||||||||||||||||||
| maxTime: sql<string>`MAX(${workflowExecutionLogs.startedAt})`, | ||||||||||||||||||||
| }) | ||||||||||||||||||||
| .from(workflowExecutionLogs) | ||||||||||||||||||||
| .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) | ||||||||||||||||||||
| .where(whereCondition) | ||||||||||||||||||||
| const now = new Date() | ||||||||||||||||||||
| const windowEnd = params.endDate ? new Date(params.endDate) : now | ||||||||||||||||||||
| const windowStart = params.startDate | ||||||||||||||||||||
| ? new Date(params.startDate) | ||||||||||||||||||||
| : new Date(windowEnd.getTime() - DEFAULT_STATS_WINDOW_MS) | ||||||||||||||||||||
| const windowFilter = and( | ||||||||||||||||||||
| gte(workflowExecutionLogs.startedAt, windowStart), | ||||||||||||||||||||
| lte(workflowExecutionLogs.startedAt, windowEnd) | ||||||||||||||||||||
| ) | ||||||||||||||||||||
| const whereCondition = commonFilters | ||||||||||||||||||||
| ? and(workspaceFilter, windowFilter, commonFilters) | ||||||||||||||||||||
| : and(workspaceFilter, windowFilter) | ||||||||||||||||||||
|
|
||||||||||||||||||||
| // The workflow join only matters when a filter references workflow columns. | ||||||||||||||||||||
| const needsWorkflowJoin = Boolean( | ||||||||||||||||||||
| params.workflowIds || params.folderIds || params.workflowName || params.folderName | ||||||||||||||||||||
| ) | ||||||||||||||||||||
|
Comment on lines
+88
to
+90
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time! |
||||||||||||||||||||
| const boundsSelection = { | ||||||||||||||||||||
| minTime: sql<string>`MIN(${workflowExecutionLogs.startedAt})`, | ||||||||||||||||||||
| maxTime: sql<string>`MAX(${workflowExecutionLogs.startedAt})`, | ||||||||||||||||||||
| } | ||||||||||||||||||||
| const boundsQuery = needsWorkflowJoin | ||||||||||||||||||||
| ? await dbReplica | ||||||||||||||||||||
| .select(boundsSelection) | ||||||||||||||||||||
| .from(workflowExecutionLogs) | ||||||||||||||||||||
| .leftJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) | ||||||||||||||||||||
| .where(whereCondition) | ||||||||||||||||||||
| : await dbReplica.select(boundsSelection).from(workflowExecutionLogs).where(whereCondition) | ||||||||||||||||||||
|
|
||||||||||||||||||||
| const bounds = boundsQuery[0] | ||||||||||||||||||||
| const now = new Date() | ||||||||||||||||||||
|
|
||||||||||||||||||||
| let startTime: Date | ||||||||||||||||||||
| let endTime: Date | ||||||||||||||||||||
|
|
||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,6 +8,7 @@ import { generateRequestId } from '@/lib/core/utils/request' | |
| import { withRouteHandler } from '@/lib/core/utils/with-route-handler' | ||
| import { buildNameById, getColumnId, rowDataIdToName } from '@/lib/table/column-keys' | ||
| import { queryRows } from '@/lib/table/rows/service' | ||
| import type { TableRowsCursor } from '@/lib/table/types' | ||
| import { accessError, checkAccess } from '@/app/api/table/utils' | ||
|
|
||
| const logger = createLogger('TableExport') | ||
|
|
@@ -65,15 +66,23 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Rou | |
| controller.enqueue(encoder.encode('[')) | ||
| } | ||
|
|
||
| // Keyset pagination on the default (order_key, id) order: `after` makes each | ||
| // page an index seek, where OFFSET re-scans every prior row (O(N²) across a | ||
| // full drain, blowing the 60s statement_timeout on large tables). Legacy rows | ||
| // without an order key fall back to offset paging, mirroring the client's | ||
| // cursor derivation in getNextTableRowsPageParam. | ||
| let after: TableRowsCursor | undefined | ||
| let offset = 0 | ||
| let firstJsonRow = true | ||
| while (true) { | ||
| const result = await queryRows( | ||
| table, | ||
| { limit: EXPORT_BATCH_SIZE, offset, includeTotal: false }, | ||
| { limit: EXPORT_BATCH_SIZE, after, offset, includeTotal: false }, | ||
| requestId | ||
| ) | ||
|
|
||
| if (result.rows.length === 0) break | ||
|
|
||
| for (const row of result.rows) { | ||
| if (format === 'csv') { | ||
| const values = columns.map((c) => formatCsvValue(row.data[getColumnId(c)])) | ||
|
|
@@ -87,7 +96,8 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Rou | |
| } | ||
| } | ||
|
|
||
| if (result.rows.length < EXPORT_BATCH_SIZE) break | ||
| const last = result.rows[result.rows.length - 1] | ||
| after = last.orderKey ? { orderKey: last.orderKey, id: last.id } : undefined | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Export omits trailing legacy rowsMedium Severity After a page whose last row has an Reviewed by Cursor Bugbot for commit 9d765dc. Configure here. |
||
| offset += result.rows.length | ||
|
Comment on lines
+99
to
101
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time! |
||
| } | ||
|
Comment on lines
+99
to
102
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When |
||
|
|
||
|
|
||


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Stats window unbounded with startDate
Medium Severity
The new
windowFilteronly applies the 30-day cap whenstartDateis omitted. A request with an oldstartDateand noendDatescans from that date throughnow, which can still exceed the 60sstatement_timeoutthe change is meant to prevent.Reviewed by Cursor Bugbot for commit 9d765dc. Configure here.