11import { db } from '@sim/db'
22import {
33 asyncJobs ,
4+ knowledgeConnectorSyncLog ,
45 tableJobs ,
56 workflowDeploymentOperation ,
67 workflowExecutionLogs ,
@@ -31,6 +32,7 @@ import {
3132 STALE_SWEEPABLE_EXECUTION_STATUSES ,
3233 type StaleSweepableExecutionStatus ,
3334} from '@/lib/logs/types'
35+ import { cancelStaleDispatches } from '@/lib/table/dispatcher'
3436import { deleteFile } from '@/lib/uploads/core/storage-service'
3537import {
3638 carrierNotIrrecoverableSql ,
@@ -52,12 +54,33 @@ const EXECUTION_DEADLINE_ERROR = getTimeoutErrorMessage(undefined)
5254const TABLE_JOB_STALE_THRESHOLD_MINUTES = 95
5355/** Terminal table-jobs older than this are pruned; only the latest job per table is ever read. */
5456const TABLE_JOB_RETENTION_HOURS = 24
57+ /**
58+ * A table run dispatch whose holder has not made progress for this long is
59+ * treated as dead. Same shape and window as the table-job threshold above: the
60+ * 90-minute Trigger.dev task ceiling (`maxDuration` in `trigger.config.ts`) plus
61+ * five minutes of cleanup grace, measured from the dispatcher's own per-window
62+ * heartbeat rather than from when the run was requested.
63+ */
64+ const TABLE_DISPATCH_STALE_THRESHOLD_MINUTES = 95
65+ /** Per-run ceiling on reaped dispatches, so one tick cannot fan out unbounded SSE. */
66+ const TABLE_DISPATCH_MAX_PER_RUN = 200
5567/**
5668 * Terminal deployment operations older than this are pruned. Every reader of
5769 * this table is latest-generation-only, and idempotency keys only need to
5870 * survive a client retry window, so 30 days is generous.
5971 */
6072const DEPLOYMENT_OPERATION_RETENTION_DAYS = 30
73+ /**
74+ * Terminal connector sync logs older than this are pruned. Nothing pruned them
75+ * before, so the table grew by one row per sync run forever — a connector on a
76+ * fifteen-minute interval writes about 35,000 rows a year on its own. That cost
77+ * lands on `loadPreviousListingObservation`, which reads the newest `completed`
78+ * row per connector through an index covering `connector_id` alone, so every
79+ * retained row makes the sort behind the deletion-safety corroboration slower.
80+ */
81+ const CONNECTOR_SYNC_LOG_RETENTION_DAYS = 30
82+ const CONNECTOR_SYNC_LOG_PRUNE_BATCH_SIZE = 2000
83+ const CONNECTOR_SYNC_LOG_MAX_ROWS_PER_RUN = 20_000
6184const DEPLOYMENT_OPERATION_PRUNE_BATCH_SIZE = 2000
6285const DEPLOYMENT_OPERATION_PRUNE_MAX_BATCHES = 10
6386const WORKFLOW_EXECUTION_MUTATION_BATCH_SIZE = 100
@@ -144,6 +167,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
144167 const staleTableJobThreshold = new Date (
145168 now . getTime ( ) - TABLE_JOB_STALE_THRESHOLD_MINUTES * 60 * 1000
146169 )
170+ const staleDispatchThreshold = new Date (
171+ now . getTime ( ) - TABLE_DISPATCH_STALE_THRESHOLD_MINUTES * 60 * 1000
172+ )
147173
148174 let staleExecutionsFound = 0
149175 let cleaned = 0
@@ -538,6 +564,90 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
538564 } )
539565 }
540566
567+ /**
568+ * Prune terminal connector sync logs past retention.
569+ *
570+ * HARD INVARIANT: the newest row per connector must survive, and so must the
571+ * newest `completed` row. `loadPreviousListingObservation` reconstructs the
572+ * previous listing from the latest `completed` log, and that reconstruction
573+ * decides whether a suspect listing is corroborated — i.e. whether
574+ * reconciliation may delete documents. Pruning the last `completed` row
575+ * would silently change deletion behaviour, so both `exists` guards below
576+ * are load-bearing rather than defensive.
577+ *
578+ * `started` rows are never eligible: they are either in flight or waiting on
579+ * the scheduler's own sweep to close them.
580+ */
581+ let connectorSyncLogsPruned = 0
582+ try {
583+ const syncLogRetention = new Date (
584+ Date . now ( ) - CONNECTOR_SYNC_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000
585+ )
586+ const newerSyncLog = alias ( knowledgeConnectorSyncLog , 'newer_sync_log' )
587+ const newerCompletedSyncLog = alias ( knowledgeConnectorSyncLog , 'newer_completed_sync_log' )
588+ const syncLogPredicate = and (
589+ inArray ( knowledgeConnectorSyncLog . status , [ 'completed' , 'failed' ] ) ,
590+ lt ( knowledgeConnectorSyncLog . startedAt , syncLogRetention ) ,
591+ exists (
592+ db
593+ . select ( { id : newerSyncLog . id } )
594+ . from ( newerSyncLog )
595+ . where (
596+ and (
597+ eq ( newerSyncLog . connectorId , knowledgeConnectorSyncLog . connectorId ) ,
598+ gt ( newerSyncLog . startedAt , knowledgeConnectorSyncLog . startedAt )
599+ )
600+ )
601+ ) ,
602+ or (
603+ ne ( knowledgeConnectorSyncLog . status , 'completed' ) ,
604+ exists (
605+ db
606+ . select ( { id : newerCompletedSyncLog . id } )
607+ . from ( newerCompletedSyncLog )
608+ . where (
609+ and (
610+ eq ( newerCompletedSyncLog . connectorId , knowledgeConnectorSyncLog . connectorId ) ,
611+ eq ( newerCompletedSyncLog . status , 'completed' ) ,
612+ gt ( newerCompletedSyncLog . startedAt , knowledgeConnectorSyncLog . startedAt )
613+ )
614+ )
615+ )
616+ )
617+ )
618+ const syncLogResult = await runBatchedMutation ( {
619+ batchSize : CONNECTOR_SYNC_LOG_PRUNE_BATCH_SIZE ,
620+ maxRowsPerRun : CONNECTOR_SYNC_LOG_MAX_ROWS_PER_RUN ,
621+ claim : ( tx , limit ) =>
622+ tx
623+ . select ( { id : knowledgeConnectorSyncLog . id } )
624+ . from ( knowledgeConnectorSyncLog )
625+ . where ( syncLogPredicate )
626+ . limit ( limit )
627+ . for ( 'update' , { skipLocked : true } ) ,
628+ mutation : ( tx , candidateIds ) =>
629+ tx
630+ . delete ( knowledgeConnectorSyncLog )
631+ . where ( inArray ( knowledgeConnectorSyncLog . id , candidateIds ) )
632+ . returning ( { id : knowledgeConnectorSyncLog . id } ) ,
633+ } )
634+ connectorSyncLogsPruned = syncLogResult . affected
635+ if ( connectorSyncLogsPruned > 0 ) {
636+ logger . info (
637+ `Pruned ${ connectorSyncLogsPruned } old connector sync logs (retention: ${ CONNECTOR_SYNC_LOG_RETENTION_DAYS } d)`
638+ )
639+ }
640+ if ( syncLogResult . reachedLimit ) {
641+ logger . info ( 'Deferred remaining connector sync logs after reaching the per-run cap' , {
642+ maxRowsPerRun : CONNECTOR_SYNC_LOG_MAX_ROWS_PER_RUN ,
643+ } )
644+ }
645+ } catch ( error ) {
646+ logger . error ( 'Failed to prune old connector sync logs:' , {
647+ error : toError ( error ) . message ,
648+ } )
649+ }
650+
541651 /**
542652 * Prune terminal deployment operations past retention. HARD INVARIANT:
543653 * the newest-generation row per workflow must always survive — the next
@@ -604,6 +714,29 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
604714 } )
605715 }
606716
717+ /**
718+ * Cancel table run dispatches abandoned by a dead dispatcher. Nothing else
719+ * reclaims them — every other terminal transition is user- or flow-initiated
720+ * — so a dispatcher killed mid-loop left the row `dispatching` forever and
721+ * the client's "X running" overlay with it. Ages from the dispatcher's
722+ * per-window heartbeat, so a slow-but-live dispatch is spared.
723+ */
724+ let staleDispatchesCancelled = 0
725+ try {
726+ staleDispatchesCancelled = (
727+ await cancelStaleDispatches ( staleDispatchThreshold , TABLE_DISPATCH_MAX_PER_RUN )
728+ ) . length
729+ if ( staleDispatchesCancelled > 0 ) {
730+ logger . warn ( `Cancelled ${ staleDispatchesCancelled } abandoned table run dispatches` , {
731+ thresholdMinutes : TABLE_DISPATCH_STALE_THRESHOLD_MINUTES ,
732+ } )
733+ }
734+ } catch ( error ) {
735+ logger . error ( 'Failed to cancel abandoned table run dispatches:' , {
736+ error : toError ( error ) . message ,
737+ } )
738+ }
739+
607740 return NextResponse . json ( {
608741 success : true ,
609742 executions : {
@@ -622,6 +755,14 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
622755 tableJobs : {
623756 staleMarkedFailed : staleTableJobsMarkedFailed ,
624757 } ,
758+ connectorSyncLogs : {
759+ pruned : connectorSyncLogsPruned ,
760+ retentionDays : CONNECTOR_SYNC_LOG_RETENTION_DAYS ,
761+ } ,
762+ tableRunDispatches : {
763+ staleCancelled : staleDispatchesCancelled ,
764+ thresholdMinutes : TABLE_DISPATCH_STALE_THRESHOLD_MINUTES ,
765+ } ,
625766 deploymentOperations : {
626767 pruned : deploymentOperationsPruned ,
627768 retentionDays : DEPLOYMENT_OPERATION_RETENTION_DAYS ,
0 commit comments