@open-mercato/shared 0.6.6-develop.5588.1.a8f6c51d1f → 0.6.6-develop.5598.1.5e7d48d297
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/lib/indexers/status-log.js +10 -2
- package/dist/lib/indexers/status-log.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/package.json +2 -2
- package/src/lib/indexers/__tests__/status-log.test.ts +94 -0
- package/src/lib/indexers/status-log.ts +23 -2
|
@@ -29,6 +29,10 @@ function pickDb(deps) {
|
|
|
29
29
|
}
|
|
30
30
|
return null;
|
|
31
31
|
}
|
|
32
|
+
function isInactiveTransactionError(error) {
|
|
33
|
+
const message = error instanceof Error ? error.message : String(error ?? "");
|
|
34
|
+
return /transaction is already (committed|rolled back)/i.test(message);
|
|
35
|
+
}
|
|
32
36
|
async function pruneExcessLogs(db, source) {
|
|
33
37
|
const rows = await db.selectFrom("indexer_status_logs").select("id").where("source", "=", source).orderBy("occurred_at", "desc").orderBy("id", "desc").offset(MAX_LOGS_PER_SOURCE).limit(MAX_DELETE_BATCH).execute();
|
|
34
38
|
if (!rows.length) return;
|
|
@@ -63,13 +67,17 @@ async function recordIndexerLog(deps, input) {
|
|
|
63
67
|
occurred_at: occurredAt
|
|
64
68
|
}).execute();
|
|
65
69
|
} catch (error) {
|
|
66
|
-
|
|
70
|
+
if (!isInactiveTransactionError(error)) {
|
|
71
|
+
console.error("[indexers] Failed to persist indexer log", error);
|
|
72
|
+
}
|
|
67
73
|
return;
|
|
68
74
|
}
|
|
69
75
|
try {
|
|
70
76
|
await pruneExcessLogs(db, input.source);
|
|
71
77
|
} catch (pruneError) {
|
|
72
|
-
|
|
78
|
+
if (!isInactiveTransactionError(pruneError)) {
|
|
79
|
+
console.warn("[indexers] Failed to prune indexer logs", pruneError);
|
|
80
|
+
}
|
|
73
81
|
}
|
|
74
82
|
}
|
|
75
83
|
export {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/lib/indexers/status-log.ts"],
|
|
4
|
-
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { type Kysely, sql } from 'kysely'\nimport type { IndexerErrorSource } from './error-log'\n\nexport type IndexerLogLevel = 'info' | 'warn'\n\nexport type RecordIndexerLogInput = {\n source: IndexerErrorSource\n handler: string\n message: string\n level?: IndexerLogLevel\n entityType?: string | null\n recordId?: string | null\n tenantId?: string | null\n organizationId?: string | null\n details?: unknown\n}\n\ntype RecordIndexerLogDeps = {\n em?: EntityManager\n db?: Kysely<any>\n}\n\nconst MAX_MESSAGE_LENGTH = 4_096\nconst MAX_DELETE_BATCH = 5_000\nconst MAX_LOGS_PER_SOURCE = 10_000\n\nfunction truncate(input: string | null | undefined, limit: number): string | null {\n if (!input) return null\n return input.length > limit ? `${input.slice(0, limit - 3)}...` : input\n}\n\nfunction safeJson(value: unknown): unknown {\n if (value === undefined) return null\n try {\n return JSON.parse(JSON.stringify(value))\n } catch {\n if (value == null) return null\n if (typeof value === 'object') {\n return { note: 'unserializable', asString: String(value) }\n }\n return value\n }\n}\n\nfunction pickDb(deps: RecordIndexerLogDeps): Kysely<any> | null {\n if (deps.db) return deps.db\n if (deps.em) {\n try {\n return deps.em.getKysely<any>()\n } catch {\n return null\n }\n }\n return null\n}\n\nasync function pruneExcessLogs(db: Kysely<any>, source: IndexerErrorSource): Promise<void> {\n const rows = await db\n .selectFrom('indexer_status_logs' as any)\n .select('id' as any)\n .where('source' as any, '=', source)\n .orderBy('occurred_at' as any, 'desc')\n .orderBy('id' as any, 'desc')\n .offset(MAX_LOGS_PER_SOURCE)\n .limit(MAX_DELETE_BATCH)\n .execute()\n\n if (!rows.length) return\n const ids = rows.map((row: any) => row.id).filter(Boolean)\n if (!ids.length) return\n await db\n .deleteFrom('indexer_status_logs' as any)\n .where('id' as any, 'in', ids)\n .execute()\n}\n\nexport async function recordIndexerLog(\n deps: RecordIndexerLogDeps,\n input: RecordIndexerLogInput,\n): Promise<void> {\n const db = pickDb(deps)\n if (!db) {\n console.warn('[indexers] Unable to record indexer log (missing db connection)', {\n source: input.source,\n handler: input.handler,\n })\n return\n }\n\n const level: IndexerLogLevel = input.level === 'warn' ? 'warn' : 'info'\n const message = truncate(input.message, MAX_MESSAGE_LENGTH) ?? '\u2014'\n const details = safeJson(input.details)\n const occurredAt = new Date()\n\n try {\n await db\n .insertInto('indexer_status_logs' as any)\n .values({\n source: input.source,\n handler: input.handler,\n level,\n entity_type: input.entityType ?? null,\n record_id: input.recordId ?? null,\n tenant_id: input.tenantId ?? null,\n organization_id: input.organizationId ?? null,\n message,\n details: details === null ? null : sql`${JSON.stringify(details)}::jsonb`,\n occurred_at: occurredAt,\n } as any)\n .execute()\n } catch (error) {\n console.error('[indexers] Failed to persist indexer log', error)\n return\n }\n\n try {\n await pruneExcessLogs(db, input.source)\n } catch (pruneError) {\n console.warn('[indexers] Failed to prune indexer logs', pruneError)\n }\n}\n"],
|
|
5
|
-
"mappings": "AACA,SAAsB,WAAW;AAsBjC,MAAM,qBAAqB;AAC3B,MAAM,mBAAmB;AACzB,MAAM,sBAAsB;AAE5B,SAAS,SAAS,OAAkC,OAA8B;AAChF,MAAI,CAAC,MAAO,QAAO;AACnB,SAAO,MAAM,SAAS,QAAQ,GAAG,MAAM,MAAM,GAAG,QAAQ,CAAC,CAAC,QAAQ;AACpE;AAEA,SAAS,SAAS,OAAyB;AACzC,MAAI,UAAU,OAAW,QAAO;AAChC,MAAI;AACF,WAAO,KAAK,MAAM,KAAK,UAAU,KAAK,CAAC;AAAA,EACzC,QAAQ;AACN,QAAI,SAAS,KAAM,QAAO;AAC1B,QAAI,OAAO,UAAU,UAAU;AAC7B,aAAO,EAAE,MAAM,kBAAkB,UAAU,OAAO,KAAK,EAAE;AAAA,IAC3D;AACA,WAAO;AAAA,EACT;AACF;AAEA,SAAS,OAAO,MAAgD;AAC9D,MAAI,KAAK,GAAI,QAAO,KAAK;AACzB,MAAI,KAAK,IAAI;AACX,QAAI;AACF,aAAO,KAAK,GAAG,UAAe;AAAA,IAChC,QAAQ;AACN,aAAO;AAAA,IACT;AAAA,EACF;AACA,SAAO;AACT;AAEA,eAAe,gBAAgB,IAAiB,QAA2C;AACzF,QAAM,OAAO,MAAM,GAChB,WAAW,qBAA4B,EACvC,OAAO,IAAW,EAClB,MAAM,UAAiB,KAAK,MAAM,EAClC,QAAQ,eAAsB,MAAM,EACpC,QAAQ,MAAa,MAAM,EAC3B,OAAO,mBAAmB,EAC1B,MAAM,gBAAgB,EACtB,QAAQ;AAEX,MAAI,CAAC,KAAK,OAAQ;AAClB,QAAM,MAAM,KAAK,IAAI,CAAC,QAAa,IAAI,EAAE,EAAE,OAAO,OAAO;AACzD,MAAI,CAAC,IAAI,OAAQ;AACjB,QAAM,GACH,WAAW,qBAA4B,EACvC,MAAM,MAAa,MAAM,GAAG,EAC5B,QAAQ;AACb;AAEA,eAAsB,iBACpB,MACA,OACe;AACf,QAAM,KAAK,OAAO,IAAI;AACtB,MAAI,CAAC,IAAI;AACP,YAAQ,KAAK,mEAAmE;AAAA,MAC9E,QAAQ,MAAM;AAAA,MACd,SAAS,MAAM;AAAA,IACjB,CAAC;AACD;AAAA,EACF;AAEA,QAAM,QAAyB,MAAM,UAAU,SAAS,SAAS;AACjE,QAAM,UAAU,SAAS,MAAM,SAAS,kBAAkB,KAAK;AAC/D,QAAM,UAAU,SAAS,MAAM,OAAO;AACtC,QAAM,aAAa,oBAAI,KAAK;AAE5B,MAAI;AACF,UAAM,GACH,WAAW,qBAA4B,EACvC,OAAO;AAAA,MACN,QAAQ,MAAM;AAAA,MACd,SAAS,MAAM;AAAA,MACf;AAAA,MACA,aAAa,MAAM,cAAc;AAAA,MACjC,WAAW,MAAM,YAAY;AAAA,MAC7B,WAAW,MAAM,YAAY;AAAA,MAC7B,iBAAiB,MAAM,kBAAkB;AAAA,MACzC;AAAA,MACA,SAAS,YAAY,OAAO,OAAO,MAAM,KAAK,UAAU,OAAO,CAAC;AAAA,MAChE,aAAa;AAAA,IACf,CAAQ,EACP,QAAQ;AAAA,EACb,SAAS,OAAO;
|
|
4
|
+
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { type Kysely, sql } from 'kysely'\nimport type { IndexerErrorSource } from './error-log'\n\nexport type IndexerLogLevel = 'info' | 'warn'\n\nexport type RecordIndexerLogInput = {\n source: IndexerErrorSource\n handler: string\n message: string\n level?: IndexerLogLevel\n entityType?: string | null\n recordId?: string | null\n tenantId?: string | null\n organizationId?: string | null\n details?: unknown\n}\n\ntype RecordIndexerLogDeps = {\n em?: EntityManager\n db?: Kysely<any>\n}\n\nconst MAX_MESSAGE_LENGTH = 4_096\nconst MAX_DELETE_BATCH = 5_000\nconst MAX_LOGS_PER_SOURCE = 10_000\n\nfunction truncate(input: string | null | undefined, limit: number): string | null {\n if (!input) return null\n return input.length > limit ? `${input.slice(0, limit - 3)}...` : input\n}\n\nfunction safeJson(value: unknown): unknown {\n if (value === undefined) return null\n try {\n return JSON.parse(JSON.stringify(value))\n } catch {\n if (value == null) return null\n if (typeof value === 'object') {\n return { note: 'unserializable', asString: String(value) }\n }\n return value\n }\n}\n\nfunction pickDb(deps: RecordIndexerLogDeps): Kysely<any> | null {\n if (deps.db) return deps.db\n if (deps.em) {\n try {\n return deps.em.getKysely<any>()\n } catch {\n return null\n }\n }\n return null\n}\n\n/**\n * Indexer status logging is best-effort observability and must never ride on \u2014\n * or be noisy about \u2014 the caller's transaction lifecycle. When index maintenance\n * runs inline on a request `em` (e.g. an inline force reindex that emits the\n * vector-purge subscriber), the captured Kysely can be a transaction handle that\n * has already committed/rolled back, so a follow-up read/write throws\n * \"Transaction is already committed\". Treat that class of error as a quiet skip\n * rather than an error the operator must triage. A fresh-EM path (the events\n * worker) never hits this; this guard only de-noises the inline path.\n */\nfunction isInactiveTransactionError(error: unknown): boolean {\n const message = error instanceof Error ? error.message : String(error ?? '')\n return /transaction is already (committed|rolled back)/i.test(message)\n}\n\nasync function pruneExcessLogs(db: Kysely<any>, source: IndexerErrorSource): Promise<void> {\n const rows = await db\n .selectFrom('indexer_status_logs' as any)\n .select('id' as any)\n .where('source' as any, '=', source)\n .orderBy('occurred_at' as any, 'desc')\n .orderBy('id' as any, 'desc')\n .offset(MAX_LOGS_PER_SOURCE)\n .limit(MAX_DELETE_BATCH)\n .execute()\n\n if (!rows.length) return\n const ids = rows.map((row: any) => row.id).filter(Boolean)\n if (!ids.length) return\n await db\n .deleteFrom('indexer_status_logs' as any)\n .where('id' as any, 'in', ids)\n .execute()\n}\n\nexport async function recordIndexerLog(\n deps: RecordIndexerLogDeps,\n input: RecordIndexerLogInput,\n): Promise<void> {\n const db = pickDb(deps)\n if (!db) {\n console.warn('[indexers] Unable to record indexer log (missing db connection)', {\n source: input.source,\n handler: input.handler,\n })\n return\n }\n\n const level: IndexerLogLevel = input.level === 'warn' ? 'warn' : 'info'\n const message = truncate(input.message, MAX_MESSAGE_LENGTH) ?? '\u2014'\n const details = safeJson(input.details)\n const occurredAt = new Date()\n\n try {\n await db\n .insertInto('indexer_status_logs' as any)\n .values({\n source: input.source,\n handler: input.handler,\n level,\n entity_type: input.entityType ?? null,\n record_id: input.recordId ?? null,\n tenant_id: input.tenantId ?? null,\n organization_id: input.organizationId ?? null,\n message,\n details: details === null ? null : sql`${JSON.stringify(details)}::jsonb`,\n occurred_at: occurredAt,\n } as any)\n .execute()\n } catch (error) {\n // A committed/rolled-back caller transaction is an expected, harmless\n // condition for best-effort logging \u2014 skip quietly instead of surfacing it.\n if (!isInactiveTransactionError(error)) {\n console.error('[indexers] Failed to persist indexer log', error)\n }\n return\n }\n\n try {\n await pruneExcessLogs(db, input.source)\n } catch (pruneError) {\n if (!isInactiveTransactionError(pruneError)) {\n console.warn('[indexers] Failed to prune indexer logs', pruneError)\n }\n }\n}\n"],
|
|
5
|
+
"mappings": "AACA,SAAsB,WAAW;AAsBjC,MAAM,qBAAqB;AAC3B,MAAM,mBAAmB;AACzB,MAAM,sBAAsB;AAE5B,SAAS,SAAS,OAAkC,OAA8B;AAChF,MAAI,CAAC,MAAO,QAAO;AACnB,SAAO,MAAM,SAAS,QAAQ,GAAG,MAAM,MAAM,GAAG,QAAQ,CAAC,CAAC,QAAQ;AACpE;AAEA,SAAS,SAAS,OAAyB;AACzC,MAAI,UAAU,OAAW,QAAO;AAChC,MAAI;AACF,WAAO,KAAK,MAAM,KAAK,UAAU,KAAK,CAAC;AAAA,EACzC,QAAQ;AACN,QAAI,SAAS,KAAM,QAAO;AAC1B,QAAI,OAAO,UAAU,UAAU;AAC7B,aAAO,EAAE,MAAM,kBAAkB,UAAU,OAAO,KAAK,EAAE;AAAA,IAC3D;AACA,WAAO;AAAA,EACT;AACF;AAEA,SAAS,OAAO,MAAgD;AAC9D,MAAI,KAAK,GAAI,QAAO,KAAK;AACzB,MAAI,KAAK,IAAI;AACX,QAAI;AACF,aAAO,KAAK,GAAG,UAAe;AAAA,IAChC,QAAQ;AACN,aAAO;AAAA,IACT;AAAA,EACF;AACA,SAAO;AACT;AAYA,SAAS,2BAA2B,OAAyB;AAC3D,QAAM,UAAU,iBAAiB,QAAQ,MAAM,UAAU,OAAO,SAAS,EAAE;AAC3E,SAAO,kDAAkD,KAAK,OAAO;AACvE;AAEA,eAAe,gBAAgB,IAAiB,QAA2C;AACzF,QAAM,OAAO,MAAM,GAChB,WAAW,qBAA4B,EACvC,OAAO,IAAW,EAClB,MAAM,UAAiB,KAAK,MAAM,EAClC,QAAQ,eAAsB,MAAM,EACpC,QAAQ,MAAa,MAAM,EAC3B,OAAO,mBAAmB,EAC1B,MAAM,gBAAgB,EACtB,QAAQ;AAEX,MAAI,CAAC,KAAK,OAAQ;AAClB,QAAM,MAAM,KAAK,IAAI,CAAC,QAAa,IAAI,EAAE,EAAE,OAAO,OAAO;AACzD,MAAI,CAAC,IAAI,OAAQ;AACjB,QAAM,GACH,WAAW,qBAA4B,EACvC,MAAM,MAAa,MAAM,GAAG,EAC5B,QAAQ;AACb;AAEA,eAAsB,iBACpB,MACA,OACe;AACf,QAAM,KAAK,OAAO,IAAI;AACtB,MAAI,CAAC,IAAI;AACP,YAAQ,KAAK,mEAAmE;AAAA,MAC9E,QAAQ,MAAM;AAAA,MACd,SAAS,MAAM;AAAA,IACjB,CAAC;AACD;AAAA,EACF;AAEA,QAAM,QAAyB,MAAM,UAAU,SAAS,SAAS;AACjE,QAAM,UAAU,SAAS,MAAM,SAAS,kBAAkB,KAAK;AAC/D,QAAM,UAAU,SAAS,MAAM,OAAO;AACtC,QAAM,aAAa,oBAAI,KAAK;AAE5B,MAAI;AACF,UAAM,GACH,WAAW,qBAA4B,EACvC,OAAO;AAAA,MACN,QAAQ,MAAM;AAAA,MACd,SAAS,MAAM;AAAA,MACf;AAAA,MACA,aAAa,MAAM,cAAc;AAAA,MACjC,WAAW,MAAM,YAAY;AAAA,MAC7B,WAAW,MAAM,YAAY;AAAA,MAC7B,iBAAiB,MAAM,kBAAkB;AAAA,MACzC;AAAA,MACA,SAAS,YAAY,OAAO,OAAO,MAAM,KAAK,UAAU,OAAO,CAAC;AAAA,MAChE,aAAa;AAAA,IACf,CAAQ,EACP,QAAQ;AAAA,EACb,SAAS,OAAO;AAGd,QAAI,CAAC,2BAA2B,KAAK,GAAG;AACtC,cAAQ,MAAM,4CAA4C,KAAK;AAAA,IACjE;AACA;AAAA,EACF;AAEA,MAAI;AACF,UAAM,gBAAgB,IAAI,MAAM,MAAM;AAAA,EACxC,SAAS,YAAY;AACnB,QAAI,CAAC,2BAA2B,UAAU,GAAG;AAC3C,cAAQ,KAAK,2CAA2C,UAAU;AAAA,IACpE;AAAA,EACF;AACF;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/dist/lib/version.js
CHANGED
package/dist/lib/version.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/lib/version.ts"],
|
|
4
|
-
"sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.6-develop.
|
|
4
|
+
"sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.6-develop.5598.1.5e7d48d297'\nexport const appVersion = APP_VERSION\n"],
|
|
5
5
|
"mappings": "AACO,MAAM,cAAc;AACpB,MAAM,aAAa;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/shared",
|
|
3
|
-
"version": "0.6.6-develop.
|
|
3
|
+
"version": "0.6.6-develop.5598.1.5e7d48d297",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -92,7 +92,7 @@
|
|
|
92
92
|
"@mikro-orm/core": "^7.1.4",
|
|
93
93
|
"@mikro-orm/decorators": "^7.1.4",
|
|
94
94
|
"@mikro-orm/postgresql": "^7.1.4",
|
|
95
|
-
"@open-mercato/cache": "0.6.6-develop.
|
|
95
|
+
"@open-mercato/cache": "0.6.6-develop.5598.1.5e7d48d297",
|
|
96
96
|
"dotenv": "^17.4.2",
|
|
97
97
|
"rate-limiter-flexible": "^11.2.0",
|
|
98
98
|
"re2js": "2.8.3",
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { recordIndexerLog } from '../status-log'
|
|
2
|
+
|
|
3
|
+
type Behaviors = {
|
|
4
|
+
insert?: () => unknown
|
|
5
|
+
select?: () => unknown
|
|
6
|
+
delete?: () => unknown
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function makeBuilder(op: keyof Behaviors, behaviors: Behaviors): any {
|
|
10
|
+
const builder: any = {}
|
|
11
|
+
for (const method of ['values', 'select', 'where', 'orderBy', 'offset', 'limit']) {
|
|
12
|
+
builder[method] = () => builder
|
|
13
|
+
}
|
|
14
|
+
builder.execute = async () => {
|
|
15
|
+
const behavior = behaviors[op]
|
|
16
|
+
return behavior ? behavior() : []
|
|
17
|
+
}
|
|
18
|
+
return builder
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function makeDb(behaviors: Behaviors): any {
|
|
22
|
+
return {
|
|
23
|
+
insertInto: () => makeBuilder('insert', behaviors),
|
|
24
|
+
selectFrom: () => makeBuilder('select', behaviors),
|
|
25
|
+
deleteFrom: () => makeBuilder('delete', behaviors),
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const INPUT = { source: 'vector' as const, handler: 'event:test', message: 'hi' }
|
|
30
|
+
|
|
31
|
+
describe('recordIndexerLog — inactive-transaction de-noising', () => {
|
|
32
|
+
let errorSpy: jest.SpyInstance
|
|
33
|
+
let warnSpy: jest.SpyInstance
|
|
34
|
+
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
|
|
37
|
+
warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
errorSpy.mockRestore()
|
|
42
|
+
warnSpy.mockRestore()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('skips quietly when the insert hits an already-committed transaction', async () => {
|
|
46
|
+
const db = makeDb({
|
|
47
|
+
insert: () => {
|
|
48
|
+
throw new Error('Transaction is already committed')
|
|
49
|
+
},
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
await recordIndexerLog({ db }, INPUT)
|
|
53
|
+
|
|
54
|
+
expect(errorSpy).not.toHaveBeenCalled()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('still logs an unexpected insert failure', async () => {
|
|
58
|
+
const db = makeDb({
|
|
59
|
+
insert: () => {
|
|
60
|
+
throw new Error('connection refused')
|
|
61
|
+
},
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
await recordIndexerLog({ db }, INPUT)
|
|
65
|
+
|
|
66
|
+
expect(errorSpy).toHaveBeenCalledTimes(1)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('skips quietly when the prune hits a rolled-back transaction', async () => {
|
|
70
|
+
const db = makeDb({
|
|
71
|
+
insert: () => undefined,
|
|
72
|
+
select: () => {
|
|
73
|
+
throw new Error('Transaction is already rolled back')
|
|
74
|
+
},
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
await recordIndexerLog({ db }, INPUT)
|
|
78
|
+
|
|
79
|
+
expect(warnSpy).not.toHaveBeenCalled()
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('still warns on an unexpected prune failure', async () => {
|
|
83
|
+
const db = makeDb({
|
|
84
|
+
insert: () => undefined,
|
|
85
|
+
select: () => {
|
|
86
|
+
throw new Error('deadlock detected')
|
|
87
|
+
},
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
await recordIndexerLog({ db }, INPUT)
|
|
91
|
+
|
|
92
|
+
expect(warnSpy).toHaveBeenCalledTimes(1)
|
|
93
|
+
})
|
|
94
|
+
})
|
|
@@ -55,6 +55,21 @@ function pickDb(deps: RecordIndexerLogDeps): Kysely<any> | null {
|
|
|
55
55
|
return null
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Indexer status logging is best-effort observability and must never ride on —
|
|
60
|
+
* or be noisy about — the caller's transaction lifecycle. When index maintenance
|
|
61
|
+
* runs inline on a request `em` (e.g. an inline force reindex that emits the
|
|
62
|
+
* vector-purge subscriber), the captured Kysely can be a transaction handle that
|
|
63
|
+
* has already committed/rolled back, so a follow-up read/write throws
|
|
64
|
+
* "Transaction is already committed". Treat that class of error as a quiet skip
|
|
65
|
+
* rather than an error the operator must triage. A fresh-EM path (the events
|
|
66
|
+
* worker) never hits this; this guard only de-noises the inline path.
|
|
67
|
+
*/
|
|
68
|
+
function isInactiveTransactionError(error: unknown): boolean {
|
|
69
|
+
const message = error instanceof Error ? error.message : String(error ?? '')
|
|
70
|
+
return /transaction is already (committed|rolled back)/i.test(message)
|
|
71
|
+
}
|
|
72
|
+
|
|
58
73
|
async function pruneExcessLogs(db: Kysely<any>, source: IndexerErrorSource): Promise<void> {
|
|
59
74
|
const rows = await db
|
|
60
75
|
.selectFrom('indexer_status_logs' as any)
|
|
@@ -110,13 +125,19 @@ export async function recordIndexerLog(
|
|
|
110
125
|
} as any)
|
|
111
126
|
.execute()
|
|
112
127
|
} catch (error) {
|
|
113
|
-
|
|
128
|
+
// A committed/rolled-back caller transaction is an expected, harmless
|
|
129
|
+
// condition for best-effort logging — skip quietly instead of surfacing it.
|
|
130
|
+
if (!isInactiveTransactionError(error)) {
|
|
131
|
+
console.error('[indexers] Failed to persist indexer log', error)
|
|
132
|
+
}
|
|
114
133
|
return
|
|
115
134
|
}
|
|
116
135
|
|
|
117
136
|
try {
|
|
118
137
|
await pruneExcessLogs(db, input.source)
|
|
119
138
|
} catch (pruneError) {
|
|
120
|
-
|
|
139
|
+
if (!isInactiveTransactionError(pruneError)) {
|
|
140
|
+
console.warn('[indexers] Failed to prune indexer logs', pruneError)
|
|
141
|
+
}
|
|
121
142
|
}
|
|
122
143
|
}
|