Skip to content

Commit 209f328

Browse files
committed
fix(knowledge): prove the lease inside every ACL rewrite batch and harden source-modified-time parsing
1 parent de8c070 commit 209f328

7 files changed

Lines changed: 112 additions & 33 deletions

File tree

‎apps/sim/lib/knowledge/connectors/member-observations.test.ts‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,12 @@ vi.mock('@/lib/knowledge/documents/service', () => ({
99
}))
1010

1111
import {
12+
rewriteConnectorAcls,
1213
staleMemberWindowMs,
1314
sweepStaleMemberObservations,
1415
} from '@/lib/knowledge/connectors/member-observations'
1516
import { MEMBER_OBSERVATION_STALE_AFTER_HOURS } from '@/lib/knowledge/connectors/sync-limits'
17+
import { SyncLockLostException } from '@/lib/knowledge/connectors/sync-lock'
1618

1719
const NOW = new Date('2026-09-01T12:00:00Z')
1820
const STALE_MEMBER = { id: 'm-1', connectorId: 'c-1', syncIntervalMinutes: 60 }
@@ -88,3 +90,33 @@ describe('sweepStaleMemberObservations', () => {
8890
expect(dbChainMockFns.delete).not.toHaveBeenCalled()
8991
})
9092
})
93+
94+
describe('rewriteConnectorAcls', () => {
95+
beforeEach(() => {
96+
vi.clearAllMocks()
97+
resetDbChainMock()
98+
})
99+
100+
it('proves the lease inside each batch transaction before rewriting', async () => {
101+
queueTableRows(schemaMock.knowledgeConnector, [{ id: 'c-1' }])
102+
dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'd-1' }])
103+
104+
await expect(
105+
rewriteConnectorAcls('c-1', [], { lease: { stillHeld: () => 'held' as never } })
106+
).resolves.toBe(true)
107+
108+
expect(dbChainMockFns.transaction).toHaveBeenCalledOnce()
109+
expect(dbChainMockFns.for).toHaveBeenCalledWith('share')
110+
expect(dbChainMockFns.update).toHaveBeenCalledWith(schemaMock.document)
111+
})
112+
113+
it('stops without writing once the lease is gone', async () => {
114+
queueTableRows(schemaMock.knowledgeConnector, [])
115+
116+
await expect(
117+
rewriteConnectorAcls('c-1', [], { lease: { stillHeld: () => 'lost' as never } })
118+
).rejects.toBeInstanceOf(SyncLockLostException)
119+
120+
expect(dbChainMockFns.update).not.toHaveBeenCalled()
121+
})
122+
})

‎apps/sim/lib/knowledge/connectors/member-observations.ts‎

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,11 @@ import {
2727
MEMBER_TOMBSTONE_PURGE_DAYS,
2828
} from '@/lib/knowledge/connectors/sync-limits'
2929
import {
30+
assertSyncLeaseHeldInTx,
3031
connectorIsLive,
3132
MEMBER_LOCKABLE_CONNECTOR_STATUSES,
3233
type SyncRunLease,
34+
type SyncWriteLease,
3335
} from '@/lib/knowledge/connectors/sync-lock'
3436
import {
3537
type ConnectorSyncDeletionGuard,
@@ -184,28 +186,41 @@ const ACCESS_REWRITE_BATCH_SIZE = 1000
184186
export async function rewriteConnectorAcls(
185187
connectorId: string,
186188
target: readonly string[],
187-
options: { deadlineAt?: number; beforeBatch?: () => Promise<void> } = {}
189+
options: {
190+
deadlineAt?: number
191+
beforeBatch?: () => Promise<void>
192+
/**
193+
* The lease the caller holds on the connector, proved inside each batch's
194+
* transaction: a heartbeat before the batch only says the lease was held
195+
* then, and a run reclaimed mid-rewrite must not land an empty ACL over
196+
* what its replacement has since materialised.
197+
*/
198+
lease?: SyncWriteLease
199+
} = {}
188200
): Promise<boolean> {
189201
const mismatch =
190202
target.length === 0
191203
? sql`cardinality(${document.acl}) > 0`
192204
: sql`${document.acl} <> ${textArrayLiteral(target)}`
193205
for (;;) {
194206
await options.beforeBatch?.()
195-
const rewritten = await db
196-
.update(document)
197-
.set({ acl: [...target] })
198-
.where(
199-
eq(
200-
document.id,
201-
sql`ANY(ARRAY(
202-
SELECT ${document.id} FROM ${document}
203-
WHERE ${document.connectorId} = ${connectorId} AND ${mismatch}
204-
LIMIT ${ACCESS_REWRITE_BATCH_SIZE}
205-
))`
207+
const rewritten = await db.transaction(async (tx) => {
208+
if (options.lease) await assertSyncLeaseHeldInTx(tx, connectorId, options.lease)
209+
return tx
210+
.update(document)
211+
.set({ acl: [...target] })
212+
.where(
213+
eq(
214+
document.id,
215+
sql`ANY(ARRAY(
216+
SELECT ${document.id} FROM ${document}
217+
WHERE ${document.connectorId} = ${connectorId} AND ${mismatch}
218+
LIMIT ${ACCESS_REWRITE_BATCH_SIZE}
219+
))`
220+
)
206221
)
207-
)
208-
.returning({ id: document.id })
222+
.returning({ id: document.id })
223+
})
209224
if (rewritten.length < ACCESS_REWRITE_BATCH_SIZE) return true
210225
if (options.deadlineAt !== undefined && Date.now() >= options.deadlineAt) return false
211226
}

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -467,7 +467,10 @@ async function insertMemberSyncLog(runId: string, connectorId: string, startedAt
467467
* makes it visible again.
468468
*/
469469
async function finishPendingAccessRewrite(run: MemberSyncRun): Promise<void> {
470-
await rewriteConnectorAcls(run.connectorId, EMPTY_ACL, { beforeBatch: run.lease.beatIfDue })
470+
await rewriteConnectorAcls(run.connectorId, EMPTY_ACL, {
471+
beforeBatch: run.lease.beatIfDue,
472+
lease: run.lease,
473+
})
471474
await db
472475
.update(knowledgeConnector)
473476
.set({ accessRewritePending: false, updatedAt: new Date() })

‎apps/sim/lib/knowledge/connectors/source-modified-at.test.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,15 @@ describe('resolveSourceModifiedAt', () => {
4646
).toBe('2026-08-01T00:00:00.000Z')
4747
})
4848

49-
it('rejects an invalid Date instance', () => {
49+
it('rejects an invalid Date instance and a number outside the Date range', () => {
5050
expect(resolveSourceModifiedAt({ modifiedTime: new Date('not a date') })).toBeNull()
51+
expect(resolveSourceModifiedAt({ modifiedTime: 1e20 })).toBeNull()
52+
})
53+
54+
it('reads the last activity a chat space or channel reports', () => {
55+
expect(resolveSourceModifiedAt({ lastActivity: '2026-08-30T10:00:00Z' })?.toISOString()).toBe(
56+
'2026-08-30T10:00:00.000Z'
57+
)
5158
})
5259

5360
it('rejects placeholders and far-future values', () => {

‎apps/sim/lib/knowledge/connectors/source-modified-at.ts‎

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,23 +16,27 @@ const SOURCE_MODIFIED_AT_KEYS = [
1616
'updated',
1717
/** JSM requests: the time the request last changed status, the list endpoint's only change signal. */
1818
'statusDate',
19+
/** Google Chat spaces and Teams channels: the latest message time, the listing's only change signal. */
20+
'lastActivity',
1921
] as const
2022

2123
/** Earlier than any plausible document; guards against epoch-zero placeholders. */
2224
const EARLIEST_PLAUSIBLE_MS = Date.UTC(1990, 0, 1)
2325
/** A source clock a day ahead is skew; further ahead is a placeholder. */
2426
const FUTURE_TOLERANCE_MS = 24 * 60 * 60 * 1000
2527

28+
/** A `Date` only when it holds a real instant; a finite number outside the Date range yields an invalid one. */
29+
function validDate(date: Date): Date | null {
30+
return Number.isNaN(date.getTime()) ? null : date
31+
}
32+
2633
function toDate(value: unknown): Date | null {
27-
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value
34+
if (value instanceof Date) return validDate(value)
2835
if (typeof value === 'number' && Number.isFinite(value)) {
2936
/** Seconds-since-epoch values are far too small to be milliseconds after 1990. */
30-
return new Date(value < 1e11 ? value * 1000 : value)
31-
}
32-
if (typeof value === 'string' && value.trim()) {
33-
const parsed = new Date(value)
34-
return Number.isNaN(parsed.getTime()) ? null : parsed
37+
return validDate(new Date(value < 1e11 ? value * 1000 : value))
3538
}
39+
if (typeof value === 'string' && value.trim()) return validDate(new Date(value))
3640
return null
3741
}
3842

‎apps/sim/lib/knowledge/orchestration/connector-access.test.ts‎

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ const mocks = vi.hoisted(() => ({
2020
dispatchMemberSync: vi.fn(),
2121
memberAccessAvailable: vi.fn(),
2222
provision: vi.fn(),
23+
rewriteAcls: vi.fn(),
24+
}))
25+
26+
vi.mock('@/lib/knowledge/connectors/member-observations', () => ({
27+
rewriteConnectorAcls: mocks.rewriteAcls,
2328
}))
2429

2530
vi.mock('@sim/audit', () => ({
@@ -242,6 +247,7 @@ describe('performUpdateKnowledgeConnectorAccess', () => {
242247
beforeEach(() => {
243248
vi.clearAllMocks()
244249
resetDbChainMock()
250+
mocks.rewriteAcls.mockResolvedValue(true)
245251
mocks.grant.mockResolvedValue(undefined)
246252
mocks.revoke.mockResolvedValue(undefined)
247253
mocks.dispatchSync.mockResolvedValue({ queued: true })
@@ -277,14 +283,21 @@ describe('performUpdateKnowledgeConnectorAccess', () => {
277283
queueGroupRow('option-1')
278284
dbChainMockFns.returning
279285
.mockResolvedValueOnce([{ ...WORKSPACE_CONNECTOR, status: 'syncing', syncLockToken: 's-1' }])
280-
/** The rewrite finds nothing left, the flip lands under the lease, then the release. */
281-
.mockResolvedValueOnce([])
286+
/** The flip lands under the lease, then the release. */
282287
.mockResolvedValueOnce([{ id: 'c-1' }])
283288
.mockResolvedValueOnce([{ ...MEMBERS_CONNECTOR, nextMemberSyncAt: new Date() }])
284289

285290
const outcome = await switchTo({ accessMode: 'members', binding: BINDING })
286291

287292
expect(outcome).toMatchObject({ success: true, changed: true })
293+
/** The rewrite hides every document and proves the switch lease inside each batch. */
294+
expect(mocks.rewriteAcls).toHaveBeenCalledWith(
295+
'c-1',
296+
[],
297+
expect.objectContaining({
298+
lease: expect.objectContaining({ stillHeld: expect.any(Function) }),
299+
})
300+
)
288301
expect(mocks.grant).toHaveBeenCalledWith(
289302
{
290303
workspaceId: 'ws-1',
@@ -294,7 +307,6 @@ describe('performUpdateKnowledgeConnectorAccess', () => {
294307
},
295308
'admin-1'
296309
)
297-
expect(dbChainMockFns.set).toHaveBeenCalledWith(expect.objectContaining({ acl: [] }))
298310
/** The flip is written inside the group's row lock. */
299311
expect(dbChainMockFns.for).toHaveBeenCalledWith('update')
300312
expect(dbChainMockFns.transaction).toHaveBeenCalledOnce()
@@ -325,9 +337,9 @@ describe('performUpdateKnowledgeConnectorAccess', () => {
325337
it('refuses the flip, and undoes the grant, when the option is gone by the time the group is locked', async () => {
326338
queueTableRows(schemaMock.knowledgeConnector, [WORKSPACE_CONNECTOR])
327339
queueGroupRow()
328-
dbChainMockFns.returning
329-
.mockResolvedValueOnce([{ ...WORKSPACE_CONNECTOR, status: 'syncing', syncLockToken: 's-1' }])
330-
.mockResolvedValueOnce([])
340+
dbChainMockFns.returning.mockResolvedValueOnce([
341+
{ ...WORKSPACE_CONNECTOR, status: 'syncing', syncLockToken: 's-1' },
342+
])
331343

332344
const outcome = await switchTo({ accessMode: 'members', binding: BINDING })
333345

@@ -348,7 +360,6 @@ describe('performUpdateKnowledgeConnectorAccess', () => {
348360
queueGroupRow('option-2')
349361
dbChainMockFns.returning
350362
.mockResolvedValueOnce([{ ...MEMBERS_CONNECTOR, status: 'syncing', syncLockToken: 's-1' }])
351-
.mockResolvedValueOnce([])
352363
.mockResolvedValueOnce([{ id: 'c-1' }])
353364
.mockResolvedValueOnce([
354365
{ ...MEMBERS_CONNECTOR, credentialGroupId: 'group-2', credentialGroupOptionId: 'option-2' },
@@ -384,16 +395,22 @@ describe('performUpdateKnowledgeConnectorAccess', () => {
384395
queueTableRows(schemaMock.knowledgeConnector, [MEMBERS_CONNECTOR])
385396
dbChainMockFns.returning
386397
.mockResolvedValueOnce([{ ...MEMBERS_CONNECTOR, status: 'syncing', syncLockToken: 's-1' }])
387-
/** The flip lands under the lease, then the rewrite finds nothing left, then the release. */
398+
/** The flip lands under the lease, then the release. */
388399
.mockResolvedValueOnce([{ id: 'c-1' }])
389-
.mockResolvedValueOnce([])
390400
.mockResolvedValueOnce([{ ...WORKSPACE_CONNECTOR, credentialId: 'cred-2' }])
391401

392402
const outcome = await switchTo({ accessMode: 'workspace', credentialId: 'cred-2' })
393403

394404
expect(outcome).toMatchObject({ success: true, changed: true })
395405
expect(dbChainMockFns.delete).toHaveBeenCalled()
396-
expect(dbChainMockFns.set).toHaveBeenCalledWith(expect.objectContaining({ acl: ['ws'] }))
406+
/** Workspace access is restored under the switch lease, proved inside each batch. */
407+
expect(mocks.rewriteAcls).toHaveBeenCalledWith(
408+
'c-1',
409+
['ws'],
410+
expect.objectContaining({
411+
lease: expect.objectContaining({ stillHeld: expect.any(Function) }),
412+
})
413+
)
397414
expect(mocks.revoke).toHaveBeenCalledWith(
398415
{ workspaceId: 'ws-1', credentialGroupId: 'group-1', connectorId: 'c-1' },
399416
'admin-1'
@@ -509,7 +526,6 @@ describe('performUpdateKnowledgeConnectorAccess', () => {
509526
queueGroupRow('option-1')
510527
dbChainMockFns.returning
511528
.mockResolvedValueOnce([{ ...WORKSPACE_CONNECTOR, status: 'paused' }])
512-
.mockResolvedValueOnce([])
513529
.mockResolvedValueOnce([{ id: 'c-1' }])
514530
.mockResolvedValueOnce([{ ...MEMBERS_CONNECTOR, status: 'paused' }])
515531

‎apps/sim/lib/knowledge/orchestration/connector-access.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,7 @@ export async function performUpdateKnowledgeConnectorAccess(
335335
try {
336336
const rewritten = await rewriteConnectorAcls(connectorId, EMPTY_ACL, {
337337
deadlineAt: deadlineAt,
338+
lease: { stillHeld: () => switchLeaseHeld(connectorId, switchId) },
338339
})
339340
/**
340341
* The flip lands under the group's row lock, which the group's option
@@ -475,6 +476,7 @@ export async function performUpdateKnowledgeConnectorAccess(
475476
})
476477
const rewritten = await rewriteConnectorAcls(connectorId, WORKSPACE_ACL, {
477478
deadlineAt: deadlineAt,
479+
lease: { stillHeld: () => switchLeaseHeld(connectorId, switchId) },
478480
})
479481
if (existing.credentialGroupId) {
480482
await revokeKnowledgeConnectorCredentialAccess(

0 commit comments

Comments
 (0)