@open-mercato/core 0.6.7-develop.6660.1.90e1e2eef6 → 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/communication_channels/lib/pg-errors.js +1 -7
- package/dist/modules/communication_channels/lib/pg-errors.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/package.json +7 -7
- package/src/modules/communication_channels/lib/pg-errors.ts +3 -10
- 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
|
@@ -1,9 +1,39 @@
|
|
|
1
1
|
import { 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 } from "./document.js";
|
|
4
5
|
import { replaceSearchTokensForBatch, isSearchDebugEnabled } from "./search-tokens.js";
|
|
5
6
|
import { createLogger } from "@open-mercato/shared/lib/logger";
|
|
6
7
|
const logger = createLogger("query_index").child({ component: "reindex-batch" });
|
|
8
|
+
const MAX_RECORDED_ROW_ERRORS_PER_BATCH = 50;
|
|
9
|
+
class QueryIndexBatchWriteError extends Error {
|
|
10
|
+
constructor(entityType, result) {
|
|
11
|
+
const preview = result.failedRecordIds.slice(0, 10);
|
|
12
|
+
const suffix = result.failedRecordIds.length > preview.length ? ", ..." : "";
|
|
13
|
+
super(
|
|
14
|
+
`[internal] Query index write incomplete for ${entityType}: wrote ${result.written} of ${result.attempted} records` + (preview.length ? ` (failed: ${preview.join(", ")}${suffix})` : "")
|
|
15
|
+
);
|
|
16
|
+
this.name = "QueryIndexBatchWriteError";
|
|
17
|
+
this.entityType = entityType;
|
|
18
|
+
this.attempted = result.attempted;
|
|
19
|
+
this.written = result.written;
|
|
20
|
+
this.failedRecordIds = result.failedRecordIds;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function assertIndexBatchWritesLanded(entityType, result) {
|
|
24
|
+
if (result.written >= result.attempted && result.failedRecordIds.length === 0) return;
|
|
25
|
+
throw new QueryIndexBatchWriteError(entityType, result);
|
|
26
|
+
}
|
|
27
|
+
function createEmptyUpsertIndexBatchResult() {
|
|
28
|
+
return { attempted: 0, written: 0, failedRecordIds: [], searchTokenFailures: 0 };
|
|
29
|
+
}
|
|
30
|
+
function mergeUpsertIndexBatchResults(target, addition) {
|
|
31
|
+
target.attempted += addition.attempted;
|
|
32
|
+
target.written += addition.written;
|
|
33
|
+
target.searchTokenFailures += addition.searchTokenFailures;
|
|
34
|
+
target.failedRecordIds.push(...addition.failedRecordIds);
|
|
35
|
+
return target;
|
|
36
|
+
}
|
|
7
37
|
function normalizeId(value) {
|
|
8
38
|
return String(value);
|
|
9
39
|
}
|
|
@@ -11,9 +41,42 @@ function normalizeScopedValue(value) {
|
|
|
11
41
|
if (value === void 0 || value === null || value === "") return null;
|
|
12
42
|
return String(value);
|
|
13
43
|
}
|
|
44
|
+
async function updateIndexRow(db, payload) {
|
|
45
|
+
let updateQuery = db.updateTable("entity_indexes").set({
|
|
46
|
+
doc: sql`${JSON.stringify(payload.doc)}::jsonb`,
|
|
47
|
+
index_version: payload.index_version,
|
|
48
|
+
organization_id: payload.organization_id ?? null,
|
|
49
|
+
tenant_id: payload.tenant_id ?? null,
|
|
50
|
+
updated_at: sql`now()`,
|
|
51
|
+
deleted_at: null
|
|
52
|
+
}).where("entity_type", "=", payload.entity_type).where("entity_id", "=", payload.entity_id);
|
|
53
|
+
updateQuery = payload.organization_id == null ? updateQuery.where("organization_id", "is", null) : updateQuery.where("organization_id", "=", payload.organization_id);
|
|
54
|
+
const result = await updateQuery.executeTakeFirst();
|
|
55
|
+
return Number(result?.numUpdatedRows ?? 0);
|
|
56
|
+
}
|
|
14
57
|
async function upsertIndexBatch(db, entityType, rows, scope, options = {}) {
|
|
15
|
-
if (!rows.length) return;
|
|
58
|
+
if (!rows.length) return createEmptyUpsertIndexBatchResult();
|
|
16
59
|
const recordIds = rows.map((row) => normalizeId(row.id));
|
|
60
|
+
const failedRecordIds = [];
|
|
61
|
+
let searchTokenFailures = 0;
|
|
62
|
+
let recordedRowErrors = 0;
|
|
63
|
+
const recordRowFailure = async (recordId, error, handler, rowScope) => {
|
|
64
|
+
failedRecordIds.push(recordId);
|
|
65
|
+
if (recordedRowErrors >= MAX_RECORDED_ROW_ERRORS_PER_BATCH) return;
|
|
66
|
+
recordedRowErrors += 1;
|
|
67
|
+
await recordIndexerError(
|
|
68
|
+
{ db },
|
|
69
|
+
{
|
|
70
|
+
source: "query_index",
|
|
71
|
+
handler,
|
|
72
|
+
error,
|
|
73
|
+
entityType,
|
|
74
|
+
recordId,
|
|
75
|
+
tenantId: rowScope.tenantId,
|
|
76
|
+
organizationId: rowScope.organizationId
|
|
77
|
+
}
|
|
78
|
+
).catch(() => void 0);
|
|
79
|
+
};
|
|
17
80
|
const shouldMergeCustomerEntity = entityType === "customers:customer_person_profile" || entityType === "customers:customer_company_profile";
|
|
18
81
|
let customerEntitiesById = null;
|
|
19
82
|
if (shouldMergeCustomerEntity) {
|
|
@@ -84,7 +147,12 @@ async function upsertIndexBatch(db, entityType, rows, scope, options = {}) {
|
|
|
84
147
|
doc = encrypted;
|
|
85
148
|
tokenDoc = encrypted;
|
|
86
149
|
}
|
|
87
|
-
} catch {
|
|
150
|
+
} catch (encryptError) {
|
|
151
|
+
await recordRowFailure(recordId, encryptError, "query_index:reindex-batch:encrypt", {
|
|
152
|
+
organizationId: scopeOrg ?? null,
|
|
153
|
+
tenantId: scopeTenant ?? null
|
|
154
|
+
});
|
|
155
|
+
continue;
|
|
88
156
|
}
|
|
89
157
|
}
|
|
90
158
|
if (typeof options.decryptDoc === "function") {
|
|
@@ -96,7 +164,20 @@ async function upsertIndexBatch(db, entityType, rows, scope, options = {}) {
|
|
|
96
164
|
if (decrypted && typeof decrypted === "object") {
|
|
97
165
|
tokenDoc = decrypted;
|
|
98
166
|
}
|
|
99
|
-
} catch {
|
|
167
|
+
} catch (decryptError) {
|
|
168
|
+
logger.warn("Failed to decrypt index document for search tokens", { entityType, recordId });
|
|
169
|
+
await recordIndexerError(
|
|
170
|
+
{ db },
|
|
171
|
+
{
|
|
172
|
+
source: "fulltext",
|
|
173
|
+
handler: "query_index:reindex-batch:decrypt",
|
|
174
|
+
error: decryptError,
|
|
175
|
+
entityType,
|
|
176
|
+
recordId,
|
|
177
|
+
tenantId: scopeTenant ?? null,
|
|
178
|
+
organizationId: scopeOrg ?? null
|
|
179
|
+
}
|
|
180
|
+
).catch(() => void 0);
|
|
100
181
|
}
|
|
101
182
|
}
|
|
102
183
|
basePayloads.push({
|
|
@@ -143,6 +224,26 @@ async function upsertIndexBatch(db, entityType, rows, scope, options = {}) {
|
|
|
143
224
|
tenantId: payload.tenant_id,
|
|
144
225
|
doc: payload.tokenDoc
|
|
145
226
|
}));
|
|
227
|
+
const writeSearchTokens = async (payloads = tokenPayloads) => {
|
|
228
|
+
if (!payloads.length) return;
|
|
229
|
+
try {
|
|
230
|
+
await replaceSearchTokensForBatch(db, payloads);
|
|
231
|
+
} catch (searchTokenError) {
|
|
232
|
+
searchTokenFailures += 1;
|
|
233
|
+
await recordIndexerError(
|
|
234
|
+
{ db },
|
|
235
|
+
{ source: "fulltext", handler: "query_index:reindex-batch", error: searchTokenError, entityType, tenantId: scope.tenantId ?? null, organizationId: scope.orgId ?? null }
|
|
236
|
+
).catch(() => void 0);
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
const buildResult = (written2) => ({
|
|
240
|
+
attempted: rows.length,
|
|
241
|
+
written: written2,
|
|
242
|
+
failedRecordIds,
|
|
243
|
+
searchTokenFailures
|
|
244
|
+
});
|
|
245
|
+
if (!basePayloads.length) return buildResult(0);
|
|
246
|
+
let bulkWriteFailed = false;
|
|
146
247
|
try {
|
|
147
248
|
await db.insertInto("entity_indexes").values(insertRows).onConflict((oc) => oc.columns(["entity_type", "entity_id", "organization_id_coalesced"]).doUpdateSet({
|
|
148
249
|
doc: sql`excluded.doc`,
|
|
@@ -152,14 +253,26 @@ async function upsertIndexBatch(db, entityType, rows, scope, options = {}) {
|
|
|
152
253
|
deleted_at: sql`excluded.deleted_at`,
|
|
153
254
|
updated_at: sql`now()`
|
|
154
255
|
})).execute();
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
256
|
+
} catch (bulkError) {
|
|
257
|
+
bulkWriteFailed = true;
|
|
258
|
+
logger.warn("Bulk index upsert failed; falling back to per-row writes", {
|
|
259
|
+
entityType,
|
|
260
|
+
records: basePayloads.length
|
|
261
|
+
});
|
|
262
|
+
await recordIndexerError(
|
|
263
|
+
{ db },
|
|
264
|
+
{
|
|
265
|
+
source: "query_index",
|
|
266
|
+
handler: "query_index:reindex-batch:bulk",
|
|
267
|
+
error: bulkError,
|
|
268
|
+
entityType,
|
|
269
|
+
tenantId: scope.tenantId ?? null,
|
|
270
|
+
organizationId: scope.orgId ?? null
|
|
271
|
+
}
|
|
272
|
+
).catch(() => void 0);
|
|
273
|
+
}
|
|
274
|
+
if (!bulkWriteFailed) {
|
|
275
|
+
await writeSearchTokens();
|
|
163
276
|
if (debugEnabled) {
|
|
164
277
|
logger.debug("Reindex batch tokens", {
|
|
165
278
|
entityType,
|
|
@@ -168,48 +281,54 @@ async function upsertIndexBatch(db, entityType, rows, scope, options = {}) {
|
|
|
168
281
|
scopeTenant: scope.tenantId ?? null
|
|
169
282
|
});
|
|
170
283
|
}
|
|
171
|
-
return;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
284
|
+
return buildResult(basePayloads.length);
|
|
285
|
+
}
|
|
286
|
+
let written = 0;
|
|
287
|
+
for (const payload of basePayloads) {
|
|
288
|
+
try {
|
|
289
|
+
if (await updateIndexRow(db, payload) > 0) {
|
|
290
|
+
written += 1;
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
try {
|
|
294
|
+
await db.insertInto("entity_indexes").values({
|
|
295
|
+
entity_type: payload.entity_type,
|
|
296
|
+
entity_id: payload.entity_id,
|
|
297
|
+
organization_id: payload.organization_id,
|
|
298
|
+
tenant_id: payload.tenant_id,
|
|
176
299
|
doc: sql`${JSON.stringify(payload.doc)}::jsonb`,
|
|
177
300
|
index_version: payload.index_version,
|
|
178
|
-
|
|
179
|
-
tenant_id: payload.tenant_id ?? null,
|
|
301
|
+
created_at: sql`now()`,
|
|
180
302
|
updated_at: sql`now()`,
|
|
181
303
|
deleted_at: null
|
|
182
|
-
}).
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
if (
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
entity_type: payload.entity_type,
|
|
189
|
-
entity_id: payload.entity_id,
|
|
190
|
-
organization_id: payload.organization_id,
|
|
191
|
-
tenant_id: payload.tenant_id,
|
|
192
|
-
doc: sql`${JSON.stringify(payload.doc)}::jsonb`,
|
|
193
|
-
index_version: payload.index_version,
|
|
194
|
-
created_at: sql`now()`,
|
|
195
|
-
updated_at: sql`now()`,
|
|
196
|
-
deleted_at: null
|
|
197
|
-
}).execute();
|
|
198
|
-
} catch {
|
|
304
|
+
}).execute();
|
|
305
|
+
written += 1;
|
|
306
|
+
} catch (insertError) {
|
|
307
|
+
if (isUniqueViolation(insertError) && await updateIndexRow(db, payload) > 0) {
|
|
308
|
+
written += 1;
|
|
309
|
+
continue;
|
|
199
310
|
}
|
|
311
|
+
await recordRowFailure(payload.entity_id, insertError, "query_index:reindex-batch:row", {
|
|
312
|
+
organizationId: payload.organization_id,
|
|
313
|
+
tenantId: payload.tenant_id
|
|
314
|
+
});
|
|
200
315
|
}
|
|
201
|
-
})
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
{ db },
|
|
208
|
-
{ source: "fulltext", handler: "query_index:reindex-batch", error: searchTokenError, entityType, tenantId: scope.tenantId ?? null, organizationId: scope.orgId ?? null }
|
|
209
|
-
).catch(() => void 0);
|
|
316
|
+
} catch (rowError) {
|
|
317
|
+
await recordRowFailure(payload.entity_id, rowError, "query_index:reindex-batch:row", {
|
|
318
|
+
organizationId: payload.organization_id,
|
|
319
|
+
tenantId: payload.tenant_id
|
|
320
|
+
});
|
|
321
|
+
}
|
|
210
322
|
}
|
|
323
|
+
const failedRecordIdSet = new Set(failedRecordIds);
|
|
324
|
+
await writeSearchTokens(tokenPayloads.filter((payload) => !failedRecordIdSet.has(payload.recordId)));
|
|
325
|
+
return buildResult(written);
|
|
211
326
|
}
|
|
212
327
|
export {
|
|
328
|
+
QueryIndexBatchWriteError,
|
|
329
|
+
assertIndexBatchWritesLanded,
|
|
330
|
+
createEmptyUpsertIndexBatchResult,
|
|
331
|
+
mergeUpsertIndexBatchResults,
|
|
213
332
|
upsertIndexBatch
|
|
214
333
|
};
|
|
215
334
|
//# sourceMappingURL=batch.js.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/query_index/lib/batch.ts"],
|
|
4
|
-
"sourcesContent": ["import { type Kysely, sql } from 'kysely'\nimport { recordIndexerError } from '@open-mercato/shared/lib/indexers/error-log'\nimport { buildIndexDocument, type IndexCustomFieldValue } from './document'\nimport { replaceSearchTokensForBatch, isSearchDebugEnabled } from './search-tokens'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('query_index').child({ component: 'reindex-batch' })\n\nexport type AnyRow = Record<string, any> & { id: string | number }\n\nexport type ScopeOverrides = {\n orgId?: string\n tenantId?: string\n}\n\ntype CustomFieldRow = {\n record_id: string\n field_key: string\n value_text: string | null\n value_multiline: string | null\n value_int: number | null\n value_float: number | null\n value_bool: boolean | null\n organization_id: string | null\n tenant_id: string | null\n}\n\nexport type IndexBatchOptions = {\n deriveOrganizationId?: (row: AnyRow) => string | null | undefined\n encryptDoc?: (\n entityType: string,\n doc: Record<string, unknown>,\n scope: { organizationId: string | null; tenantId: string | null },\n ) => Promise<Record<string, unknown> | null | undefined>\n decryptDoc?: (\n entityType: string,\n doc: Record<string, unknown>,\n scope: { organizationId: string | null; tenantId: string | null },\n ) => Promise<Record<string, unknown> | null | undefined>\n}\n\nfunction normalizeId(value: unknown): string {\n return String(value)\n}\n\nfunction normalizeScopedValue(value: unknown): string | null {\n if (value === undefined || value === null || value === '') return null\n return String(value)\n}\n\nexport async function upsertIndexBatch(\n db: Kysely<any>,\n entityType: string,\n rows: AnyRow[],\n scope: ScopeOverrides,\n options: IndexBatchOptions = {},\n): Promise<void> {\n if (!rows.length) return\n const recordIds = rows.map((row) => normalizeId(row.id))\n\n const shouldMergeCustomerEntity =\n entityType === 'customers:customer_person_profile' || entityType === 'customers:customer_company_profile'\n\n let customerEntitiesById: Map<string, AnyRow> | null = null\n if (shouldMergeCustomerEntity) {\n const entityIds = Array.from(\n new Set(\n rows\n .map((row) => (row as AnyRow).entity_id || (row as AnyRow).entityId)\n .filter((value): value is string | number => value !== undefined && value !== null && `${value}` !== '')\n .map((value) => normalizeId(value)),\n ),\n )\n if (entityIds.length) {\n const entityRows = await db\n .selectFrom('customer_entities' as any)\n .selectAll()\n .where('id' as any, 'in', entityIds)\n .execute() as AnyRow[]\n customerEntitiesById = new Map(entityRows.map((row) => [normalizeId(row.id), row]))\n }\n }\n\n const customFieldRows = await db\n .selectFrom('custom_field_values' as any)\n .select([\n 'record_id' as any,\n 'field_key' as any,\n 'value_text' as any,\n 'value_multiline' as any,\n 'value_int' as any,\n 'value_float' as any,\n 'value_bool' as any,\n 'organization_id' as any,\n 'tenant_id' as any,\n ])\n .where('entity_id' as any, '=', entityType)\n .where('record_id' as any, 'in', recordIds)\n .execute() as CustomFieldRow[]\n\n const customFieldMap = new Map<string, CustomFieldRow[]>()\n for (const fieldRow of customFieldRows) {\n const key = normalizeId(fieldRow.record_id)\n const bucket = customFieldMap.get(key)\n if (bucket) bucket.push(fieldRow)\n else customFieldMap.set(key, [fieldRow])\n }\n\n const basePayloads: Array<{\n entity_type: string\n entity_id: string\n organization_id: string | null\n tenant_id: string | null\n doc: Record<string, unknown>\n tokenDoc: Record<string, unknown>\n index_version: number\n }> = []\n\n const debugEnabled = isSearchDebugEnabled()\n\n for (const row of rows) {\n const recordId = normalizeId(row.id)\n const baseOrg = normalizeScopedValue((row as AnyRow).organization_id)\n const baseTenant = normalizeScopedValue((row as AnyRow).tenant_id)\n const derivedOrg = options?.deriveOrganizationId\n ? normalizeScopedValue(options.deriveOrganizationId(row))\n : undefined\n const scopeOrg =\n scope.orgId !== undefined\n ? scope.orgId\n : derivedOrg !== undefined\n ? derivedOrg\n : baseOrg\n const scopeTenant = scope.tenantId !== undefined ? scope.tenantId : baseTenant\n const inputRows = customFieldMap.get(recordId) ?? []\n const values: IndexCustomFieldValue[] = inputRows.map((fieldRow) => ({\n key: fieldRow.field_key,\n value:\n fieldRow.value_bool ??\n fieldRow.value_int ??\n fieldRow.value_float ??\n fieldRow.value_text ??\n fieldRow.value_multiline ??\n null,\n organizationId: normalizeScopedValue(fieldRow.organization_id),\n tenantId: normalizeScopedValue(fieldRow.tenant_id),\n }))\n const mergedRow = (() => {\n if (!shouldMergeCustomerEntity || !customerEntitiesById) return row\n const entityId = (row as AnyRow).entity_id || (row as AnyRow).entityId\n if (!entityId) return row\n const entityRow = customerEntitiesById.get(normalizeId(entityId))\n if (!entityRow) return row\n return { ...entityRow, ...row }\n })()\n let doc = buildIndexDocument(mergedRow, values, {\n organizationId: scopeOrg ?? null,\n tenantId: scopeTenant ?? null,\n })\n let tokenDoc: Record<string, unknown> = doc\n if (typeof options.encryptDoc === 'function') {\n try {\n const encrypted = await options.encryptDoc(entityType, doc, {\n organizationId: scopeOrg ?? null,\n tenantId: scopeTenant ?? null,\n })\n if (encrypted && typeof encrypted === 'object') {\n doc = encrypted\n tokenDoc = encrypted\n }\n } catch {\n // best-effort; ignore encrypt errors during indexing\n }\n }\n if (typeof options.decryptDoc === 'function') {\n try {\n const decrypted = await options.decryptDoc(entityType, doc, {\n organizationId: scopeOrg ?? null,\n tenantId: scopeTenant ?? null,\n })\n if (decrypted && typeof decrypted === 'object') {\n tokenDoc = decrypted\n }\n } catch {\n // best-effort; ignore decrypt errors during indexing\n }\n }\n basePayloads.push({\n entity_type: entityType,\n entity_id: recordId,\n organization_id: scopeOrg ?? null,\n tenant_id: scopeTenant ?? null,\n doc,\n tokenDoc,\n index_version: 1,\n })\n if (debugEnabled) {\n const sample = {\n display_name: (tokenDoc as any).display_name,\n first_name: (tokenDoc as any).first_name,\n last_name: (tokenDoc as any).last_name,\n brand_name: (tokenDoc as any).brand_name,\n legal_name: (tokenDoc as any).legal_name,\n }\n logger.debug('Reindex batch document', {\n entityType,\n recordId,\n organizationId: scopeOrg ?? null,\n tenantId: scopeTenant ?? null,\n sample,\n })\n }\n }\n\n const insertRows = basePayloads.map((payload) => ({\n entity_type: payload.entity_type,\n entity_id: payload.entity_id,\n organization_id: payload.organization_id,\n tenant_id: payload.tenant_id,\n doc: sql`${JSON.stringify(payload.doc)}::jsonb`,\n index_version: payload.index_version,\n created_at: sql`now()`,\n updated_at: sql`now()`,\n deleted_at: null,\n }))\n\n const tokenPayloads = basePayloads.map((payload) => ({\n entityType: payload.entity_type,\n recordId: payload.entity_id,\n organizationId: payload.organization_id,\n tenantId: payload.tenant_id,\n doc: payload.tokenDoc,\n }))\n\n try {\n await db\n .insertInto('entity_indexes' as any)\n .values(insertRows as any)\n .onConflict((oc: any) => oc\n .columns(['entity_type', 'entity_id', 'organization_id_coalesced'])\n .doUpdateSet({\n doc: sql`excluded.doc`,\n index_version: sql`excluded.index_version`,\n organization_id: sql`excluded.organization_id`,\n tenant_id: sql`excluded.tenant_id`,\n deleted_at: sql`excluded.deleted_at`,\n updated_at: sql`now()`,\n } as any))\n .execute()\n try {\n await replaceSearchTokensForBatch(db, tokenPayloads)\n } catch (searchTokenError) {\n // Record instead of swallowing: a failed token write leaves fulltext search stale.\n await recordIndexerError(\n { db },\n { source: 'fulltext', handler: 'query_index:reindex-batch', error: searchTokenError, entityType, tenantId: scope.tenantId ?? null, organizationId: scope.orgId ?? null },\n ).catch(() => undefined)\n }\n if (debugEnabled) {\n logger.debug('Reindex batch tokens', {\n entityType,\n records: basePayloads.length,\n scopeOrg: scope.orgId ?? null,\n scopeTenant: scope.tenantId ?? null,\n })\n }\n return\n } catch {\n await db.transaction().execute(async (trx) => {\n for (const payload of basePayloads) {\n let updateQuery = trx\n .updateTable('entity_indexes' as any)\n .set({\n doc: sql`${JSON.stringify(payload.doc)}::jsonb`,\n index_version: payload.index_version,\n organization_id: payload.organization_id ?? null,\n tenant_id: payload.tenant_id ?? null,\n updated_at: sql`now()`,\n deleted_at: null,\n } as any)\n .where('entity_type' as any, '=', payload.entity_type)\n .where('entity_id' as any, '=', payload.entity_id)\n updateQuery = payload.organization_id == null\n ? updateQuery.where('organization_id' as any, 'is', null as any)\n : updateQuery.where('organization_id' as any, '=', payload.organization_id)\n const result = await updateQuery.executeTakeFirst() as { numUpdatedRows?: bigint | number } | undefined\n if (result && Number(result.numUpdatedRows ?? 0) > 0) continue\n try {\n await trx\n .insertInto('entity_indexes' as any)\n .values({\n entity_type: payload.entity_type,\n entity_id: payload.entity_id,\n organization_id: payload.organization_id,\n tenant_id: payload.tenant_id,\n doc: sql`${JSON.stringify(payload.doc)}::jsonb`,\n index_version: payload.index_version,\n created_at: sql`now()`,\n updated_at: sql`now()`,\n deleted_at: null,\n } as any)\n .execute()\n } catch {\n // ignore duplicate insert race; another concurrent worker updated the row\n }\n }\n })\n }\n try {\n await replaceSearchTokensForBatch(db, tokenPayloads)\n } catch (searchTokenError) {\n // Record instead of swallowing: a failed token write leaves fulltext search stale.\n await recordIndexerError(\n { db },\n { source: 'fulltext', handler: 'query_index:reindex-batch', error: searchTokenError, entityType, tenantId: scope.tenantId ?? null, organizationId: scope.orgId ?? null },\n ).catch(() => undefined)\n }\n}\n"],
|
|
5
|
-
"mappings": "AAAA,SAAsB,WAAW;AACjC,SAAS,0BAA0B;AACnC,SAAS,0BAAsD;AAC/D,SAAS,6BAA6B,4BAA4B;AAClE,SAAS,oBAAoB;AAE7B,MAAM,SAAS,aAAa,aAAa,EAAE,MAAM,EAAE,WAAW,gBAAgB,CAAC;
|
|
6
|
-
"names": []
|
|
4
|
+
"sourcesContent": ["import { type Kysely, sql } from 'kysely'\nimport { recordIndexerError } from '@open-mercato/shared/lib/indexers/error-log'\nimport { isUniqueViolation } from '@open-mercato/shared/lib/db/pg-errors'\nimport { buildIndexDocument, type IndexCustomFieldValue } from './document'\nimport { replaceSearchTokensForBatch, isSearchDebugEnabled } from './search-tokens'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('query_index').child({ component: 'reindex-batch' })\n\n/**\n * Cap on how many per-row failures a single batch persists to `indexer_error_logs`.\n * Under a systemic failure (pool exhausted, disk full, KMS down) every row fails, and\n * writing one error row per record would hammer the database that is already failing.\n * The count of suppressed failures still reaches the caller via `failedRecordIds`.\n */\nconst MAX_RECORDED_ROW_ERRORS_PER_BATCH = 50\n\nexport type AnyRow = Record<string, any> & { id: string | number }\n\nexport type ScopeOverrides = {\n orgId?: string\n tenantId?: string\n}\n\n/**\n * What a batch actually wrote, as opposed to what it was asked to write. Callers MUST\n * reconcile `written` against `attempted` \u2014 a batch that loses rows and reports success\n * is how records go permanently missing from the index (issue GSM-266).\n */\nexport type UpsertIndexBatchResult = {\n attempted: number\n written: number\n failedRecordIds: string[]\n searchTokenFailures: number\n}\n\nexport class QueryIndexBatchWriteError extends Error {\n readonly entityType: string\n readonly attempted: number\n readonly written: number\n readonly failedRecordIds: string[]\n\n constructor(entityType: string, result: UpsertIndexBatchResult) {\n const preview = result.failedRecordIds.slice(0, 10)\n const suffix = result.failedRecordIds.length > preview.length ? ', ...' : ''\n super(\n `[internal] Query index write incomplete for ${entityType}: wrote ${result.written} of ${result.attempted} records` +\n (preview.length ? ` (failed: ${preview.join(', ')}${suffix})` : ''),\n )\n this.name = 'QueryIndexBatchWriteError'\n this.entityType = entityType\n this.attempted = result.attempted\n this.written = result.written\n this.failedRecordIds = result.failedRecordIds\n }\n}\n\nexport function assertIndexBatchWritesLanded(entityType: string, result: UpsertIndexBatchResult): void {\n if (result.written >= result.attempted && result.failedRecordIds.length === 0) return\n throw new QueryIndexBatchWriteError(entityType, result)\n}\n\nexport function createEmptyUpsertIndexBatchResult(): UpsertIndexBatchResult {\n return { attempted: 0, written: 0, failedRecordIds: [], searchTokenFailures: 0 }\n}\n\nexport function mergeUpsertIndexBatchResults(\n target: UpsertIndexBatchResult,\n addition: UpsertIndexBatchResult,\n): UpsertIndexBatchResult {\n target.attempted += addition.attempted\n target.written += addition.written\n target.searchTokenFailures += addition.searchTokenFailures\n target.failedRecordIds.push(...addition.failedRecordIds)\n return target\n}\n\ntype CustomFieldRow = {\n record_id: string\n field_key: string\n value_text: string | null\n value_multiline: string | null\n value_int: number | null\n value_float: number | null\n value_bool: boolean | null\n organization_id: string | null\n tenant_id: string | null\n}\n\nexport type IndexBatchOptions = {\n deriveOrganizationId?: (row: AnyRow) => string | null | undefined\n encryptDoc?: (\n entityType: string,\n doc: Record<string, unknown>,\n scope: { organizationId: string | null; tenantId: string | null },\n ) => Promise<Record<string, unknown> | null | undefined>\n decryptDoc?: (\n entityType: string,\n doc: Record<string, unknown>,\n scope: { organizationId: string | null; tenantId: string | null },\n ) => Promise<Record<string, unknown> | null | undefined>\n}\n\nfunction normalizeId(value: unknown): string {\n return String(value)\n}\n\nfunction normalizeScopedValue(value: unknown): string | null {\n if (value === undefined || value === null || value === '') return null\n return String(value)\n}\n\ntype IndexRowPayload = {\n entity_type: string\n entity_id: string\n organization_id: string | null\n tenant_id: string | null\n doc: Record<string, unknown>\n tokenDoc: Record<string, unknown>\n index_version: number\n}\n\nasync function updateIndexRow(db: Kysely<any>, payload: IndexRowPayload): Promise<number> {\n let updateQuery = db\n .updateTable('entity_indexes' as any)\n .set({\n doc: sql`${JSON.stringify(payload.doc)}::jsonb`,\n index_version: payload.index_version,\n organization_id: payload.organization_id ?? null,\n tenant_id: payload.tenant_id ?? null,\n updated_at: sql`now()`,\n deleted_at: null,\n } as any)\n .where('entity_type' as any, '=', payload.entity_type)\n .where('entity_id' as any, '=', payload.entity_id)\n updateQuery = payload.organization_id == null\n ? updateQuery.where('organization_id' as any, 'is', null as any)\n : updateQuery.where('organization_id' as any, '=', payload.organization_id)\n const result = await updateQuery.executeTakeFirst() as { numUpdatedRows?: bigint | number } | undefined\n return Number(result?.numUpdatedRows ?? 0)\n}\n\nexport async function upsertIndexBatch(\n db: Kysely<any>,\n entityType: string,\n rows: AnyRow[],\n scope: ScopeOverrides,\n options: IndexBatchOptions = {},\n): Promise<UpsertIndexBatchResult> {\n if (!rows.length) return createEmptyUpsertIndexBatchResult()\n const recordIds = rows.map((row) => normalizeId(row.id))\n\n const failedRecordIds: string[] = []\n let searchTokenFailures = 0\n let recordedRowErrors = 0\n\n const recordRowFailure = async (\n recordId: string,\n error: unknown,\n handler: string,\n rowScope: { organizationId: string | null; tenantId: string | null },\n ): Promise<void> => {\n failedRecordIds.push(recordId)\n if (recordedRowErrors >= MAX_RECORDED_ROW_ERRORS_PER_BATCH) return\n recordedRowErrors += 1\n await recordIndexerError(\n { db },\n {\n source: 'query_index',\n handler,\n error,\n entityType,\n recordId,\n tenantId: rowScope.tenantId,\n organizationId: rowScope.organizationId,\n },\n ).catch(() => undefined)\n }\n\n const shouldMergeCustomerEntity =\n entityType === 'customers:customer_person_profile' || entityType === 'customers:customer_company_profile'\n\n let customerEntitiesById: Map<string, AnyRow> | null = null\n if (shouldMergeCustomerEntity) {\n const entityIds = Array.from(\n new Set(\n rows\n .map((row) => (row as AnyRow).entity_id || (row as AnyRow).entityId)\n .filter((value): value is string | number => value !== undefined && value !== null && `${value}` !== '')\n .map((value) => normalizeId(value)),\n ),\n )\n if (entityIds.length) {\n const entityRows = await db\n .selectFrom('customer_entities' as any)\n .selectAll()\n .where('id' as any, 'in', entityIds)\n .execute() as AnyRow[]\n customerEntitiesById = new Map(entityRows.map((row) => [normalizeId(row.id), row]))\n }\n }\n\n const customFieldRows = await db\n .selectFrom('custom_field_values' as any)\n .select([\n 'record_id' as any,\n 'field_key' as any,\n 'value_text' as any,\n 'value_multiline' as any,\n 'value_int' as any,\n 'value_float' as any,\n 'value_bool' as any,\n 'organization_id' as any,\n 'tenant_id' as any,\n ])\n .where('entity_id' as any, '=', entityType)\n .where('record_id' as any, 'in', recordIds)\n .execute() as CustomFieldRow[]\n\n const customFieldMap = new Map<string, CustomFieldRow[]>()\n for (const fieldRow of customFieldRows) {\n const key = normalizeId(fieldRow.record_id)\n const bucket = customFieldMap.get(key)\n if (bucket) bucket.push(fieldRow)\n else customFieldMap.set(key, [fieldRow])\n }\n\n const basePayloads: IndexRowPayload[] = []\n\n const debugEnabled = isSearchDebugEnabled()\n\n for (const row of rows) {\n const recordId = normalizeId(row.id)\n const baseOrg = normalizeScopedValue((row as AnyRow).organization_id)\n const baseTenant = normalizeScopedValue((row as AnyRow).tenant_id)\n const derivedOrg = options?.deriveOrganizationId\n ? normalizeScopedValue(options.deriveOrganizationId(row))\n : undefined\n const scopeOrg =\n scope.orgId !== undefined\n ? scope.orgId\n : derivedOrg !== undefined\n ? derivedOrg\n : baseOrg\n const scopeTenant = scope.tenantId !== undefined ? scope.tenantId : baseTenant\n const inputRows = customFieldMap.get(recordId) ?? []\n const values: IndexCustomFieldValue[] = inputRows.map((fieldRow) => ({\n key: fieldRow.field_key,\n value:\n fieldRow.value_bool ??\n fieldRow.value_int ??\n fieldRow.value_float ??\n fieldRow.value_text ??\n fieldRow.value_multiline ??\n null,\n organizationId: normalizeScopedValue(fieldRow.organization_id),\n tenantId: normalizeScopedValue(fieldRow.tenant_id),\n }))\n const mergedRow = (() => {\n if (!shouldMergeCustomerEntity || !customerEntitiesById) return row\n const entityId = (row as AnyRow).entity_id || (row as AnyRow).entityId\n if (!entityId) return row\n const entityRow = customerEntitiesById.get(normalizeId(entityId))\n if (!entityRow) return row\n return { ...entityRow, ...row }\n })()\n let doc = buildIndexDocument(mergedRow, values, {\n organizationId: scopeOrg ?? null,\n tenantId: scopeTenant ?? null,\n })\n let tokenDoc: Record<string, unknown> = doc\n if (typeof options.encryptDoc === 'function') {\n try {\n const encrypted = await options.encryptDoc(entityType, doc, {\n organizationId: scopeOrg ?? null,\n tenantId: scopeTenant ?? null,\n })\n if (encrypted && typeof encrypted === 'object') {\n doc = encrypted\n tokenDoc = encrypted\n }\n } catch (encryptError) {\n // Falling through would write the plaintext document into entity_indexes,\n // which must stay encrypted at rest. Skip the row instead so the previously\n // encrypted row survives, and let the caller fail the run.\n await recordRowFailure(recordId, encryptError, 'query_index:reindex-batch:encrypt', {\n organizationId: scopeOrg ?? null,\n tenantId: scopeTenant ?? null,\n })\n continue\n }\n }\n if (typeof options.decryptDoc === 'function') {\n try {\n const decrypted = await options.decryptDoc(entityType, doc, {\n organizationId: scopeOrg ?? null,\n tenantId: scopeTenant ?? null,\n })\n if (decrypted && typeof decrypted === 'object') {\n tokenDoc = decrypted\n }\n } catch (decryptError) {\n // Only affects the search tokens built below; the indexed document itself is\n // unharmed, so the row still counts as written.\n logger.warn('Failed to decrypt index document for search tokens', { entityType, recordId })\n await recordIndexerError(\n { db },\n {\n source: 'fulltext',\n handler: 'query_index:reindex-batch:decrypt',\n error: decryptError,\n entityType,\n recordId,\n tenantId: scopeTenant ?? null,\n organizationId: scopeOrg ?? null,\n },\n ).catch(() => undefined)\n }\n }\n basePayloads.push({\n entity_type: entityType,\n entity_id: recordId,\n organization_id: scopeOrg ?? null,\n tenant_id: scopeTenant ?? null,\n doc,\n tokenDoc,\n index_version: 1,\n })\n if (debugEnabled) {\n const sample = {\n display_name: (tokenDoc as any).display_name,\n first_name: (tokenDoc as any).first_name,\n last_name: (tokenDoc as any).last_name,\n brand_name: (tokenDoc as any).brand_name,\n legal_name: (tokenDoc as any).legal_name,\n }\n logger.debug('Reindex batch document', {\n entityType,\n recordId,\n organizationId: scopeOrg ?? null,\n tenantId: scopeTenant ?? null,\n sample,\n })\n }\n }\n\n const insertRows = basePayloads.map((payload) => ({\n entity_type: payload.entity_type,\n entity_id: payload.entity_id,\n organization_id: payload.organization_id,\n tenant_id: payload.tenant_id,\n doc: sql`${JSON.stringify(payload.doc)}::jsonb`,\n index_version: payload.index_version,\n created_at: sql`now()`,\n updated_at: sql`now()`,\n deleted_at: null,\n }))\n\n const tokenPayloads = basePayloads.map((payload) => ({\n entityType: payload.entity_type,\n recordId: payload.entity_id,\n organizationId: payload.organization_id,\n tenantId: payload.tenant_id,\n doc: payload.tokenDoc,\n }))\n\n const writeSearchTokens = async (payloads = tokenPayloads): Promise<void> => {\n if (!payloads.length) return\n try {\n await replaceSearchTokensForBatch(db, payloads)\n } catch (searchTokenError) {\n // Record instead of swallowing: a failed token write leaves fulltext search stale.\n // Not counted as a write failure \u2014 replaceSearchTokensForBatch is transactional, so\n // tokens end up stale rather than missing, and the record's next write rebuilds them.\n searchTokenFailures += 1\n await recordIndexerError(\n { db },\n { source: 'fulltext', handler: 'query_index:reindex-batch', error: searchTokenError, entityType, tenantId: scope.tenantId ?? null, organizationId: scope.orgId ?? null },\n ).catch(() => undefined)\n }\n }\n\n const buildResult = (written: number): UpsertIndexBatchResult => ({\n attempted: rows.length,\n written,\n failedRecordIds,\n searchTokenFailures,\n })\n\n if (!basePayloads.length) return buildResult(0)\n\n let bulkWriteFailed = false\n try {\n // One statement, so it is atomic on its own. Note the arbiter is\n // (entity_type, entity_id, organization_id_coalesced): two payloads sharing a\n // conflict key in the same batch would raise 21000 for the whole statement and\n // fall through to the per-row path below.\n await db\n .insertInto('entity_indexes' as any)\n .values(insertRows as any)\n .onConflict((oc: any) => oc\n .columns(['entity_type', 'entity_id', 'organization_id_coalesced'])\n .doUpdateSet({\n doc: sql`excluded.doc`,\n index_version: sql`excluded.index_version`,\n organization_id: sql`excluded.organization_id`,\n tenant_id: sql`excluded.tenant_id`,\n deleted_at: sql`excluded.deleted_at`,\n updated_at: sql`now()`,\n } as any))\n .execute()\n } catch (bulkError) {\n bulkWriteFailed = true\n logger.warn('Bulk index upsert failed; falling back to per-row writes', {\n entityType,\n records: basePayloads.length,\n })\n await recordIndexerError(\n { db },\n {\n source: 'query_index',\n handler: 'query_index:reindex-batch:bulk',\n error: bulkError,\n entityType,\n tenantId: scope.tenantId ?? null,\n organizationId: scope.orgId ?? null,\n },\n ).catch(() => undefined)\n }\n\n if (!bulkWriteFailed) {\n await writeSearchTokens()\n if (debugEnabled) {\n logger.debug('Reindex batch tokens', {\n entityType,\n records: basePayloads.length,\n scopeOrg: scope.orgId ?? null,\n scopeTenant: scope.tenantId ?? null,\n })\n }\n return buildResult(basePayloads.length)\n }\n\n // Deliberately NOT wrapped in a transaction. In Postgres a single failing statement\n // aborts the surrounding transaction, and COMMIT on an aborted transaction answers\n // with a ROLLBACK tag instead of raising \u2014 so one bad row used to discard the entire\n // batch while this function reported success (issue GSM-266). Each row here is an\n // independent, idempotent upsert; they share no invariant worth a transaction.\n let written = 0\n for (const payload of basePayloads) {\n try {\n if (await updateIndexRow(db, payload) > 0) {\n written += 1\n continue\n }\n try {\n await db\n .insertInto('entity_indexes' as any)\n .values({\n entity_type: payload.entity_type,\n entity_id: payload.entity_id,\n organization_id: payload.organization_id,\n tenant_id: payload.tenant_id,\n doc: sql`${JSON.stringify(payload.doc)}::jsonb`,\n index_version: payload.index_version,\n created_at: sql`now()`,\n updated_at: sql`now()`,\n deleted_at: null,\n } as any)\n .execute()\n written += 1\n } catch (insertError) {\n // A concurrent worker may have inserted the row between our UPDATE and INSERT.\n // Only a successful retry of the UPDATE proves the row is actually there.\n if (isUniqueViolation(insertError) && await updateIndexRow(db, payload) > 0) {\n written += 1\n continue\n }\n await recordRowFailure(payload.entity_id, insertError, 'query_index:reindex-batch:row', {\n organizationId: payload.organization_id,\n tenantId: payload.tenant_id,\n })\n }\n } catch (rowError) {\n await recordRowFailure(payload.entity_id, rowError, 'query_index:reindex-batch:row', {\n organizationId: payload.organization_id,\n tenantId: payload.tenant_id,\n })\n }\n }\n\n const failedRecordIdSet = new Set(failedRecordIds)\n await writeSearchTokens(tokenPayloads.filter((payload) => !failedRecordIdSet.has(payload.recordId)))\n return buildResult(written)\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": ["written"]
|
|
7
7
|
}
|
|
@@ -74,11 +74,12 @@ async function updateJobProgress(db, scope, processedDelta) {
|
|
|
74
74
|
scope
|
|
75
75
|
).execute();
|
|
76
76
|
}
|
|
77
|
-
async function finalizeJob(db, scope) {
|
|
77
|
+
async function finalizeJob(db, scope, options = {}) {
|
|
78
78
|
await applyScopeWhere(
|
|
79
79
|
db.updateTable("entity_index_jobs").set({
|
|
80
80
|
finished_at: sql`now()`,
|
|
81
|
-
heartbeat_at: sql`now()
|
|
81
|
+
heartbeat_at: sql`now()`,
|
|
82
|
+
...options.status ? { status: options.status } : {}
|
|
82
83
|
}),
|
|
83
84
|
scope
|
|
84
85
|
).execute();
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/query_index/lib/jobs.ts"],
|
|
4
|
-
"sourcesContent": ["import { type Kysely, sql } from 'kysely'\n\nexport type JobScope = {\n entityType: string\n organizationId?: string | null\n tenantId?: string | null\n partitionIndex?: number | null\n partitionCount?: number | null\n}\n\nfunction applyScopeWhere<QB extends { where: (...args: any[]) => QB }>(\n builder: QB,\n scope: JobScope,\n): QB {\n let q = builder.where('entity_type' as any, '=', scope.entityType)\n q = q.where(sql`organization_id is not distinct from ${scope.organizationId ?? null}`)\n q = q.where(sql`tenant_id is not distinct from ${scope.tenantId ?? null}`)\n q = q.where(sql`partition_index is not distinct from ${scope.partitionIndex ?? null}`)\n q = q.where(sql`partition_count is not distinct from ${scope.partitionCount ?? null}`)\n return q\n}\n\n// True when ON CONFLICT could not infer an arbiter index (Postgres SQLSTATE\n// 42P10) \u2014 i.e. entity_index_jobs_scope_unique is not present yet. Used to scope\n// prepareJob's fallback to the missing-index case only.\nfunction isMissingConflictArbiterError(err: unknown): boolean {\n const code = (err as { code?: unknown } | null | undefined)?.code\n if (code === '42P10') return true\n const message = (err as { message?: unknown } | null | undefined)?.message\n return typeof message === 'string'\n && /no unique or exclusion constraint matching the on conflict/i.test(message)\n}\n\nexport async function prepareJob(\n db: Kysely<any>,\n scope: JobScope,\n status: 'reindexing' | 'purging',\n options: { totalCount?: number | null } = {},\n): Promise<string | null> {\n const base = {\n organization_id: scope.organizationId ?? null,\n tenant_id: scope.tenantId ?? null,\n partition_index: scope.partitionIndex ?? null,\n partition_count: scope.partitionCount ?? null,\n status,\n started_at: sql`now()`,\n finished_at: null,\n heartbeat_at: sql`now()`,\n processed_count: 0,\n total_count: options.totalCount ?? null,\n }\n\n // Single atomic upsert keyed by the coalesced scope tuple. This closes the\n // read-then-write race where two concurrent schedulers each see \"no row\" and\n // both INSERT a duplicate for the same scope, after which updateJobProgress /\n // finalizeJob corrupt each other's progress and finish state (#2739). The\n // ON CONFLICT target must match entity_index_jobs_scope_unique exactly.\n try {\n const upserted = await db\n .insertInto('entity_index_jobs' as any)\n .values({ entity_type: scope.entityType, ...base } as any)\n .onConflict((oc: any) => oc\n .expression(sql`\n \"entity_type\",\n coalesce(\"organization_id\", '00000000-0000-0000-0000-000000000000'::uuid),\n coalesce(\"tenant_id\", '00000000-0000-0000-0000-000000000000'::uuid),\n coalesce(\"partition_index\", -1),\n coalesce(\"partition_count\", -1)\n `)\n .doUpdateSet({\n status,\n started_at: sql`now()`,\n finished_at: null,\n heartbeat_at: sql`now()`,\n processed_count: 0,\n total_count: options.totalCount ?? null,\n } as any))\n .returning(['id' as any])\n .execute() as Array<{ id: string }>\n return upserted?.[0]?.id ?? null\n } catch (err) {\n // Only degrade to the legacy path when the scope unique index is absent\n // (e.g. a rolling deploy running this code against the pre-migration schema):\n // Postgres raises 42P10. Any other failure is real and must surface rather\n // than silently fall back to the racy read-then-write path and re-open #2739.\n if (!isMissingConflictArbiterError(err)) throw err\n return prepareJobLegacy(db, scope, base)\n }\n}\n\n// Pre-#2739 read-then-write path. Retained only as a fallback when the\n// entity_index_jobs scope unique index is unavailable; it carries the original\n// (racy) semantics and must never be reached once the migration has applied.\nasync function prepareJobLegacy(\n db: Kysely<any>,\n scope: JobScope,\n base: Record<string, unknown>,\n): Promise<string | null> {\n const existing = await applyScopeWhere(\n db.selectFrom('entity_index_jobs' as any).select(['id' as any]),\n scope,\n ).executeTakeFirst() as { id: string } | undefined\n\n if (existing) {\n await applyScopeWhere(\n db.updateTable('entity_index_jobs' as any).set(base as any) as any,\n scope,\n ).execute()\n return existing.id\n }\n\n const inserted = await db\n .insertInto('entity_index_jobs' as any)\n .values({\n entity_type: scope.entityType,\n ...base,\n } as any)\n .returning(['id' as any])\n .execute() as Array<{ id: string }>\n\n return inserted?.[0]?.id ?? null\n}\n\nexport async function updateJobProgress(\n db: Kysely<any>,\n scope: JobScope,\n processedDelta: number,\n): Promise<void> {\n await applyScopeWhere(\n db.updateTable('entity_index_jobs' as any).set({\n processed_count: sql`coalesce(processed_count, 0) + ${Math.max(0, processedDelta)}`,\n heartbeat_at: sql`now()`,\n } as any) as any,\n scope,\n ).execute()\n}\n\nexport async function finalizeJob(\n db: Kysely<any>,\n scope: JobScope,\n): Promise<void> {\n await applyScopeWhere(\n db.updateTable('entity_index_jobs' as any).set({\n finished_at: sql`now()`,\n heartbeat_at: sql`now()`,\n } as any) as any,\n scope,\n ).execute()\n}\n"],
|
|
5
|
-
"mappings": "AAAA,SAAsB,WAAW;AAUjC,SAAS,gBACP,SACA,OACI;AACJ,MAAI,IAAI,QAAQ,MAAM,eAAsB,KAAK,MAAM,UAAU;AACjE,MAAI,EAAE,MAAM,2CAA2C,MAAM,kBAAkB,IAAI,EAAE;AACrF,MAAI,EAAE,MAAM,qCAAqC,MAAM,YAAY,IAAI,EAAE;AACzE,MAAI,EAAE,MAAM,2CAA2C,MAAM,kBAAkB,IAAI,EAAE;AACrF,MAAI,EAAE,MAAM,2CAA2C,MAAM,kBAAkB,IAAI,EAAE;AACrF,SAAO;AACT;AAKA,SAAS,8BAA8B,KAAuB;AAC5D,QAAM,OAAQ,KAA+C;AAC7D,MAAI,SAAS,QAAS,QAAO;AAC7B,QAAM,UAAW,KAAkD;AACnE,SAAO,OAAO,YAAY,YACrB,8DAA8D,KAAK,OAAO;AACjF;AAEA,eAAsB,WACpB,IACA,OACA,QACA,UAA0C,CAAC,GACnB;AACxB,QAAM,OAAO;AAAA,IACX,iBAAiB,MAAM,kBAAkB;AAAA,IACzC,WAAW,MAAM,YAAY;AAAA,IAC7B,iBAAiB,MAAM,kBAAkB;AAAA,IACzC,iBAAiB,MAAM,kBAAkB;AAAA,IACzC;AAAA,IACA,YAAY;AAAA,IACZ,aAAa;AAAA,IACb,cAAc;AAAA,IACd,iBAAiB;AAAA,IACjB,aAAa,QAAQ,cAAc;AAAA,EACrC;AAOA,MAAI;AACF,UAAM,WAAW,MAAM,GACpB,WAAW,mBAA0B,EACrC,OAAO,EAAE,aAAa,MAAM,YAAY,GAAG,KAAK,CAAQ,EACxD,WAAW,CAAC,OAAY,GACtB,WAAW;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,SAMX,EACA,YAAY;AAAA,MACX;AAAA,MACA,YAAY;AAAA,MACZ,aAAa;AAAA,MACb,cAAc;AAAA,MACd,iBAAiB;AAAA,MACjB,aAAa,QAAQ,cAAc;AAAA,IACrC,CAAQ,CAAC,EACV,UAAU,CAAC,IAAW,CAAC,EACvB,QAAQ;AACX,WAAO,WAAW,CAAC,GAAG,MAAM;AAAA,EAC9B,SAAS,KAAK;AAKZ,QAAI,CAAC,8BAA8B,GAAG,EAAG,OAAM;AAC/C,WAAO,iBAAiB,IAAI,OAAO,IAAI;AAAA,EACzC;AACF;AAKA,eAAe,iBACb,IACA,OACA,MACwB;AACxB,QAAM,WAAW,MAAM;AAAA,IACrB,GAAG,WAAW,mBAA0B,EAAE,OAAO,CAAC,IAAW,CAAC;AAAA,IAC9D;AAAA,EACF,EAAE,iBAAiB;AAEnB,MAAI,UAAU;AACZ,UAAM;AAAA,MACJ,GAAG,YAAY,mBAA0B,EAAE,IAAI,IAAW;AAAA,MAC1D;AAAA,IACF,EAAE,QAAQ;AACV,WAAO,SAAS;AAAA,EAClB;AAEA,QAAM,WAAW,MAAM,GACpB,WAAW,mBAA0B,EACrC,OAAO;AAAA,IACN,aAAa,MAAM;AAAA,IACnB,GAAG;AAAA,EACL,CAAQ,EACP,UAAU,CAAC,IAAW,CAAC,EACvB,QAAQ;AAEX,SAAO,WAAW,CAAC,GAAG,MAAM;AAC9B;AAEA,eAAsB,kBACpB,IACA,OACA,gBACe;AACf,QAAM;AAAA,IACJ,GAAG,YAAY,mBAA0B,EAAE,IAAI;AAAA,MAC7C,iBAAiB,qCAAqC,KAAK,IAAI,GAAG,cAAc,CAAC;AAAA,MACjF,cAAc;AAAA,IAChB,CAAQ;AAAA,IACR;AAAA,EACF,EAAE,QAAQ;AACZ;
|
|
4
|
+
"sourcesContent": ["import { type Kysely, sql } from 'kysely'\n\nexport type JobScope = {\n entityType: string\n organizationId?: string | null\n tenantId?: string | null\n partitionIndex?: number | null\n partitionCount?: number | null\n}\n\nfunction applyScopeWhere<QB extends { where: (...args: any[]) => QB }>(\n builder: QB,\n scope: JobScope,\n): QB {\n let q = builder.where('entity_type' as any, '=', scope.entityType)\n q = q.where(sql`organization_id is not distinct from ${scope.organizationId ?? null}`)\n q = q.where(sql`tenant_id is not distinct from ${scope.tenantId ?? null}`)\n q = q.where(sql`partition_index is not distinct from ${scope.partitionIndex ?? null}`)\n q = q.where(sql`partition_count is not distinct from ${scope.partitionCount ?? null}`)\n return q\n}\n\n// True when ON CONFLICT could not infer an arbiter index (Postgres SQLSTATE\n// 42P10) \u2014 i.e. entity_index_jobs_scope_unique is not present yet. Used to scope\n// prepareJob's fallback to the missing-index case only.\nfunction isMissingConflictArbiterError(err: unknown): boolean {\n const code = (err as { code?: unknown } | null | undefined)?.code\n if (code === '42P10') return true\n const message = (err as { message?: unknown } | null | undefined)?.message\n return typeof message === 'string'\n && /no unique or exclusion constraint matching the on conflict/i.test(message)\n}\n\nexport async function prepareJob(\n db: Kysely<any>,\n scope: JobScope,\n status: 'reindexing' | 'purging',\n options: { totalCount?: number | null } = {},\n): Promise<string | null> {\n const base = {\n organization_id: scope.organizationId ?? null,\n tenant_id: scope.tenantId ?? null,\n partition_index: scope.partitionIndex ?? null,\n partition_count: scope.partitionCount ?? null,\n status,\n started_at: sql`now()`,\n finished_at: null,\n heartbeat_at: sql`now()`,\n processed_count: 0,\n total_count: options.totalCount ?? null,\n }\n\n // Single atomic upsert keyed by the coalesced scope tuple. This closes the\n // read-then-write race where two concurrent schedulers each see \"no row\" and\n // both INSERT a duplicate for the same scope, after which updateJobProgress /\n // finalizeJob corrupt each other's progress and finish state (#2739). The\n // ON CONFLICT target must match entity_index_jobs_scope_unique exactly.\n try {\n const upserted = await db\n .insertInto('entity_index_jobs' as any)\n .values({ entity_type: scope.entityType, ...base } as any)\n .onConflict((oc: any) => oc\n .expression(sql`\n \"entity_type\",\n coalesce(\"organization_id\", '00000000-0000-0000-0000-000000000000'::uuid),\n coalesce(\"tenant_id\", '00000000-0000-0000-0000-000000000000'::uuid),\n coalesce(\"partition_index\", -1),\n coalesce(\"partition_count\", -1)\n `)\n .doUpdateSet({\n status,\n started_at: sql`now()`,\n finished_at: null,\n heartbeat_at: sql`now()`,\n processed_count: 0,\n total_count: options.totalCount ?? null,\n } as any))\n .returning(['id' as any])\n .execute() as Array<{ id: string }>\n return upserted?.[0]?.id ?? null\n } catch (err) {\n // Only degrade to the legacy path when the scope unique index is absent\n // (e.g. a rolling deploy running this code against the pre-migration schema):\n // Postgres raises 42P10. Any other failure is real and must surface rather\n // than silently fall back to the racy read-then-write path and re-open #2739.\n if (!isMissingConflictArbiterError(err)) throw err\n return prepareJobLegacy(db, scope, base)\n }\n}\n\n// Pre-#2739 read-then-write path. Retained only as a fallback when the\n// entity_index_jobs scope unique index is unavailable; it carries the original\n// (racy) semantics and must never be reached once the migration has applied.\nasync function prepareJobLegacy(\n db: Kysely<any>,\n scope: JobScope,\n base: Record<string, unknown>,\n): Promise<string | null> {\n const existing = await applyScopeWhere(\n db.selectFrom('entity_index_jobs' as any).select(['id' as any]),\n scope,\n ).executeTakeFirst() as { id: string } | undefined\n\n if (existing) {\n await applyScopeWhere(\n db.updateTable('entity_index_jobs' as any).set(base as any) as any,\n scope,\n ).execute()\n return existing.id\n }\n\n const inserted = await db\n .insertInto('entity_index_jobs' as any)\n .values({\n entity_type: scope.entityType,\n ...base,\n } as any)\n .returning(['id' as any])\n .execute() as Array<{ id: string }>\n\n return inserted?.[0]?.id ?? null\n}\n\nexport async function updateJobProgress(\n db: Kysely<any>,\n scope: JobScope,\n processedDelta: number,\n): Promise<void> {\n await applyScopeWhere(\n db.updateTable('entity_index_jobs' as any).set({\n processed_count: sql`coalesce(processed_count, 0) + ${Math.max(0, processedDelta)}`,\n heartbeat_at: sql`now()`,\n } as any) as any,\n scope,\n ).execute()\n}\n\nexport type FinalizeJobOptions = {\n /**\n * Marks the finished job as failed. `finished_at` alone cannot express this \u2014 readers\n * derive \"completed\" from its presence \u2014 so a run that lost records would otherwise\n * still report success in the status API and UI.\n */\n status?: 'failed'\n}\n\nexport async function finalizeJob(\n db: Kysely<any>,\n scope: JobScope,\n options: FinalizeJobOptions = {},\n): Promise<void> {\n await applyScopeWhere(\n db.updateTable('entity_index_jobs' as any).set({\n finished_at: sql`now()`,\n heartbeat_at: sql`now()`,\n ...(options.status ? { status: options.status } : {}),\n } as any) as any,\n scope,\n ).execute()\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -2,7 +2,13 @@ import { sql } from "kysely";
|
|
|
2
2
|
import { resolveRegisteredEntityTableName } from "@open-mercato/shared/lib/query/engine";
|
|
3
3
|
import { resolveTenantEncryptionService } from "@open-mercato/shared/lib/encryption/customFieldValues";
|
|
4
4
|
import { decryptIndexDocForSearch, encryptIndexDocForStorage } from "@open-mercato/shared/lib/encryption/indexDoc";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
upsertIndexBatch,
|
|
7
|
+
assertIndexBatchWritesLanded,
|
|
8
|
+
createEmptyUpsertIndexBatchResult,
|
|
9
|
+
mergeUpsertIndexBatchResults,
|
|
10
|
+
QueryIndexBatchWriteError
|
|
11
|
+
} from "./batch.js";
|
|
6
12
|
import { refreshCoverageSnapshot, writeCoverageCounts, applyCoverageAdjustments } from "./coverage.js";
|
|
7
13
|
import { prepareJob, updateJobProgress, finalizeJob } from "./jobs.js";
|
|
8
14
|
import { purgeOrphans } from "./stale.js";
|
|
@@ -11,6 +17,7 @@ import { createLogger } from "@open-mercato/shared/lib/logger";
|
|
|
11
17
|
const logger = createLogger("query_index").child({ component: "reindexer" });
|
|
12
18
|
const DEFAULT_REINDEX_PARTITIONS = 5;
|
|
13
19
|
const DEFAULT_BATCH_SIZE = 500;
|
|
20
|
+
const MAX_PURGE_EXCLUSIONS = 1e3;
|
|
14
21
|
const deriveOrgFromId = /* @__PURE__ */ new Set(["directory:organization"]);
|
|
15
22
|
const COVERAGE_REFRESH_THROTTLE_MS = 5 * 60 * 1e3;
|
|
16
23
|
const lastCoverageReset = /* @__PURE__ */ new Map();
|
|
@@ -183,6 +190,8 @@ async function reindexEntity(em, options) {
|
|
|
183
190
|
);
|
|
184
191
|
let processed = 0;
|
|
185
192
|
let lastId = null;
|
|
193
|
+
let jobFailed = false;
|
|
194
|
+
const writeTotals = createEmptyUpsertIndexBatchResult();
|
|
186
195
|
options?.onProgress?.({ processed, total, chunkSize: 0 });
|
|
187
196
|
if (resetCoverage) {
|
|
188
197
|
if (force) {
|
|
@@ -279,9 +288,15 @@ async function reindexEntity(em, options) {
|
|
|
279
288
|
}
|
|
280
289
|
return result;
|
|
281
290
|
};
|
|
282
|
-
await upsertIndexBatch(db, entityType, rows, scopeOverrides, { deriveOrganizationId: deriveOrg, encryptDoc, decryptDoc });
|
|
291
|
+
const batchResult = await upsertIndexBatch(db, entityType, rows, scopeOverrides, { deriveOrganizationId: deriveOrg, encryptDoc, decryptDoc });
|
|
292
|
+
mergeUpsertIndexBatchResults(writeTotals, batchResult);
|
|
293
|
+
if (batchResult.written === 0 && batchResult.attempted > 0) {
|
|
294
|
+
throw new QueryIndexBatchWriteError(entityType, writeTotals);
|
|
295
|
+
}
|
|
296
|
+
const failedInBatch = new Set(batchResult.failedRecordIds);
|
|
297
|
+
const writtenRows = failedInBatch.size ? rows.filter((row) => !failedInBatch.has(String(row.id))) : rows;
|
|
283
298
|
const coverageDeltas = /* @__PURE__ */ new Map();
|
|
284
|
-
for (const row of
|
|
299
|
+
for (const row of writtenRows) {
|
|
285
300
|
const scopeTenant = tenantId !== void 0 ? tenantId ?? null : hasTenantCol ? row.tenant_id ?? null : null;
|
|
286
301
|
const scopeOrg = organizationId !== void 0 ? organizationId ?? null : hasOrgCol ? row.organization_id ?? null : deriveOrg ? deriveOrg(row) ?? null : null;
|
|
287
302
|
const key = scopeKey(scopeTenant ?? null, scopeOrg ?? null);
|
|
@@ -308,7 +323,7 @@ async function reindexEntity(em, options) {
|
|
|
308
323
|
}
|
|
309
324
|
if (emitVectorize && eventBus) {
|
|
310
325
|
await Promise.all(
|
|
311
|
-
|
|
326
|
+
writtenRows.map((row) => {
|
|
312
327
|
const scopeOrg = organizationId !== void 0 ? organizationId ?? null : hasOrgCol ? row.organization_id ?? null : deriveOrg ? deriveOrg(row) ?? null : null;
|
|
313
328
|
const scopeTenant = tenantId !== void 0 ? tenantId ?? null : hasTenantCol ? row.tenant_id ?? null : null;
|
|
314
329
|
return eventBus.emitEvent("query_index.vectorize_one", {
|
|
@@ -320,19 +335,28 @@ async function reindexEntity(em, options) {
|
|
|
320
335
|
})
|
|
321
336
|
);
|
|
322
337
|
}
|
|
323
|
-
processed +=
|
|
338
|
+
processed += batchResult.written;
|
|
324
339
|
lastId = String(rows[rows.length - 1].id);
|
|
325
|
-
options?.onProgress?.({ processed, total, chunkSize:
|
|
326
|
-
await updateJobProgress(db, jobScope,
|
|
340
|
+
options?.onProgress?.({ processed, total, chunkSize: batchResult.written });
|
|
341
|
+
await updateJobProgress(db, jobScope, batchResult.written);
|
|
342
|
+
}
|
|
343
|
+
const purgeExclusions = writeTotals.failedRecordIds;
|
|
344
|
+
if (purgeExclusions.length > MAX_PURGE_EXCLUSIONS) {
|
|
345
|
+
logger.warn("Skipping orphan purge after widespread write failures", {
|
|
346
|
+
entityType,
|
|
347
|
+
failedRecords: purgeExclusions.length
|
|
348
|
+
});
|
|
349
|
+
} else {
|
|
350
|
+
await purgeOrphans(db, {
|
|
351
|
+
entityType,
|
|
352
|
+
tenantId,
|
|
353
|
+
organizationId,
|
|
354
|
+
partitionIndex: usingPartitions ? partitionIndex : null,
|
|
355
|
+
partitionCount: usingPartitions ? partitionCountRaw : null,
|
|
356
|
+
startedAt: jobStartedAt,
|
|
357
|
+
excludeRecordIds: purgeExclusions
|
|
358
|
+
});
|
|
327
359
|
}
|
|
328
|
-
await purgeOrphans(db, {
|
|
329
|
-
entityType,
|
|
330
|
-
tenantId,
|
|
331
|
-
organizationId,
|
|
332
|
-
partitionIndex: usingPartitions ? partitionIndex : null,
|
|
333
|
-
partitionCount: usingPartitions ? partitionCountRaw : null,
|
|
334
|
-
startedAt: jobStartedAt
|
|
335
|
-
});
|
|
336
360
|
if (force && vectorService && (!usingPartitions || partitionIndex === null)) {
|
|
337
361
|
try {
|
|
338
362
|
await vectorService.removeOrphans({
|
|
@@ -361,8 +385,18 @@ async function reindexEntity(em, options) {
|
|
|
361
385
|
}
|
|
362
386
|
);
|
|
363
387
|
}
|
|
388
|
+
if (writeTotals.searchTokenFailures > 0) {
|
|
389
|
+
logger.warn("Search token writes failed during reindex", {
|
|
390
|
+
entityType,
|
|
391
|
+
batches: writeTotals.searchTokenFailures
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
assertIndexBatchWritesLanded(entityType, writeTotals);
|
|
395
|
+
} catch (error) {
|
|
396
|
+
jobFailed = true;
|
|
397
|
+
throw error;
|
|
364
398
|
} finally {
|
|
365
|
-
await finalizeJob(db, jobScope);
|
|
399
|
+
await finalizeJob(db, jobScope, jobFailed ? { status: "failed" } : {});
|
|
366
400
|
}
|
|
367
401
|
return {
|
|
368
402
|
processed,
|