33 */
44import { beforeEach , describe , expect , it , vi } from 'vitest'
55
6- const { mockBackfill, mockEnd, mockPostgres, mockPrewarm, mockTasksTrigger } = vi . hoisted ( ( ) => ( {
6+ const {
7+ mockBackfill,
8+ mockEnd,
9+ mockPostgres,
10+ mockPrewarm,
11+ mockRunsList,
12+ mockTasksTrigger,
13+ mockUnsafe,
14+ } = vi . hoisted ( ( ) => ( {
715 mockBackfill : vi . fn ( ) ,
816 mockEnd : vi . fn ( async ( ) => undefined ) ,
917 mockPostgres : vi . fn ( ) ,
1018 mockPrewarm : vi . fn ( async ( ) => [ ] ) ,
19+ mockRunsList : vi . fn (
20+ ( _query : unknown ) : AsyncIterable < { id : string ; status : string } > => ( async function * ( ) { } ) ( )
21+ ) ,
1122 mockTasksTrigger : vi . fn ( async ( ) => ( { id : 'run-1' } ) ) ,
23+ mockUnsafe : vi . fn ( async ( ) => [ { unfilled : false } ] ) ,
1224} ) )
1325
1426vi . mock ( '@sim/db' , ( ) => ( { resolveDbUrl : ( ) => 'postgres://localhost:5432/sim' } ) )
@@ -18,7 +30,10 @@ vi.mock('@sim/db/script-migrations/0021_embedding_search_connector', () => ({
1830} ) )
1931vi . mock ( 'postgres' , ( ) => ( { default : mockPostgres } ) )
2032vi . mock ( '@/lib/knowledge/search/prewarm' , ( ) => ( { prewarmSearchProjection : mockPrewarm } ) )
21- vi . mock ( '@trigger.dev/sdk' , ( ) => ( { tasks : { trigger : mockTasksTrigger } } ) )
33+ vi . mock ( '@trigger.dev/sdk' , ( ) => ( {
34+ runs : { list : mockRunsList } ,
35+ tasks : { trigger : mockTasksTrigger } ,
36+ } ) )
2237vi . mock ( '@/lib/core/async-jobs/region' , ( ) => ( { resolveTriggerRegion : async ( ) => 'us-east-1' } ) )
2338vi . mock ( '@/lib/core/utils/background' , ( ) => ( {
2439 runDetached : ( _label : string , work : ( ) => Promise < unknown > ) => {
@@ -29,10 +44,11 @@ vi.mock('@/lib/core/utils/background', () => ({
2944import {
3045 enqueueProjectionSourceAclBackfill ,
3146 PROJECTION_PREWARM_BUDGET_MS ,
47+ projectionSourceAclShardRange ,
3248 runProjectionSourceAclBackfill ,
3349} from '@/lib/knowledge/search/projection-source-acl-backfill'
3450
35- const connection = { end : mockEnd }
51+ const connection = { end : mockEnd , unsafe : mockUnsafe }
3652
3753describe ( 'runProjectionSourceAclBackfill' , ( ) => {
3854 beforeEach ( ( ) => {
@@ -60,8 +76,17 @@ describe('runProjectionSourceAclBackfill', () => {
6076 expect ( mockEnd ) . toHaveBeenCalledTimes ( 1 )
6177 } )
6278
63- it ( 'warms the projections on the same connection once both are filled, before closing it' , async ( ) => {
79+ it ( 'analyzes and warms the projections on the same connection once both are filled, before closing it' , async ( ) => {
6480 await runProjectionSourceAclBackfill ( { } )
81+ /** A row whose document is gone is not the fill's to finish; the probe joins the document. */
82+ expect (
83+ mockUnsafe . mock . calls . some ( ( [ query ] ) =>
84+ String ( query ) . includes ( 'JOIN document d ON d.id = s.document_id WHERE s.acl IS NULL' )
85+ )
86+ ) . toBe ( true )
87+ expect ( mockUnsafe . mock . calls . map ( ( [ query ] ) => query ) ) . toEqual (
88+ expect . arrayContaining ( [ 'ANALYZE embedding_search' , 'ANALYZE embedding_keyword_tin' ] )
89+ )
6590 expect ( mockPrewarm ) . toHaveBeenCalledTimes ( 1 )
6691 expect ( mockPrewarm ) . toHaveBeenCalledWith ( connection , { budgetMs : PROJECTION_PREWARM_BUDGET_MS } )
6792 expect ( mockPrewarm . mock . invocationCallOrder [ 0 ] ) . toBeLessThan (
@@ -101,11 +126,65 @@ describe('runProjectionSourceAclBackfill', () => {
101126 await expect ( runProjectionSourceAclBackfill ( { } ) ) . rejects . toThrow ( 'statement timeout' )
102127 expect ( mockEnd ) . toHaveBeenCalledTimes ( 1 )
103128 } )
129+
130+ it ( 'fills only its shard of the id space in both projections' , async ( ) => {
131+ await runProjectionSourceAclBackfill ( { shard : { index : 1 , count : 4 } } )
132+ for ( const [ , , options ] of mockBackfill . mock . calls ) {
133+ expect ( options ) . toMatchObject ( { afterId : '4' , beforeId : '8' } )
134+ }
135+ } )
136+
137+ it ( 'resumes a shard after its cursor and keeps its upper bound' , async ( ) => {
138+ await runProjectionSourceAclBackfill ( {
139+ shard : { index : 1 , count : 4 } ,
140+ cursor : { projection : 'embedding_search' , afterId : '5a' } ,
141+ } )
142+ expect ( mockBackfill . mock . calls [ 0 ] [ 2 ] ) . toMatchObject ( { afterId : '5a' , beforeId : '8' } )
143+ expect ( mockBackfill . mock . calls [ 1 ] [ 2 ] ) . toMatchObject ( { afterId : '4' , beforeId : '8' } )
144+ } )
145+
146+ it ( 'leaves the analysis and the warm to whoever fills the rows another shard still holds' , async ( ) => {
147+ mockUnsafe . mockResolvedValueOnce ( [ { unfilled : true } ] )
148+ await expect (
149+ runProjectionSourceAclBackfill ( { shard : { index : 0 , count : 4 } } )
150+ ) . resolves . toBeNull ( )
151+ expect ( mockUnsafe . mock . calls . map ( ( [ query ] ) => query ) ) . not . toContain ( 'ANALYZE embedding_search' )
152+ expect ( mockPrewarm ) . not . toHaveBeenCalled ( )
153+ expect ( mockEnd ) . toHaveBeenCalledTimes ( 1 )
154+ } )
155+ } )
156+
157+ describe ( 'projectionSourceAclShardRange' , ( ) => {
158+ it ( 'slices the hex id space into contiguous ranges' , ( ) => {
159+ expect ( projectionSourceAclShardRange ( { index : 0 , count : 4 } ) ) . toEqual ( {
160+ afterId : '' ,
161+ beforeId : '4' ,
162+ } )
163+ expect ( projectionSourceAclShardRange ( { index : 3 , count : 4 } ) ) . toEqual ( {
164+ afterId : 'c' ,
165+ beforeId : undefined ,
166+ } )
167+ expect ( projectionSourceAclShardRange ( { index : 0 , count : 1 } ) ) . toEqual ( {
168+ afterId : '' ,
169+ beforeId : undefined ,
170+ } )
171+ } )
172+
173+ it . each ( [
174+ [ { index : 0 , count : 3 } , 'shard count must divide 16' ] ,
175+ [ { index : 0 , count : 8 } , 'shard count must be at most 4' ] ,
176+ [ { index : 4 , count : 4 } , 'shard index must be within 0..3' ] ,
177+ [ { index : 0.5 , count : 2 } , 'shard index must be within 0..1' ] ,
178+ ] ) ( 'refuses %j' , ( shard , message ) => {
179+ expect ( ( ) => projectionSourceAclShardRange ( shard ) ) . toThrow ( message )
180+ } )
104181} )
105182
106183describe ( 'enqueueProjectionSourceAclBackfill' , ( ) => {
107184 beforeEach ( ( ) => {
108185 vi . clearAllMocks ( )
186+ /** No chain in flight unless a case says so. */
187+ mockRunsList . mockImplementation ( ( ) => ( async function * ( ) { } ) ( ) )
109188 mockPostgres . mockReturnValue ( connection )
110189 mockBackfill . mockResolvedValue ( {
111190 projection : 'embedding_search' ,
@@ -118,13 +197,91 @@ describe('enqueueProjectionSourceAclBackfill', () => {
118197
119198 it ( 'hands the backfill to the Trigger.dev worker when one is configured' , async ( ) => {
120199 await expect ( enqueueProjectionSourceAclBackfill ( { pageSize : 25 } ) ) . resolves . toEqual ( {
121- runId : 'run-1' ,
200+ runIds : [ 'run-1' ] ,
201+ inFlight : [ ] ,
122202 } )
203+ expect ( mockRunsList ) . toHaveBeenCalledWith (
204+ expect . objectContaining ( { tag : 'projection-source-acl-backfill:shard:0/1' } )
205+ )
123206 expect ( mockTasksTrigger ) . toHaveBeenCalledWith (
124207 'projection-source-acl-backfill' ,
125208 { pageSize : 25 } ,
126- { region : 'us-east-1' }
209+ {
210+ region : 'us-east-1' ,
211+ tags : [ 'projection-source-acl-backfill:shard:0/1' ] ,
212+ idempotencyKey : 'projection-source-acl-backfill:shard:0/1:after:none' ,
213+ idempotencyKeyTTL : '2m' ,
214+ }
127215 )
128216 expect ( mockBackfill ) . not . toHaveBeenCalled ( )
129217 } )
218+
219+ it ( 'keys a start after a chain that ended on that chain, so a restart is its own start' , async ( ) => {
220+ mockRunsList . mockImplementation ( ( ) =>
221+ ( async function * ( ) {
222+ yield { id : 'run-done' , status : 'COMPLETED' }
223+ } ) ( )
224+ )
225+ await expect ( enqueueProjectionSourceAclBackfill ( { } ) ) . resolves . toEqual ( {
226+ runIds : [ 'run-1' ] ,
227+ inFlight : [ ] ,
228+ } )
229+ expect ( mockTasksTrigger . mock . calls [ 0 ] [ 2 ] . idempotencyKey ) . toBe (
230+ 'projection-source-acl-backfill:shard:0/1:after:run-done'
231+ )
232+ } )
233+
234+ it ( 'refuses a shard the id space cannot be sliced into before starting anything' , async ( ) => {
235+ await expect (
236+ enqueueProjectionSourceAclBackfill ( { shard : { index : 5 , count : 4 } } )
237+ ) . rejects . toThrow ( 'shard index must be within 0..3' )
238+ expect ( mockTasksTrigger ) . not . toHaveBeenCalled ( )
239+ } )
240+
241+ it ( 'leaves a range whose chain is still in flight to that chain' , async ( ) => {
242+ mockRunsList . mockImplementation ( ( query : unknown ) =>
243+ ( async function * ( ) {
244+ if ( ( query as { tag : string } ) . tag . endsWith ( ':shard:1/4' ) )
245+ yield { id : 'run-live' , status : 'EXECUTING' }
246+ } ) ( )
247+ )
248+ await expect ( enqueueProjectionSourceAclBackfill ( { } , 4 ) ) . resolves . toEqual ( {
249+ runIds : [ 'run-1' , 'run-1' , 'run-1' ] ,
250+ inFlight : [ 'run-live' ] ,
251+ } )
252+ expect ( mockTasksTrigger . mock . calls . map ( ( [ , payload ] ) => payload . shard ?. index ) ) . toEqual ( [
253+ 0 , 2 , 3 ,
254+ ] )
255+ } )
256+
257+ it ( 'starts one run per shard, each on its own slice under its own chain tag' , async ( ) => {
258+ await expect ( enqueueProjectionSourceAclBackfill ( { pageSize : 25 } , 4 ) ) . resolves . toEqual ( {
259+ runIds : [ 'run-1' , 'run-1' , 'run-1' , 'run-1' ] ,
260+ inFlight : [ ] ,
261+ } )
262+ expect ( mockTasksTrigger . mock . calls . map ( ( [ , payload ] ) => payload ) ) . toEqual (
263+ [ 0 , 1 , 2 , 3 ] . map ( ( index ) => ( { pageSize : 25 , shard : { index, count : 4 } } ) )
264+ )
265+ expect ( mockTasksTrigger . mock . calls . map ( ( [ , , options ] ) => options . tags ) ) . toEqual (
266+ [ 0 , 1 , 2 , 3 ] . map ( ( index ) => [ `projection-source-acl-backfill:shard:${ index } /4` ] )
267+ )
268+ } )
269+
270+ it . each ( [
271+ [ 3 , 'must divide 16' ] ,
272+ [ 8 , 'must be at most 4' ] ,
273+ ] ) ( 'refuses %s shards before starting anything' , async ( shards , message ) => {
274+ await expect ( enqueueProjectionSourceAclBackfill ( { } , shards ) ) . rejects . toThrow ( message )
275+ expect ( mockTasksTrigger ) . not . toHaveBeenCalled ( )
276+ } )
277+
278+ it ( 'refuses to slice a start that carries a cursor, which belongs to one chain' , async ( ) => {
279+ await expect (
280+ enqueueProjectionSourceAclBackfill (
281+ { cursor : { projection : 'embedding_search' , afterId : '5a' } } ,
282+ 4
283+ )
284+ ) . rejects . toThrow ( 'cannot start from a cursor' )
285+ expect ( mockTasksTrigger ) . not . toHaveBeenCalled ( )
286+ } )
130287} )
0 commit comments