@@ -32,6 +32,14 @@ const DISPATCH_CONCURRENCY = 10
3232
3333const STALE_LOCK_ERROR_MESSAGE = 'Sync timed out (stale lock recovered)'
3434
35+ /**
36+ * A connector left `pending` past the TTL — its sync was queued but no worker
37+ * ever took the lock, so the hand-off was lost (the process died between the
38+ * two writes, or the queued run was dropped). Distinct from the stale-lock
39+ * message because nothing timed out: the sync never started.
40+ */
41+ const LOST_DISPATCH_ERROR_MESSAGE = 'Sync was queued but never started'
42+
3543/**
3644 * How long the connector holding the lock has gone without proving it is alive.
3745 *
@@ -57,8 +65,8 @@ function syncLockLease(): SQL {
5765 * breaker and this SQL breaker cannot drift into two different messages for one
5866 * verdict.
5967 */
60- function reclaimedError ( ) : SQL {
61- return sql `CASE WHEN COALESCE(${ knowledgeConnector . consecutiveFailures } , 0) + 1 >= ${ MAX_CONSECUTIVE_FAILURES } THEN ${ CONNECTOR_AUTO_DISABLED_ERROR } ELSE ${ STALE_LOCK_ERROR_MESSAGE } END`
68+ function reclaimedError ( message : string ) : SQL {
69+ return sql `CASE WHEN COALESCE(${ knowledgeConnector . consecutiveFailures } , 0) + 1 >= ${ MAX_CONSECUTIVE_FAILURES } THEN ${ CONNECTOR_AUTO_DISABLED_ERROR } ELSE ${ message } END`
6270}
6371
6472/**
@@ -123,6 +131,22 @@ function reclaimedNextSyncAt(): SQL {
123131 return sql `CASE WHEN COALESCE(${ knowledgeConnector . consecutiveFailures } , 0) + 1 >= ${ MAX_CONSECUTIVE_FAILURES } THEN NULL ELSE now() + LEAST((COALESCE(${ knowledgeConnector . consecutiveFailures } , 0) + 1) * ${ CONNECTOR_FAILURE_BACKOFF_STEP_MINUTES } , ${ CONNECTOR_FAILURE_BACKOFF_CAP_MINUTES } ) * INTERVAL '1 minute' END`
124132}
125133
134+ /**
135+ * The write shared by both reclaims: a connector that stopped making progress
136+ * re-enters the failure ladder. Factored so the two callers cannot drift into
137+ * different ladders for the same verdict — the same reason
138+ * {@link reclaimedError} takes the message rather than hardcoding it.
139+ */
140+ function reclaimPayload ( message : string ) {
141+ return {
142+ status : reclaimedStatus ( ) ,
143+ lastSyncError : reclaimedError ( message ) ,
144+ nextSyncAt : reclaimedNextSyncAt ( ) ,
145+ consecutiveFailures : reclaimedFailureCount ( ) ,
146+ updatedAt : sql `now()` ,
147+ }
148+ }
149+
126150/**
127151 * Cron endpoint that checks for connectors due for sync and dispatches sync jobs.
128152 * Should be called every 5 minutes by an external cron service.
@@ -141,29 +165,115 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
141165
142166 const staleCutoff = new Date ( now . getTime ( ) - CONNECTOR_SYNC_STALE_LOCK_TTL_MS )
143167
144- const recoveredConnectors = await db
145- . update ( knowledgeConnector )
146- . set ( {
147- status : reclaimedStatus ( ) ,
148- lastSyncError : reclaimedError ( ) ,
149- nextSyncAt : reclaimedNextSyncAt ( ) ,
150- consecutiveFailures : reclaimedFailureCount ( ) ,
151- // Releases the reclaimed run's ownership token so its terminal write can
152- // no longer match, even before a replacement takes the lock, and closes
153- // its lease so a re-locked row starts from a fresh one.
154- syncLockToken : null ,
155- syncLockLeaseAt : null ,
156- updatedAt : sql `now()` ,
157- } )
158- . where (
159- and (
160- eq ( knowledgeConnector . status , 'syncing' ) ,
161- sql `${ syncLockLease ( ) } <= ${ sql . param ( staleCutoff , knowledgeConnector . syncLockLeaseAt ) } ` ,
162- isNull ( knowledgeConnector . archivedAt ) ,
163- isNull ( knowledgeConnector . deletedAt )
168+ /**
169+ * The three recovery passes target disjoint row sets — a held-but-silent
170+ * lock, a queue entry that never became one, and a sync-log row orphaned by
171+ * a killed run — and none reads another's result, so they go out together
172+ * rather than as three serialized round trips.
173+ *
174+ * `logRowNotHeldByLiveRun` is the one apparent coupling and it is benign:
175+ * it spares a log row only while its connector's lease is still live, and
176+ * every row the lock reclaim targets has an expired lease, so the sweep
177+ * reaches the same verdict against either snapshot.
178+ */
179+ const [ recoveredConnectors , recoveredPendingConnectors , closedSyncLogs ] = await Promise . all ( [
180+ db
181+ . update ( knowledgeConnector )
182+ . set ( {
183+ ...reclaimPayload ( STALE_LOCK_ERROR_MESSAGE ) ,
184+ /**
185+ * Releases the reclaimed run's ownership token so its terminal write
186+ * can no longer match, even before a replacement takes the lock, and
187+ * closes its lease so a re-locked row starts from a fresh one.
188+ */
189+ syncLockToken : null ,
190+ syncLockLeaseAt : null ,
191+ } )
192+ . where (
193+ and (
194+ eq ( knowledgeConnector . status , 'syncing' ) ,
195+ sql `${ syncLockLease ( ) } <= ${ sql . param ( staleCutoff , knowledgeConnector . syncLockLeaseAt ) } ` ,
196+ isNull ( knowledgeConnector . archivedAt ) ,
197+ isNull ( knowledgeConnector . deletedAt )
198+ )
164199 )
165- )
166- . returning ( { id : knowledgeConnector . id } )
200+ . returning ( { id : knowledgeConnector . id } ) ,
201+ /**
202+ * Recovers connectors whose queued sync was never picked up.
203+ *
204+ * `pending` is written just before the hand-off to the queue, so a row that
205+ * is still `pending` past the TTL means no worker ever took the lock: the
206+ * process died between the two writes, or the queued run was dropped. Left
207+ * alone the connector would sit `pending` forever — the stale-lock reclaim
208+ * above only looks at `syncing` rows, and the due-sweep below only at
209+ * `active`/`error`.
210+ *
211+ * Flipped to `error` rather than straight back to `active` so it re-enters
212+ * through the same failure ladder as any other unsuccessful sync: repeated
213+ * lost dispatches back off and eventually disable, instead of re-queueing
214+ * every tick forever.
215+ *
216+ * Ages against {@link syncLockLease}, the same expression the stale-lock
217+ * pass reads, because `markSyncPending` opens the lease when it queues.
218+ * `updatedAt` would be wrong here for exactly the reason the lease column
219+ * exists: a `pending` connector is still editable, so every unrelated write
220+ * to the row would renew the recovery it is meant to trigger — a config
221+ * edit on a stuck connector could defer it forever.
222+ */
223+ db
224+ . update ( knowledgeConnector )
225+ . set ( {
226+ ...reclaimPayload ( LOST_DISPATCH_ERROR_MESSAGE ) ,
227+ /** Releases the queue entry's token so a late hand-off cannot match it. */
228+ syncLockToken : null ,
229+ syncLockLeaseAt : null ,
230+ } )
231+ . where (
232+ and (
233+ eq ( knowledgeConnector . status , 'pending' ) ,
234+ sql `${ syncLockLease ( ) } <= ${ sql . param ( staleCutoff , knowledgeConnector . syncLockLeaseAt ) } ` ,
235+ isNull ( knowledgeConnector . archivedAt ) ,
236+ isNull ( knowledgeConnector . deletedAt )
237+ )
238+ )
239+ . returning ( { id : knowledgeConnector . id } ) ,
240+ /**
241+ * Closes sync-log rows left `started` by a killed run. Nothing else ever
242+ * reconciles them, and `loadPreviousListingObservation` reads only
243+ * `completed` rows, so a never-closed run silently ages out the previous
244+ * observation it should have provided.
245+ *
246+ * Deliberately independent of this tick's reclaims rather than scoped to
247+ * them. A row orphaned before this shipped — or by a transient failure of
248+ * this very statement — belongs to a connector already flipped out of
249+ * `syncing`, so it would never appear in a future reclaim batch and would
250+ * stay stranded forever. Keying off the row's own `startedAt` instead makes
251+ * the sweep self-healing and lets it drain the existing backlog.
252+ *
253+ * Age alone does not prove a run is dead: the in-process fallback path has
254+ * no duration cap, so a large self-hosted sync can genuinely still be
255+ * working past the TTL. `logRowNotHeldByLiveRun` is what makes this safe —
256+ * a run whose lock is still being heartbeated is spared regardless of age.
257+ * The age predicate is also per-row on `startedAt`, so a fresh run's log row
258+ * can never be caught by it, even on a connector whose previous run is being
259+ * reclaimed in this same tick.
260+ */
261+ db
262+ . update ( knowledgeConnectorSyncLog )
263+ . set ( {
264+ status : 'failed' ,
265+ completedAt : sql `now()` ,
266+ errorMessage : STALE_LOCK_ERROR_MESSAGE ,
267+ } )
268+ . where (
269+ and (
270+ eq ( knowledgeConnectorSyncLog . status , 'started' ) ,
271+ lte ( knowledgeConnectorSyncLog . startedAt , staleCutoff ) ,
272+ logRowNotHeldByLiveRun ( staleCutoff )
273+ )
274+ )
275+ . returning ( { id : knowledgeConnectorSyncLog . id } ) ,
276+ ] )
167277
168278 if ( recoveredConnectors . length > 0 ) {
169279 logger . warn (
@@ -172,42 +282,12 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
172282 )
173283 }
174284
175- /**
176- * Closes sync-log rows left `started` by a killed run. Nothing else ever
177- * reconciles them, and `loadPreviousListingObservation` reads only
178- * `completed` rows, so a never-closed run silently ages out the previous
179- * observation it should have provided.
180- *
181- * Deliberately independent of this tick's reclaims rather than scoped to
182- * them. A row orphaned before this shipped — or by a transient failure of
183- * this very statement — belongs to a connector already flipped out of
184- * `syncing`, so it would never appear in a future reclaim batch and would
185- * stay stranded forever. Keying off the row's own `startedAt` instead makes
186- * the sweep self-healing and lets it drain the existing backlog.
187- *
188- * Age alone does not prove a run is dead: the in-process fallback path has
189- * no duration cap, so a large self-hosted sync can genuinely still be
190- * working past the TTL. `logRowNotHeldByLiveRun` is what makes this safe —
191- * a run whose lock is still being heartbeated is spared regardless of age.
192- * The age predicate is also per-row on `startedAt`, so a fresh run's log row
193- * can never be caught by it, even on a connector whose previous run is being
194- * reclaimed in this same tick.
195- */
196- const closedSyncLogs = await db
197- . update ( knowledgeConnectorSyncLog )
198- . set ( {
199- status : 'failed' ,
200- completedAt : sql `now()` ,
201- errorMessage : STALE_LOCK_ERROR_MESSAGE ,
202- } )
203- . where (
204- and (
205- eq ( knowledgeConnectorSyncLog . status , 'started' ) ,
206- lte ( knowledgeConnectorSyncLog . startedAt , staleCutoff ) ,
207- logRowNotHeldByLiveRun ( staleCutoff )
208- )
285+ if ( recoveredPendingConnectors . length > 0 ) {
286+ logger . warn (
287+ `[${ requestId } ] Recovered ${ recoveredPendingConnectors . length } connectors whose queued sync was never started` ,
288+ { ids : recoveredPendingConnectors . map ( ( c ) => c . id ) }
209289 )
210- . returning ( { id : knowledgeConnectorSyncLog . id } )
290+ }
211291
212292 if ( closedSyncLogs . length > 0 ) {
213293 logger . warn ( `[${ requestId } ] Closed ${ closedSyncLogs . length } orphaned connector sync log(s)` )
0 commit comments