Skip to content

Commit bfaeaba

Browse files
authored
fix(knowledge): write external group membership as a diff (#7943)
* fix(knowledge): write external group membership as a diff Every directory sync deleted a group's entire membership and reinserted it, whether or not anything had changed. That took the membership table to roughly 55M lifetime inserts and 55M deletes against ~127k live rows — about 430x write amplification — holding it near 91% dead tuples across 2,447 autovacuum cycles, an order of magnitude more churn than any comparable table. The vacuum load that generates competes for the same I/O as every other query on the instance. Membership is now written as a difference: read the group's current tokens, delete only those the enumeration no longer lists, insert only those it newly lists. An unchanged group writes nothing. The group row is locked before that read. The two callers fence on different leases — the directory lease and the connector sync lease — so neither excludes the other, and on the directory path the group upsert commits in a separate transaction, so its row lock is already released. Without the lock, the removal set is computed against a snapshot a concurrent pass may have moved past, and a subject that pass inserted would survive an enumeration that never observed it: membership retained rather than revoked. Locking also closes the narrower window the blind delete already had between its delete and its commit. `created_at` on a member row now means first-observed rather than last-observed. No reader projects it, and the group's `lastSyncedAt` remains the freshness signal and is still written every pass. Also corrects the processing-queue TSDoc, which argued the per-tenant lane limits were sized rather than inherited and cited a concurrency figure derived from an unsound measurement. * test(knowledge): assert the group lock precedes the membership read Presence of the lock was the only thing asserted, so moving it below the read would have kept the test green while reopening the stale-snapshot race the lock exists to close. The assertion is now the relative call order of the lock and the member-table read.
1 parent 7931dcc commit bfaeaba

3 files changed

Lines changed: 165 additions & 16 deletions

File tree

‎apps/sim/background/knowledge-processing.ts‎

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -211,20 +211,24 @@ export async function runDocumentProcessing(
211211

212212
/**
213213
* Both lanes are keyed by tenant at dispatch, so `concurrencyLimit` is the
214-
* ceiling one tenant may hold in that lane, not a ceiling for the fleet. The
215-
* shared bound is the Trigger.dev environment concurrency limit, which is where
216-
* a global ceiling belongs; observed peak there is ~97 across every task.
214+
* ceiling one tenant may hold in that lane, not a ceiling for the fleet. There
215+
* is no longer a fleet-wide ceiling for document processing: the aggregate is
216+
* active tenants times the lane limit, bounded only by the Trigger.dev
217+
* environment concurrency limit, which every other task shares.
217218
*
218-
* Both default to the limit the single shared queue carried, which is what
219-
* keeps this split from ever draining slower than the queue it replaces: the
220-
* busiest case it has to beat is one tenant alone, and one tenant alone still
221-
* gets the same slots it used to get for backfill plus a separate allowance for
222-
* work someone is waiting on. Any second tenant is pure gain, because under the
223-
* shared queue it got whatever the first one left.
219+
* Both carry 20 because that is the number the single shared queue carried, not
220+
* because 20 was derived for a per-tenant ceiling — it has been the default
221+
* since the queue was introduced and the split changed its unit rather than its
222+
* value. One tenant alone therefore still gets what it used to for backfill,
223+
* plus a separate allowance for work someone is waiting on; two tenants draw
224+
* twice the aggregate the shared queue ever allowed.
224225
*
225-
* Splitting the two into separate variables is for operating them, not for
226-
* sizing them: backfill is the one to lower when the environment ceiling is the
227-
* binding constraint, and lowering it must not slow down a person's upload.
226+
* So backfill is the one to lower, and the database is what decides when: it is
227+
* the resource the aggregate actually lands on, and the per-document embedding
228+
* writes are the load. Lower it when their latency climbs, not when the
229+
* Trigger.dev environment limit is approached. The queue concurrency override
230+
* API applies a new value without a redeploy; this variable is read when the
231+
* worker deploy registers the queue, so changing it here needs one.
228232
*/
229233
export const interactiveProcessingQueue = queue({
230234
name: INTERACTIVE_PROCESSING_QUEUE_NAME,

‎apps/sim/lib/knowledge/connectors/external-group-sync.test.ts‎

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,89 @@ describe('syncExternalDirectoryGroups', () => {
124124
)
125125
})
126126

127+
/**
128+
* The reason membership writes are a diff: a directory sync overwhelmingly
129+
* re-observes membership that has not changed, and rewriting the group would
130+
* charge two row writes per member to autovacuum for no change at all.
131+
*/
132+
it('writes nothing when the observed membership already matches', async () => {
133+
queueTableRows(schemaMock.knowledgeExternalGroup, [])
134+
queueTableRows(schemaMock.knowledgeExternalGroupMember, [{ subjectToken: 'u:alice@corp.com' }])
135+
const dir = directory({
136+
listGroups: vi.fn(async () => [{ id: 'eng@corp.com' }]),
137+
listGroupMembers: vi.fn(async (group) => ({
138+
group,
139+
memberTokens: ['u:alice@corp.com'],
140+
complete: true,
141+
})),
142+
})
143+
144+
await syncExternalDirectoryGroups({ workspaceId: 'ws-1', directory: dir })
145+
146+
expect(dbChainMockFns.delete).not.toHaveBeenCalled()
147+
expect(dbChainMockFns.values).not.toHaveBeenCalledWith(
148+
expect.arrayContaining([expect.objectContaining({ subjectToken: 'u:alice@corp.com' })])
149+
)
150+
expect(dbChainMockFns.set.mock.calls.some(([value]) => 'lastSyncedAt' in value)).toBe(true)
151+
})
152+
153+
/**
154+
* The removal set is computed from a read, so that read has to be serialized
155+
* against the other writer. Both callers fence on different leases, and the
156+
* directory path commits its group upsert in a separate transaction, so the
157+
* lock has to be taken here.
158+
*/
159+
it('locks the group row before reading the membership it will diff against', async () => {
160+
queueTableRows(schemaMock.knowledgeExternalGroup, [])
161+
queueTableRows(schemaMock.knowledgeExternalGroupMember, [{ subjectToken: 'u:alice@corp.com' }])
162+
const dir = directory({
163+
listGroups: vi.fn(async () => [{ id: 'eng@corp.com' }]),
164+
listGroupMembers: vi.fn(async (group) => ({
165+
group,
166+
memberTokens: ['u:alice@corp.com'],
167+
complete: true,
168+
})),
169+
})
170+
171+
await syncExternalDirectoryGroups({ workspaceId: 'ws-1', directory: dir })
172+
173+
expect(dbChainMockFns.for).toHaveBeenCalledWith('update')
174+
const memberReadIndex = dbChainMockFns.from.mock.calls.findIndex(
175+
([table]) => table === schemaMock.knowledgeExternalGroupMember
176+
)
177+
expect(memberReadIndex).toBeGreaterThanOrEqual(0)
178+
/**
179+
* Ordering is the property, not the presence: a lock taken after the read
180+
* leaves exactly the stale-snapshot race it exists to close.
181+
*/
182+
expect(dbChainMockFns.for.mock.invocationCallOrder[0]).toBeLessThan(
183+
dbChainMockFns.from.mock.invocationCallOrder[memberReadIndex]
184+
)
185+
})
186+
187+
it('writes only the difference when membership changed', async () => {
188+
queueTableRows(schemaMock.knowledgeExternalGroup, [])
189+
queueTableRows(schemaMock.knowledgeExternalGroupMember, [
190+
{ subjectToken: 'u:alice@corp.com' },
191+
{ subjectToken: 'u:bob@corp.com' },
192+
])
193+
const dir = directory({
194+
listGroups: vi.fn(async () => [{ id: 'eng@corp.com' }]),
195+
listGroupMembers: vi.fn(async (group) => ({
196+
group,
197+
memberTokens: ['u:alice@corp.com', 'u:carol@corp.com'],
198+
complete: true,
199+
})),
200+
})
201+
202+
await syncExternalDirectoryGroups({ workspaceId: 'ws-1', directory: dir })
203+
204+
expect(dbChainMockFns.values).toHaveBeenCalledWith([
205+
{ groupId: expect.any(String), subjectToken: 'u:carol@corp.com' },
206+
])
207+
expect(dbChainMockFns.delete).toHaveBeenCalledTimes(1)
208+
})
209+
127210
it.each(['ws', 'pub', 'link', 'g:confluence:cloud:group', 'alice@corp.com', 'u:Alice@corp.com'])(
128211
'rejects invalid member %s before replacing membership or updating freshness',
129212
async (invalid) => {
@@ -370,7 +453,15 @@ describe('refreshConnectorDirectory', () => {
370453
expect(dbChainMockFns.set.mock.calls.some(([value]) => 'lastCompleteSyncAt' in value)).toBe(
371454
false
372455
)
373-
expect(dbChainMockFns.delete).toHaveBeenCalledTimes(1)
456+
/**
457+
* The accessible group's membership is written; the denied one is left
458+
* alone. Nothing is deleted because the diff found no member to remove —
459+
* membership writes are the difference, not a full rewrite.
460+
*/
461+
expect(dbChainMockFns.values).toHaveBeenCalledWith([
462+
{ groupId: expect.any(String), subjectToken: 'u:alice@corp.com' },
463+
])
464+
expect(dbChainMockFns.delete).not.toHaveBeenCalled()
374465
})
375466

376467
it('keeps unknown failures blocking even when other group memberships refreshed', async () => {

‎apps/sim/lib/knowledge/connectors/external-group-sync.ts‎

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -353,7 +353,23 @@ export async function persistExternalGroupMembership(
353353
)
354354
}
355355

356-
/** Membership replacement and its freshness watermark commit together. */
356+
/**
357+
* Membership replacement and its freshness watermark commit together.
358+
*
359+
* Writes only the difference. Rewriting a whole group per sync costs two row
360+
* writes per member every time, and a directory sync overwhelmingly re-observes
361+
* membership that has not changed. Unconditional replacement had taken this
362+
* table to ~55M lifetime inserts and ~55M deletes against ~127k live rows —
363+
* roughly 430x write amplification, holding it at ~91% dead tuples through
364+
* 2,447 autovacuum cycles, an order of magnitude more than any comparable
365+
* table. That vacuum load is charged to the same I/O every other query on the
366+
* instance competes for. The extra read is one index scan of the group's
367+
* primary-key prefix, and it is what lets an unchanged group write nothing.
368+
*
369+
* `created_at` therefore becomes first-observed rather than last-observed. No
370+
* reader projects it; the group's own `lastSyncedAt` below is the freshness
371+
* signal, and it is still written every pass.
372+
*/
357373
async function replaceGroupMembers(
358374
groupId: string,
359375
memberTokens: string[],
@@ -364,13 +380,51 @@ async function replaceGroupMembers(
364380
if (memberTokens.some((token) => !isDirectoryMemberToken(token, directory))) {
365381
throw new Error('Directory membership contains an invalid identity token')
366382
}
383+
/**
384+
* Serializes membership writes for this group before the set is read.
385+
*
386+
* The two callers fence on different leases — the directory lease and the
387+
* connector sync lease — so neither excludes the other, and on the directory
388+
* path the group upsert commits in a separate transaction from this one, so
389+
* its row lock is already gone. Without this, the removal set is computed
390+
* from a snapshot a concurrent pass may have moved past, and a subject that
391+
* pass inserted would survive a complete enumeration that did not observe it:
392+
* membership retained rather than revoked. The blind delete this replaced was
393+
* immune because it never read first.
394+
*/
367395
await tx
368-
.delete(knowledgeExternalGroupMember)
396+
.select({ id: knowledgeExternalGroup.id })
397+
.from(knowledgeExternalGroup)
398+
.where(eq(knowledgeExternalGroup.id, groupId))
399+
.for('update')
400+
const desired = new Set(memberTokens)
401+
const existing = await tx
402+
.select({ subjectToken: knowledgeExternalGroupMember.subjectToken })
403+
.from(knowledgeExternalGroupMember)
369404
.where(eq(knowledgeExternalGroupMember.groupId, groupId))
370-
for (const batch of chunkArray([...new Set(memberTokens)], MEMBER_WRITE_BATCH_SIZE)) {
405+
const retained = new Set<string>()
406+
const removed: string[] = []
407+
for (const row of existing) {
408+
if (desired.has(row.subjectToken)) retained.add(row.subjectToken)
409+
else removed.push(row.subjectToken)
410+
}
411+
const added = [...desired].filter((subjectToken) => !retained.has(subjectToken))
412+
413+
for (const batch of chunkArray(removed, MEMBER_WRITE_BATCH_SIZE)) {
414+
await tx
415+
.delete(knowledgeExternalGroupMember)
416+
.where(
417+
and(
418+
eq(knowledgeExternalGroupMember.groupId, groupId),
419+
inArray(knowledgeExternalGroupMember.subjectToken, batch)
420+
)
421+
)
422+
}
423+
for (const batch of chunkArray(added, MEMBER_WRITE_BATCH_SIZE)) {
371424
await tx
372425
.insert(knowledgeExternalGroupMember)
373426
.values(batch.map((subjectToken) => ({ groupId, subjectToken })))
427+
.onConflictDoNothing()
374428
}
375429
await tx
376430
.update(knowledgeExternalGroup)

0 commit comments

Comments
 (0)