Skip to content

Commit 536f56b

Browse files
committed
fix(knowledge): don't resurrect a tombstoned document whose content refresh failed
Critical race found by adversarial audit: resurrectIds was derived purely from 'externalId was seen in the listing', independent of whether the paired content update actually succeeded. If updateDocument threw (hydration failure, storage upload failure, or any other transient error) or the deferred-hydration fetch itself failed, the document's row was never touched — yet the separate unconditional resurrect step still cleared deletedAt for it anyway, reproducing the exact bug this PR fixes (visible again, serving stale pre-tombstone content), just triggered by a failed write instead of a gated one. Track externalIds whose refresh attempt failed (hydration rejection or write rejection) and exclude them from partitionSyncReconciliation's resurrectIds. A failed refresh leaves the document tombstoned as-is — not soft-deleted, not hard-deleted, not resurrected — so a later sync gets a clean retry instead of the row landing in an inconsistent state either way.
1 parent 4ceff36 commit 536f56b

2 files changed

Lines changed: 93 additions & 9 deletions

File tree

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

Lines changed: 69 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -78,51 +78,70 @@ describe('shouldReconcileDeletions', () => {
7878

7979
describe('partitionSyncReconciliation', () => {
8080
const live = (id: string, externalId: string | null = id) => ({ id, externalId })
81+
const noFailures = new Set<string>()
8182

8283
it('marks a live document missing from the listing as pending removal, not hard-deleted', async () => {
8384
const { partitionSyncReconciliation } = await import('@/lib/knowledge/connectors/sync-engine')
8485

85-
const result = partitionSyncReconciliation([live('a')], [], new Set(), undefined)
86+
const result = partitionSyncReconciliation([live('a')], [], new Set(), noFailures, undefined)
8687

8788
expect(result).toEqual({ resurrectIds: [], softDeleteIds: ['a'], hardDeleteIds: [] })
8889
})
8990

9091
it('hard-deletes a document already pending removal that is still absent', async () => {
9192
const { partitionSyncReconciliation } = await import('@/lib/knowledge/connectors/sync-engine')
9293

93-
const result = partitionSyncReconciliation([], [live('a')], new Set(), undefined)
94+
const result = partitionSyncReconciliation([], [live('a')], new Set(), noFailures, undefined)
9495

9596
expect(result).toEqual({ resurrectIds: [], softDeleteIds: [], hardDeleteIds: ['a'] })
9697
})
9798

9899
it('resurrects a pending-removal document that reappears in the listing', async () => {
99100
const { partitionSyncReconciliation } = await import('@/lib/knowledge/connectors/sync-engine')
100101

101-
const result = partitionSyncReconciliation([], [live('a')], new Set(['a']), undefined)
102+
const result = partitionSyncReconciliation(
103+
[],
104+
[live('a')],
105+
new Set(['a']),
106+
noFailures,
107+
undefined
108+
)
102109

103110
expect(result).toEqual({ resurrectIds: ['a'], softDeleteIds: [], hardDeleteIds: [] })
104111
})
105112

106113
it('leaves a document untouched when it is still present in the listing', async () => {
107114
const { partitionSyncReconciliation } = await import('@/lib/knowledge/connectors/sync-engine')
108115

109-
const result = partitionSyncReconciliation([live('a')], [], new Set(['a']), undefined)
116+
const result = partitionSyncReconciliation(
117+
[live('a')],
118+
[],
119+
new Set(['a']),
120+
noFailures,
121+
undefined
122+
)
110123

111124
expect(result).toEqual({ resurrectIds: [], softDeleteIds: [], hardDeleteIds: [] })
112125
})
113126

114127
it('resurrects even on a forced fullSync', async () => {
115128
const { partitionSyncReconciliation } = await import('@/lib/knowledge/connectors/sync-engine')
116129

117-
const result = partitionSyncReconciliation([], [live('a')], new Set(['a']), true)
130+
const result = partitionSyncReconciliation([], [live('a')], new Set(['a']), noFailures, true)
118131

119132
expect(result.resurrectIds).toEqual(['a'])
120133
})
121134

122135
it('hard-deletes both live and pending-removal documents immediately on a forced fullSync', async () => {
123136
const { partitionSyncReconciliation } = await import('@/lib/knowledge/connectors/sync-engine')
124137

125-
const result = partitionSyncReconciliation([live('a')], [live('b')], new Set(), true)
138+
const result = partitionSyncReconciliation(
139+
[live('a')],
140+
[live('b')],
141+
new Set(),
142+
noFailures,
143+
true
144+
)
126145

127146
expect(result.softDeleteIds).toEqual([])
128147
expect(result.hardDeleteIds.sort()).toEqual(['a', 'b'])
@@ -135,6 +154,7 @@ describe('partitionSyncReconciliation', () => {
135154
[live('kept'), live('newly-missing')],
136155
[live('resurrected'), live('confirmed-gone')],
137156
new Set(['kept', 'resurrected']),
157+
noFailures,
138158
undefined
139159
)
140160

@@ -152,11 +172,54 @@ describe('partitionSyncReconciliation', () => {
152172
[live('a', null)],
153173
[live('b', null)],
154174
new Set(),
175+
noFailures,
155176
undefined
156177
)
157178

158179
expect(result).toEqual({ resurrectIds: [], softDeleteIds: [], hardDeleteIds: [] })
159180
})
181+
182+
it('does not resurrect a reappearing document whose content refresh failed', async () => {
183+
const { partitionSyncReconciliation } = await import('@/lib/knowledge/connectors/sync-engine')
184+
185+
const result = partitionSyncReconciliation(
186+
[],
187+
[live('a')],
188+
new Set(['a']),
189+
new Set(['a']),
190+
undefined
191+
)
192+
193+
expect(result).toEqual({ resurrectIds: [], softDeleteIds: [], hardDeleteIds: [] })
194+
})
195+
196+
it('still refuses to resurrect a failed refresh even on a forced fullSync', async () => {
197+
const { partitionSyncReconciliation } = await import('@/lib/knowledge/connectors/sync-engine')
198+
199+
const result = partitionSyncReconciliation(
200+
[],
201+
[live('a')],
202+
new Set(['a']),
203+
new Set(['a']),
204+
true
205+
)
206+
207+
expect(result.resurrectIds).toEqual([])
208+
})
209+
210+
it('resurrects the ones that succeeded while excluding the one that failed', async () => {
211+
const { partitionSyncReconciliation } = await import('@/lib/knowledge/connectors/sync-engine')
212+
213+
const result = partitionSyncReconciliation(
214+
[],
215+
[live('ok'), live('failed')],
216+
new Set(['ok', 'failed']),
217+
new Set(['failed']),
218+
undefined
219+
)
220+
221+
expect(result.resurrectIds).toEqual(['ok'])
222+
})
160223
})
161224

162225
describe('resolveTagMapping', () => {

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

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,11 @@ type ReconciliationDoc = { id: string; externalId: string | null }
260260
* was already pending-removal (`tombstonedDocs`) coming into this sync. A
261261
* document that reappears while pending-removal is resurrected
262262
* (`resurrectIds`) regardless of `fullSync`, since presence — unlike absence —
263-
* is trustworthy evidence even from a partial listing.
263+
* is trustworthy evidence even from a partial listing. A document whose
264+
* content refresh was attempted but failed (`failedExternalIds`) is excluded
265+
* from resurrection even though it was seen — surfacing it now would show
266+
* known-stale pre-tombstone content; it stays tombstoned for a later sync to
267+
* retry.
264268
*
265269
* A forced `fullSync` is an explicit request to reconcile right now: it skips
266270
* the grace period and purges everything absent in one pass.
@@ -269,10 +273,14 @@ export function partitionSyncReconciliation(
269273
existingDocs: ReconciliationDoc[],
270274
tombstonedDocs: ReconciliationDoc[],
271275
seenExternalIds: Set<string>,
276+
failedExternalIds: Set<string>,
272277
fullSync: boolean | undefined
273278
): { resurrectIds: string[]; softDeleteIds: string[]; hardDeleteIds: string[] } {
274279
const resurrectIds = tombstonedDocs
275-
.filter((d) => d.externalId && seenExternalIds.has(d.externalId))
280+
.filter(
281+
(d) =>
282+
d.externalId && seenExternalIds.has(d.externalId) && !failedExternalIds.has(d.externalId)
283+
)
276284
.map((d) => d.id)
277285
const liveMissingIds = existingDocs
278286
.filter((d) => d.externalId && !seenExternalIds.has(d.externalId))
@@ -610,6 +618,14 @@ export async function executeSync(
610618
)
611619

612620
const seenExternalIds = new Set<string>()
621+
/**
622+
* externalIds whose content update was attempted but failed (hydration
623+
* error, or the write itself rejected) — as opposed to `docsFailed` counts
624+
* elsewhere, this specifically excludes them from resurrection below: a
625+
* tombstoned document whose refresh failed must stay tombstoned rather
626+
* than come back visible while still serving stale pre-tombstone content.
627+
*/
628+
const failedExternalIds = new Set<string>()
613629

614630
const pendingOps: DocOp[] = []
615631
for (const extDoc of externalDocs) {
@@ -735,13 +751,16 @@ export async function executeSync(
735751
})
736752
)
737753

738-
for (const outcome of hydrated) {
754+
for (let i = 0; i < hydrated.length; i++) {
755+
const outcome = hydrated[i]
739756
if (outcome.status === 'fulfilled' && outcome.value) {
740757
readyOps.push(outcome.value)
741758
} else if (outcome.status === 'rejected') {
742759
result.docsFailed++
760+
failedExternalIds.add(deferredOps[i].extDoc.externalId)
743761
logger.error('Failed to hydrate deferred document', {
744762
connectorId,
763+
externalId: deferredOps[i].extDoc.externalId,
745764
error: getErrorMessage(outcome.reason),
746765
})
747766
}
@@ -804,6 +823,7 @@ export async function executeSync(
804823
else result.docsUpdated++
805824
} else {
806825
result.docsFailed++
826+
failedExternalIds.add(batch[j].extDoc.externalId)
807827
logger.error('Failed to process document', {
808828
connectorId,
809829
externalId: batch[j].extDoc.externalId,
@@ -835,6 +855,7 @@ export async function executeSync(
835855
existingDocs,
836856
tombstonedDocs,
837857
seenExternalIds,
858+
failedExternalIds,
838859
options?.fullSync
839860
)
840861

0 commit comments

Comments
 (0)