@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.
@@ -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
- * All indexing operations (single and batch) use searchIndexer.indexRecordById() to load
82
- * fresh data, ensuring consistency with the vector worker pattern.
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: Use searchIndexer.indexRecordById() for fresh data ==========
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
- // Process each record using indexRecordById (same pattern as vector worker)
220
- let successCount = 0
221
- let failCount = 0
222
-
223
- for (const { entityId, recordId } of records) {
224
- try {
225
- const result = await searchIndexer.indexRecordById({
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
- failCount,
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, failCount },
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
- return Promise.all(records.map((record) => this.executeStrategyIndex(strategy, record)))
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