@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/business_rules/lib/value-resolver.js +7 -0
- package/dist/modules/business_rules/lib/value-resolver.js.map +2 -2
- package/dist/modules/communication_channels/lib/pg-errors.js +1 -7
- package/dist/modules/communication_channels/lib/pg-errors.js.map +2 -2
- package/dist/modules/currencies/commands/currencies.js +22 -8
- package/dist/modules/currencies/commands/currencies.js.map +2 -2
- package/dist/modules/currencies/commands/exchange-rates.js +22 -8
- package/dist/modules/currencies/commands/exchange-rates.js.map +2 -2
- package/dist/modules/currencies/commands/scope.js +22 -0
- package/dist/modules/currencies/commands/scope.js.map +7 -0
- package/dist/modules/customers/components/detail/ActivitiesSection.js +5 -4
- package/dist/modules/customers/components/detail/ActivitiesSection.js.map +2 -2
- package/dist/modules/customers/components/detail/ActivityHistorySection.js +5 -4
- package/dist/modules/customers/components/detail/ActivityHistorySection.js.map +2 -2
- package/dist/modules/feature_toggles/api/global/route.js +6 -0
- package/dist/modules/feature_toggles/api/global/route.js.map +2 -2
- package/dist/modules/feature_toggles/commands/global.js +9 -9
- package/dist/modules/feature_toggles/commands/global.js.map +2 -2
- package/dist/modules/query_index/api/openapi.js +2 -2
- package/dist/modules/query_index/api/openapi.js.map +2 -2
- package/dist/modules/query_index/api/status.js +6 -4
- package/dist/modules/query_index/api/status.js.map +2 -2
- package/dist/modules/query_index/cli.js +31 -28
- package/dist/modules/query_index/cli.js.map +2 -2
- package/dist/modules/query_index/components/QueryIndexesTable.js +4 -1
- package/dist/modules/query_index/components/QueryIndexesTable.js.map +2 -2
- package/dist/modules/query_index/lib/batch.js +163 -44
- package/dist/modules/query_index/lib/batch.js.map +3 -3
- package/dist/modules/query_index/lib/jobs.js +3 -2
- package/dist/modules/query_index/lib/jobs.js.map +2 -2
- package/dist/modules/query_index/lib/reindexer.js +50 -16
- package/dist/modules/query_index/lib/reindexer.js.map +2 -2
- package/dist/modules/query_index/lib/stale.js +3 -0
- package/dist/modules/query_index/lib/stale.js.map +2 -2
- package/dist/modules/query_index/lib/subscriber-scope.js +63 -10
- package/dist/modules/query_index/lib/subscriber-scope.js.map +2 -2
- package/dist/modules/query_index/subscribers/delete_one.js +20 -6
- package/dist/modules/query_index/subscribers/delete_one.js.map +2 -2
- package/dist/modules/query_index/subscribers/upsert_one.js +8 -3
- package/dist/modules/query_index/subscribers/upsert_one.js.map +2 -2
- package/dist/modules/workflows/api/definitions/[id]/customize/route.js +2 -0
- package/dist/modules/workflows/api/definitions/[id]/customize/route.js.map +2 -2
- package/dist/modules/workflows/api/definitions/[id]/reset-to-code/route.js +4 -0
- package/dist/modules/workflows/api/definitions/[id]/reset-to-code/route.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/business_rules/lib/value-resolver.ts +16 -0
- package/src/modules/communication_channels/lib/pg-errors.ts +3 -10
- package/src/modules/currencies/commands/currencies.ts +26 -8
- package/src/modules/currencies/commands/exchange-rates.ts +26 -8
- package/src/modules/currencies/commands/scope.ts +32 -0
- package/src/modules/customers/components/detail/ActivitiesSection.tsx +10 -6
- package/src/modules/customers/components/detail/ActivityHistorySection.tsx +6 -3
- package/src/modules/feature_toggles/api/global/route.ts +7 -1
- package/src/modules/feature_toggles/commands/global.ts +9 -12
- package/src/modules/query_index/api/openapi.ts +2 -2
- package/src/modules/query_index/api/status.ts +14 -5
- package/src/modules/query_index/cli.ts +36 -28
- package/src/modules/query_index/components/QueryIndexesTable.tsx +7 -4
- package/src/modules/query_index/i18n/de.json +2 -0
- package/src/modules/query_index/i18n/en.json +2 -0
- package/src/modules/query_index/i18n/es.json +2 -0
- package/src/modules/query_index/i18n/pl.json +2 -0
- package/src/modules/query_index/lib/batch.ts +244 -67
- package/src/modules/query_index/lib/jobs.ts +11 -0
- package/src/modules/query_index/lib/reindexer.ts +69 -16
- package/src/modules/query_index/lib/stale.ts +10 -0
- package/src/modules/query_index/lib/subscriber-scope.ts +98 -14
- package/src/modules/query_index/subscribers/delete_one.ts +21 -10
- package/src/modules/query_index/subscribers/upsert_one.ts +8 -3
- package/src/modules/workflows/AGENTS.md +22 -0
- package/src/modules/workflows/api/definitions/[id]/customize/route.ts +9 -0
- 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<
|
|
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:
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
}
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
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
|
-
.
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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 +=
|
|
506
|
+
processed += batchResult.written
|
|
482
507
|
lastId = String(rows[rows.length - 1]!.id)
|
|
483
|
-
options?.onProgress?.({ processed, total, chunkSize:
|
|
484
|
-
await updateJobProgress(db, jobScope,
|
|
508
|
+
options?.onProgress?.({ processed, total, chunkSize: batchResult.written })
|
|
509
|
+
await updateJobProgress(db, jobScope, batchResult.written)
|
|
485
510
|
}
|
|
486
511
|
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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
|
-
|
|
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
|
}
|