@cosmicdrift/kumiko-framework 0.235.2 → 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.
- package/package.json +3 -3
- package/src/__tests__/upgrade-cli.test.ts +85 -0
- package/src/db/queries/event-consumer.ts +27 -0
- package/src/engine/__tests__/build-app-schema.test.ts +2 -0
- package/src/engine/access.ts +4 -1
- package/src/engine/feature-ast/__tests__/parse.test.ts +95 -0
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +2 -2
- package/src/engine/feature-ast/extractors/handlers.ts +91 -14
- package/src/engine/feature-ast/patterns.ts +31 -1
- package/src/engine/feature-ast/render.ts +4 -0
- package/src/engine/feature-entity-handlers.ts +28 -2
- package/src/engine/index.ts +4 -1
- package/src/engine/pattern-library/mixed-schemas.ts +28 -0
- package/src/engine/registry-facade.ts +4 -0
- package/src/engine/types/index.ts +4 -0
- package/src/errors/__tests__/error-helpers.test.ts +15 -1
- package/src/errors/index.ts +2 -2
- package/src/errors/reasons.ts +30 -0
- package/src/migrations/__tests__/pending-rebuilds.integration.test.ts +36 -2
- package/src/migrations/pending-rebuilds.ts +16 -10
- package/src/pipeline/__tests__/event-dispatcher-pass-failure.integration.test.ts +266 -0
- package/src/pipeline/event-dispatcher-delivery.ts +14 -0
- package/src/pipeline/event-dispatcher.ts +68 -1
- package/src/upgrade-cli.ts +37 -10
|
@@ -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 {
|
|
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
|
+
});
|
package/src/errors/index.ts
CHANGED
|
@@ -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";
|
package/src/errors/reasons.ts
CHANGED
|
@@ -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, {
|
|
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
|
|
98
|
-
*
|
|
99
|
-
*
|
|
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
|
-
//
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
|
|
157
|
-
|
|
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
|
|
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
|
}
|