@open-mercato/search 0.6.8-develop.7096.1.97319f09f6 → 0.6.8-develop.7100.1.fbf66fca35
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/indexer/search-indexer.js +66 -0
- package/dist/indexer/search-indexer.js.map +2 -2
- package/dist/modules/search/workers/fulltext-index.worker.js +7 -24
- package/dist/modules/search/workers/fulltext-index.worker.js.map +2 -2
- package/dist/service.js +18 -1
- package/dist/service.js.map +2 -2
- package/package.json +4 -4
- package/src/__tests__/search-indexer-batch.test.ts +214 -0
- package/src/__tests__/service.test.ts +24 -0
- package/src/__tests__/workers.test.ts +46 -17
- package/src/indexer/search-indexer.ts +84 -0
- package/src/modules/search/workers/fulltext-index.worker.ts +13 -29
- package/src/service.ts +37 -2
|
@@ -78,8 +78,9 @@ async function advanceFulltextReindexProgress(params: {
|
|
|
78
78
|
* This handler processes single record indexing, batch indexing, deletion, and purge
|
|
79
79
|
* operations for the fulltext search strategy.
|
|
80
80
|
*
|
|
81
|
-
*
|
|
82
|
-
* fresh data,
|
|
81
|
+
* Single-record jobs load fresh data via searchIndexer.indexRecordById(). Batch jobs load
|
|
82
|
+
* fresh data per record via searchIndexer.indexRecordsById(), which flushes the whole batch
|
|
83
|
+
* through a single bulk write instead of one write per record.
|
|
83
84
|
*
|
|
84
85
|
* @param job - The queued job containing payload
|
|
85
86
|
* @param jobCtx - Queue job context with job ID and attempt info
|
|
@@ -202,7 +203,7 @@ export async function handleFulltextIndexJob(
|
|
|
202
203
|
return
|
|
203
204
|
}
|
|
204
205
|
|
|
205
|
-
// ========== BATCH-INDEX:
|
|
206
|
+
// ========== BATCH-INDEX: Load fresh data, write the whole batch in one call ==========
|
|
206
207
|
if (jobType === 'batch-index') {
|
|
207
208
|
const { records, organizationId } = job.payload
|
|
208
209
|
if (!records || records.length === 0) {
|
|
@@ -216,30 +217,13 @@ export async function handleFulltextIndexJob(
|
|
|
216
217
|
throw new Error('searchIndexer not available for batch indexing')
|
|
217
218
|
}
|
|
218
219
|
|
|
219
|
-
//
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
entityId: entityId as EntityId,
|
|
227
|
-
recordId,
|
|
228
|
-
tenantId,
|
|
229
|
-
organizationId,
|
|
230
|
-
})
|
|
231
|
-
if (result.action === 'indexed') {
|
|
232
|
-
successCount++
|
|
233
|
-
}
|
|
234
|
-
} catch (error) {
|
|
235
|
-
failCount++
|
|
236
|
-
searchDebugWarn('fulltext-index.worker', 'Failed to index record in batch', {
|
|
237
|
-
entityId,
|
|
238
|
-
recordId,
|
|
239
|
-
error: error instanceof Error ? error.message : error,
|
|
240
|
-
})
|
|
241
|
-
}
|
|
242
|
-
}
|
|
220
|
+
// Load and index the whole batch through a single bulk write instead of
|
|
221
|
+
// one indexRecordById() call per record.
|
|
222
|
+
const { indexed: successCount, skipped: skippedCount } = await searchIndexer.indexRecordsById({
|
|
223
|
+
items: records.map(({ entityId, recordId }) => ({ entityId: entityId as EntityId, recordId })),
|
|
224
|
+
tenantId,
|
|
225
|
+
organizationId,
|
|
226
|
+
})
|
|
243
227
|
|
|
244
228
|
await advanceFulltextReindexProgress({
|
|
245
229
|
db,
|
|
@@ -255,7 +239,7 @@ export async function handleFulltextIndexJob(
|
|
|
255
239
|
tenantId,
|
|
256
240
|
requestedCount: records.length,
|
|
257
241
|
successCount,
|
|
258
|
-
|
|
242
|
+
skippedCount,
|
|
259
243
|
})
|
|
260
244
|
|
|
261
245
|
await recordIndexerLog(
|
|
@@ -265,7 +249,7 @@ export async function handleFulltextIndexJob(
|
|
|
265
249
|
handler: 'worker:fulltext:batch-index',
|
|
266
250
|
message: `Indexed ${successCount}/${records.length} records to fulltext`,
|
|
267
251
|
tenantId,
|
|
268
|
-
details: { jobId: jobCtx.jobId, requestedCount: records.length, successCount,
|
|
252
|
+
details: { jobId: jobCtx.jobId, requestedCount: records.length, successCount, skippedCount },
|
|
269
253
|
},
|
|
270
254
|
)
|
|
271
255
|
return
|
package/src/service.ts
CHANGED
|
@@ -25,6 +25,38 @@ const DEFAULT_MERGE_CONFIG: ResultMergeConfig = {
|
|
|
25
25
|
*/
|
|
26
26
|
const STRATEGY_AVAILABILITY_CACHE_TTL_MS = 2_000
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Maximum records indexed at once when bulkIndex falls back to per-record writes
|
|
30
|
+
* for a strategy that has no bulkIndex implementation (currently the vector
|
|
31
|
+
* strategy, whose index() performs an embedding-provider round trip per record).
|
|
32
|
+
* A whole reindex page arrives in one bulkIndex call, so an unbounded fan-out
|
|
33
|
+
* would burst hundreds of concurrent provider requests from a single job.
|
|
34
|
+
*/
|
|
35
|
+
const BULK_INDEX_FALLBACK_CONCURRENCY = 4
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Map items through an async worker with a fixed number of in-flight calls.
|
|
39
|
+
* Rejects with the first error, matching Promise.all semantics.
|
|
40
|
+
*/
|
|
41
|
+
async function mapWithConcurrency<T>(
|
|
42
|
+
items: T[],
|
|
43
|
+
limit: number,
|
|
44
|
+
worker: (item: T) => Promise<void>,
|
|
45
|
+
): Promise<void> {
|
|
46
|
+
if (items.length === 0) return
|
|
47
|
+
|
|
48
|
+
let nextIndex = 0
|
|
49
|
+
const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
50
|
+
for (;;) {
|
|
51
|
+
const currentIndex = nextIndex++
|
|
52
|
+
if (currentIndex >= items.length) return
|
|
53
|
+
await worker(items[currentIndex])
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
await Promise.all(runners)
|
|
58
|
+
}
|
|
59
|
+
|
|
28
60
|
function normalizeOrganizationFilter(options: SearchOptions): string[] | null {
|
|
29
61
|
const single = typeof options.organizationId === 'string' ? options.organizationId.trim() : ''
|
|
30
62
|
if (single) return [single]
|
|
@@ -235,8 +267,11 @@ export class SearchService {
|
|
|
235
267
|
if (strategy.bulkIndex) {
|
|
236
268
|
return strategy.bulkIndex(records)
|
|
237
269
|
}
|
|
238
|
-
// Fallback to individual indexing
|
|
239
|
-
|
|
270
|
+
// Fallback to individual indexing, bounded so a strategy without a batch
|
|
271
|
+
// implementation cannot turn one batch job into hundreds of concurrent writes.
|
|
272
|
+
return mapWithConcurrency(records, BULK_INDEX_FALLBACK_CONCURRENCY, (record) =>
|
|
273
|
+
this.executeStrategyIndex(strategy, record),
|
|
274
|
+
)
|
|
240
275
|
}),
|
|
241
276
|
)
|
|
242
277
|
|