Skip to content

Commit 6205cc8

Browse files
committed
fix(knowledge): close remaining hard-delete race window, guard listing-time skip/drop resurrection
Two more real findings, both closing gaps in the previous round's fixes: - The FOR UPDATE lock protected resurrect/soft-delete (applied inside the same transaction) but hardDeleteDocuments still ran after that transaction committed, using IDs snapshotted under the lock. A concurrent 'delete connector, keep documents' request could still detach those same documents in the gap between commit and the hardDeleteDocuments call. Added an optional expectedConnectorId parameter to hardDeleteDocuments/hardDeleteDocumentBatch — when provided, it re-verifies connectorId at the moment of the actual delete query, not just the caller's earlier snapshot. Every other caller is unaffected (parameter is optional, defaults to no filter). - Two more listing-time paths could resurrect a tombstoned document without ever verifying its content: a listing-time skippedReason short-circuits classification straight to 'unchanged' before the hash comparison ever runs, and empty non-deferred content classifies as 'drop' unconditionally regardless of hash. Both are now added to failedExternalIds when reappearing on an existing (possibly tombstoned) document, same treatment as the deferred-hydration equivalents from the prior round.
1 parent d2fe611 commit 6205cc8

2 files changed

Lines changed: 50 additions & 14 deletions

File tree

apps/sim/lib/knowledge/connectors/sync-engine.ts

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -689,14 +689,16 @@ export async function executeSync(
689689
const seenExternalIds = new Set<string>()
690690
/**
691691
* externalIds whose content was never verified as current: a hydration
692-
* error, a rejected write, or a fulfilled-but-unusable hydration (skipped
693-
* as oversized, or an empty re-fetch) that falls back to keeping the
694-
* stored content as last-known-good. That fallback is fine for an
695-
* already-visible document, but for a tombstoned one it means we still
696-
* don't have confirmed-current content — so this excludes them from
697-
* resurrection below: a tombstoned document whose refresh didn't actually
698-
* land must stay tombstoned rather than come back visible while still
699-
* serving stale pre-tombstone content.
692+
* error, a rejected write, a fulfilled-but-unusable hydration (skipped as
693+
* oversized, or an empty re-fetch), a listing-time skippedReason
694+
* short-circuit, or empty non-deferred content (`drop`) — all fall back to
695+
* either keeping the stored content as last-known-good or discarding the
696+
* listing entry outright, without ever comparing or refreshing content.
697+
* That's fine for an already-visible document, but for a tombstoned one it
698+
* means we still don't have confirmed-current content — so this excludes
699+
* them from resurrection below: a tombstoned document whose refresh didn't
700+
* actually land must stay tombstoned rather than come back visible while
701+
* still serving stale pre-tombstone content.
700702
*/
701703
const failedExternalIds = new Set<string>()
702704

@@ -718,6 +720,10 @@ export async function executeSync(
718720
pendingOps.push({ type: 'skip', extDoc })
719721
break
720722
case 'drop':
723+
// Empty, non-deferred content is never usable. If this was a
724+
// reappearing tombstoned document, its content was never verified as
725+
// current — see failedExternalIds below.
726+
if (existing) failedExternalIds.add(extDoc.externalId)
721727
logger.info(`Skipping empty document: ${extDoc.title}`, {
722728
externalId: extDoc.externalId,
723729
})
@@ -729,6 +735,12 @@ export async function executeSync(
729735
pendingOps.push({ type: 'update', existingId: classification.existingId, extDoc })
730736
break
731737
case 'unchanged':
738+
// A listing-time skippedReason short-circuits classification before
739+
// the hash comparison, so this is "kept as last-known-good", not a
740+
// verified-unchanged match — same as the deferred-hydration
741+
// equivalent above. A genuine hash match never sets skippedReason,
742+
// so this only fires for the short-circuited case.
743+
if (extDoc.skippedReason && existing) failedExternalIds.add(extDoc.externalId)
732744
result.docsUnchanged++
733745
break
734746
}
@@ -1019,7 +1031,11 @@ export async function executeSync(
10191031
)
10201032
}
10211033
if (safeHardDeleteIds.length > 0) {
1022-
await hardDeleteDocuments(safeHardDeleteIds, syncLogId)
1034+
// Re-verifies connectorId once more at the moment of the actual delete
1035+
// query — the FOR UPDATE lock above only covers the window up to its
1036+
// own commit; this closes the remaining gap between that commit and
1037+
// this call.
1038+
await hardDeleteDocuments(safeHardDeleteIds, syncLogId, connectorId)
10231039
result.docsDeleted += safeHardDeleteIds.length
10241040
}
10251041

@@ -1166,7 +1182,8 @@ export async function executeSync(
11661182

11671183
await hardDeleteDocuments(
11681184
connectorDocs.map((doc) => doc.id),
1169-
syncLogId
1185+
syncLogId,
1186+
connectorId
11701187
)
11711188

11721189
await completeSyncLog(syncLogId, 'failed', result, 'Connector deleted during sync')

apps/sim/lib/knowledge/documents/service.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2325,7 +2325,17 @@ async function deleteDocumentsByLifecyclePolicy(
23252325

23262326
export async function hardDeleteDocuments(
23272327
documentIds: string[],
2328-
requestId: string
2328+
requestId: string,
2329+
/**
2330+
* When provided, re-verifies each document's connectorId still matches at
2331+
* the moment of the actual delete query — not just the caller's earlier
2332+
* snapshot. A caller (e.g. connector sync reconciliation) can compute this
2333+
* ID list well before the delete runs; a concurrent request that detaches
2334+
* these same documents from the connector in between (e.g. "delete
2335+
* connector, keep documents") would otherwise still have them purged here
2336+
* despite no longer belonging to the connector the caller reasoned about.
2337+
*/
2338+
expectedConnectorId?: string
23292339
): Promise<number> {
23302340
const ids = [...new Set(documentIds)]
23312341
if (ids.length === 0) {
@@ -2336,7 +2346,8 @@ export async function hardDeleteDocuments(
23362346
for (let offset = 0; offset < ids.length; offset += HARD_DELETE_DOCUMENT_BATCH_SIZE) {
23372347
deletedCount += await hardDeleteDocumentBatch(
23382348
ids.slice(offset, offset + HARD_DELETE_DOCUMENT_BATCH_SIZE),
2339-
requestId
2349+
requestId,
2350+
expectedConnectorId
23402351
)
23412352
}
23422353
return deletedCount
@@ -2346,7 +2357,11 @@ export async function hardDeleteDocuments(
23462357
* Hard-deletes one bounded metadata batch and applies every associated ledger
23472358
* delta atomically.
23482359
*/
2349-
async function hardDeleteDocumentBatch(documentIds: string[], requestId: string): Promise<number> {
2360+
async function hardDeleteDocumentBatch(
2361+
documentIds: string[],
2362+
requestId: string,
2363+
expectedConnectorId?: string
2364+
): Promise<number> {
23502365
const ids = [...new Set(documentIds)]
23512366
const documentsToDelete = await db
23522367
.select({
@@ -2361,7 +2376,11 @@ async function hardDeleteDocumentBatch(documentIds: string[], requestId: string)
23612376
})
23622377
.from(document)
23632378
.innerJoin(knowledgeBase, eq(document.knowledgeBaseId, knowledgeBase.id))
2364-
.where(inArray(document.id, ids))
2379+
.where(
2380+
expectedConnectorId
2381+
? and(inArray(document.id, ids), eq(document.connectorId, expectedConnectorId))
2382+
: inArray(document.id, ids)
2383+
)
23652384

23662385
if (documentsToDelete.length === 0) {
23672386
return 0

0 commit comments

Comments
 (0)