@aikirun/client 0.23.1 → 0.24.1
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 +1 -6
- package/dist/index.d.ts +5 -32
- package/dist/index.js +7 -607
- package/package.json +3 -5
package/README.md
CHANGED
|
@@ -14,10 +14,9 @@ npm install @aikirun/client
|
|
|
14
14
|
import { client } from "@aikirun/client";
|
|
15
15
|
import { orderWorkflowV1 } from "./workflows.ts";
|
|
16
16
|
|
|
17
|
-
// Set AIKI_API_KEY env variable or pass apiKey option
|
|
18
17
|
const aikiClient = client({
|
|
19
18
|
url: "http://localhost:9850",
|
|
20
|
-
|
|
19
|
+
apiKey: "your-api-key",
|
|
21
20
|
});
|
|
22
21
|
|
|
23
22
|
// Start a workflow
|
|
@@ -27,16 +26,12 @@ const handle = await orderWorkflowV1.start(aikiClient, {
|
|
|
27
26
|
|
|
28
27
|
// Wait for completion
|
|
29
28
|
const result = await handle.waitForStatus("completed");
|
|
30
|
-
|
|
31
|
-
// Close when done
|
|
32
|
-
await aikiClient.close();
|
|
33
29
|
```
|
|
34
30
|
|
|
35
31
|
## Features
|
|
36
32
|
|
|
37
33
|
- **Server Connection** - Connect to the Aiki server via HTTP
|
|
38
34
|
- **Workflow Management** - Start workflows with type-safe inputs
|
|
39
|
-
- **Redis Integration** - Distributed state and message streaming
|
|
40
35
|
- **Context Injection** - Pass application context to workflows
|
|
41
36
|
- **Custom Logging** - Plug in your own logger
|
|
42
37
|
|
package/dist/index.d.ts
CHANGED
|
@@ -1,20 +1,15 @@
|
|
|
1
|
-
import { ClientParams, Client
|
|
2
|
-
export { ApiClient, Client, ClientParams, DbSubscriberStrategy, Logger, RedisConfig, RedisStreamsSubscriberStrategy, ResolvedSubscriberStrategy, SubscriberStrategy, WorkflowRunBatch } from '@aikirun/types/client';
|
|
1
|
+
import { ClientParams, Client } from '@aikirun/types/client';
|
|
3
2
|
|
|
4
3
|
/**
|
|
5
4
|
* Creates an Aiki client for starting and managing workflows.
|
|
6
5
|
*
|
|
7
|
-
* The client connects to the Aiki server via HTTP
|
|
6
|
+
* The client connects to the Aiki server via HTTP.
|
|
8
7
|
* It provides methods to start workflows and monitor their execution.
|
|
9
8
|
*
|
|
10
9
|
* @template AppContext - Type of application context passed to workflows (default: null)
|
|
11
10
|
* @param params - Client configuration parameters
|
|
12
11
|
* @param params.url - HTTP URL of the Aiki server (e.g., "http://localhost:9850")
|
|
13
|
-
* @param params.apiKey - API key for authentication
|
|
14
|
-
* @param params.redis - Redis connection configuration
|
|
15
|
-
* @param params.redis.host - Redis server hostname
|
|
16
|
-
* @param params.redis.port - Redis server port
|
|
17
|
-
* @param params.redis.password - Optional Redis password
|
|
12
|
+
* @param params.apiKey - API key for authentication
|
|
18
13
|
* @param params.createContext - Optional function to create context for each workflow run
|
|
19
14
|
* @param params.logger - Optional custom logger (defaults to ConsoleLogger)
|
|
20
15
|
* @returns Promise resolving to a configured Client instance
|
|
@@ -23,8 +18,7 @@ export { ApiClient, Client, ClientParams, DbSubscriberStrategy, Logger, RedisCon
|
|
|
23
18
|
* ```typescript
|
|
24
19
|
* const aikiClient = client({
|
|
25
20
|
* url: "http://localhost:9850",
|
|
26
|
-
* apiKey: "yourApiKey",
|
|
27
|
-
* redis: { host: "localhost", port: 6379 },
|
|
21
|
+
* apiKey: "yourApiKey",
|
|
28
22
|
* createContext: (run) => ({
|
|
29
23
|
* traceId: generateTraceId(),
|
|
30
24
|
* userId: extractUserId(run),
|
|
@@ -39,29 +33,8 @@ export { ApiClient, Client, ClientParams, DbSubscriberStrategy, Logger, RedisCon
|
|
|
39
33
|
* { type: "status", status: "completed" },
|
|
40
34
|
* { maxDurationMs: 60_000 }
|
|
41
35
|
* );
|
|
42
|
-
*
|
|
43
|
-
* // Cleanup
|
|
44
|
-
* await aikiClient.close();
|
|
45
36
|
* ```
|
|
46
37
|
*/
|
|
47
38
|
declare function client<AppContext = null>(params: ClientParams<AppContext>): Client<AppContext>;
|
|
48
39
|
|
|
49
|
-
|
|
50
|
-
interface ConsoleLoggerOptions {
|
|
51
|
-
level?: LogLevel;
|
|
52
|
-
context?: Record<string, unknown>;
|
|
53
|
-
}
|
|
54
|
-
declare class ConsoleLogger implements Logger {
|
|
55
|
-
private readonly level;
|
|
56
|
-
private readonly context;
|
|
57
|
-
constructor(options?: ConsoleLoggerOptions);
|
|
58
|
-
trace(message: string, metadata?: Record<string, unknown>): void;
|
|
59
|
-
debug(message: string, metadata?: Record<string, unknown>): void;
|
|
60
|
-
info(message: string, metadata?: Record<string, unknown>): void;
|
|
61
|
-
warn(message: string, metadata?: Record<string, unknown>): void;
|
|
62
|
-
error(message: string, metadata?: Record<string, unknown>): void;
|
|
63
|
-
child(bindings: Record<string, unknown>): Logger;
|
|
64
|
-
private format;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export { ConsoleLogger, client };
|
|
40
|
+
export { client };
|
package/dist/index.js
CHANGED
|
@@ -1,11 +1,4 @@
|
|
|
1
|
-
//
|
|
2
|
-
import process from "process";
|
|
3
|
-
import { INTERNAL as INTERNAL2 } from "@aikirun/types/symbols";
|
|
4
|
-
import { createORPCClient } from "@orpc/client";
|
|
5
|
-
import { RPCLink } from "@orpc/client/fetch";
|
|
6
|
-
import { Redis } from "ioredis";
|
|
7
|
-
|
|
8
|
-
// logger/console-logger.ts
|
|
1
|
+
// ../../lib/logger/console-logger.ts
|
|
9
2
|
var colors = {
|
|
10
3
|
reset: "\x1B[0m",
|
|
11
4
|
dim: "\x1B[2m",
|
|
@@ -83,572 +76,10 @@ var ConsoleLogger = class _ConsoleLogger {
|
|
|
83
76
|
}
|
|
84
77
|
};
|
|
85
78
|
|
|
86
|
-
// ../../lib/array/utils.ts
|
|
87
|
-
function isNonEmptyArray(value) {
|
|
88
|
-
return value !== void 0 && value.length > 0;
|
|
89
|
-
}
|
|
90
|
-
function shuffleArray(array) {
|
|
91
|
-
const shuffledArray = Array.from(array);
|
|
92
|
-
for (let i = shuffledArray.length - 1; i > 0; i--) {
|
|
93
|
-
const j = Math.floor(Math.random() * (i + 1));
|
|
94
|
-
[shuffledArray[i], shuffledArray[j]] = [shuffledArray[j], shuffledArray[i]];
|
|
95
|
-
}
|
|
96
|
-
return shuffledArray;
|
|
97
|
-
}
|
|
98
|
-
function distributeRoundRobin(totalSize, itemCount) {
|
|
99
|
-
if (itemCount <= 0) return [];
|
|
100
|
-
const distribution = Array(itemCount).fill(0);
|
|
101
|
-
for (let i = 0; i < totalSize; i++) {
|
|
102
|
-
distribution[i % itemCount]++;
|
|
103
|
-
}
|
|
104
|
-
return distribution;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
// ../../lib/crypto/hash.ts
|
|
108
|
-
import { createHash } from "crypto";
|
|
109
|
-
|
|
110
|
-
// ../../lib/duration/convert.ts
|
|
111
|
-
var MS_PER_SECOND = 1e3;
|
|
112
|
-
var MS_PER_MINUTE = 60 * MS_PER_SECOND;
|
|
113
|
-
var MS_PER_HOUR = 60 * MS_PER_MINUTE;
|
|
114
|
-
var MS_PER_DAY = 24 * MS_PER_HOUR;
|
|
115
|
-
|
|
116
|
-
// ../../lib/retry/strategy.ts
|
|
117
|
-
function getRetryParams(attempts, strategy) {
|
|
118
|
-
const strategyType = strategy.type;
|
|
119
|
-
switch (strategyType) {
|
|
120
|
-
case "never":
|
|
121
|
-
return {
|
|
122
|
-
retriesLeft: false
|
|
123
|
-
};
|
|
124
|
-
case "fixed":
|
|
125
|
-
if (attempts >= strategy.maxAttempts) {
|
|
126
|
-
return {
|
|
127
|
-
retriesLeft: false
|
|
128
|
-
};
|
|
129
|
-
}
|
|
130
|
-
return {
|
|
131
|
-
retriesLeft: true,
|
|
132
|
-
delayMs: strategy.delayMs
|
|
133
|
-
};
|
|
134
|
-
case "exponential": {
|
|
135
|
-
if (attempts >= strategy.maxAttempts) {
|
|
136
|
-
return {
|
|
137
|
-
retriesLeft: false
|
|
138
|
-
};
|
|
139
|
-
}
|
|
140
|
-
const delayMs = strategy.baseDelayMs * (strategy.factor ?? 2) ** (attempts - 1);
|
|
141
|
-
return {
|
|
142
|
-
retriesLeft: true,
|
|
143
|
-
delayMs: Math.min(delayMs, strategy.maxDelayMs ?? Number.POSITIVE_INFINITY)
|
|
144
|
-
};
|
|
145
|
-
}
|
|
146
|
-
case "jittered": {
|
|
147
|
-
if (attempts >= strategy.maxAttempts) {
|
|
148
|
-
return {
|
|
149
|
-
retriesLeft: false
|
|
150
|
-
};
|
|
151
|
-
}
|
|
152
|
-
const base = strategy.baseDelayMs * (strategy.jitterFactor ?? 2) ** (attempts - 1);
|
|
153
|
-
const delayMs = Math.random() * base;
|
|
154
|
-
return {
|
|
155
|
-
retriesLeft: true,
|
|
156
|
-
delayMs: Math.min(delayMs, strategy.maxDelayMs ?? Number.POSITIVE_INFINITY)
|
|
157
|
-
};
|
|
158
|
-
}
|
|
159
|
-
default:
|
|
160
|
-
return strategyType;
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
// subscribers/db.ts
|
|
165
|
-
function createDbStrategy(client2, strategy, workflows, workerShards) {
|
|
166
|
-
const logger = client2.logger.child({
|
|
167
|
-
"aiki.component": "db-subscriber"
|
|
168
|
-
});
|
|
169
|
-
const intervalMs = strategy.intervalMs ?? 1e3;
|
|
170
|
-
const maxRetryIntervalMs = strategy.maxRetryIntervalMs ?? 3e4;
|
|
171
|
-
const atCapacityIntervalMs = strategy.atCapacityIntervalMs ?? 500;
|
|
172
|
-
const claimMinIdleTimeMs = strategy.claimMinIdleTimeMs ?? 9e4;
|
|
173
|
-
const workflowFilters = !isNonEmptyArray(workerShards) ? workflows.map((workflow) => ({ name: workflow.name, versionId: workflow.versionId })) : workflows.flatMap(
|
|
174
|
-
(workflow) => workerShards.map((shard) => ({ name: workflow.name, versionId: workflow.versionId, shard }))
|
|
175
|
-
);
|
|
176
|
-
const getNextDelay = (params) => {
|
|
177
|
-
switch (params.type) {
|
|
178
|
-
case "polled":
|
|
179
|
-
case "heartbeat":
|
|
180
|
-
return intervalMs;
|
|
181
|
-
case "at_capacity":
|
|
182
|
-
return atCapacityIntervalMs;
|
|
183
|
-
case "retry": {
|
|
184
|
-
const retryParams = getRetryParams(params.attemptNumber, {
|
|
185
|
-
type: "jittered",
|
|
186
|
-
maxAttempts: Number.POSITIVE_INFINITY,
|
|
187
|
-
baseDelayMs: intervalMs,
|
|
188
|
-
maxDelayMs: maxRetryIntervalMs
|
|
189
|
-
});
|
|
190
|
-
if (!retryParams.retriesLeft) {
|
|
191
|
-
return maxRetryIntervalMs;
|
|
192
|
-
}
|
|
193
|
-
return retryParams.delayMs;
|
|
194
|
-
}
|
|
195
|
-
default:
|
|
196
|
-
return params;
|
|
197
|
-
}
|
|
198
|
-
};
|
|
199
|
-
return {
|
|
200
|
-
async init(workerId, _callbacks) {
|
|
201
|
-
return {
|
|
202
|
-
type: strategy.type,
|
|
203
|
-
getNextDelay,
|
|
204
|
-
getNextBatch: async (size) => {
|
|
205
|
-
const response = await client2.api.workflowRun.claimReadyV1({
|
|
206
|
-
workerId,
|
|
207
|
-
workflows: workflowFilters,
|
|
208
|
-
limit: size,
|
|
209
|
-
claimMinIdleTimeMs
|
|
210
|
-
});
|
|
211
|
-
return response.runs.map((run) => ({
|
|
212
|
-
data: { workflowRunId: run.id }
|
|
213
|
-
}));
|
|
214
|
-
},
|
|
215
|
-
heartbeat: async (workflowRunId) => {
|
|
216
|
-
try {
|
|
217
|
-
await client2.api.workflowRun.heartbeatV1({ id: workflowRunId });
|
|
218
|
-
logger.debug("Heartbeat sent", {
|
|
219
|
-
"aiki.workerId": workerId,
|
|
220
|
-
"aiki.workflowRunId": workflowRunId
|
|
221
|
-
});
|
|
222
|
-
} catch (error) {
|
|
223
|
-
logger.warn("Heartbeat failed", {
|
|
224
|
-
"aiki.workerId": workerId,
|
|
225
|
-
"aiki.workflowRunId": workflowRunId,
|
|
226
|
-
"aiki.error": error instanceof Error ? error.message : String(error)
|
|
227
|
-
});
|
|
228
|
-
}
|
|
229
|
-
},
|
|
230
|
-
acknowledge: async () => {
|
|
231
|
-
}
|
|
232
|
-
};
|
|
233
|
-
}
|
|
234
|
-
};
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
// ../../lib/address/index.ts
|
|
238
|
-
function getWorkflowStreamName(name, versionId, shard) {
|
|
239
|
-
return shard ? `workflow:${name}:${versionId}:${shard}` : `workflow:${name}:${versionId}`;
|
|
240
|
-
}
|
|
241
|
-
function getWorkerConsumerGroupName(workflowName, workflowVersionId, shard) {
|
|
242
|
-
return shard ? `worker:${workflowName}:${workflowVersionId}:${shard}` : `worker:${workflowName}:${workflowVersionId}`;
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
// subscribers/redis-streams.ts
|
|
246
|
-
import { INTERNAL } from "@aikirun/types/symbols";
|
|
247
|
-
import { type } from "arktype";
|
|
248
|
-
var streamEntriesSchema = type(["string", type(["string", "unknown[]"]).array()]).array();
|
|
249
|
-
var rawStreamMessageFieldsToRecord = (rawFields) => {
|
|
250
|
-
const data = {};
|
|
251
|
-
for (let i = 0; i < rawFields.length; i += 2) {
|
|
252
|
-
if (i + 1 < rawFields.length) {
|
|
253
|
-
const key = rawFields[i];
|
|
254
|
-
if (typeof key === "string") {
|
|
255
|
-
data[key] = rawFields[i + 1];
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
return data;
|
|
260
|
-
};
|
|
261
|
-
var streamMessageDataSchema = type({
|
|
262
|
-
type: "'workflow_run_ready'",
|
|
263
|
-
workflowRunId: "string > 0"
|
|
264
|
-
});
|
|
265
|
-
var streamPendingMessagesSchema = type(["string", "string", "number", "number"]).array();
|
|
266
|
-
function createRedisStreamsStrategy(client2, strategy, workflows, workerShards) {
|
|
267
|
-
const redis = client2[INTERNAL].redis.getConnection();
|
|
268
|
-
const logger = client2.logger.child({
|
|
269
|
-
"aiki.component": "redis-subscriber"
|
|
270
|
-
});
|
|
271
|
-
const streamConsumerGroupMap = getRedisStreamConsumerGroupMap(workflows, workerShards);
|
|
272
|
-
const streams = Array.from(streamConsumerGroupMap.keys());
|
|
273
|
-
const intervalMs = strategy.intervalMs ?? 50;
|
|
274
|
-
const maxRetryIntervalMs = strategy.maxRetryIntervalMs ?? 3e4;
|
|
275
|
-
const atCapacityIntervalMs = strategy.atCapacityIntervalMs ?? 50;
|
|
276
|
-
const blockTimeMs = strategy.blockTimeMs ?? 1e3;
|
|
277
|
-
const claimMinIdleTimeMs = strategy.claimMinIdleTimeMs ?? 9e4;
|
|
278
|
-
const getNextDelay = (params) => {
|
|
279
|
-
switch (params.type) {
|
|
280
|
-
case "polled":
|
|
281
|
-
case "heartbeat":
|
|
282
|
-
return intervalMs;
|
|
283
|
-
case "retry": {
|
|
284
|
-
const retryParams = getRetryParams(params.attemptNumber, {
|
|
285
|
-
type: "jittered",
|
|
286
|
-
maxAttempts: Number.POSITIVE_INFINITY,
|
|
287
|
-
baseDelayMs: intervalMs,
|
|
288
|
-
maxDelayMs: maxRetryIntervalMs
|
|
289
|
-
});
|
|
290
|
-
if (!retryParams.retriesLeft) {
|
|
291
|
-
return maxRetryIntervalMs;
|
|
292
|
-
}
|
|
293
|
-
return retryParams.delayMs;
|
|
294
|
-
}
|
|
295
|
-
case "at_capacity":
|
|
296
|
-
return atCapacityIntervalMs;
|
|
297
|
-
default:
|
|
298
|
-
return params;
|
|
299
|
-
}
|
|
300
|
-
};
|
|
301
|
-
return {
|
|
302
|
-
async init(workerId, _callbacks) {
|
|
303
|
-
for (const [stream, consumerGroup] of streamConsumerGroupMap) {
|
|
304
|
-
try {
|
|
305
|
-
await redis.xgroup("CREATE", stream, consumerGroup, "0", "MKSTREAM");
|
|
306
|
-
} catch (error) {
|
|
307
|
-
if (!error.message?.includes("BUSYGROUP")) {
|
|
308
|
-
throw error;
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
const pendingMessageMetaByWorkflowRunId = /* @__PURE__ */ new Map();
|
|
313
|
-
return {
|
|
314
|
-
type: strategy.type,
|
|
315
|
-
getNextDelay,
|
|
316
|
-
getNextBatch: async (size) => {
|
|
317
|
-
const messages = await fetchRedisStreamMessages(
|
|
318
|
-
redis,
|
|
319
|
-
logger.child({ "aiki.workerId": workerId }),
|
|
320
|
-
streams,
|
|
321
|
-
streamConsumerGroupMap,
|
|
322
|
-
workerId,
|
|
323
|
-
size,
|
|
324
|
-
blockTimeMs,
|
|
325
|
-
claimMinIdleTimeMs
|
|
326
|
-
);
|
|
327
|
-
const batches = [];
|
|
328
|
-
for (const message of messages) {
|
|
329
|
-
pendingMessageMetaByWorkflowRunId.set(message.data.workflowRunId, message.meta);
|
|
330
|
-
batches.push({ data: message.data });
|
|
331
|
-
}
|
|
332
|
-
return batches;
|
|
333
|
-
},
|
|
334
|
-
heartbeat: async (workflowRunId) => {
|
|
335
|
-
const meta = pendingMessageMetaByWorkflowRunId.get(workflowRunId);
|
|
336
|
-
if (!meta) {
|
|
337
|
-
return;
|
|
338
|
-
}
|
|
339
|
-
try {
|
|
340
|
-
await redis.xclaim(meta.stream, meta.consumerGroup, workerId, 0, meta.messageId, "JUSTID");
|
|
341
|
-
logger.debug("Heartbeat sent", {
|
|
342
|
-
"aiki.workerId": workerId,
|
|
343
|
-
"aiki.workflowRunId": workflowRunId,
|
|
344
|
-
"aiki.messageId": meta.messageId
|
|
345
|
-
});
|
|
346
|
-
} catch (error) {
|
|
347
|
-
logger.warn("Heartbeat failed", {
|
|
348
|
-
"aiki.workerId": workerId,
|
|
349
|
-
"aiki.workflowRunId": workflowRunId,
|
|
350
|
-
"aiki.error": error instanceof Error ? error.message : String(error)
|
|
351
|
-
});
|
|
352
|
-
}
|
|
353
|
-
},
|
|
354
|
-
acknowledge: async (workflowRunId) => {
|
|
355
|
-
const meta = pendingMessageMetaByWorkflowRunId.get(workflowRunId);
|
|
356
|
-
if (!meta) {
|
|
357
|
-
return;
|
|
358
|
-
}
|
|
359
|
-
try {
|
|
360
|
-
const result = await redis.xack(meta.stream, meta.consumerGroup, meta.messageId);
|
|
361
|
-
if (result === 0) {
|
|
362
|
-
logger.warn("Message already acknowledged", {
|
|
363
|
-
"aiki.workerId": workerId,
|
|
364
|
-
"aiki.workflowRunId": workflowRunId,
|
|
365
|
-
"aiki.messageId": meta.messageId
|
|
366
|
-
});
|
|
367
|
-
} else {
|
|
368
|
-
logger.debug("Message acknowledged", {
|
|
369
|
-
"aiki.workerId": workerId,
|
|
370
|
-
"aiki.workflowRunId": workflowRunId,
|
|
371
|
-
"aiki.messageId": meta.messageId
|
|
372
|
-
});
|
|
373
|
-
}
|
|
374
|
-
} catch (error) {
|
|
375
|
-
logger.error("Failed to acknowledge message", {
|
|
376
|
-
"aiki.workerId": workerId,
|
|
377
|
-
"aiki.workflowRunId": workflowRunId,
|
|
378
|
-
"aiki.messageId": meta.messageId,
|
|
379
|
-
"aiki.error": error instanceof Error ? error.message : String(error)
|
|
380
|
-
});
|
|
381
|
-
throw error;
|
|
382
|
-
} finally {
|
|
383
|
-
pendingMessageMetaByWorkflowRunId.delete(workflowRunId);
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
|
-
};
|
|
387
|
-
}
|
|
388
|
-
};
|
|
389
|
-
}
|
|
390
|
-
function getRedisStreamConsumerGroupMap(workflows, shards) {
|
|
391
|
-
if (!shards || !isNonEmptyArray(shards)) {
|
|
392
|
-
return new Map(
|
|
393
|
-
workflows.map((workflow) => [
|
|
394
|
-
getWorkflowStreamName(workflow.name, workflow.versionId),
|
|
395
|
-
getWorkerConsumerGroupName(workflow.name, workflow.versionId)
|
|
396
|
-
])
|
|
397
|
-
);
|
|
398
|
-
}
|
|
399
|
-
return new Map(
|
|
400
|
-
workflows.flatMap(
|
|
401
|
-
(workflow) => shards.map((shard) => [
|
|
402
|
-
getWorkflowStreamName(workflow.name, workflow.versionId, shard),
|
|
403
|
-
getWorkerConsumerGroupName(workflow.name, workflow.versionId, shard)
|
|
404
|
-
])
|
|
405
|
-
)
|
|
406
|
-
);
|
|
407
|
-
}
|
|
408
|
-
async function fetchRedisStreamMessages(redis, logger, streams, streamConsumerGroupMap, workerId, size, blockTimeMs, claimMinIdleTimeMs) {
|
|
409
|
-
if (!isNonEmptyArray(streams)) {
|
|
410
|
-
return [];
|
|
411
|
-
}
|
|
412
|
-
const perStreamBlockTimeMs = Math.max(50, Math.floor(blockTimeMs / streams.length));
|
|
413
|
-
const batchSizePerStream = distributeRoundRobin(size, streams.length);
|
|
414
|
-
const shuffledStreams = shuffleArray(streams);
|
|
415
|
-
const streamEntries = [];
|
|
416
|
-
for (let i = 0; i < shuffledStreams.length; i++) {
|
|
417
|
-
const stream = shuffledStreams[i];
|
|
418
|
-
if (!stream) {
|
|
419
|
-
continue;
|
|
420
|
-
}
|
|
421
|
-
const streamBatchSize = batchSizePerStream[i];
|
|
422
|
-
if (!streamBatchSize || streamBatchSize === 0) {
|
|
423
|
-
continue;
|
|
424
|
-
}
|
|
425
|
-
const consumerGroup = streamConsumerGroupMap.get(stream);
|
|
426
|
-
if (!consumerGroup) {
|
|
427
|
-
continue;
|
|
428
|
-
}
|
|
429
|
-
try {
|
|
430
|
-
const result = await redis.xreadgroup(
|
|
431
|
-
"GROUP",
|
|
432
|
-
consumerGroup,
|
|
433
|
-
workerId,
|
|
434
|
-
"COUNT",
|
|
435
|
-
streamBatchSize,
|
|
436
|
-
"BLOCK",
|
|
437
|
-
perStreamBlockTimeMs,
|
|
438
|
-
"STREAMS",
|
|
439
|
-
stream,
|
|
440
|
-
">"
|
|
441
|
-
);
|
|
442
|
-
if (result) {
|
|
443
|
-
streamEntries.push(result);
|
|
444
|
-
}
|
|
445
|
-
} catch (error) {
|
|
446
|
-
logger.error("XREADGROUP failed", {
|
|
447
|
-
"aiki.stream": stream,
|
|
448
|
-
"aiki.error": error instanceof Error ? error.message : String(error)
|
|
449
|
-
});
|
|
450
|
-
}
|
|
451
|
-
}
|
|
452
|
-
const workflowRuns = isNonEmptyArray(streamEntries) ? await processRedisStreamMessages(redis, logger, streamConsumerGroupMap, streamEntries) : [];
|
|
453
|
-
const remainingCapacity = size - workflowRuns.length;
|
|
454
|
-
if (remainingCapacity > 0 && claimMinIdleTimeMs > 0) {
|
|
455
|
-
const claimedWorkflowRuns = await claimStuckRedisStreamMessages(
|
|
456
|
-
redis,
|
|
457
|
-
logger,
|
|
458
|
-
shuffledStreams,
|
|
459
|
-
streamConsumerGroupMap,
|
|
460
|
-
workerId,
|
|
461
|
-
remainingCapacity,
|
|
462
|
-
claimMinIdleTimeMs
|
|
463
|
-
);
|
|
464
|
-
for (const workflowRun of claimedWorkflowRuns) {
|
|
465
|
-
workflowRuns.push(workflowRun);
|
|
466
|
-
}
|
|
467
|
-
}
|
|
468
|
-
return workflowRuns;
|
|
469
|
-
}
|
|
470
|
-
async function processRedisStreamMessages(redis, logger, streamConsumerGroupMap, streamsEntries) {
|
|
471
|
-
const workflowRuns = [];
|
|
472
|
-
for (const streamEntriesRaw of streamsEntries) {
|
|
473
|
-
logger.debug("Raw stream entries", { "aiki.entries": streamEntriesRaw });
|
|
474
|
-
const streamEntriesResult = streamEntriesSchema(streamEntriesRaw);
|
|
475
|
-
if (streamEntriesResult instanceof type.errors) {
|
|
476
|
-
logger.error("Invalid stream entries format", {
|
|
477
|
-
"aiki.error": streamEntriesResult.summary
|
|
478
|
-
});
|
|
479
|
-
continue;
|
|
480
|
-
}
|
|
481
|
-
for (const streamEntry of streamEntriesResult) {
|
|
482
|
-
const [stream, messages] = streamEntry;
|
|
483
|
-
const consumerGroup = streamConsumerGroupMap.get(stream);
|
|
484
|
-
if (!consumerGroup) {
|
|
485
|
-
logger.error("No consumer group found for stream", {
|
|
486
|
-
"aiki.stream": stream
|
|
487
|
-
});
|
|
488
|
-
continue;
|
|
489
|
-
}
|
|
490
|
-
for (const [messageId, rawFields] of messages) {
|
|
491
|
-
const rawMessageData = rawStreamMessageFieldsToRecord(rawFields);
|
|
492
|
-
const messageData = streamMessageDataSchema(rawMessageData);
|
|
493
|
-
if (messageData instanceof type.errors) {
|
|
494
|
-
logger.warn("Invalid message structure", {
|
|
495
|
-
"aiki.stream": stream,
|
|
496
|
-
"aiki.messageId": messageId,
|
|
497
|
-
"aiki.error": messageData.summary
|
|
498
|
-
});
|
|
499
|
-
await redis.xack(stream, consumerGroup, messageId);
|
|
500
|
-
continue;
|
|
501
|
-
}
|
|
502
|
-
switch (messageData.type) {
|
|
503
|
-
case "workflow_run_ready": {
|
|
504
|
-
const workflowRunId = messageData.workflowRunId;
|
|
505
|
-
workflowRuns.push({
|
|
506
|
-
data: { workflowRunId },
|
|
507
|
-
meta: {
|
|
508
|
-
stream,
|
|
509
|
-
messageId,
|
|
510
|
-
consumerGroup
|
|
511
|
-
}
|
|
512
|
-
});
|
|
513
|
-
break;
|
|
514
|
-
}
|
|
515
|
-
default:
|
|
516
|
-
messageData.type;
|
|
517
|
-
continue;
|
|
518
|
-
}
|
|
519
|
-
}
|
|
520
|
-
}
|
|
521
|
-
}
|
|
522
|
-
return workflowRuns;
|
|
523
|
-
}
|
|
524
|
-
async function claimStuckRedisStreamMessages(redis, logger, shuffledStreams, streamConsumerGroupMap, workerId, maxClaim, minIdleMs) {
|
|
525
|
-
if (maxClaim <= 0 || minIdleMs <= 0) {
|
|
526
|
-
return [];
|
|
527
|
-
}
|
|
528
|
-
const claimaibleMessagesByStream = await findClaimableMessagesByStream(
|
|
529
|
-
redis,
|
|
530
|
-
logger,
|
|
531
|
-
shuffledStreams,
|
|
532
|
-
streamConsumerGroupMap,
|
|
533
|
-
workerId,
|
|
534
|
-
maxClaim,
|
|
535
|
-
minIdleMs
|
|
536
|
-
);
|
|
537
|
-
if (!claimaibleMessagesByStream.size) {
|
|
538
|
-
return [];
|
|
539
|
-
}
|
|
540
|
-
const claimPromises = Array.from(claimaibleMessagesByStream.entries()).map(async ([stream, messageIds]) => {
|
|
541
|
-
if (!messageIds.length) {
|
|
542
|
-
return null;
|
|
543
|
-
}
|
|
544
|
-
const consumerGroup = streamConsumerGroupMap.get(stream);
|
|
545
|
-
if (!consumerGroup) {
|
|
546
|
-
return null;
|
|
547
|
-
}
|
|
548
|
-
try {
|
|
549
|
-
const claimedMessages = await redis.xclaim(stream, consumerGroup, workerId, minIdleMs, ...messageIds);
|
|
550
|
-
return { stream, claimedMessages };
|
|
551
|
-
} catch (error) {
|
|
552
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
553
|
-
if (errorMessage.includes("NOGROUP")) {
|
|
554
|
-
logger.warn("Consumer group does not exist for stream, skipping claim operation", {
|
|
555
|
-
"aiki.stream": stream,
|
|
556
|
-
"aiki.consumerGroup": consumerGroup
|
|
557
|
-
});
|
|
558
|
-
} else if (errorMessage.includes("BUSYGROUP")) {
|
|
559
|
-
logger.warn("Consumer group busy for stream, skipping claim operation", {
|
|
560
|
-
"aiki.stream": stream,
|
|
561
|
-
"aiki.consumerGroup": consumerGroup
|
|
562
|
-
});
|
|
563
|
-
} else if (errorMessage.includes("NOSCRIPT")) {
|
|
564
|
-
logger.warn("Redis script not loaded for stream, skipping claim operation", {
|
|
565
|
-
"aiki.stream": stream,
|
|
566
|
-
"aiki.consumerGroup": consumerGroup
|
|
567
|
-
});
|
|
568
|
-
} else {
|
|
569
|
-
logger.error("Failed to claim messages from stream", {
|
|
570
|
-
"aiki.stream": stream,
|
|
571
|
-
"aiki.consumerGroup": consumerGroup,
|
|
572
|
-
"aiki.messageIds": messageIds.length,
|
|
573
|
-
"aiki.error": errorMessage
|
|
574
|
-
});
|
|
575
|
-
}
|
|
576
|
-
return null;
|
|
577
|
-
}
|
|
578
|
-
});
|
|
579
|
-
const claimResults = await Promise.allSettled(claimPromises);
|
|
580
|
-
const claimedStreamEntries = [];
|
|
581
|
-
for (const result of claimResults) {
|
|
582
|
-
if (result.status === "fulfilled" && result.value !== null) {
|
|
583
|
-
const { stream, claimedMessages } = result.value;
|
|
584
|
-
claimedStreamEntries.push([stream, claimedMessages]);
|
|
585
|
-
}
|
|
586
|
-
}
|
|
587
|
-
if (!isNonEmptyArray(claimedStreamEntries)) {
|
|
588
|
-
return [];
|
|
589
|
-
}
|
|
590
|
-
return processRedisStreamMessages(redis, logger, streamConsumerGroupMap, [claimedStreamEntries]);
|
|
591
|
-
}
|
|
592
|
-
async function findClaimableMessagesByStream(redis, logger, shuffledStreams, streamConsumerGroupMap, workerId, maxClaim, minIdleMs) {
|
|
593
|
-
const claimSizePerStream = distributeRoundRobin(maxClaim, shuffledStreams.length);
|
|
594
|
-
const pendingPromises = [];
|
|
595
|
-
for (let i = 0; i < shuffledStreams.length; i++) {
|
|
596
|
-
const stream = shuffledStreams[i];
|
|
597
|
-
if (!stream) {
|
|
598
|
-
continue;
|
|
599
|
-
}
|
|
600
|
-
const claimSize = claimSizePerStream[i];
|
|
601
|
-
if (!claimSize || claimSize === 0) {
|
|
602
|
-
continue;
|
|
603
|
-
}
|
|
604
|
-
const consumerGroup = streamConsumerGroupMap.get(stream);
|
|
605
|
-
if (!consumerGroup) {
|
|
606
|
-
continue;
|
|
607
|
-
}
|
|
608
|
-
const readPromise = redis.xpending(stream, consumerGroup, "IDLE", minIdleMs, "-", "+", claimSize).then((result) => ({ stream, result }));
|
|
609
|
-
pendingPromises.push(readPromise);
|
|
610
|
-
}
|
|
611
|
-
const pendingResults = await Promise.allSettled(pendingPromises);
|
|
612
|
-
const claimableMessagesByStream = /* @__PURE__ */ new Map();
|
|
613
|
-
for (const pendingResult of pendingResults) {
|
|
614
|
-
if (pendingResult.status !== "fulfilled") {
|
|
615
|
-
continue;
|
|
616
|
-
}
|
|
617
|
-
const { stream, result } = pendingResult.value;
|
|
618
|
-
const parsedResult = streamPendingMessagesSchema(result);
|
|
619
|
-
if (parsedResult instanceof type.errors) {
|
|
620
|
-
logger.error("Invalid XPENDING response", {
|
|
621
|
-
"aiki.stream": stream,
|
|
622
|
-
"aiki.error": parsedResult.summary
|
|
623
|
-
});
|
|
624
|
-
continue;
|
|
625
|
-
}
|
|
626
|
-
const claimableStreamMessages = claimableMessagesByStream.get(stream) ?? [];
|
|
627
|
-
for (const [messageId, consumerName, _idleTimeMs, _deliveryCount] of parsedResult) {
|
|
628
|
-
if (consumerName !== workerId) {
|
|
629
|
-
claimableStreamMessages.push(messageId);
|
|
630
|
-
}
|
|
631
|
-
}
|
|
632
|
-
if (claimableStreamMessages.length) {
|
|
633
|
-
claimableMessagesByStream.set(stream, claimableStreamMessages);
|
|
634
|
-
}
|
|
635
|
-
}
|
|
636
|
-
return claimableMessagesByStream;
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
// subscribers/strategy-resolver.ts
|
|
640
|
-
function resolveSubscriberStrategy(client2, strategy, workflows, workerShards) {
|
|
641
|
-
switch (strategy.type) {
|
|
642
|
-
case "redis":
|
|
643
|
-
return createRedisStreamsStrategy(client2, strategy, workflows, workerShards);
|
|
644
|
-
case "db":
|
|
645
|
-
return createDbStrategy(client2, strategy, workflows, workerShards);
|
|
646
|
-
default:
|
|
647
|
-
return strategy;
|
|
648
|
-
}
|
|
649
|
-
}
|
|
650
|
-
|
|
651
79
|
// client.ts
|
|
80
|
+
import { INTERNAL } from "@aikirun/types/symbols";
|
|
81
|
+
import { createORPCClient } from "@orpc/client";
|
|
82
|
+
import { RPCLink } from "@orpc/client/fetch";
|
|
652
83
|
function client(params) {
|
|
653
84
|
return new ClientImpl(params);
|
|
654
85
|
}
|
|
@@ -656,10 +87,7 @@ var ClientImpl = class {
|
|
|
656
87
|
constructor(params) {
|
|
657
88
|
this.params = params;
|
|
658
89
|
this.logger = params.logger ?? new ConsoleLogger();
|
|
659
|
-
const apiKey = params
|
|
660
|
-
if (!apiKey) {
|
|
661
|
-
throw new Error(`API key is required. Provide it via 'apiKey' param or AIKI_API_KEY env variable`);
|
|
662
|
-
}
|
|
90
|
+
const { apiKey } = params;
|
|
663
91
|
const rpcLink = new RPCLink({
|
|
664
92
|
url: `${params.url}/api`,
|
|
665
93
|
headers: () => ({
|
|
@@ -670,42 +98,14 @@ var ClientImpl = class {
|
|
|
670
98
|
this.logger.info("Aiki client initialized", {
|
|
671
99
|
"aiki.url": params.url
|
|
672
100
|
});
|
|
673
|
-
this[
|
|
674
|
-
subscriber: {
|
|
675
|
-
create: (strategy, workflows, workerShards) => resolveSubscriberStrategy(this, strategy, workflows, workerShards)
|
|
676
|
-
},
|
|
677
|
-
redis: {
|
|
678
|
-
getConnection: () => this.getRedisConnection(),
|
|
679
|
-
closeConnection: () => this.closeRedisConnection()
|
|
680
|
-
},
|
|
101
|
+
this[INTERNAL] = {
|
|
681
102
|
createContext: this.params.createContext
|
|
682
103
|
};
|
|
683
104
|
}
|
|
684
105
|
api;
|
|
685
|
-
[
|
|
106
|
+
[INTERNAL];
|
|
686
107
|
logger;
|
|
687
|
-
redisConnection;
|
|
688
|
-
async close() {
|
|
689
|
-
this.logger.info("Closing Aiki client");
|
|
690
|
-
await this.closeRedisConnection();
|
|
691
|
-
}
|
|
692
|
-
getRedisConnection() {
|
|
693
|
-
if (!this.redisConnection) {
|
|
694
|
-
if (!this.params.redis) {
|
|
695
|
-
throw new Error("Redis configuration not provided to client. Add 'redis' to ClientParams.");
|
|
696
|
-
}
|
|
697
|
-
this.redisConnection = new Redis(this.params.redis);
|
|
698
|
-
}
|
|
699
|
-
return this.redisConnection;
|
|
700
|
-
}
|
|
701
|
-
async closeRedisConnection() {
|
|
702
|
-
if (this.redisConnection) {
|
|
703
|
-
this.redisConnection.disconnect();
|
|
704
|
-
this.redisConnection = void 0;
|
|
705
|
-
}
|
|
706
|
-
}
|
|
707
108
|
};
|
|
708
109
|
export {
|
|
709
|
-
ConsoleLogger,
|
|
710
110
|
client
|
|
711
111
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aikirun/client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.1",
|
|
4
4
|
"description": "Client SDK for Aiki - connect to the server, start workflows, and manage execution",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -18,10 +18,8 @@
|
|
|
18
18
|
"build": "tsup"
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
|
-
"@aikirun/types": "0.
|
|
22
|
-
"@orpc/client": "^1.9.3"
|
|
23
|
-
"ioredis": "^5.4.1",
|
|
24
|
-
"arktype": "^2.1.29"
|
|
21
|
+
"@aikirun/types": "0.24.1",
|
|
22
|
+
"@orpc/client": "^1.9.3"
|
|
25
23
|
},
|
|
26
24
|
"publishConfig": {
|
|
27
25
|
"access": "public"
|