@cosmicdrift/kumiko-framework 0.235.1 → 0.235.3

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.
@@ -75,6 +75,20 @@ export const writeHandlerSchema: PatternFormSchema = {
75
75
  readOnly: true,
76
76
  },
77
77
  accessRuleField,
78
+ {
79
+ path: "description",
80
+ label: { en: "Description", de: "Beschreibung" },
81
+ hint: {
82
+ en: "Prose the AI agent sees as the tool description. Without it the handler stays invisible to the agent.",
83
+ },
84
+ input: "textarea",
85
+ },
86
+ {
87
+ path: "agent",
88
+ label: { en: "Agent hints", de: "Agent-Hinweise" },
89
+ hint: { en: "expose override + risk level for the AI-agent manifest." },
90
+ input: "json-readonly",
91
+ },
78
92
  {
79
93
  path: "rateLimit",
80
94
  label: { en: "Rate limit", de: "Rate-Limit" },
@@ -117,6 +131,20 @@ export const queryHandlerSchema: PatternFormSchema = {
117
131
  readOnly: true,
118
132
  },
119
133
  accessRuleField,
134
+ {
135
+ path: "description",
136
+ label: { en: "Description", de: "Beschreibung" },
137
+ hint: {
138
+ en: "Prose the AI agent sees as the tool description. Without it the handler stays invisible to the agent.",
139
+ },
140
+ input: "textarea",
141
+ },
142
+ {
143
+ path: "agent",
144
+ label: { en: "Agent hints", de: "Agent-Hinweise" },
145
+ hint: { en: "expose override + risk level for the AI-agent manifest." },
146
+ input: "json-readonly",
147
+ },
120
148
  {
121
149
  path: "rateLimit",
122
150
  label: { en: "Rate limit", de: "Rate-Limit" },
@@ -73,6 +73,10 @@ export function buildRegistryFacade(state: RegistryState): Registry {
73
73
  return state.queryHandlerMap;
74
74
  },
75
75
 
76
+ getAllWriteHandlers(): ReadonlyMap<string, WriteHandlerDef> {
77
+ return state.writeHandlerMap;
78
+ },
79
+
76
80
  getStreamHandler(name: string): StreamHandlerDef | undefined {
77
81
  return state.streamHandlerMap.get(name);
78
82
  },
@@ -1,6 +1,7 @@
1
1
  // Barrel: re-exports all types from @cosmicdrift/kumiko-types, plus the
2
2
  // runtime helpers below that stay framework-side.
3
3
 
4
+ export { resolveAgentExposure } from "@cosmicdrift/kumiko-types/agent-exposure";
4
5
  export type {
5
6
  ConfigAccessor,
6
7
  ConfigAccessorFactory,
@@ -133,6 +134,9 @@ export type {
133
134
  } from "@cosmicdrift/kumiko-types/fields";
134
135
  export type {
135
136
  AccessRule,
137
+ AgentExposure,
138
+ AgentHandlerHints,
139
+ AgentRisk,
136
140
  AggregateStreamHandle,
137
141
  AppContext,
138
142
  AppendEventArgs,
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { NotFoundError } from "../classes";
3
- import { FrameworkReasons } from "../reasons";
3
+ import { AgentReasons as BarrelAgentReasons } from "../index";
4
+ import { AgentReasons, FrameworkReasons } from "../reasons";
4
5
  import { buildInvalidTransitionDetails } from "../transition-details";
5
6
  import { reraiseAsKumikoError, toWriteErrorInfo, writeFailure } from "../write-error-info";
6
7
 
@@ -42,3 +43,16 @@ describe("FrameworkReasons", () => {
42
43
  expect(FrameworkReasons.staleState).toBe("stale_state");
43
44
  });
44
45
  });
46
+
47
+ describe("AgentReasons", () => {
48
+ test("exposes stable agent-prefixed reason codes", () => {
49
+ expect(AgentReasons.toolNotAllowed).toBe("agent.tool_not_allowed");
50
+ expect(AgentReasons.iterationLimit).toBe("agent.iteration_limit");
51
+ expect(AgentReasons.permissionDenied).toBe("agent.permission_denied");
52
+ expect(AgentReasons.highRiskNoAlways).toBe("agent.high_risk_no_always");
53
+ });
54
+
55
+ test("is importable from the errors barrel", () => {
56
+ expect(BarrelAgentReasons).toBe(AgentReasons);
57
+ });
58
+ });
@@ -25,8 +25,8 @@ export {
25
25
  } from "./classes";
26
26
  export type { ErrorCtorInput, ErrorOpts } from "./kumiko-error";
27
27
  export { isKumikoError, KumikoError } from "./kumiko-error";
28
- export type { FrameworkReason } from "./reasons";
29
- export { FrameworkReasons } from "./reasons";
28
+ export type { AgentReason, FrameworkReason } from "./reasons";
29
+ export { AgentReasons, FrameworkReasons } from "./reasons";
30
30
  export type { ErrorLogEntry, ErrorResponseBody } from "./serialize";
31
31
  export { buildErrorLog, serializeError } from "./serialize";
32
32
  export { toKumikoError } from "./to-kumiko-error";
@@ -8,6 +8,13 @@
8
8
  // framework deliberately does NOT enforce uniqueness across features — two
9
9
  // features may use the same reason string if the semantics match. The
10
10
  // convention: snake_case, no spaces, no feature prefix for framework reasons.
11
+ //
12
+ // AgentReasons below is a deliberate exception to "no feature prefix": those
13
+ // reasons surface from the AI-agent runtime (tool dispatch, permission and
14
+ // risk gates), not from a handler's own domain logic, and the `agent.`
15
+ // prefix keeps them visibly distinct from FrameworkReasons in error payloads.
16
+ // Both the docs site (`errors/` pages) and the enterprise repo read this same
17
+ // source, so the prefix stays a naming convention here, not a new registry.
11
18
 
12
19
  export const FrameworkReasons = {
13
20
  // ConflictError: atomic UPDATE lost the race (another writer moved the row
@@ -34,3 +41,26 @@ export const FrameworkReasons = {
34
41
  } as const;
35
42
 
36
43
  export type FrameworkReason = (typeof FrameworkReasons)[keyof typeof FrameworkReasons];
44
+
45
+ // Reason codes the AI-agent runtime surfaces via `details.reason`. Kept as a
46
+ // sister catalog to FrameworkReasons instead of extending it — see the
47
+ // file-header note above for why these carry the `agent.` prefix.
48
+ export const AgentReasons = {
49
+ // AccessDeniedError: the agent called a tool name outside its allowed
50
+ // catalog for this run.
51
+ toolNotAllowed: "agent.tool_not_allowed",
52
+
53
+ // UnprocessableError: the agent loop hit its maximum round count before
54
+ // producing a final answer.
55
+ iterationLimit: "agent.iteration_limit",
56
+
57
+ // AccessDeniedError: the tool exists, but the caller's roles don't cover
58
+ // the handler behind it.
59
+ permissionDenied: "agent.permission_denied",
60
+
61
+ // AccessDeniedError: an `agent.risk: "high"` handler was invoked without
62
+ // the required explicit confirmation.
63
+ highRiskNoAlways: "agent.high_risk_no_always",
64
+ } as const;
65
+
66
+ export type AgentReason = (typeof AgentReasons)[keyof typeof AgentReasons];
@@ -55,6 +55,13 @@ const countsTable = pgTable("read_pending_counts", {
55
55
  itemCount: integer("item_count").notNull().default(0),
56
56
  });
57
57
 
58
+ // Exists in the DB but has no registered projection — models an owning
59
+ // feature missing from the composition (distinct from a deliberately DROPped
60
+ // table, which is never pushed at all).
61
+ const orphanProjectionTable = pgTable("read_orphan_projection", {
62
+ groupId: uuid("group_id").primaryKey(),
63
+ });
64
+
58
65
  // Steuerbarer Fail: simuliert einen transienten Rebuild-Fehler.
59
66
  let failApply = false;
60
67
 
@@ -92,7 +99,10 @@ beforeAll(async () => {
92
99
  await unsafeCreateEntityTable(testDb.db, itemEntity, "pending-item");
93
100
  await createEventsTable(testDb.db);
94
101
  await createProjectionStateTable(testDb.db);
95
- await unsafePushTables(testDb.db, { readPendingCounts: countsTable });
102
+ await unsafePushTables(testDb.db, {
103
+ readPendingCounts: countsTable,
104
+ readOrphanProjection: orphanProjectionTable,
105
+ });
96
106
  tdb = createTenantDb(testDb.db, admin.tenantId);
97
107
  markerDir = mkdtempSync(join(tmpdir(), "pending-rebuilds-"));
98
108
  });
@@ -105,7 +115,7 @@ afterAll(async () => {
105
115
  beforeEach(async () => {
106
116
  failApply = false;
107
117
  await asRawClient(testDb.db).unsafe(
108
- `TRUNCATE kumiko_events, read_pending_items, read_pending_counts, kumiko_projections RESTART IDENTITY CASCADE`,
118
+ `TRUNCATE kumiko_events, read_pending_items, read_pending_counts, read_orphan_projection, kumiko_projections RESTART IDENTITY CASCADE`,
109
119
  );
110
120
  await asRawClient(testDb.db).unsafe(`DROP TABLE IF EXISTS kumiko_pending_rebuilds`);
111
121
  });
@@ -233,6 +243,30 @@ describe("pending-rebuilds queue", () => {
233
243
  expect(await listPendingRebuilds(testDb.db)).toEqual([]);
234
244
  });
235
245
 
246
+ // A later migration in the SAME run can deliberately DROP a table it just
247
+ // emptied (e.g. after copying its rows elsewhere, phronexsis 0013/0025) —
248
+ // that's not data loss, so it must drain as unmapped instead of firing
249
+ // unresolvedManaged.
250
+ test("table emptied THIS run then dropped by a later migration → unmapped, not flagged", async () => {
251
+ await asRawClient(testDb.db).unsafe(
252
+ `CREATE TABLE IF NOT EXISTS read_dropped_by_later_migration (group_id uuid PRIMARY KEY)`,
253
+ );
254
+ writeRebuildMarker(markerDir, "0007_dropped.sql", ["read_dropped_by_later_migration"]);
255
+ const queued = await queueRebuildsFromMarkers(testDb.db, {
256
+ migrationsDir: markerDir,
257
+ appliedIds: ["0007_dropped"],
258
+ });
259
+ expect(queued).toEqual(["read_dropped_by_later_migration"]);
260
+
261
+ // Migration 0008 in the same run migrates the data elsewhere and drops the table.
262
+ await asRawClient(testDb.db).unsafe(`DROP TABLE read_dropped_by_later_migration`);
263
+
264
+ const run = await runPendingRebuilds(testDb.db, registry, { thisRunTables: queued });
265
+ expect(run.unresolvedManaged).toEqual([]);
266
+ expect(run.unmapped).toEqual(["read_dropped_by_later_migration"]);
267
+ expect(await listPendingRebuilds(testDb.db)).toEqual([]);
268
+ });
269
+
236
270
  // #361: dieselbe unmapped-Tabelle, aber NICHT in thisRunTables (pre-existing
237
271
  // pending aus einem früheren Run / evtl. altem unmanaged-Marker) → benign,
238
272
  // still gedraint, NICHT als unresolvedManaged geflaggt (kein False-Positive).
@@ -94,9 +94,10 @@ export type PendingRebuildRun = {
94
94
  readonly rebuilt: readonly { readonly projection: string; readonly eventsProcessed: number }[];
95
95
  /** Fehlgeschlagene Projektionen — ihre Tabellen BLEIBEN pending. */
96
96
  readonly failed: readonly { readonly projection: string; readonly error: string }[];
97
- /** Pending-Tabellen ohne registrierte Projektion, die NICHT in diesem Run
98
- * frisch via Marker geleert wurden (pre-existing / Legacy-unmanaged-Marker)
99
- * — geräumt, still (nicht von echten Legacy-Tabellen unterscheidbar). */
97
+ /** Pending tables without a registered projection that are either NOT
98
+ * freshly emptied via marker in this run (pre-existing / legacy unmanaged
99
+ * marker) OR were deliberately DROPped by a later migration in the same
100
+ * run (no data loss, no projection left to resolve) — drained, silent. */
100
101
  readonly unmapped: readonly string[];
101
102
  /** In DIESEM Run via Marker geleerte managed-Tabellen ohne auflösbare
102
103
  * Projektion = das owning-Feature fehlt in der Komposition. Geräumt (kein
@@ -148,13 +149,18 @@ export async function runPendingRebuilds(
148
149
  for (const tableName of pending) {
149
150
  const projection = tableToProjection.get(tableName);
150
151
  if (projection === undefined) {
151
- // Marker tragen nur managed Tabellen (rebuild-marker.ts). Eine in DIESEM
152
- // Run frisch geleerte Tabelle ohne auflösbare Projektion ist daher ein
153
- // echter Defekt (owning-Feature fehlt in der Komposition) → laut. Pre-
154
- // existing pending Tabellen sind nicht von alten unmanaged-Markern
155
- // unterscheidbar → still drainen wie bisher (kein Hard-Throw, siehe #361).
156
- if (thisRun.has(tableName)) unresolvedManaged.push(tableName);
157
- else unmapped.push(tableName);
152
+ // Markers only carry managed tables (rebuild-marker.ts). A table emptied
153
+ // in THIS run with no resolving projection is a real defect (owning
154
+ // feature missing from the composition) → loud (#361) — unless the table
155
+ // no longer exists: then a later migration in the same run deliberately
156
+ // DROPped it after migrating its data elsewhere, no data loss. Pre-
157
+ // existing pending tables stay indistinguishable from old unmanaged
158
+ // markers → still drain silently as before.
159
+ if (thisRun.has(tableName) && (await tableExists(db, `public.${tableName}`))) {
160
+ unresolvedManaged.push(tableName);
161
+ } else {
162
+ unmapped.push(tableName);
163
+ }
158
164
  continue;
159
165
  }
160
166
  byProjection.set(projection, [...(byProjection.get(projection) ?? []), tableName]);
@@ -0,0 +1,266 @@
1
+ // kumiko-framework#2616 — processConsumer's db.begin rolled back the WHOLE
2
+ // pass (including markProcessing/persistConsumerOutcome) whenever anything
3
+ // inside it threw AFTER delivery succeeded, silently reverting attempts/
4
+ // last_error/cursor to their pre-pass values. Combined with `context.log?.`,
5
+ // a process without a logger lost the error entirely — in prod this looked
6
+ // like a healthy consumer (status=idle, attempts=0, last_error=NULL) that
7
+ // was actually looping at 24 rollbacks/sec.
8
+ //
9
+ // The real prod trigger was never identified (see the issue), so this test
10
+ // reproduces the SHAPE of the bug via a legitimate fault-injection point
11
+ // instead of the unknown original cause: EventDispatcherOptions.meter is
12
+ // dependency-injected, and emitLagFromTx (called INSIDE the same db.begin,
13
+ // after delivery+persistConsumerOutcome already ran) is where a thrown
14
+ // metric emission rolls back an otherwise-successful pass. No production
15
+ // code is touched to make this reproducible.
16
+
17
+ import { afterEach, beforeAll, describe, expect, test } from "bun:test";
18
+ import { createEventStoreExecutor } from "../../db/event-store-executor";
19
+ import { asRawClient } from "../../db/query";
20
+ import { createTenantDb, type TenantDb } from "../../db/tenant-db";
21
+ import { defineFeature } from "../../engine";
22
+ import { type Gauge, getFallbackMeter, type Meter } from "../../observability";
23
+ import {
24
+ createEventDispatcher,
25
+ type EventConsumer,
26
+ type EventDispatcher,
27
+ getConsumerState,
28
+ } from "../../pipeline";
29
+ import {
30
+ resetEventStore,
31
+ setupTestStack,
32
+ type TestStack,
33
+ TestUsers,
34
+ unsafeCreateEntityTable,
35
+ } from "../../stack";
36
+ import { sharedWidgetEntity, sharedWidgetTable } from "../../testing";
37
+
38
+ const executor = createEventStoreExecutor(sharedWidgetTable, sharedWidgetEntity, {
39
+ entityName: "widget",
40
+ });
41
+
42
+ const feature = defineFeature("passfailure", (r) => {
43
+ r.entity("widget", sharedWidgetEntity);
44
+ });
45
+
46
+ const admin = TestUsers.admin;
47
+ let stack: TestStack;
48
+ let tdb: TenantDb;
49
+
50
+ beforeAll(async () => {
51
+ stack = await setupTestStack({ features: [feature], systemHooks: [] });
52
+ await unsafeCreateEntityTable(stack.db, sharedWidgetEntity, "widget");
53
+ tdb = createTenantDb(stack.db, admin.tenantId);
54
+ });
55
+
56
+ afterEach(async () => {
57
+ await resetEventStore(stack, ["read_widgets"]);
58
+ });
59
+
60
+ async function appendWidget(name: string): Promise<void> {
61
+ await executor.create({ name }, admin, tdb);
62
+ }
63
+
64
+ // A meter that throws on the lag-gauge emission for one specific consumer —
65
+ // the same injection point prod's unidentified exception would have hit,
66
+ // since emitLagFromTx runs last inside processConsumer's db.begin.
67
+ function makeLagFailingMeter(shouldFail: (consumer: string) => boolean): Meter {
68
+ const base = getFallbackMeter();
69
+ return {
70
+ registerMetric: (def) => base.registerMetric(def),
71
+ counter: (name) => base.counter(name),
72
+ histogram: (name) => base.histogram(name),
73
+ gauge: (name): Gauge => {
74
+ const real = base.gauge(name);
75
+ if (name !== "kumiko_event_consumer_lag_events") return real;
76
+ return {
77
+ set: (value, labels) => {
78
+ const consumer = String(labels?.["consumer"] ?? "");
79
+ if (shouldFail(consumer)) {
80
+ throw new Error(`injected-lag-failure-for-${consumer}`);
81
+ }
82
+ real.set(value, labels);
83
+ },
84
+ inc: (value, labels) => real.inc(value, labels),
85
+ dec: (value, labels) => real.dec(value, labels),
86
+ };
87
+ },
88
+ definitions: () => base.definitions(),
89
+ };
90
+ }
91
+
92
+ function buildDispatcher(consumers: readonly EventConsumer[], meter: Meter): EventDispatcher {
93
+ return createEventDispatcher({
94
+ db: stack.db,
95
+ consumers,
96
+ // Deliberately no `log` field — proves the console.error fallback, not
97
+ // the pre-existing context.log?.error(...) path.
98
+ context: { db: stack.db, redis: stack.redis.redis, registry: stack.registry },
99
+ meter,
100
+ batchSize: 200,
101
+ pollIntervalMs: 5000,
102
+ });
103
+ }
104
+
105
+ describe("event-dispatcher — pass-level throw survives the rolled-back tx (#2616)", () => {
106
+ test("logs via console.error when context.log is absent", async () => {
107
+ const flaky = "passfailure:flaky-log";
108
+ const consumer: EventConsumer = { name: flaky, handler: async () => {} };
109
+ const dispatcher = buildDispatcher(
110
+ [consumer],
111
+ makeLagFailingMeter((c) => c === flaky),
112
+ );
113
+ await dispatcher.ensureRegistered();
114
+ await appendWidget("one");
115
+
116
+ const originalError = console.error;
117
+ const logged: string[] = [];
118
+ console.error = (...args: unknown[]) => {
119
+ logged.push(args.map(String).join(" "));
120
+ };
121
+ try {
122
+ await dispatcher.runOnce();
123
+ } finally {
124
+ console.error = originalError;
125
+ }
126
+
127
+ expect(logged.some((l) => l.includes(flaky) && l.includes("injected-lag-failure"))).toBe(true);
128
+ });
129
+
130
+ test("last_error survives the rollback while attempts stays untouched", async () => {
131
+ const flaky = "passfailure:attempts";
132
+ const consumer: EventConsumer = { name: flaky, handler: async () => {} };
133
+ const dispatcher = buildDispatcher(
134
+ [consumer],
135
+ makeLagFailingMeter((c) => c === flaky),
136
+ );
137
+ await dispatcher.ensureRegistered();
138
+ await appendWidget("one");
139
+
140
+ const before = await getConsumerState(stack.db, flaky);
141
+ expect(before?.attempts).toBe(0);
142
+ expect(before?.lastError).toBeNull();
143
+
144
+ await dispatcher.runOnce();
145
+
146
+ const after = await getConsumerState(stack.db, flaky);
147
+ // Delivery itself succeeded (the handler never threw) — only the lag
148
+ // emission did, rolling back markProcessing/persistConsumerOutcome. The
149
+ // dedicated post-catch write in its own transaction lands last_error,
150
+ // but must NOT touch attempts: that counter is deliverEvents' dead-letter
151
+ // budget, and this is an infra-level pass failure, not a handler throw.
152
+ expect(after?.attempts).toBe(0);
153
+ expect(after?.lastError).toMatch(/injected-lag-failure/);
154
+ // The cursor update rolled back with the rest of the transaction — the
155
+ // consumer will redeliver "one" once its backoff clears.
156
+ expect(after?.lastProcessedEventId).toBe(0n);
157
+ });
158
+
159
+ test("backs off after a thrown pass instead of retrying every tick, other consumers unaffected", async () => {
160
+ const flaky = "passfailure:backoff";
161
+ const healthy = "passfailure:healthy";
162
+ const healthySeen: string[] = [];
163
+
164
+ const flakyConsumer: EventConsumer = { name: flaky, handler: async () => {} };
165
+ const healthyConsumer: EventConsumer = {
166
+ name: healthy,
167
+ handler: async (event) => {
168
+ healthySeen.push(String(event.payload["name"]));
169
+ },
170
+ };
171
+ const dispatcher = buildDispatcher(
172
+ [flakyConsumer, healthyConsumer],
173
+ makeLagFailingMeter((c) => c === flaky),
174
+ );
175
+ await dispatcher.ensureRegistered();
176
+ await appendWidget("first");
177
+
178
+ await dispatcher.runOnce();
179
+ const afterFirst = await getConsumerState(stack.db, flaky);
180
+ expect(afterFirst?.attempts).toBe(0);
181
+ expect(afterFirst?.lastProcessedEventId).toBe(0n);
182
+
183
+ // Retry on the very next tick: the backoff must skip the flaky consumer
184
+ // (no delivery attempt, so no change to attempts) while the healthy one
185
+ // keeps advancing.
186
+ await appendWidget("second-for-healthy");
187
+ const second = await dispatcher.runOnce();
188
+ const afterSecond = await getConsumerState(stack.db, flaky);
189
+ expect(afterSecond?.attempts).toBe(0);
190
+ expect(healthySeen).toEqual(["first", "second-for-healthy"]);
191
+ expect(second.byConsumer[healthy]?.processed).toBeGreaterThan(0);
192
+ });
193
+
194
+ test("a successful pass clears the backoff so a later failure isn't skipped", async () => {
195
+ const name = "passfailure:recovers";
196
+ let failOnce = true;
197
+ const consumer: EventConsumer = { name, handler: async () => {} };
198
+ const meter = makeLagFailingMeter((c) => {
199
+ if (c === name && failOnce) {
200
+ failOnce = false;
201
+ return true;
202
+ }
203
+ return false;
204
+ });
205
+
206
+ const dispatcher = buildDispatcher([consumer], meter);
207
+ await dispatcher.ensureRegistered();
208
+ await appendWidget("one");
209
+
210
+ await dispatcher.runOnce();
211
+ const afterFailure = await getConsumerState(stack.db, name);
212
+ expect(afterFailure?.attempts).toBe(0);
213
+ expect(afterFailure?.lastProcessedEventId).toBe(0n);
214
+
215
+ // Wait out the (short, base-case) backoff window rather than reaching
216
+ // into dispatcher internals.
217
+ await new Promise((r) => setTimeout(r, 1100));
218
+ await dispatcher.runOnce();
219
+
220
+ const afterSuccess = await getConsumerState(stack.db, name);
221
+ expect(afterSuccess?.status).toBe("idle");
222
+ expect(afterSuccess?.attempts).toBe(0);
223
+ expect(afterSuccess?.lastProcessedEventId).toBe(1n);
224
+
225
+ // A fresh failure right after the successful pass must NOT be skipped —
226
+ // proves the backoff entry was cleared, not left at its escalated delay.
227
+ // attempts stays at 0: this is the same infra-level pass failure as
228
+ // above, which never touches deliverEvents' dead-letter budget.
229
+ failOnce = true;
230
+ await appendWidget("two");
231
+ await dispatcher.runOnce();
232
+ const afterSecondFailure = await getConsumerState(stack.db, name);
233
+ expect(afterSecondFailure?.attempts).toBe(0);
234
+ });
235
+
236
+ test("repeated infra-level pass failures never spend deliverEvents' dead-letter budget", async () => {
237
+ const flaky = "passfailure:budget";
238
+ const consumer: EventConsumer = { name: flaky, handler: async () => {} };
239
+ const meter = makeLagFailingMeter((c) => c === flaky);
240
+ await buildDispatcher([consumer], meter).ensureRegistered();
241
+
242
+ // Seed as if 9 of the real deliverEvents budget (maxAttempts=10) were
243
+ // already spent on genuine handler throws.
244
+ await asRawClient(stack.db).unsafe(
245
+ `UPDATE "kumiko_event_consumers" SET "attempts" = 9 WHERE "name" = $1`,
246
+ [flaky],
247
+ );
248
+ await appendWidget("one");
249
+
250
+ // Three infra-level pass failures in a row. Each uses a fresh dispatcher
251
+ // instance so its in-memory backoff never delays the next runOnce() —
252
+ // the DB row (and its seeded attempts=9) is what's shared and under test.
253
+ for (let i = 0; i < 3; i++) {
254
+ const dispatcher = buildDispatcher([consumer], meter);
255
+ await dispatcher.ensureRegistered();
256
+ await dispatcher.runOnce();
257
+ }
258
+
259
+ const after = await getConsumerState(stack.db, flaky);
260
+ // A 10th genuine handler throw would now dead-letter the consumer if
261
+ // these infra failures had bumped attempts past the seeded 9 — they
262
+ // must not, since none of them ran a real delivery pass.
263
+ expect(after?.attempts).toBe(9);
264
+ expect(after?.status).not.toBe("dead");
265
+ });
266
+ });
@@ -8,6 +8,7 @@ import {
8
8
  insertConsumerIfAbsent,
9
9
  markConsumerProcessing,
10
10
  rearmDeadConsumer,
11
+ recordConsumerPassFailure,
11
12
  selectConsumerForUpdateSkipLocked,
12
13
  updateConsumerDeliveryOutcome,
13
14
  } from "../db/queries/event-consumer";
@@ -289,6 +290,19 @@ export async function persistConsumerOutcome(
289
290
  await updateConsumerDeliveryOutcome(tx, name, instanceId, outcome);
290
291
  }
291
292
 
293
+ // Best-effort record of a pass that threw before persistConsumerOutcome
294
+ // could run (event-dispatcher.ts's processConsumer catch). The caller opens
295
+ // its OWN transaction for this — the one that rolled back never committed
296
+ // anything, including this write, if it happened inside the same tx.
297
+ export async function persistConsumerPassFailure(
298
+ tx: DbTx,
299
+ name: string,
300
+ instanceId: string,
301
+ errorMessage: string,
302
+ ): Promise<void> {
303
+ await recordConsumerPassFailure(tx, name, instanceId, errorMessage);
304
+ }
305
+
292
306
  // Emit the lag gauge inside the consumer pass's tx so ops sees a snapshot
293
307
  // consistent with the cursor we just advanced to. `MAX(id)` on the events
294
308
  // table is an O(1) reverse-index scan — cheap even under load.
@@ -20,6 +20,7 @@ import {
20
20
  fetchPendingEvents,
21
21
  markProcessing,
22
22
  persistConsumerOutcome,
23
+ persistConsumerPassFailure,
23
24
  preRegisterConsumers,
24
25
  } from "./event-dispatcher-delivery";
25
26
 
@@ -175,6 +176,13 @@ const DEFAULT_POLL_MS = 100;
175
176
  const DEFAULT_MAX_ATTEMPTS = 10;
176
177
  const DEFAULT_REARM_COOLDOWN_MS = 5 * 60_000;
177
178
  const DEFAULT_MAX_REARM_COUNT = 3;
179
+ // Backoff for a consumer whose pass THREW (the processConsumer catch below)
180
+ // — an infra-level failure (db.begin's callback rejected outside deliverEvents'
181
+ // own per-event try/catch), not a poisoned event. Doubles per consecutive
182
+ // failure, capped at PASS_FAILURE_MAX_BACKOFF_MS so a consumer stuck failing
183
+ // still gets retried within a minute; a successful pass resets it to 0.
184
+ const PASS_FAILURE_BASE_BACKOFF_MS = 1_000;
185
+ const PASS_FAILURE_MAX_BACKOFF_MS = 60_000;
178
186
 
179
187
  export function createEventDispatcher(options: EventDispatcherOptions): EventDispatcher {
180
188
  const {
@@ -217,6 +225,14 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
217
225
  // exactly the "still needs a human" signal ops wants.
218
226
  const reportedDeadConsumers = new Set<string>();
219
227
 
228
+ // Per-(consumer, instanceId) backoff after a thrown pass (see the catch in
229
+ // processConsumer). Keyed the same way as reportedDeadConsumers.
230
+ // consecutiveFailures drives the exponential delay; retryAtMs is the
231
+ // Date.now() timestamp doPass compares against to decide whether this
232
+ // consumer's turn is skipped this tick. A successful pass deletes the
233
+ // entry — the next failure (if any) starts a fresh backoff from scratch.
234
+ const consumerBackoff = new Map<string, { consecutiveFailures: number; retryAtMs: number }>();
235
+
220
236
  let running = false;
221
237
  // Separate from `running` on purpose: pre-registration of consumer state
222
238
  // rows is a one-time boot action, while running/timer/LISTEN is a
@@ -282,6 +298,16 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
282
298
  byConsumer[consumer.name] = { processed: 0, failed: 0 };
283
299
  continue;
284
300
  }
301
+ // Backoff-gate: a consumer whose last pass threw waits out its
302
+ // exponential delay before being tried again — see the catch in
303
+ // processConsumer. Other consumers are unaffected; only this one's
304
+ // turn is skipped this tick.
305
+ const backoffKey = `${consumer.name}:${consumerInstanceId(consumer, options.instanceId)}`;
306
+ const backoff = consumerBackoff.get(backoffKey);
307
+ if (backoff && backoff.retryAtMs > Date.now()) {
308
+ byConsumer[consumer.name] = { processed: 0, failed: 0 };
309
+ continue;
310
+ }
285
311
  const perConsumer = await processConsumer(consumer);
286
312
  byConsumer[consumer.name] = perConsumer;
287
313
  totalProcessed += perConsumer.processed;
@@ -352,6 +378,11 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
352
378
  await emitLagFromTx(tx, consumer.name, instanceId, outcome.cursor, meter);
353
379
  });
354
380
 
381
+ // Pass completed without throwing — clear any backoff from a prior
382
+ // failure so the next unrelated outage gets its own fresh delay
383
+ // instead of inheriting a partially-escalated one.
384
+ consumerBackoff.delete(`${consumer.name}:${instanceId}`);
385
+
355
386
  emitEventConsumerPassOutcome(
356
387
  meter,
357
388
  { consumer: consumer.name, instanceId },
@@ -370,8 +401,44 @@ export function createEventDispatcher(options: EventDispatcherOptions): EventDis
370
401
  // callers and at-least-once-with-duplicate-delivery on the next pass;
371
402
  // neither is what we want, so ops needs to see it.
372
403
  const msg = e instanceof Error ? e.message : String(e);
373
- context.log?.error(`[event-dispatcher] ${consumer.name} pass failed: ${msg}`);
404
+ if (context.log) {
405
+ context.log.error(`[event-dispatcher] ${consumer.name} pass failed: ${msg}`);
406
+ } else {
407
+ // No logger wired onto this context (e.g. createEventDispatcher used
408
+ // standalone) — console.error is the only way ops still sees this in
409
+ // pod logs instead of the failure vanishing with the rolled-back tx.
410
+ console.error(`[event-dispatcher] ${consumer.name} pass failed: ${msg}`);
411
+ }
374
412
  span.setStatus("error", msg);
413
+
414
+ // The whole pass rolled back, so markProcessing/persistConsumerOutcome
415
+ // never committed — attempts/last_error are stuck at their pre-pass
416
+ // values. Record the failure in its OWN transaction so it survives.
417
+ // Best-effort: this write must never itself take down the dispatcher,
418
+ // so any failure here is swallowed after logging.
419
+ try {
420
+ await db.begin(async (tx: DbTx) => {
421
+ await persistConsumerPassFailure(tx, consumer.name, instanceId, msg);
422
+ });
423
+ } catch (persistErr) {
424
+ const persistMsg = persistErr instanceof Error ? persistErr.message : String(persistErr);
425
+ const failureMsg = `[event-dispatcher] ${consumer.name} failed to persist pass-failure state: ${persistMsg}`;
426
+ if (context.log) {
427
+ context.log.error(failureMsg);
428
+ } else {
429
+ console.error(failureMsg);
430
+ }
431
+ }
432
+
433
+ // Escalate the backoff so this consumer isn't retried on the very
434
+ // next tick — see PASS_FAILURE_BASE_BACKOFF_MS/MAX_MS above.
435
+ const key = `${consumer.name}:${instanceId}`;
436
+ const consecutiveFailures = (consumerBackoff.get(key)?.consecutiveFailures ?? 0) + 1;
437
+ const delayMs = Math.min(
438
+ PASS_FAILURE_BASE_BACKOFF_MS * 2 ** (consecutiveFailures - 1),
439
+ PASS_FAILURE_MAX_BACKOFF_MS,
440
+ );
441
+ consumerBackoff.set(key, { consecutiveFailures, retryAtMs: Date.now() + delayMs });
375
442
  } finally {
376
443
  span.end();
377
444
  }