Skip to content

Commit aba70b2

Browse files
authored
fix(knowledge): address release review follow-ups for ACL pages, projection fill, and sync retries (#8202)
* fix(knowledge): address release review follow-ups for ACL pages, projection fill, and sync retries - Scope the observed-ACL aggregate to members of the document's own connector - Advance a member's listing checkpoint only after the ACLs it decides are written, and rematerialise every document a replayed feed removal names - Plan ACL pages from an unlocked read and lock only the page that will be written - Lock fill documents FOR KEY SHARE SKIP LOCKED so a concurrent delete cannot fail the mark's foreign key - Report an unfinished projection fill as remaining work - Record the database failure class on failed sync-log rows and count only those toward the database retry streak (expand-only migration 0379) - Heartbeat the lease between revokeDocumentAcls transactions - Extract isTriggerAvailable to lib/core/config/trigger-availability and use it for every dispatch decision - Exercise a concurrently committed deletion in the processing lock-scope test * fix(knowledge): constrain the recorded database failure class
1 parent 9fc3c5d commit aba70b2

41 files changed

Lines changed: 29229 additions & 170 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/sim/lib/billing/cleanup-dispatcher.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ vi.mock('@/lib/core/async-jobs', () => ({
2828
}))
2929
vi.mock('@/lib/core/async-jobs/config', () => ({ shouldExecuteInline: vi.fn(() => false) }))
3030
vi.mock('@/lib/core/async-jobs/region', () => ({ resolveTriggerRegion: vi.fn() }))
31-
vi.mock('@/lib/knowledge/documents/service', () => ({
31+
vi.mock('@/lib/core/config/trigger-availability', () => ({
3232
isTriggerAvailable: mockIsTriggerAvailable,
3333
}))
3434
vi.mock('@/lib/workspaces/policy', () => ({

apps/sim/lib/billing/cleanup-dispatcher.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { shouldExecuteInline } from '@/lib/core/async-jobs/config'
1616
import { resolveTriggerRegion } from '@/lib/core/async-jobs/region'
1717
import type { EnqueueOptions } from '@/lib/core/async-jobs/types'
1818
import { isBillingEnabled, isDataRetentionEnabled } from '@/lib/core/config/env-flags'
19-
import { isTriggerAvailable } from '@/lib/knowledge/documents/service'
19+
import { isTriggerAvailable } from '@/lib/core/config/trigger-availability'
2020
import { isOrganizationWorkspace, WORKSPACE_MODE } from '@/lib/workspaces/policy'
2121

2222
const logger = createLogger('RetentionDispatcher')
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { beforeEach, describe, expect, it, vi } from 'vitest'
5+
6+
const mocks = vi.hoisted(() => ({
7+
env: { TRIGGER_SECRET_KEY: undefined as string | undefined },
8+
flags: { isTriggerDevEnabled: false },
9+
insideRun: vi.fn(() => false),
10+
}))
11+
12+
vi.mock('@/lib/core/config/env', () => ({ env: mocks.env }))
13+
vi.mock('@/lib/core/config/env-flags', () => ({
14+
get isTriggerDevEnabled() {
15+
return mocks.flags.isTriggerDevEnabled
16+
},
17+
}))
18+
vi.mock('@/lib/core/config/trigger-runtime', () => ({ isInsideTriggerRun: mocks.insideRun }))
19+
20+
import { isTriggerAvailable } from '@/lib/core/config/trigger-availability'
21+
22+
describe('isTriggerAvailable', () => {
23+
beforeEach(() => {
24+
mocks.env.TRIGGER_SECRET_KEY = undefined
25+
mocks.flags.isTriggerDevEnabled = false
26+
mocks.insideRun.mockReturnValue(false)
27+
})
28+
29+
it('is available inside a Trigger.dev run whatever the environment says', () => {
30+
mocks.insideRun.mockReturnValue(true)
31+
expect(isTriggerAvailable()).toBe(true)
32+
})
33+
34+
it('needs both the enable flag and the secret key outside a run', () => {
35+
mocks.flags.isTriggerDevEnabled = true
36+
expect(isTriggerAvailable()).toBe(false)
37+
mocks.flags.isTriggerDevEnabled = false
38+
mocks.env.TRIGGER_SECRET_KEY = 'fixture-key'
39+
expect(isTriggerAvailable()).toBe(false)
40+
mocks.flags.isTriggerDevEnabled = true
41+
expect(isTriggerAvailable()).toBe(true)
42+
})
43+
})
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { createLogger } from '@sim/logger'
2+
import { env } from '@/lib/core/config/env'
3+
import { isTriggerDevEnabled } from '@/lib/core/config/env-flags'
4+
import { isInsideTriggerRun } from '@/lib/core/config/trigger-runtime'
5+
6+
const logger = createLogger('TriggerAvailability')
7+
8+
let triggerAvailabilityLogged = false
9+
10+
/**
11+
* Whether background work may be dispatched to Trigger.dev rather than run
12+
* in-process.
13+
*
14+
* Inside a Trigger.dev run the answer is unconditionally yes: the platform is
15+
* what is executing this process, so no environment guess can be more reliable
16+
* than the run marker. Outside a run the deployment must both enable
17+
* Trigger.dev and hold the secret key the SDK authenticates with.
18+
*
19+
* Resolving `true` inside a run is safe even if the run process turns out not
20+
* to expose `TRIGGER_SECRET_KEY`: the SDK would then reject the trigger and a
21+
* caller that falls back to in-process work lands exactly where a `false`
22+
* predicate lands anyway.
23+
*
24+
* The first evaluation in a process logs the resolved inputs. That is once per
25+
* worker process rather than once per dispatch, and it is the signal that makes
26+
* an app-vs-worker asymmetry visible without reading a crashed run's spans.
27+
*/
28+
export function isTriggerAvailable(): boolean {
29+
const insideRun = isInsideTriggerRun()
30+
const hasSecretKey = Boolean(env.TRIGGER_SECRET_KEY)
31+
const available = insideRun || (hasSecretKey && isTriggerDevEnabled)
32+
33+
if (!triggerAvailabilityLogged) {
34+
triggerAvailabilityLogged = true
35+
logger.info('Resolved Trigger.dev dispatch availability', {
36+
available,
37+
insideTriggerRun: insideRun,
38+
triggerDevEnabled: isTriggerDevEnabled,
39+
hasSecretKey,
40+
})
41+
}
42+
43+
return available
44+
}

apps/sim/lib/knowledge/__integration__/connector-lease-pages.integration.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { installProjectionSourceAcl } from '@sim/db/script-migrations/0021_embed
2121
import { installKnowledgeProjectionAsync } from '@sim/db/script-migrations/0024_knowledge_projection_async'
2222
import { generateId } from '@sim/utils/id'
2323
import { and, eq, inArray, sql } from 'drizzle-orm'
24+
import postgres from 'postgres'
2425
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
2526

2627
const provider = vi.hoisted(() => ({ list: vi.fn(), get: vi.fn(), changes: vi.fn() }))
@@ -1050,6 +1051,69 @@ describe('connector lease ACL pages in PostgreSQL', () => {
10501051
})
10511052
})
10521053

1054+
describe('lockProjectionPage', () => {
1055+
const withChunks = async (rows: { id: string }[], chunkCount: number) =>
1056+
db
1057+
.update(document)
1058+
.set({ chunkCount })
1059+
.where(
1060+
inArray(
1061+
document.id,
1062+
rows.map((row) => row.id)
1063+
)
1064+
)
1065+
const lockPage = (documentIds: string[]) =>
1066+
db.transaction(async (tx) => {
1067+
await tx.execute(sql`SET LOCAL lock_timeout = '500ms'`)
1068+
return memberObservations.lockProjectionPage(tx, documentIds)
1069+
})
1070+
const waitingOnLock = async () => {
1071+
const [row] = await db.execute<{ waiting: boolean }>(
1072+
sql`SELECT EXISTS (SELECT 1 FROM pg_locks WHERE NOT granted) AS waiting`
1073+
)
1074+
return Boolean(row?.waiting)
1075+
}
1076+
1077+
it('locks only the page it will write, so a held document past it stalls nothing', async () => {
1078+
const [first, held, last] = await seedDocuments(members.connectorId, [], 3)
1079+
await withChunks([first, held, last], PROJECTION_ROW_BATCH_SIZE)
1080+
const holder = postgres(process.env.DATABASE_URL!, { max: 1, onnotice: () => undefined })
1081+
try {
1082+
await holder.begin(async (tx) => {
1083+
await tx`SELECT id FROM document WHERE id = ${held.id} FOR UPDATE`
1084+
await expect(lockPage([first.id, held.id, last.id])).resolves.toEqual({
1085+
page: [first.id],
1086+
rest: [held.id, last.id],
1087+
})
1088+
})
1089+
} finally {
1090+
await holder.end()
1091+
}
1092+
})
1093+
1094+
it('cuts the page to what still fits once a concurrent commit grows its chunks', async () => {
1095+
const [first, grown] = await seedDocuments(members.connectorId, [], 2)
1096+
await withChunks([first, grown], PROJECTION_ROW_BATCH_SIZE / 2)
1097+
const holder = postgres(process.env.DATABASE_URL!, { max: 1, onnotice: () => undefined })
1098+
try {
1099+
let page: Promise<{ page: string[]; rest: string[] }> | undefined
1100+
await holder.begin(async (tx) => {
1101+
await tx`UPDATE document SET chunk_count = ${PROJECTION_ROW_BATCH_SIZE} WHERE id = ${grown.id}`
1102+
page = db.transaction(async (lockTx) =>
1103+
memberObservations.lockProjectionPage(lockTx, [first.id, grown.id])
1104+
)
1105+
await vi.waitFor(async () => expect(await waitingOnLock()).toBe(true), {
1106+
timeout: 5_000,
1107+
interval: 10,
1108+
})
1109+
})
1110+
await expect(page).resolves.toEqual({ page: [first.id], rest: [grown.id] })
1111+
} finally {
1112+
await holder.end()
1113+
}
1114+
})
1115+
})
1116+
10531117
describe('member listing materialisation', () => {
10541118
beforeEach(async () => {
10551119
provider.list.mockReset()

apps/sim/lib/knowledge/__integration__/knowledge-projection.integration.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -815,6 +815,73 @@ describe('the projector', () => {
815815
await db.delete(document).where(inArray(document.id, documents))
816816
})
817817

818+
it('marks what it can while a document it chose is deleted under it', async () => {
819+
const [deleted, kept] = [generateId(), generateId()]
820+
await db.insert(document).values(
821+
[deleted, kept].map((id, index) => ({
822+
id,
823+
connectorId,
824+
knowledgeBaseId: ids.knowledgeBaseId,
825+
externalId: `fill-race-${index}`,
826+
filename: `fill-race-${index}.md`,
827+
fileUrl: `https://fixture.test/fill-race-${index}`,
828+
fileSize: 12,
829+
mimeType: 'text/plain',
830+
processingStatus: 'completed' as const,
831+
acl: aclOf('alice', 'bob'),
832+
}))
833+
)
834+
await write('async', (tx) =>
835+
tx
836+
.insert(embedding)
837+
.values([deleted, kept].map((id) => ({ ...chunkRow(generateId(), 0), documentId: id })))
838+
)
839+
await project()
840+
for (const table of [embeddingSearch, embeddingKeywordTin]) {
841+
await db
842+
.update(table)
843+
.set({ connectorId: null, acl: null })
844+
.where(inArray(table.documentId, [deleted, kept]))
845+
}
846+
const deleter = postgres(process.env.DATABASE_URL!, { max: 1, onnotice: () => undefined })
847+
try {
848+
/** The deletion is under way when the fill reads, and commits while the fill still runs. */
849+
let fill: ReturnType<typeof markUnfilledProjectionDocuments> | undefined
850+
let settled = false
851+
await deleter.begin(async (tx) => {
852+
await tx`DELETE FROM document WHERE id = ${deleted}`
853+
fill = markUnfilledProjectionDocuments(projector)
854+
void fill.then(
855+
() => {
856+
settled = true
857+
},
858+
() => {
859+
settled = true
860+
}
861+
)
862+
await vi.waitFor(
863+
async () => {
864+
const [row] = await db.execute<{ waiting: boolean }>(
865+
sql`SELECT EXISTS (SELECT 1 FROM pg_locks WHERE NOT granted) AS waiting`
866+
)
867+
expect(settled || Boolean(row?.waiting)).toBe(true)
868+
},
869+
{ timeout: 5_000, interval: 10 }
870+
)
871+
})
872+
await expect(fill).resolves.toMatchObject({ marked: expect.any(Number) })
873+
const marks = await db
874+
.select({ documentId: knowledgeProjectionDirty.documentId })
875+
.from(knowledgeProjectionDirty)
876+
.where(inArray(knowledgeProjectionDirty.documentId, [deleted, kept]))
877+
expect(marks.map((mark) => mark.documentId)).toEqual([kept])
878+
} finally {
879+
await deleter.end()
880+
await project()
881+
await db.delete(document).where(inArray(document.id, [deleted, kept]))
882+
}
883+
})
884+
818885
it.each(['sync', 'async'] as const)(
819886
'writes %s projection rows from a chunk commit only when the writer did not defer them',
820887
async (mode) => {

apps/sim/lib/knowledge/__integration__/member-document-lifecycle.integration.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@ import {
1919
} from '@/lib/knowledge/__integration__/seed-source-access-fixture'
2020
import {
2121
applyMemberDocumentLifecycle,
22+
materializeDocumentAcls,
2223
recordMemberObservations,
24+
rematerializeDocumentAcls,
2325
removeMemberObservationsForDocuments,
2426
} from '@/lib/knowledge/connectors/member-observations'
2527
import { resumeMembershipRewrites } from '@/lib/knowledge/connectors/member-sync-engine'
@@ -141,6 +143,24 @@ describe('member document lifecycle in PostgreSQL', () => {
141143
.where(eq(knowledgeConnector.id, members.connectorId))
142144
)[0].cursor
143145

146+
it('never grants a re-owned document to observers of the connector it left', async () => {
147+
const moved = row('re-owned')
148+
await insertRows([moved])
149+
await observe([moved.id])
150+
const aclOf = async () =>
151+
(await db.select({ acl: document.acl }).from(document).where(eq(document.id, moved.id)))[0]
152+
?.acl
153+
expect(await materializeDocumentAcls(members.connectorId, [moved.id])).toBe(1)
154+
expect(await aclOf()).toEqual([members.members[0].subjectToken])
155+
156+
await db.update(document).set({ connectorId: ids.connectorId }).where(eq(document.id, moved.id))
157+
expect(
158+
await rematerializeDocumentAcls(ids.connectorId, [moved.id], (write) => db.transaction(write))
159+
).toBe(1)
160+
expect(await aclOf()).toEqual([])
161+
expect(await materializeDocumentAcls(ids.connectorId, [moved.id])).toBe(0)
162+
})
163+
144164
it('tombstones what this run unobserved right away and leaves the rest of a large connector to later runs', async () => {
145165
const pageBudget = MEMBER_TOMBSTONE_RECONCILE_PAGES_PER_RUN * 500
146166
const unobserved = Array.from({ length: pageBudget + 20 }, (_, index) =>

0 commit comments

Comments
 (0)