@griffin-app/griffin-executor 0.1.0
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/README.md +152 -0
- package/dist/adapters/axios.d.ts +5 -0
- package/dist/adapters/axios.d.ts.map +1 -0
- package/dist/adapters/axios.js +36 -0
- package/dist/adapters/axios.js.map +1 -0
- package/dist/adapters/index.d.ts +3 -0
- package/dist/adapters/index.d.ts.map +1 -0
- package/dist/adapters/index.js +3 -0
- package/dist/adapters/index.js.map +1 -0
- package/dist/adapters/stub.d.ts +22 -0
- package/dist/adapters/stub.d.ts.map +1 -0
- package/dist/adapters/stub.js +36 -0
- package/dist/adapters/stub.js.map +1 -0
- package/dist/events/adapters/in-memory.d.ts +52 -0
- package/dist/events/adapters/in-memory.d.ts.map +1 -0
- package/dist/events/adapters/in-memory.js +70 -0
- package/dist/events/adapters/in-memory.js.map +1 -0
- package/dist/events/adapters/in-memory.test.d.ts +2 -0
- package/dist/events/adapters/in-memory.test.d.ts.map +1 -0
- package/dist/events/adapters/in-memory.test.js +109 -0
- package/dist/events/adapters/in-memory.test.js.map +1 -0
- package/dist/events/adapters/index.d.ts +9 -0
- package/dist/events/adapters/index.d.ts.map +1 -0
- package/dist/events/adapters/index.js +9 -0
- package/dist/events/adapters/index.js.map +1 -0
- package/dist/events/adapters/kinesis.d.ts +91 -0
- package/dist/events/adapters/kinesis.d.ts.map +1 -0
- package/dist/events/adapters/kinesis.js +136 -0
- package/dist/events/adapters/kinesis.js.map +1 -0
- package/dist/events/adapters/kinesis.test.d.ts +2 -0
- package/dist/events/adapters/kinesis.test.d.ts.map +1 -0
- package/dist/events/adapters/kinesis.test.js +249 -0
- package/dist/events/adapters/kinesis.test.js.map +1 -0
- package/dist/events/emitter.d.ts +68 -0
- package/dist/events/emitter.d.ts.map +1 -0
- package/dist/events/emitter.js +83 -0
- package/dist/events/emitter.js.map +1 -0
- package/dist/events/emitter.test.d.ts +2 -0
- package/dist/events/emitter.test.d.ts.map +1 -0
- package/dist/events/emitter.test.js +262 -0
- package/dist/events/emitter.test.js.map +1 -0
- package/dist/events/index.d.ts +4 -0
- package/dist/events/index.d.ts.map +1 -0
- package/dist/events/index.js +4 -0
- package/dist/events/index.js.map +1 -0
- package/dist/events/types.d.ts +112 -0
- package/dist/events/types.d.ts.map +1 -0
- package/dist/events/types.js +9 -0
- package/dist/events/types.js.map +1 -0
- package/dist/executor.d.ts +4 -0
- package/dist/executor.d.ts.map +1 -0
- package/dist/executor.js +799 -0
- package/dist/executor.js.map +1 -0
- package/dist/executor.test.d.ts +2 -0
- package/dist/executor.test.d.ts.map +1 -0
- package/dist/executor.test.js +1584 -0
- package/dist/executor.test.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/dist/secrets/factory.d.ts +121 -0
- package/dist/secrets/factory.d.ts.map +1 -0
- package/dist/secrets/factory.js +137 -0
- package/dist/secrets/factory.js.map +1 -0
- package/dist/secrets/index.d.ts +14 -0
- package/dist/secrets/index.d.ts.map +1 -0
- package/dist/secrets/index.js +18 -0
- package/dist/secrets/index.js.map +1 -0
- package/dist/secrets/providers/aws.d.ts +63 -0
- package/dist/secrets/providers/aws.d.ts.map +1 -0
- package/dist/secrets/providers/aws.js +110 -0
- package/dist/secrets/providers/aws.js.map +1 -0
- package/dist/secrets/providers/env.d.ts +36 -0
- package/dist/secrets/providers/env.d.ts.map +1 -0
- package/dist/secrets/providers/env.js +37 -0
- package/dist/secrets/providers/env.js.map +1 -0
- package/dist/secrets/providers/index.d.ts +7 -0
- package/dist/secrets/providers/index.d.ts.map +1 -0
- package/dist/secrets/providers/index.js +7 -0
- package/dist/secrets/providers/index.js.map +1 -0
- package/dist/secrets/providers/vault.d.ts +75 -0
- package/dist/secrets/providers/vault.d.ts.map +1 -0
- package/dist/secrets/providers/vault.js +143 -0
- package/dist/secrets/providers/vault.js.map +1 -0
- package/dist/secrets/registry.d.ts +39 -0
- package/dist/secrets/registry.d.ts.map +1 -0
- package/dist/secrets/registry.js +134 -0
- package/dist/secrets/registry.js.map +1 -0
- package/dist/secrets/resolver.d.ts +45 -0
- package/dist/secrets/resolver.d.ts.map +1 -0
- package/dist/secrets/resolver.js +188 -0
- package/dist/secrets/resolver.js.map +1 -0
- package/dist/secrets/secrets.test.d.ts +2 -0
- package/dist/secrets/secrets.test.d.ts.map +1 -0
- package/dist/secrets/secrets.test.js +317 -0
- package/dist/secrets/secrets.test.js.map +1 -0
- package/dist/secrets/types.d.ts +70 -0
- package/dist/secrets/types.d.ts.map +1 -0
- package/dist/secrets/types.js +42 -0
- package/dist/secrets/types.js.map +1 -0
- package/dist/shared.d.ts +8 -0
- package/dist/shared.d.ts.map +1 -0
- package/dist/shared.js +30 -0
- package/dist/shared.js.map +1 -0
- package/dist/test-monitor-types.d.ts +43 -0
- package/dist/test-monitor-types.d.ts.map +1 -0
- package/dist/test-monitor-types.js +2 -0
- package/dist/test-monitor-types.js.map +1 -0
- package/dist/test-plan-types.d.ts +43 -0
- package/dist/test-plan-types.d.ts.map +1 -0
- package/dist/test-plan-types.js +2 -0
- package/dist/test-plan-types.js.map +1 -0
- package/dist/types.d.ts +93 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/dates.d.ts +11 -0
- package/dist/utils/dates.d.ts.map +1 -0
- package/dist/utils/dates.js +13 -0
- package/dist/utils/dates.js.map +1 -0
- package/package.json +39 -0
- package/src/adapters/axios.ts +39 -0
- package/src/adapters/index.ts +2 -0
- package/src/adapters/stub.ts +47 -0
- package/src/events/adapters/README.md +144 -0
- package/src/events/adapters/in-memory.test.ts +146 -0
- package/src/events/adapters/in-memory.ts +93 -0
- package/src/events/adapters/index.ts +9 -0
- package/src/events/adapters/kinesis.test.ts +323 -0
- package/src/events/adapters/kinesis.ts +211 -0
- package/src/events/emitter.test.ts +327 -0
- package/src/events/emitter.ts +133 -0
- package/src/events/index.ts +3 -0
- package/src/events/types.ts +136 -0
- package/src/executor.test.ts +1732 -0
- package/src/executor.ts +1075 -0
- package/src/index.ts +81 -0
- package/src/secrets/factory.ts +248 -0
- package/src/secrets/index.ts +48 -0
- package/src/secrets/providers/aws.ts +178 -0
- package/src/secrets/providers/env.ts +66 -0
- package/src/secrets/providers/index.ts +15 -0
- package/src/secrets/providers/vault.ts +257 -0
- package/src/secrets/resolver.ts +269 -0
- package/src/secrets/secrets.test.ts +402 -0
- package/src/secrets/types.ts +106 -0
- package/src/shared.ts +46 -0
- package/src/test-monitor-types.ts +49 -0
- package/src/types.ts +114 -0
- package/src/utils/dates.ts +13 -0
- package/tsconfig.json +20 -0
- package/vitest.config.ts +14 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AWS Kinesis event bus adapter.
|
|
3
|
+
*
|
|
4
|
+
* Publishes execution events to an AWS Kinesis stream.
|
|
5
|
+
* Supports batching, retries, and automatic partitioning by executionId.
|
|
6
|
+
*
|
|
7
|
+
* Features:
|
|
8
|
+
* - Automatic batching (max 500 records per PutRecords call)
|
|
9
|
+
* - Retry logic for failed records with exponential backoff
|
|
10
|
+
* - Partition key strategies for optimal shard distribution
|
|
11
|
+
* - EventType included in data for Lambda filtering
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { DurableEventBusAdapter } from "../emitter.js";
|
|
15
|
+
import type { ExecutionEvent } from "../types.js";
|
|
16
|
+
import {
|
|
17
|
+
Kinesis,
|
|
18
|
+
KinesisClient,
|
|
19
|
+
PutRecordsCommand,
|
|
20
|
+
type PutRecordsRequestEntry,
|
|
21
|
+
} from "@aws-sdk/client-kinesis";
|
|
22
|
+
|
|
23
|
+
export interface KinesisAdapterOptions {
|
|
24
|
+
/**
|
|
25
|
+
* AWS Kinesis client instance.
|
|
26
|
+
* Should be pre-configured with region and credentials.
|
|
27
|
+
*
|
|
28
|
+
* Example:
|
|
29
|
+
* import { KinesisClient } from "@aws-sdk/client-kinesis";
|
|
30
|
+
* const client = new KinesisClient({ region: "us-east-1" });
|
|
31
|
+
*/
|
|
32
|
+
client: KinesisClient;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Name of the Kinesis stream to publish to.
|
|
36
|
+
*/
|
|
37
|
+
streamName: string;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Maximum number of retries for failed records.
|
|
41
|
+
* Default: 3
|
|
42
|
+
*/
|
|
43
|
+
maxRetries?: number;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Milliseconds to wait between retries.
|
|
47
|
+
* Default: 1000
|
|
48
|
+
*/
|
|
49
|
+
retryDelayMs?: number;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Partition key strategy.
|
|
53
|
+
* - "executionId": Use executionId as partition key (maintains ordering within execution)
|
|
54
|
+
* - "organizationId": Use organizationId as partition key (better shard distribution for multi-tenant)
|
|
55
|
+
* - "composite": Use "orgId:executionId" for balanced distribution with execution ordering
|
|
56
|
+
* Default: "executionId"
|
|
57
|
+
*/
|
|
58
|
+
partitionKeyStrategy?: "executionId" | "organizationId" | "composite";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* AWS Kinesis adapter for durable event publishing.
|
|
63
|
+
*
|
|
64
|
+
* Batches events and publishes them to a Kinesis stream.
|
|
65
|
+
* Implements retry logic for failed records and respects Kinesis batch limits.
|
|
66
|
+
*/
|
|
67
|
+
export class KinesisAdapter implements DurableEventBusAdapter {
|
|
68
|
+
private readonly client: KinesisClient;
|
|
69
|
+
private readonly streamName: string;
|
|
70
|
+
private readonly maxRetries: number;
|
|
71
|
+
private readonly retryDelayMs: number;
|
|
72
|
+
private readonly partitionKeyStrategy:
|
|
73
|
+
| "executionId"
|
|
74
|
+
| "organizationId"
|
|
75
|
+
| "composite";
|
|
76
|
+
private static readonly KINESIS_MAX_BATCH = 500;
|
|
77
|
+
|
|
78
|
+
constructor(options: KinesisAdapterOptions) {
|
|
79
|
+
this.client = options.client;
|
|
80
|
+
this.streamName = options.streamName;
|
|
81
|
+
this.maxRetries = options.maxRetries ?? 3;
|
|
82
|
+
this.retryDelayMs = options.retryDelayMs ?? 1000;
|
|
83
|
+
this.partitionKeyStrategy = options.partitionKeyStrategy ?? "executionId";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async publish(events: ExecutionEvent[]): Promise<void> {
|
|
87
|
+
if (events.length === 0) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Convert events to Kinesis records
|
|
92
|
+
const records = events.map((event) => this.eventToRecord(event));
|
|
93
|
+
|
|
94
|
+
// Process in batches of 500 (Kinesis limit)
|
|
95
|
+
const batches = this.chunkArray(records, KinesisAdapter.KINESIS_MAX_BATCH);
|
|
96
|
+
|
|
97
|
+
for (const batch of batches) {
|
|
98
|
+
await this.publishBatchWithRetry(batch);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Publish a single batch with retry logic for failed records.
|
|
104
|
+
*/
|
|
105
|
+
private async publishBatchWithRetry(
|
|
106
|
+
records: PutRecordsRequestEntry[],
|
|
107
|
+
attempt = 0,
|
|
108
|
+
): Promise<void> {
|
|
109
|
+
try {
|
|
110
|
+
const command = new PutRecordsCommand({
|
|
111
|
+
StreamName: this.streamName,
|
|
112
|
+
Records: records,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const response = await this.client.send(command);
|
|
116
|
+
|
|
117
|
+
// Check for failed records
|
|
118
|
+
if (response.FailedRecordCount && response.FailedRecordCount > 0) {
|
|
119
|
+
const failedRecords = records.filter((_, idx) => {
|
|
120
|
+
const result = response.Records?.[idx];
|
|
121
|
+
return result?.ErrorCode !== undefined;
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
if (attempt < this.maxRetries) {
|
|
125
|
+
// Exponential backoff
|
|
126
|
+
const delay = this.retryDelayMs * Math.pow(2, attempt);
|
|
127
|
+
console.warn(
|
|
128
|
+
`Retrying ${failedRecords.length} failed records (attempt ${attempt + 1}/${this.maxRetries}) after ${delay}ms`,
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
await this.sleep(delay);
|
|
132
|
+
await this.publishBatchWithRetry(failedRecords, attempt + 1);
|
|
133
|
+
} else {
|
|
134
|
+
console.error(
|
|
135
|
+
`Failed to publish ${failedRecords.length} records after ${this.maxRetries} attempts`,
|
|
136
|
+
);
|
|
137
|
+
// Events are lost after max retries - caller should implement dead letter queue if needed
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
} catch (error) {
|
|
141
|
+
if (attempt < this.maxRetries) {
|
|
142
|
+
const delay = this.retryDelayMs * Math.pow(2, attempt);
|
|
143
|
+
console.warn(
|
|
144
|
+
`Kinesis publish error, retrying (attempt ${attempt + 1}/${this.maxRetries}) after ${delay}ms:`,
|
|
145
|
+
error,
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
await this.sleep(delay);
|
|
149
|
+
await this.publishBatchWithRetry(records, attempt + 1);
|
|
150
|
+
} else {
|
|
151
|
+
console.error(
|
|
152
|
+
`Failed to publish batch after ${this.maxRetries} attempts:`,
|
|
153
|
+
error,
|
|
154
|
+
);
|
|
155
|
+
throw error;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Convert ExecutionEvent to Kinesis record format.
|
|
162
|
+
*/
|
|
163
|
+
private eventToRecord(event: ExecutionEvent): PutRecordsRequestEntry {
|
|
164
|
+
return {
|
|
165
|
+
Data: this.serializeEvent(event),
|
|
166
|
+
PartitionKey: this.getPartitionKey(event),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Get partition key based on configured strategy.
|
|
172
|
+
*/
|
|
173
|
+
private getPartitionKey(event: ExecutionEvent): string {
|
|
174
|
+
switch (this.partitionKeyStrategy) {
|
|
175
|
+
case "executionId":
|
|
176
|
+
return event.execution_id;
|
|
177
|
+
case "organizationId":
|
|
178
|
+
return event.organization_id;
|
|
179
|
+
case "composite":
|
|
180
|
+
return `${event.organization_id}:${event.execution_id}`;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Serialize event to JSON and encode as UTF-8 bytes.
|
|
186
|
+
* Events are structured to enable efficient Lambda filtering by including
|
|
187
|
+
* type as a top-level field in the JSON payload.
|
|
188
|
+
*/
|
|
189
|
+
private serializeEvent(event: ExecutionEvent): Uint8Array {
|
|
190
|
+
const json = JSON.stringify(event);
|
|
191
|
+
return new TextEncoder().encode(json);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Split array into chunks of specified size.
|
|
196
|
+
*/
|
|
197
|
+
private chunkArray<T>(array: T[], chunkSize: number): T[][] {
|
|
198
|
+
const chunks: T[][] = [];
|
|
199
|
+
for (let i = 0; i < array.length; i += chunkSize) {
|
|
200
|
+
chunks.push(array.slice(i, i + chunkSize));
|
|
201
|
+
}
|
|
202
|
+
return chunks;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Sleep for specified milliseconds.
|
|
207
|
+
*/
|
|
208
|
+
private sleep(ms: number): Promise<void> {
|
|
209
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
210
|
+
}
|
|
211
|
+
}
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
LocalEventEmitter,
|
|
4
|
+
DurableEventEmitter,
|
|
5
|
+
type DurableEventBusAdapter,
|
|
6
|
+
} from "./emitter.js";
|
|
7
|
+
import type { ExecutionEvent } from "./types.js";
|
|
8
|
+
|
|
9
|
+
describe("LocalEventEmitter", () => {
|
|
10
|
+
let emitter: LocalEventEmitter;
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
emitter = new LocalEventEmitter();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("should deliver events to subscribers synchronously", () => {
|
|
17
|
+
const events: ExecutionEvent[] = [];
|
|
18
|
+
emitter.subscribe((event) => events.push(event));
|
|
19
|
+
|
|
20
|
+
const testEvent: ExecutionEvent = {
|
|
21
|
+
type: "MONITOR_START",
|
|
22
|
+
event_id: "123",
|
|
23
|
+
seq: 0,
|
|
24
|
+
timestamp: Date.now(),
|
|
25
|
+
organization_id: "test-org",
|
|
26
|
+
monitor_id: "monitor-1",
|
|
27
|
+
execution_id: "exec-1",
|
|
28
|
+
monitor_name: "Test Monitor",
|
|
29
|
+
monitor_version: "1.0",
|
|
30
|
+
node_count: 2,
|
|
31
|
+
edge_count: 1,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
emitter.emit(testEvent);
|
|
35
|
+
|
|
36
|
+
expect(events).toHaveLength(1);
|
|
37
|
+
expect(events[0]).toEqual(testEvent);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("should deliver events to multiple subscribers", () => {
|
|
41
|
+
const events1: ExecutionEvent[] = [];
|
|
42
|
+
const events2: ExecutionEvent[] = [];
|
|
43
|
+
|
|
44
|
+
emitter.subscribe((event) => events1.push(event));
|
|
45
|
+
emitter.subscribe((event) => events2.push(event));
|
|
46
|
+
|
|
47
|
+
const testEvent: ExecutionEvent = {
|
|
48
|
+
type: "NODE_START",
|
|
49
|
+
event_id: "456",
|
|
50
|
+
seq: 1,
|
|
51
|
+
timestamp: Date.now(),
|
|
52
|
+
monitor_id: "monitor-1",
|
|
53
|
+
execution_id: "exec-1",
|
|
54
|
+
organization_id: "test-org",
|
|
55
|
+
node_id: "node-1",
|
|
56
|
+
node_type: "HTTP_REQUEST",
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
emitter.emit(testEvent);
|
|
60
|
+
|
|
61
|
+
expect(events1).toHaveLength(1);
|
|
62
|
+
expect(events2).toHaveLength(1);
|
|
63
|
+
expect(events1[0]).toEqual(testEvent);
|
|
64
|
+
expect(events2[0]).toEqual(testEvent);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("should allow unsubscribing", () => {
|
|
68
|
+
const events: ExecutionEvent[] = [];
|
|
69
|
+
const unsubscribe = emitter.subscribe((event) => events.push(event));
|
|
70
|
+
|
|
71
|
+
const testEvent: ExecutionEvent = {
|
|
72
|
+
type: "NODE_END",
|
|
73
|
+
event_id: "789",
|
|
74
|
+
seq: 2,
|
|
75
|
+
timestamp: Date.now(),
|
|
76
|
+
monitor_id: "monitor-1",
|
|
77
|
+
execution_id: "exec-1",
|
|
78
|
+
organization_id: "test-org",
|
|
79
|
+
node_id: "node-1",
|
|
80
|
+
node_type: "HTTP_REQUEST",
|
|
81
|
+
success: true,
|
|
82
|
+
duration_ms: 100,
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
emitter.emit(testEvent);
|
|
86
|
+
expect(events).toHaveLength(1);
|
|
87
|
+
|
|
88
|
+
unsubscribe();
|
|
89
|
+
|
|
90
|
+
emitter.emit(testEvent);
|
|
91
|
+
expect(events).toHaveLength(1); // Still 1, not 2
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("should not propagate listener errors to other listeners", () => {
|
|
95
|
+
const events: ExecutionEvent[] = [];
|
|
96
|
+
const consoleErrorSpy = vi
|
|
97
|
+
.spyOn(console, "error")
|
|
98
|
+
.mockImplementation(() => {});
|
|
99
|
+
|
|
100
|
+
emitter.subscribe(() => {
|
|
101
|
+
throw new Error("Listener error");
|
|
102
|
+
});
|
|
103
|
+
emitter.subscribe((event) => events.push(event));
|
|
104
|
+
|
|
105
|
+
const testEvent: ExecutionEvent = {
|
|
106
|
+
type: "MONITOR_END",
|
|
107
|
+
event_id: "abc",
|
|
108
|
+
seq: 3,
|
|
109
|
+
timestamp: Date.now(),
|
|
110
|
+
monitor_id: "monitor-1",
|
|
111
|
+
execution_id: "exec-1",
|
|
112
|
+
organization_id: "test-org",
|
|
113
|
+
success: true,
|
|
114
|
+
total_duration_ms: 1000,
|
|
115
|
+
node_result_count: 2,
|
|
116
|
+
error_count: 0,
|
|
117
|
+
errors: [],
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
emitter.emit(testEvent);
|
|
121
|
+
|
|
122
|
+
expect(events).toHaveLength(1);
|
|
123
|
+
expect(consoleErrorSpy).toHaveBeenCalled();
|
|
124
|
+
|
|
125
|
+
consoleErrorSpy.mockRestore();
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("DurableEventEmitter", () => {
|
|
130
|
+
let adapter: DurableEventBusAdapter;
|
|
131
|
+
let publishSpy: ReturnType<
|
|
132
|
+
typeof vi.fn<(events: ExecutionEvent[]) => Promise<void>>
|
|
133
|
+
>;
|
|
134
|
+
let emitter: DurableEventEmitter;
|
|
135
|
+
|
|
136
|
+
beforeEach(() => {
|
|
137
|
+
publishSpy = vi
|
|
138
|
+
.fn<(events: ExecutionEvent[]) => Promise<void>>()
|
|
139
|
+
.mockResolvedValue(undefined);
|
|
140
|
+
adapter = { publish: publishSpy };
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
afterEach(() => {
|
|
144
|
+
emitter?.destroy();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("should batch events and flush on batch size", async () => {
|
|
148
|
+
emitter = new DurableEventEmitter(adapter, {
|
|
149
|
+
batchSize: 3,
|
|
150
|
+
flushIntervalMs: 10000,
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const event1: ExecutionEvent = {
|
|
154
|
+
type: "NODE_START",
|
|
155
|
+
event_id: "1",
|
|
156
|
+
seq: 0,
|
|
157
|
+
timestamp: Date.now(),
|
|
158
|
+
monitor_id: "monitor-1",
|
|
159
|
+
execution_id: "exec-1",
|
|
160
|
+
organization_id: "test-org",
|
|
161
|
+
node_id: "node-1",
|
|
162
|
+
node_type: "HTTP_REQUEST",
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const event2: ExecutionEvent = {
|
|
166
|
+
type: "NODE_START",
|
|
167
|
+
event_id: "2",
|
|
168
|
+
seq: 1,
|
|
169
|
+
timestamp: Date.now(),
|
|
170
|
+
monitor_id: "monitor-1",
|
|
171
|
+
execution_id: "exec-1",
|
|
172
|
+
organization_id: "test-org",
|
|
173
|
+
node_id: "node-2",
|
|
174
|
+
node_type: "WAIT",
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const event3: ExecutionEvent = {
|
|
178
|
+
type: "NODE_START",
|
|
179
|
+
event_id: "3",
|
|
180
|
+
seq: 2,
|
|
181
|
+
timestamp: Date.now(),
|
|
182
|
+
monitor_id: "monitor-1",
|
|
183
|
+
execution_id: "exec-1",
|
|
184
|
+
organization_id: "test-org",
|
|
185
|
+
node_id: "node-3",
|
|
186
|
+
node_type: "ASSERTION",
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
emitter.emit(event1);
|
|
190
|
+
emitter.emit(event2);
|
|
191
|
+
expect(publishSpy).not.toHaveBeenCalled();
|
|
192
|
+
|
|
193
|
+
emitter.emit(event3); // Should trigger auto-flush
|
|
194
|
+
|
|
195
|
+
// Wait for async flush
|
|
196
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
197
|
+
|
|
198
|
+
expect(publishSpy).toHaveBeenCalledTimes(1);
|
|
199
|
+
expect(publishSpy).toHaveBeenCalledWith([event1, event2, event3]);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("should flush on interval", async () => {
|
|
203
|
+
emitter = new DurableEventEmitter(adapter, {
|
|
204
|
+
batchSize: 100,
|
|
205
|
+
flushIntervalMs: 50,
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
const event: ExecutionEvent = {
|
|
209
|
+
type: "HTTP_REQUEST",
|
|
210
|
+
event_id: "1",
|
|
211
|
+
seq: 0,
|
|
212
|
+
timestamp: Date.now(),
|
|
213
|
+
monitor_id: "monitor-1",
|
|
214
|
+
execution_id: "exec-1",
|
|
215
|
+
organization_id: "test-org",
|
|
216
|
+
node_id: "node-1",
|
|
217
|
+
attempt: 1,
|
|
218
|
+
method: "GET",
|
|
219
|
+
url: "https://api.example.com/test",
|
|
220
|
+
has_body: false,
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
emitter.emit(event);
|
|
224
|
+
|
|
225
|
+
// Wait for interval flush
|
|
226
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
227
|
+
|
|
228
|
+
expect(publishSpy).toHaveBeenCalledWith([event]);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("should flush manually when requested", async () => {
|
|
232
|
+
emitter = new DurableEventEmitter(adapter, {
|
|
233
|
+
batchSize: 100,
|
|
234
|
+
flushIntervalMs: 10000,
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
const event: ExecutionEvent = {
|
|
238
|
+
type: "HTTP_RESPONSE",
|
|
239
|
+
event_id: "1",
|
|
240
|
+
seq: 0,
|
|
241
|
+
timestamp: Date.now(),
|
|
242
|
+
monitor_id: "monitor-1",
|
|
243
|
+
execution_id: "exec-1",
|
|
244
|
+
organization_id: "test-org",
|
|
245
|
+
node_id: "node-1",
|
|
246
|
+
attempt: 1,
|
|
247
|
+
status: 200,
|
|
248
|
+
status_text: "OK",
|
|
249
|
+
duration_ms: 100,
|
|
250
|
+
has_body: true,
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
emitter.emit(event);
|
|
254
|
+
await emitter.flush();
|
|
255
|
+
|
|
256
|
+
expect(publishSpy).toHaveBeenCalledWith([event]);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("should not emit after being destroyed", async () => {
|
|
260
|
+
emitter = new DurableEventEmitter(adapter, {
|
|
261
|
+
batchSize: 100,
|
|
262
|
+
flushIntervalMs: 50,
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
const consoleWarnSpy = vi
|
|
266
|
+
.spyOn(console, "warn")
|
|
267
|
+
.mockImplementation(() => {});
|
|
268
|
+
|
|
269
|
+
emitter.destroy();
|
|
270
|
+
|
|
271
|
+
const event: ExecutionEvent = {
|
|
272
|
+
type: "ERROR",
|
|
273
|
+
event_id: "1",
|
|
274
|
+
seq: 0,
|
|
275
|
+
timestamp: Date.now(),
|
|
276
|
+
monitor_id: "monitor-1",
|
|
277
|
+
execution_id: "exec-1",
|
|
278
|
+
organization_id: "test-org",
|
|
279
|
+
error_name: "TestError",
|
|
280
|
+
message: "Test error message",
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
emitter.emit(event);
|
|
284
|
+
|
|
285
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
286
|
+
|
|
287
|
+
expect(publishSpy).not.toHaveBeenCalled();
|
|
288
|
+
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
|
289
|
+
"Cannot emit event: emitter is destroyed",
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
consoleWarnSpy.mockRestore();
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("should handle publish failures gracefully", async () => {
|
|
296
|
+
const consoleErrorSpy = vi
|
|
297
|
+
.spyOn(console, "error")
|
|
298
|
+
.mockImplementation(() => {});
|
|
299
|
+
publishSpy.mockRejectedValueOnce(new Error("Publish failed"));
|
|
300
|
+
|
|
301
|
+
emitter = new DurableEventEmitter(adapter, {
|
|
302
|
+
batchSize: 1,
|
|
303
|
+
flushIntervalMs: 10000,
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
const event: ExecutionEvent = {
|
|
307
|
+
type: "WAIT_START",
|
|
308
|
+
event_id: "1",
|
|
309
|
+
seq: 0,
|
|
310
|
+
timestamp: Date.now(),
|
|
311
|
+
monitor_id: "monitor-1",
|
|
312
|
+
execution_id: "exec-1",
|
|
313
|
+
organization_id: "test-org",
|
|
314
|
+
node_id: "wait-1",
|
|
315
|
+
duration_ms: 1000,
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
emitter.emit(event);
|
|
319
|
+
|
|
320
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
321
|
+
|
|
322
|
+
expect(publishSpy).toHaveBeenCalled();
|
|
323
|
+
expect(consoleErrorSpy).toHaveBeenCalled();
|
|
324
|
+
|
|
325
|
+
consoleErrorSpy.mockRestore();
|
|
326
|
+
});
|
|
327
|
+
});
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import type { ExecutionEvent } from "./types.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pluggable event emitter interface for execution observability.
|
|
5
|
+
*
|
|
6
|
+
* Implementations can be synchronous (local) or asynchronous (durable bus).
|
|
7
|
+
* The executor treats emission as best-effort and non-blocking.
|
|
8
|
+
*/
|
|
9
|
+
export interface ExecutionEventEmitter {
|
|
10
|
+
/**
|
|
11
|
+
* Emit a single event. May be synchronous or asynchronous depending on implementation.
|
|
12
|
+
* Errors during emission should not propagate to the executor.
|
|
13
|
+
*/
|
|
14
|
+
emit(event: ExecutionEvent): void | Promise<void>;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Optional: flush any buffered events (for batching implementations).
|
|
18
|
+
* Called at the end of execution to ensure all events are delivered.
|
|
19
|
+
*/
|
|
20
|
+
flush?(): Promise<void>;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Optional: cleanup resources (timers, connections, etc.).
|
|
24
|
+
*/
|
|
25
|
+
destroy?(): void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* In-memory event emitter for local/development use.
|
|
30
|
+
* Events are delivered synchronously to all subscribers.
|
|
31
|
+
*/
|
|
32
|
+
export class LocalEventEmitter implements ExecutionEventEmitter {
|
|
33
|
+
private listeners: Array<(event: ExecutionEvent) => void> = [];
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Subscribe to all execution events.
|
|
37
|
+
* @returns Unsubscribe function
|
|
38
|
+
*/
|
|
39
|
+
subscribe(listener: (event: ExecutionEvent) => void): () => void {
|
|
40
|
+
this.listeners.push(listener);
|
|
41
|
+
return () => {
|
|
42
|
+
this.listeners = this.listeners.filter((l) => l !== listener);
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
emit(event: ExecutionEvent): void {
|
|
47
|
+
for (const listener of this.listeners) {
|
|
48
|
+
try {
|
|
49
|
+
listener(event);
|
|
50
|
+
} catch (error) {
|
|
51
|
+
// Don't let listener errors break execution
|
|
52
|
+
console.error("Error in event listener:", error);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Adapter interface for durable event buses (SQS, Kafka, Redis Streams, etc.).
|
|
60
|
+
* Implementations provided by callers based on their infrastructure.
|
|
61
|
+
*/
|
|
62
|
+
export interface DurableEventBusAdapter {
|
|
63
|
+
/**
|
|
64
|
+
* Publish a batch of events to the durable bus.
|
|
65
|
+
* Should handle serialization, retries, and error handling internally.
|
|
66
|
+
*/
|
|
67
|
+
publish(events: ExecutionEvent[]): Promise<void>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Event emitter that batches events for efficient delivery to durable event buses.
|
|
72
|
+
* Flushes on interval and when batch size is reached.
|
|
73
|
+
*/
|
|
74
|
+
export class DurableEventEmitter implements ExecutionEventEmitter {
|
|
75
|
+
private buffer: ExecutionEvent[] = [];
|
|
76
|
+
private flushInterval: NodeJS.Timeout | null = null;
|
|
77
|
+
private isDestroyed = false;
|
|
78
|
+
|
|
79
|
+
constructor(
|
|
80
|
+
private adapter: DurableEventBusAdapter,
|
|
81
|
+
private options: {
|
|
82
|
+
/** Number of events to batch before auto-flushing (default: 50) */
|
|
83
|
+
batchSize?: number;
|
|
84
|
+
/** Milliseconds between auto-flushes (default: 100) */
|
|
85
|
+
flushIntervalMs?: number;
|
|
86
|
+
} = {},
|
|
87
|
+
) {
|
|
88
|
+
const intervalMs = options.flushIntervalMs ?? 100;
|
|
89
|
+
this.flushInterval = setInterval(() => {
|
|
90
|
+
this.flush().catch((error) => {
|
|
91
|
+
console.error("Error flushing events:", error);
|
|
92
|
+
});
|
|
93
|
+
}, intervalMs);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
emit(event: ExecutionEvent): void {
|
|
97
|
+
if (this.isDestroyed) {
|
|
98
|
+
console.warn("Cannot emit event: emitter is destroyed");
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
this.buffer.push(event);
|
|
103
|
+
|
|
104
|
+
// Auto-flush when batch size is reached
|
|
105
|
+
if (this.buffer.length >= (this.options.batchSize ?? 50)) {
|
|
106
|
+
this.flush().catch((error) => {
|
|
107
|
+
console.error("Error auto-flushing events:", error);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async flush(): Promise<void> {
|
|
113
|
+
if (this.buffer.length === 0) return;
|
|
114
|
+
|
|
115
|
+
const events = this.buffer;
|
|
116
|
+
this.buffer = [];
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
await this.adapter.publish(events);
|
|
120
|
+
} catch (error) {
|
|
121
|
+
console.error("Failed to publish events:", error);
|
|
122
|
+
// Events are lost on failure - caller adapter should implement retries if needed
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
destroy(): void {
|
|
127
|
+
this.isDestroyed = true;
|
|
128
|
+
if (this.flushInterval) {
|
|
129
|
+
clearInterval(this.flushInterval);
|
|
130
|
+
this.flushInterval = null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|