@open-mercato/core 0.6.7-develop.6653.1.b5bc7194a0 → 0.6.7-develop.6661.1.0043ed1d03

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.
Files changed (73) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/business_rules/lib/value-resolver.js +7 -0
  3. package/dist/modules/business_rules/lib/value-resolver.js.map +2 -2
  4. package/dist/modules/communication_channels/lib/pg-errors.js +1 -7
  5. package/dist/modules/communication_channels/lib/pg-errors.js.map +2 -2
  6. package/dist/modules/currencies/commands/currencies.js +22 -8
  7. package/dist/modules/currencies/commands/currencies.js.map +2 -2
  8. package/dist/modules/currencies/commands/exchange-rates.js +22 -8
  9. package/dist/modules/currencies/commands/exchange-rates.js.map +2 -2
  10. package/dist/modules/currencies/commands/scope.js +22 -0
  11. package/dist/modules/currencies/commands/scope.js.map +7 -0
  12. package/dist/modules/customers/components/detail/ActivitiesSection.js +5 -4
  13. package/dist/modules/customers/components/detail/ActivitiesSection.js.map +2 -2
  14. package/dist/modules/customers/components/detail/ActivityHistorySection.js +5 -4
  15. package/dist/modules/customers/components/detail/ActivityHistorySection.js.map +2 -2
  16. package/dist/modules/feature_toggles/api/global/route.js +6 -0
  17. package/dist/modules/feature_toggles/api/global/route.js.map +2 -2
  18. package/dist/modules/feature_toggles/commands/global.js +9 -9
  19. package/dist/modules/feature_toggles/commands/global.js.map +2 -2
  20. package/dist/modules/query_index/api/openapi.js +2 -2
  21. package/dist/modules/query_index/api/openapi.js.map +2 -2
  22. package/dist/modules/query_index/api/status.js +6 -4
  23. package/dist/modules/query_index/api/status.js.map +2 -2
  24. package/dist/modules/query_index/cli.js +31 -28
  25. package/dist/modules/query_index/cli.js.map +2 -2
  26. package/dist/modules/query_index/components/QueryIndexesTable.js +4 -1
  27. package/dist/modules/query_index/components/QueryIndexesTable.js.map +2 -2
  28. package/dist/modules/query_index/lib/batch.js +163 -44
  29. package/dist/modules/query_index/lib/batch.js.map +3 -3
  30. package/dist/modules/query_index/lib/jobs.js +3 -2
  31. package/dist/modules/query_index/lib/jobs.js.map +2 -2
  32. package/dist/modules/query_index/lib/reindexer.js +50 -16
  33. package/dist/modules/query_index/lib/reindexer.js.map +2 -2
  34. package/dist/modules/query_index/lib/stale.js +3 -0
  35. package/dist/modules/query_index/lib/stale.js.map +2 -2
  36. package/dist/modules/query_index/lib/subscriber-scope.js +63 -10
  37. package/dist/modules/query_index/lib/subscriber-scope.js.map +2 -2
  38. package/dist/modules/query_index/subscribers/delete_one.js +20 -6
  39. package/dist/modules/query_index/subscribers/delete_one.js.map +2 -2
  40. package/dist/modules/query_index/subscribers/upsert_one.js +8 -3
  41. package/dist/modules/query_index/subscribers/upsert_one.js.map +2 -2
  42. package/dist/modules/workflows/api/definitions/[id]/customize/route.js +2 -0
  43. package/dist/modules/workflows/api/definitions/[id]/customize/route.js.map +2 -2
  44. package/dist/modules/workflows/api/definitions/[id]/reset-to-code/route.js +4 -0
  45. package/dist/modules/workflows/api/definitions/[id]/reset-to-code/route.js.map +2 -2
  46. package/package.json +7 -7
  47. package/src/modules/business_rules/lib/value-resolver.ts +16 -0
  48. package/src/modules/communication_channels/lib/pg-errors.ts +3 -10
  49. package/src/modules/currencies/commands/currencies.ts +26 -8
  50. package/src/modules/currencies/commands/exchange-rates.ts +26 -8
  51. package/src/modules/currencies/commands/scope.ts +32 -0
  52. package/src/modules/customers/components/detail/ActivitiesSection.tsx +10 -6
  53. package/src/modules/customers/components/detail/ActivityHistorySection.tsx +6 -3
  54. package/src/modules/feature_toggles/api/global/route.ts +7 -1
  55. package/src/modules/feature_toggles/commands/global.ts +9 -12
  56. package/src/modules/query_index/api/openapi.ts +2 -2
  57. package/src/modules/query_index/api/status.ts +14 -5
  58. package/src/modules/query_index/cli.ts +36 -28
  59. package/src/modules/query_index/components/QueryIndexesTable.tsx +7 -4
  60. package/src/modules/query_index/i18n/de.json +2 -0
  61. package/src/modules/query_index/i18n/en.json +2 -0
  62. package/src/modules/query_index/i18n/es.json +2 -0
  63. package/src/modules/query_index/i18n/pl.json +2 -0
  64. package/src/modules/query_index/lib/batch.ts +244 -67
  65. package/src/modules/query_index/lib/jobs.ts +11 -0
  66. package/src/modules/query_index/lib/reindexer.ts +69 -16
  67. package/src/modules/query_index/lib/stale.ts +10 -0
  68. package/src/modules/query_index/lib/subscriber-scope.ts +98 -14
  69. package/src/modules/query_index/subscribers/delete_one.ts +21 -10
  70. package/src/modules/query_index/subscribers/upsert_one.ts +8 -3
  71. package/src/modules/workflows/AGENTS.md +22 -0
  72. package/src/modules/workflows/api/definitions/[id]/customize/route.ts +9 -0
  73. package/src/modules/workflows/api/definitions/[id]/reset-to-code/route.ts +7 -0
@@ -1,11 +1,20 @@
1
1
  import { type Kysely, sql } from 'kysely'
2
2
  import { recordIndexerError } from '@open-mercato/shared/lib/indexers/error-log'
3
+ import { isUniqueViolation } from '@open-mercato/shared/lib/db/pg-errors'
3
4
  import { buildIndexDocument, type IndexCustomFieldValue } from './document'
4
5
  import { replaceSearchTokensForBatch, isSearchDebugEnabled } from './search-tokens'
5
6
  import { createLogger } from '@open-mercato/shared/lib/logger'
6
7
 
7
8
  const logger = createLogger('query_index').child({ component: 'reindex-batch' })
8
9
 
10
+ /**
11
+ * Cap on how many per-row failures a single batch persists to `indexer_error_logs`.
12
+ * Under a systemic failure (pool exhausted, disk full, KMS down) every row fails, and
13
+ * writing one error row per record would hammer the database that is already failing.
14
+ * The count of suppressed failures still reaches the caller via `failedRecordIds`.
15
+ */
16
+ const MAX_RECORDED_ROW_ERRORS_PER_BATCH = 50
17
+
9
18
  export type AnyRow = Record<string, any> & { id: string | number }
10
19
 
11
20
  export type ScopeOverrides = {
@@ -13,6 +22,59 @@ export type ScopeOverrides = {
13
22
  tenantId?: string
14
23
  }
15
24
 
25
+ /**
26
+ * What a batch actually wrote, as opposed to what it was asked to write. Callers MUST
27
+ * reconcile `written` against `attempted` — a batch that loses rows and reports success
28
+ * is how records go permanently missing from the index (issue GSM-266).
29
+ */
30
+ export type UpsertIndexBatchResult = {
31
+ attempted: number
32
+ written: number
33
+ failedRecordIds: string[]
34
+ searchTokenFailures: number
35
+ }
36
+
37
+ export class QueryIndexBatchWriteError extends Error {
38
+ readonly entityType: string
39
+ readonly attempted: number
40
+ readonly written: number
41
+ readonly failedRecordIds: string[]
42
+
43
+ constructor(entityType: string, result: UpsertIndexBatchResult) {
44
+ const preview = result.failedRecordIds.slice(0, 10)
45
+ const suffix = result.failedRecordIds.length > preview.length ? ', ...' : ''
46
+ super(
47
+ `[internal] Query index write incomplete for ${entityType}: wrote ${result.written} of ${result.attempted} records` +
48
+ (preview.length ? ` (failed: ${preview.join(', ')}${suffix})` : ''),
49
+ )
50
+ this.name = 'QueryIndexBatchWriteError'
51
+ this.entityType = entityType
52
+ this.attempted = result.attempted
53
+ this.written = result.written
54
+ this.failedRecordIds = result.failedRecordIds
55
+ }
56
+ }
57
+
58
+ export function assertIndexBatchWritesLanded(entityType: string, result: UpsertIndexBatchResult): void {
59
+ if (result.written >= result.attempted && result.failedRecordIds.length === 0) return
60
+ throw new QueryIndexBatchWriteError(entityType, result)
61
+ }
62
+
63
+ export function createEmptyUpsertIndexBatchResult(): UpsertIndexBatchResult {
64
+ return { attempted: 0, written: 0, failedRecordIds: [], searchTokenFailures: 0 }
65
+ }
66
+
67
+ export function mergeUpsertIndexBatchResults(
68
+ target: UpsertIndexBatchResult,
69
+ addition: UpsertIndexBatchResult,
70
+ ): UpsertIndexBatchResult {
71
+ target.attempted += addition.attempted
72
+ target.written += addition.written
73
+ target.searchTokenFailures += addition.searchTokenFailures
74
+ target.failedRecordIds.push(...addition.failedRecordIds)
75
+ return target
76
+ }
77
+
16
78
  type CustomFieldRow = {
17
79
  record_id: string
18
80
  field_key: string
@@ -48,16 +110,73 @@ function normalizeScopedValue(value: unknown): string | null {
48
110
  return String(value)
49
111
  }
50
112
 
113
+ type IndexRowPayload = {
114
+ entity_type: string
115
+ entity_id: string
116
+ organization_id: string | null
117
+ tenant_id: string | null
118
+ doc: Record<string, unknown>
119
+ tokenDoc: Record<string, unknown>
120
+ index_version: number
121
+ }
122
+
123
+ async function updateIndexRow(db: Kysely<any>, payload: IndexRowPayload): Promise<number> {
124
+ let updateQuery = db
125
+ .updateTable('entity_indexes' as any)
126
+ .set({
127
+ doc: sql`${JSON.stringify(payload.doc)}::jsonb`,
128
+ index_version: payload.index_version,
129
+ organization_id: payload.organization_id ?? null,
130
+ tenant_id: payload.tenant_id ?? null,
131
+ updated_at: sql`now()`,
132
+ deleted_at: null,
133
+ } as any)
134
+ .where('entity_type' as any, '=', payload.entity_type)
135
+ .where('entity_id' as any, '=', payload.entity_id)
136
+ updateQuery = payload.organization_id == null
137
+ ? updateQuery.where('organization_id' as any, 'is', null as any)
138
+ : updateQuery.where('organization_id' as any, '=', payload.organization_id)
139
+ const result = await updateQuery.executeTakeFirst() as { numUpdatedRows?: bigint | number } | undefined
140
+ return Number(result?.numUpdatedRows ?? 0)
141
+ }
142
+
51
143
  export async function upsertIndexBatch(
52
144
  db: Kysely<any>,
53
145
  entityType: string,
54
146
  rows: AnyRow[],
55
147
  scope: ScopeOverrides,
56
148
  options: IndexBatchOptions = {},
57
- ): Promise<void> {
58
- if (!rows.length) return
149
+ ): Promise<UpsertIndexBatchResult> {
150
+ if (!rows.length) return createEmptyUpsertIndexBatchResult()
59
151
  const recordIds = rows.map((row) => normalizeId(row.id))
60
152
 
153
+ const failedRecordIds: string[] = []
154
+ let searchTokenFailures = 0
155
+ let recordedRowErrors = 0
156
+
157
+ const recordRowFailure = async (
158
+ recordId: string,
159
+ error: unknown,
160
+ handler: string,
161
+ rowScope: { organizationId: string | null; tenantId: string | null },
162
+ ): Promise<void> => {
163
+ failedRecordIds.push(recordId)
164
+ if (recordedRowErrors >= MAX_RECORDED_ROW_ERRORS_PER_BATCH) return
165
+ recordedRowErrors += 1
166
+ await recordIndexerError(
167
+ { db },
168
+ {
169
+ source: 'query_index',
170
+ handler,
171
+ error,
172
+ entityType,
173
+ recordId,
174
+ tenantId: rowScope.tenantId,
175
+ organizationId: rowScope.organizationId,
176
+ },
177
+ ).catch(() => undefined)
178
+ }
179
+
61
180
  const shouldMergeCustomerEntity =
62
181
  entityType === 'customers:customer_person_profile' || entityType === 'customers:customer_company_profile'
63
182
 
@@ -106,15 +225,7 @@ export async function upsertIndexBatch(
106
225
  else customFieldMap.set(key, [fieldRow])
107
226
  }
108
227
 
109
- const basePayloads: Array<{
110
- entity_type: string
111
- entity_id: string
112
- organization_id: string | null
113
- tenant_id: string | null
114
- doc: Record<string, unknown>
115
- tokenDoc: Record<string, unknown>
116
- index_version: number
117
- }> = []
228
+ const basePayloads: IndexRowPayload[] = []
118
229
 
119
230
  const debugEnabled = isSearchDebugEnabled()
120
231
 
@@ -168,8 +279,15 @@ export async function upsertIndexBatch(
168
279
  doc = encrypted
169
280
  tokenDoc = encrypted
170
281
  }
171
- } catch {
172
- // best-effort; ignore encrypt errors during indexing
282
+ } catch (encryptError) {
283
+ // Falling through would write the plaintext document into entity_indexes,
284
+ // which must stay encrypted at rest. Skip the row instead so the previously
285
+ // encrypted row survives, and let the caller fail the run.
286
+ await recordRowFailure(recordId, encryptError, 'query_index:reindex-batch:encrypt', {
287
+ organizationId: scopeOrg ?? null,
288
+ tenantId: scopeTenant ?? null,
289
+ })
290
+ continue
173
291
  }
174
292
  }
175
293
  if (typeof options.decryptDoc === 'function') {
@@ -181,8 +299,22 @@ export async function upsertIndexBatch(
181
299
  if (decrypted && typeof decrypted === 'object') {
182
300
  tokenDoc = decrypted
183
301
  }
184
- } catch {
185
- // best-effort; ignore decrypt errors during indexing
302
+ } catch (decryptError) {
303
+ // Only affects the search tokens built below; the indexed document itself is
304
+ // unharmed, so the row still counts as written.
305
+ logger.warn('Failed to decrypt index document for search tokens', { entityType, recordId })
306
+ await recordIndexerError(
307
+ { db },
308
+ {
309
+ source: 'fulltext',
310
+ handler: 'query_index:reindex-batch:decrypt',
311
+ error: decryptError,
312
+ entityType,
313
+ recordId,
314
+ tenantId: scopeTenant ?? null,
315
+ organizationId: scopeOrg ?? null,
316
+ },
317
+ ).catch(() => undefined)
186
318
  }
187
319
  }
188
320
  basePayloads.push({
@@ -232,7 +364,37 @@ export async function upsertIndexBatch(
232
364
  doc: payload.tokenDoc,
233
365
  }))
234
366
 
367
+ const writeSearchTokens = async (payloads = tokenPayloads): Promise<void> => {
368
+ if (!payloads.length) return
369
+ try {
370
+ await replaceSearchTokensForBatch(db, payloads)
371
+ } catch (searchTokenError) {
372
+ // Record instead of swallowing: a failed token write leaves fulltext search stale.
373
+ // Not counted as a write failure — replaceSearchTokensForBatch is transactional, so
374
+ // tokens end up stale rather than missing, and the record's next write rebuilds them.
375
+ searchTokenFailures += 1
376
+ await recordIndexerError(
377
+ { db },
378
+ { source: 'fulltext', handler: 'query_index:reindex-batch', error: searchTokenError, entityType, tenantId: scope.tenantId ?? null, organizationId: scope.orgId ?? null },
379
+ ).catch(() => undefined)
380
+ }
381
+ }
382
+
383
+ const buildResult = (written: number): UpsertIndexBatchResult => ({
384
+ attempted: rows.length,
385
+ written,
386
+ failedRecordIds,
387
+ searchTokenFailures,
388
+ })
389
+
390
+ if (!basePayloads.length) return buildResult(0)
391
+
392
+ let bulkWriteFailed = false
235
393
  try {
394
+ // One statement, so it is atomic on its own. Note the arbiter is
395
+ // (entity_type, entity_id, organization_id_coalesced): two payloads sharing a
396
+ // conflict key in the same batch would raise 21000 for the whole statement and
397
+ // fall through to the per-row path below.
236
398
  await db
237
399
  .insertInto('entity_indexes' as any)
238
400
  .values(insertRows as any)
@@ -247,15 +409,27 @@ export async function upsertIndexBatch(
247
409
  updated_at: sql`now()`,
248
410
  } as any))
249
411
  .execute()
250
- try {
251
- await replaceSearchTokensForBatch(db, tokenPayloads)
252
- } catch (searchTokenError) {
253
- // Record instead of swallowing: a failed token write leaves fulltext search stale.
254
- await recordIndexerError(
255
- { db },
256
- { source: 'fulltext', handler: 'query_index:reindex-batch', error: searchTokenError, entityType, tenantId: scope.tenantId ?? null, organizationId: scope.orgId ?? null },
257
- ).catch(() => undefined)
258
- }
412
+ } catch (bulkError) {
413
+ bulkWriteFailed = true
414
+ logger.warn('Bulk index upsert failed; falling back to per-row writes', {
415
+ entityType,
416
+ records: basePayloads.length,
417
+ })
418
+ await recordIndexerError(
419
+ { db },
420
+ {
421
+ source: 'query_index',
422
+ handler: 'query_index:reindex-batch:bulk',
423
+ error: bulkError,
424
+ entityType,
425
+ tenantId: scope.tenantId ?? null,
426
+ organizationId: scope.orgId ?? null,
427
+ },
428
+ ).catch(() => undefined)
429
+ }
430
+
431
+ if (!bulkWriteFailed) {
432
+ await writeSearchTokens()
259
433
  if (debugEnabled) {
260
434
  logger.debug('Reindex batch tokens', {
261
435
  entityType,
@@ -264,55 +438,58 @@ export async function upsertIndexBatch(
264
438
  scopeTenant: scope.tenantId ?? null,
265
439
  })
266
440
  }
267
- return
268
- } catch {
269
- await db.transaction().execute(async (trx) => {
270
- for (const payload of basePayloads) {
271
- let updateQuery = trx
272
- .updateTable('entity_indexes' as any)
273
- .set({
441
+ return buildResult(basePayloads.length)
442
+ }
443
+
444
+ // Deliberately NOT wrapped in a transaction. In Postgres a single failing statement
445
+ // aborts the surrounding transaction, and COMMIT on an aborted transaction answers
446
+ // with a ROLLBACK tag instead of raising — so one bad row used to discard the entire
447
+ // batch while this function reported success (issue GSM-266). Each row here is an
448
+ // independent, idempotent upsert; they share no invariant worth a transaction.
449
+ let written = 0
450
+ for (const payload of basePayloads) {
451
+ try {
452
+ if (await updateIndexRow(db, payload) > 0) {
453
+ written += 1
454
+ continue
455
+ }
456
+ try {
457
+ await db
458
+ .insertInto('entity_indexes' as any)
459
+ .values({
460
+ entity_type: payload.entity_type,
461
+ entity_id: payload.entity_id,
462
+ organization_id: payload.organization_id,
463
+ tenant_id: payload.tenant_id,
274
464
  doc: sql`${JSON.stringify(payload.doc)}::jsonb`,
275
465
  index_version: payload.index_version,
276
- organization_id: payload.organization_id ?? null,
277
- tenant_id: payload.tenant_id ?? null,
466
+ created_at: sql`now()`,
278
467
  updated_at: sql`now()`,
279
468
  deleted_at: null,
280
469
  } as any)
281
- .where('entity_type' as any, '=', payload.entity_type)
282
- .where('entity_id' as any, '=', payload.entity_id)
283
- updateQuery = payload.organization_id == null
284
- ? updateQuery.where('organization_id' as any, 'is', null as any)
285
- : updateQuery.where('organization_id' as any, '=', payload.organization_id)
286
- const result = await updateQuery.executeTakeFirst() as { numUpdatedRows?: bigint | number } | undefined
287
- if (result && Number(result.numUpdatedRows ?? 0) > 0) continue
288
- try {
289
- await trx
290
- .insertInto('entity_indexes' as any)
291
- .values({
292
- entity_type: payload.entity_type,
293
- entity_id: payload.entity_id,
294
- organization_id: payload.organization_id,
295
- tenant_id: payload.tenant_id,
296
- doc: sql`${JSON.stringify(payload.doc)}::jsonb`,
297
- index_version: payload.index_version,
298
- created_at: sql`now()`,
299
- updated_at: sql`now()`,
300
- deleted_at: null,
301
- } as any)
302
- .execute()
303
- } catch {
304
- // ignore duplicate insert race; another concurrent worker updated the row
470
+ .execute()
471
+ written += 1
472
+ } catch (insertError) {
473
+ // A concurrent worker may have inserted the row between our UPDATE and INSERT.
474
+ // Only a successful retry of the UPDATE proves the row is actually there.
475
+ if (isUniqueViolation(insertError) && await updateIndexRow(db, payload) > 0) {
476
+ written += 1
477
+ continue
305
478
  }
479
+ await recordRowFailure(payload.entity_id, insertError, 'query_index:reindex-batch:row', {
480
+ organizationId: payload.organization_id,
481
+ tenantId: payload.tenant_id,
482
+ })
306
483
  }
307
- })
308
- }
309
- try {
310
- await replaceSearchTokensForBatch(db, tokenPayloads)
311
- } catch (searchTokenError) {
312
- // Record instead of swallowing: a failed token write leaves fulltext search stale.
313
- await recordIndexerError(
314
- { db },
315
- { source: 'fulltext', handler: 'query_index:reindex-batch', error: searchTokenError, entityType, tenantId: scope.tenantId ?? null, organizationId: scope.orgId ?? null },
316
- ).catch(() => undefined)
484
+ } catch (rowError) {
485
+ await recordRowFailure(payload.entity_id, rowError, 'query_index:reindex-batch:row', {
486
+ organizationId: payload.organization_id,
487
+ tenantId: payload.tenant_id,
488
+ })
489
+ }
317
490
  }
491
+
492
+ const failedRecordIdSet = new Set(failedRecordIds)
493
+ await writeSearchTokens(tokenPayloads.filter((payload) => !failedRecordIdSet.has(payload.recordId)))
494
+ return buildResult(written)
318
495
  }
@@ -135,14 +135,25 @@ export async function updateJobProgress(
135
135
  ).execute()
136
136
  }
137
137
 
138
+ export type FinalizeJobOptions = {
139
+ /**
140
+ * Marks the finished job as failed. `finished_at` alone cannot express this — readers
141
+ * derive "completed" from its presence — so a run that lost records would otherwise
142
+ * still report success in the status API and UI.
143
+ */
144
+ status?: 'failed'
145
+ }
146
+
138
147
  export async function finalizeJob(
139
148
  db: Kysely<any>,
140
149
  scope: JobScope,
150
+ options: FinalizeJobOptions = {},
141
151
  ): Promise<void> {
142
152
  await applyScopeWhere(
143
153
  db.updateTable('entity_index_jobs' as any).set({
144
154
  finished_at: sql`now()`,
145
155
  heartbeat_at: sql`now()`,
156
+ ...(options.status ? { status: options.status } : {}),
146
157
  } as any) as any,
147
158
  scope,
148
159
  ).execute()
@@ -3,7 +3,14 @@ import { type Kysely, sql } from 'kysely'
3
3
  import { resolveRegisteredEntityTableName } from '@open-mercato/shared/lib/query/engine'
4
4
  import { resolveTenantEncryptionService } from '@open-mercato/shared/lib/encryption/customFieldValues'
5
5
  import { decryptIndexDocForSearch, encryptIndexDocForStorage } from '@open-mercato/shared/lib/encryption/indexDoc'
6
- import { upsertIndexBatch, type AnyRow } from './batch'
6
+ import {
7
+ upsertIndexBatch,
8
+ assertIndexBatchWritesLanded,
9
+ createEmptyUpsertIndexBatchResult,
10
+ mergeUpsertIndexBatchResults,
11
+ QueryIndexBatchWriteError,
12
+ type AnyRow,
13
+ } from './batch'
7
14
  import { refreshCoverageSnapshot, writeCoverageCounts, applyCoverageAdjustments } from './coverage'
8
15
  import { prepareJob, updateJobProgress, finalizeJob, type JobScope } from './jobs'
9
16
  import { purgeOrphans } from './stale'
@@ -39,6 +46,12 @@ export type ReindexJobResult = {
39
46
 
40
47
  export const DEFAULT_REINDEX_PARTITIONS = 5
41
48
  const DEFAULT_BATCH_SIZE = 500
49
+ /**
50
+ * Above this many failed records the purge exclusion list stops being a sane query, so
51
+ * the purge is skipped entirely instead. Failing closed keeps stale rows; the alternative
52
+ * deletes index entries the run failed to rebuild.
53
+ */
54
+ const MAX_PURGE_EXCLUSIONS = 1000
42
55
  const deriveOrgFromId = new Set<string>(['directory:organization'])
43
56
  const COVERAGE_REFRESH_THROTTLE_MS = 5 * 60 * 1000
44
57
  const lastCoverageReset = new Map<string, number>()
@@ -305,6 +318,8 @@ export async function reindexEntity(
305
318
 
306
319
  let processed = 0
307
320
  let lastId: string | null = null
321
+ let jobFailed = false
322
+ const writeTotals = createEmptyUpsertIndexBatchResult()
308
323
 
309
324
  options?.onProgress?.({ processed, total, chunkSize: 0 })
310
325
 
@@ -422,10 +437,20 @@ export async function reindexEntity(
422
437
  return result
423
438
  }
424
439
 
425
- await upsertIndexBatch(db, entityType, rows, scopeOverrides, { deriveOrganizationId: deriveOrg, encryptDoc, decryptDoc })
440
+ const batchResult = await upsertIndexBatch(db, entityType, rows, scopeOverrides, { deriveOrganizationId: deriveOrg, encryptDoc, decryptDoc })
441
+ mergeUpsertIndexBatchResults(writeTotals, batchResult)
442
+
443
+ // A whole batch failing is infrastructural (pool exhausted, disk full, KMS down),
444
+ // not a poison record. Abort now instead of grinding through the rest of the table.
445
+ if (batchResult.written === 0 && batchResult.attempted > 0) {
446
+ throw new QueryIndexBatchWriteError(entityType, writeTotals)
447
+ }
448
+
449
+ const failedInBatch = new Set(batchResult.failedRecordIds)
450
+ const writtenRows = failedInBatch.size ? rows.filter((row) => !failedInBatch.has(String(row.id))) : rows
426
451
 
427
452
  const coverageDeltas = new Map<string, { tenantId: string | null; organizationId: string | null; delta: number }>()
428
- for (const row of rows) {
453
+ for (const row of writtenRows) {
429
454
  const scopeTenant = tenantId !== undefined
430
455
  ? tenantId ?? null
431
456
  : (hasTenantCol ? ((row as AnyRow).tenant_id ?? null) : null)
@@ -457,7 +482,7 @@ export async function reindexEntity(
457
482
 
458
483
  if (emitVectorize && eventBus) {
459
484
  await Promise.all(
460
- rows.map((row) => {
485
+ writtenRows.map((row) => {
461
486
  const scopeOrg = organizationId !== undefined
462
487
  ? organizationId ?? null
463
488
  : hasOrgCol
@@ -478,20 +503,32 @@ export async function reindexEntity(
478
503
  )
479
504
  }
480
505
 
481
- processed += rows.length
506
+ processed += batchResult.written
482
507
  lastId = String(rows[rows.length - 1]!.id)
483
- options?.onProgress?.({ processed, total, chunkSize: rows.length })
484
- await updateJobProgress(db, jobScope, rows.length)
508
+ options?.onProgress?.({ processed, total, chunkSize: batchResult.written })
509
+ await updateJobProgress(db, jobScope, batchResult.written)
485
510
  }
486
511
 
487
- await purgeOrphans(db, {
488
- entityType,
489
- tenantId,
490
- organizationId,
491
- partitionIndex: usingPartitions ? partitionIndex : null,
492
- partitionCount: usingPartitions ? partitionCountRaw : null,
493
- startedAt: jobStartedAt,
494
- })
512
+ // Records this run failed to write still look untouched to the purge predicate, so
513
+ // without the exclusion the purge would delete the index rows it just failed to
514
+ // rebuild — turning a stale entry into a missing one.
515
+ const purgeExclusions = writeTotals.failedRecordIds
516
+ if (purgeExclusions.length > MAX_PURGE_EXCLUSIONS) {
517
+ logger.warn('Skipping orphan purge after widespread write failures', {
518
+ entityType,
519
+ failedRecords: purgeExclusions.length,
520
+ })
521
+ } else {
522
+ await purgeOrphans(db, {
523
+ entityType,
524
+ tenantId,
525
+ organizationId,
526
+ partitionIndex: usingPartitions ? partitionIndex : null,
527
+ partitionCount: usingPartitions ? partitionCountRaw : null,
528
+ startedAt: jobStartedAt,
529
+ excludeRecordIds: purgeExclusions,
530
+ })
531
+ }
495
532
 
496
533
  if (force && vectorService && (!usingPartitions || partitionIndex === null)) {
497
534
  try {
@@ -522,8 +559,24 @@ export async function reindexEntity(
522
559
  },
523
560
  )
524
561
  }
562
+
563
+ // Deliberately after the coverage refresh: the authoritative recount is what keeps
564
+ // indexed_count truthful, and it is most worth having when a run has just failed.
565
+ // Throwing here fails the queue job so the loss is visible instead of silent.
566
+ if (writeTotals.searchTokenFailures > 0) {
567
+ logger.warn('Search token writes failed during reindex', {
568
+ entityType,
569
+ batches: writeTotals.searchTokenFailures,
570
+ })
571
+ }
572
+ assertIndexBatchWritesLanded(entityType, writeTotals)
573
+ } catch (error) {
574
+ jobFailed = true
575
+ throw error
525
576
  } finally {
526
- await finalizeJob(db, jobScope)
577
+ // Still finalized on failure: the scope stays wedged behind the active-job guard
578
+ // while finished_at is null. The status carries the outcome instead.
579
+ await finalizeJob(db, jobScope, jobFailed ? { status: 'failed' } : {})
527
580
  }
528
581
 
529
582
  return {
@@ -7,6 +7,13 @@ type PurgeOrphansOptions = {
7
7
  partitionIndex: number | null
8
8
  partitionCount: number | null
9
9
  startedAt: Date
10
+ /**
11
+ * Records whose index rows must be preserved even though this run did not refresh
12
+ * them. A row that failed to be written still looks untouched to the `updated_at`
13
+ * predicate below, so without this the purge would delete the very entries the run
14
+ * failed to rebuild.
15
+ */
16
+ excludeRecordIds?: string[]
10
17
  }
11
18
 
12
19
  export async function purgeOrphans(
@@ -25,5 +32,8 @@ export async function purgeOrphans(
25
32
  q = q.where(sql<boolean>`mod(abs(hashtext(entity_id::text)), ${partitionCount}) = ${partitionIndex}`)
26
33
  }
27
34
  q = q.where('updated_at' as any, '<', startedAt as any)
35
+ if (options.excludeRecordIds?.length) {
36
+ q = q.where('entity_id' as any, 'not in', options.excludeRecordIds)
37
+ }
28
38
  await q.execute()
29
39
  }