Skip to content

Commit 4293d95

Browse files
committed
fix(knowledge): serialize reconciliation writes against a concurrent connector delete
An independent adversarial audit (not just Greptile/Cursor) found the one genuinely critical gap 6 rounds of bot review missed: resurrect/ soft-delete/hard-delete writes applied raw document IDs snapshotted at the top of the sync, with no re-check immediately before the write. A connector-DELETE request choosing to keep documents detaches them (connectorId set to NULL) via the exact same FOR UPDATE lock on the connector row that this fix now also takes before applying any reconciliation write — serializing the two: whichever transaction commits first wins, and the loser's re-check sees the up-to-date connectorId and skips any document the other request already claimed. Without this, a sync racing a 'delete connector, keep documents' request could silently resurrect-then-strand or soft/hard-delete a document the user explicitly chose to keep, with a secondary effect of misclassifying it for storage-billing decrement (which keys off whether connectorId is still set). Also tightened the excludedDocs query: it previously required deletedAt IS NULL, so a document that was both userExcluded and tombstoned (reachable via excludeConnectorDocuments, which has no deletedAt filter) fell out of the exclusion set and could be silently un-excluded and re-indexed on reappearing. Dropped that requirement so userExcluded is honored regardless of tombstone state, consistent with how existingDocs/tombstonedDocs are already merged for classification. Documented (not code-changed) the remaining lower-severity finding: a document that outlives the 7-day hasTombstonedDocs bound on a persistently-incremental connector can stay unresolved indefinitely. Deliberately not hard-deleting it after the window expires — that would delete a document with no positive evidence it's actually gone, reintroducing the exact risk this whole design exists to avoid. It's already fully excluded from search/billing/listings either way, so this is an accepted, bounded, orphaned-row trade-off, not a correctness or security issue.
1 parent 09e6e58 commit 4293d95

1 file changed

Lines changed: 98 additions & 27 deletions

File tree

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

Lines changed: 98 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -523,6 +523,17 @@ export async function executeSync(
523523
* full listing — and its listing-time overhead — for this connector
524524
* forever. Past the window, this connector stops forcing full syncs on its
525525
* account; the document itself is unaffected and stays tombstoned either way.
526+
*
527+
* Known accepted trade-off: once past the window, a still-tombstoned
528+
* document that's unchanged-but-genuinely-present at the source can only
529+
* be resurrected by a full listing — and nothing here forces one anymore.
530+
* On a connector that never runs a full sync again (persistent incremental
531+
* syncMode, no manual full resync), that document stays correctly
532+
* invisible (excluded everywhere by `isNull(deletedAt)`, so no
533+
* search/billing/listing leakage) but unresolved indefinitely. This is
534+
* deliberately not "fixed" by hard-deleting it after the window expires —
535+
* that would delete a document we have no positive evidence is actually
536+
* gone, reintroducing the exact risk this whole design exists to avoid.
526537
*/
527538
const hasTombstonedDocs = await db
528539
.select({ id: document.id })
@@ -640,6 +651,7 @@ export async function executeSync(
640651
id: document.id,
641652
externalId: document.externalId,
642653
contentHash: document.contentHash,
654+
deletedAt: document.deletedAt,
643655
})
644656
.from(document)
645657
.where(
@@ -649,15 +661,19 @@ export async function executeSync(
649661
isNotNull(document.deletedAt)
650662
)
651663
),
664+
// Not filtered on deletedAt: a document can be both userExcluded and
665+
// tombstoned (e.g. excluded via a bulk request that raced a sync marking
666+
// it pending-removal). Excluding it here regardless of tombstone state
667+
// keeps it short-circuited in the classification loop below instead of
668+
// silently reappearing through the normal update/resurrect path.
652669
db
653670
.select({ externalId: document.externalId })
654671
.from(document)
655672
.where(
656673
and(
657674
eq(document.connectorId, connectorId),
658675
eq(document.userExcluded, true),
659-
isNull(document.archivedAt),
660-
isNull(document.deletedAt)
676+
isNull(document.archivedAt)
661677
)
662678
),
663679
])
@@ -922,34 +938,89 @@ export async function executeSync(
922938
options?.fullSync
923939
)
924940

925-
/**
926-
* A document reappearing at the source is trustworthy evidence on its own —
927-
* unlike absence, presence never depends on the listing being complete — so
928-
* resurrection runs unconditionally, even on an incremental or otherwise
929-
* gated sync.
930-
*/
931-
if (resurrectIds.length > 0) {
932-
await db.update(document).set({ deletedAt: null }).where(inArray(document.id, resurrectIds))
933-
logger.info(`Resurrected ${resurrectIds.length} documents that reappeared at the source`, {
934-
connectorId,
941+
const reconcileDeletionsAllowed = shouldReconcileDeletions(
942+
isIncremental,
943+
syncContext,
944+
options?.fullSync
945+
)
946+
const gatedSoftDeleteIds = reconcileDeletionsAllowed ? softDeleteIds : []
947+
const gatedHardDeleteIds = reconcileDeletionsAllowed ? hardDeleteIds : []
948+
949+
const candidateIds = [
950+
...new Set([...resurrectIds, ...gatedSoftDeleteIds, ...gatedHardDeleteIds]),
951+
]
952+
953+
let safeResurrectIds: string[] = []
954+
let safeSoftDeleteIds: string[] = []
955+
let safeHardDeleteIds: string[] = []
956+
957+
if (candidateIds.length > 0) {
958+
/**
959+
* A concurrent "delete connector, keep documents" request detaches these
960+
* same documents (connectorId set to NULL) under the same FOR UPDATE lock
961+
* the DELETE route takes on this connector row. Taking that lock here
962+
* serializes the two requests: whichever commits first wins, and the
963+
* loser's re-check below sees the up-to-date connectorId and skips any
964+
* document the other request already claimed — instead of resurrecting or
965+
* deleting a document that another request just detached (and possibly
966+
* already billed) as a standalone KB entry.
967+
*/
968+
await db.transaction(async (tx) => {
969+
await tx.execute(
970+
sql`SELECT 1 FROM knowledge_connector WHERE id = ${connectorId} FOR UPDATE`
971+
)
972+
973+
const stillOwned = new Set(
974+
(
975+
await tx
976+
.select({ id: document.id })
977+
.from(document)
978+
.where(and(inArray(document.id, candidateIds), eq(document.connectorId, connectorId)))
979+
).map((d) => d.id)
980+
)
981+
982+
safeResurrectIds = resurrectIds.filter((id) => stillOwned.has(id))
983+
safeSoftDeleteIds = gatedSoftDeleteIds.filter((id) => stillOwned.has(id))
984+
safeHardDeleteIds = gatedHardDeleteIds.filter((id) => stillOwned.has(id))
985+
986+
/**
987+
* A document reappearing at the source is trustworthy evidence on its
988+
* own — unlike absence, presence never depends on the listing being
989+
* complete — so resurrection runs unconditionally, even on an
990+
* incremental or otherwise gated sync.
991+
*/
992+
if (safeResurrectIds.length > 0) {
993+
await tx
994+
.update(document)
995+
.set({ deletedAt: null })
996+
.where(inArray(document.id, safeResurrectIds))
997+
}
998+
if (safeSoftDeleteIds.length > 0) {
999+
await tx
1000+
.update(document)
1001+
.set({ deletedAt: new Date() })
1002+
.where(inArray(document.id, safeSoftDeleteIds))
1003+
}
9351004
})
9361005
}
9371006

938-
if (shouldReconcileDeletions(isIncremental, syncContext, options?.fullSync)) {
939-
if (softDeleteIds.length > 0) {
940-
await db
941-
.update(document)
942-
.set({ deletedAt: new Date() })
943-
.where(inArray(document.id, softDeleteIds))
944-
logger.info(
945-
`Marked ${softDeleteIds.length} documents pending removal — absent from source, confirming on next sync`,
946-
{ connectorId }
947-
)
948-
}
949-
if (hardDeleteIds.length > 0) {
950-
await hardDeleteDocuments(hardDeleteIds, syncLogId)
951-
result.docsDeleted += hardDeleteIds.length
952-
}
1007+
if (safeResurrectIds.length > 0) {
1008+
logger.info(
1009+
`Resurrected ${safeResurrectIds.length} documents that reappeared at the source`,
1010+
{
1011+
connectorId,
1012+
}
1013+
)
1014+
}
1015+
if (safeSoftDeleteIds.length > 0) {
1016+
logger.info(
1017+
`Marked ${safeSoftDeleteIds.length} documents pending removal — absent from source, confirming on next sync`,
1018+
{ connectorId }
1019+
)
1020+
}
1021+
if (safeHardDeleteIds.length > 0) {
1022+
await hardDeleteDocuments(safeHardDeleteIds, syncLogId)
1023+
result.docsDeleted += safeHardDeleteIds.length
9531024
}
9541025

9551026
// Check if connector/KB were deleted before retrying stuck documents

0 commit comments

Comments
 (0)