@open-mercato/shared 0.6.6-develop.5586.1.c9ed1d68a8 → 0.6.6-develop.5594.1.30cd738303
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/AGENTS.md +12 -0
- 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
package/AGENTS.md
CHANGED
|
@@ -41,6 +41,7 @@ yarn workspace @open-mercato/shared build
|
|
|
41
41
|
| `crud/` | When building CRUD routes | `@open-mercato/shared/lib/crud` |
|
|
42
42
|
| `custom-fields/` | When handling custom field payloads | `@open-mercato/shared/lib/custom-fields` |
|
|
43
43
|
| `data/` | When you need `DataEngine` or `QueryEngine` types | `@open-mercato/shared/lib/data/engine` |
|
|
44
|
+
| `db/` | When resolving the ORM/connection-pool config (`resolvePoolConfig`, pool/timeout env knobs) | `@open-mercato/shared/lib/db/mikro` |
|
|
44
45
|
| `di/` | When setting up dependency injection (Awilix) | `@open-mercato/shared/lib/di` |
|
|
45
46
|
| `encryption/` | When querying encrypted entities (MUST use instead of raw `em.find`) | `@open-mercato/shared/lib/encryption/find` |
|
|
46
47
|
| `i18n/` | When translating strings — `useT()` client-side, `resolveTranslations()` server-side | `@open-mercato/shared/lib/i18n/context` or `/server` |
|
|
@@ -66,6 +67,17 @@ When you need shared type definitions, import from these:
|
|
|
66
67
|
|
|
67
68
|
## Key Patterns
|
|
68
69
|
|
|
70
|
+
### Database Connection Pool — MUST keep total demand under `max_connections`
|
|
71
|
+
|
|
72
|
+
The MikroORM pool is configured from env via `resolvePoolConfig(process.env)` in
|
|
73
|
+
`@open-mercato/shared/lib/db/mikro` (pure and unit-testable). Each process gets
|
|
74
|
+
its own pool (`DB_POOL_MAX`, default 20). Because background worker jobs each run
|
|
75
|
+
in their own request container (one pooled connection per in-flight job), the
|
|
76
|
+
peak connection demand of all processes against one database is additive.
|
|
77
|
+
|
|
78
|
+
- **Invariant:** `web_pool_max + worker_pool_max + scheduler/overhead ≤ Postgres max_connections` (with headroom). Violating it lets background work starve the request/onboarding path — see `packages/queue/AGENTS.md` → Connection Budget.
|
|
79
|
+
- Tune per process with `DB_POOL_MAX` (and the opt-in `DB_STATEMENT_TIMEOUT_MS` / `DB_LOCK_TIMEOUT_MS`; `idle_in_transaction` defaults to a finite 120s). Bound the worker's job concurrency with `OM_WORKERS_DB_CONNECTION_BUDGET`.
|
|
80
|
+
|
|
69
81
|
### Encryption — MUST use instead of raw ORM queries
|
|
70
82
|
|
|
71
83
|
```typescript
|
|
@@ -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.5594.1.30cd738303'\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.5594.1.30cd738303",
|
|
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.5594.1.30cd738303",
|
|
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
|
}
|