@pikku/redis 0.11.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/CHANGELOG.md +18 -0
- package/README.md +202 -0
- package/dist/src/index.d.ts +3 -0
- package/dist/src/index.js +4 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/redis-channel-store.d.ts +18 -0
- package/dist/src/redis-channel-store.js +73 -0
- package/dist/src/redis-channel-store.js.map +1 -0
- package/dist/src/redis-eventhub-store.d.ts +15 -0
- package/dist/src/redis-eventhub-store.js +58 -0
- package/dist/src/redis-eventhub-store.js.map +1 -0
- package/dist/src/redis-workflow-service.d.ts +35 -0
- package/dist/src/redis-workflow-service.js +341 -0
- package/dist/src/redis-workflow-service.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/package.json +27 -0
- package/run-tests.sh +33 -0
- package/src/index.ts +3 -0
- package/src/redis-channel-store.ts +136 -0
- package/src/redis-eventhub-store.ts +104 -0
- package/src/redis-workflow-service.ts +678 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,678 @@
|
|
|
1
|
+
import type { SerializedError } from '@pikku/core'
|
|
2
|
+
import {
|
|
3
|
+
PikkuWorkflowService,
|
|
4
|
+
type WorkflowRun,
|
|
5
|
+
type StepState,
|
|
6
|
+
type WorkflowStatus,
|
|
7
|
+
} from '@pikku/core/workflow'
|
|
8
|
+
import { Redis, type RedisOptions } from 'ioredis'
|
|
9
|
+
import { randomUUID } from 'crypto'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Redis-based implementation of WorkflowStateService
|
|
13
|
+
*
|
|
14
|
+
* Stores workflow run state and step state in Redis with row-level locking.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```typescript
|
|
18
|
+
* const redis = new Redis('redis://localhost:6379')
|
|
19
|
+
* const workflowService = new RedisWorkflowService(redis, 'workflows')
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
export class RedisWorkflowService extends PikkuWorkflowService {
|
|
23
|
+
private redis: Redis
|
|
24
|
+
private keyPrefix: string
|
|
25
|
+
private ownsConnection: boolean
|
|
26
|
+
private lockTTL = 30000 // Lock TTL in milliseconds (30 seconds)
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param connectionOrConfig - ioredis Redis instance, RedisOptions config, or connection string
|
|
30
|
+
* @param keyPrefix - Redis key prefix (default: 'workflows')
|
|
31
|
+
*/
|
|
32
|
+
constructor(
|
|
33
|
+
connectionOrConfig: Redis | RedisOptions | string | undefined,
|
|
34
|
+
keyPrefix = 'workflows'
|
|
35
|
+
) {
|
|
36
|
+
super()
|
|
37
|
+
this.keyPrefix = keyPrefix
|
|
38
|
+
|
|
39
|
+
// Check if it's a Redis instance or config options
|
|
40
|
+
if (connectionOrConfig instanceof Redis) {
|
|
41
|
+
this.redis = connectionOrConfig
|
|
42
|
+
this.ownsConnection = false
|
|
43
|
+
} else {
|
|
44
|
+
this.redis = new Redis(connectionOrConfig as any)
|
|
45
|
+
this.ownsConnection = true
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Initialize the service (no-op for Redis, always ready)
|
|
51
|
+
*/
|
|
52
|
+
public async init(): Promise<void> {
|
|
53
|
+
// Redis doesn't require schema initialization
|
|
54
|
+
await this.redis.ping()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private runKey(runId: string): string {
|
|
58
|
+
return `${this.keyPrefix}:run:${runId}`
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private stepKey(runId: string, stepName: string): string {
|
|
62
|
+
return `${this.keyPrefix}:step:${runId}:${stepName}`
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private stepHistoryKey(stepId: string): string {
|
|
66
|
+
return `${this.keyPrefix}:step-history:${stepId}`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private lockKey(runId: string): string {
|
|
70
|
+
return `${this.keyPrefix}:lock:${runId}`
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Save a step history entry (creates new entry)
|
|
75
|
+
*/
|
|
76
|
+
private async saveStepHistory(
|
|
77
|
+
stepId: string,
|
|
78
|
+
stepName: string,
|
|
79
|
+
attemptCount: number,
|
|
80
|
+
status: 'pending' | 'running' | 'scheduled' | 'succeeded' | 'failed',
|
|
81
|
+
result?: any,
|
|
82
|
+
error?: SerializedError,
|
|
83
|
+
retries?: number,
|
|
84
|
+
retryDelay?: string
|
|
85
|
+
): Promise<void> {
|
|
86
|
+
const historyKey = this.stepHistoryKey(stepId)
|
|
87
|
+
const now = Date.now()
|
|
88
|
+
|
|
89
|
+
const entry: any = {
|
|
90
|
+
stepId,
|
|
91
|
+
stepName,
|
|
92
|
+
attemptCount,
|
|
93
|
+
status,
|
|
94
|
+
result: result !== undefined ? JSON.stringify(result) : undefined,
|
|
95
|
+
error: error !== undefined ? JSON.stringify(error) : undefined,
|
|
96
|
+
retries,
|
|
97
|
+
retryDelay,
|
|
98
|
+
createdAt: now,
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Add status-specific timestamp
|
|
102
|
+
switch (status) {
|
|
103
|
+
case 'running':
|
|
104
|
+
entry.runningAt = now
|
|
105
|
+
break
|
|
106
|
+
case 'scheduled':
|
|
107
|
+
entry.scheduledAt = now
|
|
108
|
+
break
|
|
109
|
+
case 'succeeded':
|
|
110
|
+
entry.succeededAt = now
|
|
111
|
+
break
|
|
112
|
+
case 'failed':
|
|
113
|
+
entry.failedAt = now
|
|
114
|
+
break
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Remove undefined fields
|
|
118
|
+
Object.keys(entry).forEach(
|
|
119
|
+
(key) => entry[key] === undefined && delete entry[key]
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
// Store in sorted set with attemptCount as score for ordering
|
|
123
|
+
await this.redis.zadd(historyKey, attemptCount, JSON.stringify(entry))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Update the current history entry in-place (for state transitions within same attempt)
|
|
128
|
+
*/
|
|
129
|
+
private async updateCurrentHistoryRecord(
|
|
130
|
+
stepId: string,
|
|
131
|
+
stepName: string,
|
|
132
|
+
attemptCount: number,
|
|
133
|
+
status: 'running' | 'scheduled' | 'succeeded' | 'failed',
|
|
134
|
+
result?: any,
|
|
135
|
+
error?: SerializedError,
|
|
136
|
+
retries?: number,
|
|
137
|
+
retryDelay?: string
|
|
138
|
+
): Promise<void> {
|
|
139
|
+
const historyKey = this.stepHistoryKey(stepId)
|
|
140
|
+
|
|
141
|
+
// Get current history entry for this attempt
|
|
142
|
+
const historyEntries = await this.redis.zrangebyscore(
|
|
143
|
+
historyKey,
|
|
144
|
+
attemptCount,
|
|
145
|
+
attemptCount
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
if (historyEntries.length === 0) {
|
|
149
|
+
// No existing entry - this shouldn't happen, but create one if missing
|
|
150
|
+
await this.saveStepHistory(
|
|
151
|
+
stepId,
|
|
152
|
+
stepName,
|
|
153
|
+
attemptCount,
|
|
154
|
+
status,
|
|
155
|
+
result,
|
|
156
|
+
error,
|
|
157
|
+
retries,
|
|
158
|
+
retryDelay
|
|
159
|
+
)
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Parse existing entry
|
|
164
|
+
const existingEntry = JSON.parse(historyEntries[0]!)
|
|
165
|
+
const now = Date.now()
|
|
166
|
+
|
|
167
|
+
// Update the entry with new status and keep original createdAt
|
|
168
|
+
const updatedEntry: any = {
|
|
169
|
+
...existingEntry,
|
|
170
|
+
status,
|
|
171
|
+
result: result !== undefined ? JSON.stringify(result) : undefined,
|
|
172
|
+
error: error !== undefined ? JSON.stringify(error) : undefined,
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Add status-specific timestamp
|
|
176
|
+
switch (status) {
|
|
177
|
+
case 'running':
|
|
178
|
+
updatedEntry.runningAt = now
|
|
179
|
+
break
|
|
180
|
+
case 'scheduled':
|
|
181
|
+
updatedEntry.scheduledAt = now
|
|
182
|
+
break
|
|
183
|
+
case 'succeeded':
|
|
184
|
+
updatedEntry.succeededAt = now
|
|
185
|
+
break
|
|
186
|
+
case 'failed':
|
|
187
|
+
updatedEntry.failedAt = now
|
|
188
|
+
break
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Remove old entry and add updated one (Redis sorted set will replace if score is same)
|
|
192
|
+
await this.redis.zremrangebyscore(historyKey, attemptCount, attemptCount)
|
|
193
|
+
await this.redis.zadd(
|
|
194
|
+
historyKey,
|
|
195
|
+
attemptCount,
|
|
196
|
+
JSON.stringify(updatedEntry)
|
|
197
|
+
)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async createRun(workflowName: string, input: any): Promise<string> {
|
|
201
|
+
const id = randomUUID()
|
|
202
|
+
const now = Date.now()
|
|
203
|
+
|
|
204
|
+
const key = this.runKey(id)
|
|
205
|
+
|
|
206
|
+
await this.redis.hmset(
|
|
207
|
+
key,
|
|
208
|
+
'id',
|
|
209
|
+
id,
|
|
210
|
+
'workflow',
|
|
211
|
+
workflowName,
|
|
212
|
+
'status',
|
|
213
|
+
'running',
|
|
214
|
+
'input',
|
|
215
|
+
JSON.stringify(input),
|
|
216
|
+
'createdAt',
|
|
217
|
+
now.toString(),
|
|
218
|
+
'updatedAt',
|
|
219
|
+
now.toString()
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
return id
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async getRun(id: string): Promise<WorkflowRun | null> {
|
|
226
|
+
const key = this.runKey(id)
|
|
227
|
+
const data = await this.redis.hgetall(key)
|
|
228
|
+
|
|
229
|
+
if (!data.id) {
|
|
230
|
+
return null
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
id: data.id!,
|
|
235
|
+
workflow: data.workflow!,
|
|
236
|
+
status: data.status! as WorkflowStatus,
|
|
237
|
+
input: JSON.parse(data.input!),
|
|
238
|
+
output: data.output ? JSON.parse(data.output) : undefined,
|
|
239
|
+
error: data.error ? JSON.parse(data.error) : undefined,
|
|
240
|
+
createdAt: new Date(Number(data.createdAt!)),
|
|
241
|
+
updatedAt: new Date(Number(data.updatedAt!)),
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async updateRunStatus(
|
|
246
|
+
id: string,
|
|
247
|
+
status: WorkflowStatus,
|
|
248
|
+
output?: any,
|
|
249
|
+
error?: SerializedError
|
|
250
|
+
): Promise<void> {
|
|
251
|
+
const now = Date.now()
|
|
252
|
+
const key = this.runKey(id)
|
|
253
|
+
|
|
254
|
+
const fields: Record<string, string> = {
|
|
255
|
+
status,
|
|
256
|
+
updatedAt: now.toString(),
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (output !== undefined) {
|
|
260
|
+
fields.output = JSON.stringify(output)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (error !== undefined) {
|
|
264
|
+
fields.error = JSON.stringify(error)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
await this.redis.hmset(key, fields)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async insertStepState(
|
|
271
|
+
runId: string,
|
|
272
|
+
stepName: string,
|
|
273
|
+
rpcName: string,
|
|
274
|
+
data: any,
|
|
275
|
+
stepOptions?: { retries?: number; retryDelay?: string | number }
|
|
276
|
+
): Promise<StepState> {
|
|
277
|
+
const now = Date.now()
|
|
278
|
+
const stepId = `${runId}:${stepName}:${now}`
|
|
279
|
+
const key = this.stepKey(runId, stepName)
|
|
280
|
+
|
|
281
|
+
const fields: Record<string, string> = {
|
|
282
|
+
stepId,
|
|
283
|
+
rpcName,
|
|
284
|
+
data: JSON.stringify(data),
|
|
285
|
+
status: 'pending',
|
|
286
|
+
attemptCount: '1',
|
|
287
|
+
createdAt: now.toString(),
|
|
288
|
+
updatedAt: now.toString(),
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (stepOptions?.retries !== undefined) {
|
|
292
|
+
fields.retries = stepOptions.retries.toString()
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (stepOptions?.retryDelay !== undefined) {
|
|
296
|
+
fields.retryDelay = stepOptions.retryDelay.toString()
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
await this.redis.hmset(key, fields)
|
|
300
|
+
|
|
301
|
+
// Save initial history entry
|
|
302
|
+
await this.saveStepHistory(
|
|
303
|
+
stepId,
|
|
304
|
+
stepName,
|
|
305
|
+
1,
|
|
306
|
+
'pending',
|
|
307
|
+
undefined,
|
|
308
|
+
undefined,
|
|
309
|
+
stepOptions?.retries,
|
|
310
|
+
stepOptions?.retryDelay?.toString()
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
return {
|
|
314
|
+
stepId,
|
|
315
|
+
status: 'pending',
|
|
316
|
+
attemptCount: 1,
|
|
317
|
+
retries: stepOptions?.retries,
|
|
318
|
+
retryDelay:
|
|
319
|
+
stepOptions?.retryDelay !== undefined
|
|
320
|
+
? stepOptions.retryDelay.toString()
|
|
321
|
+
: undefined,
|
|
322
|
+
createdAt: new Date(now),
|
|
323
|
+
updatedAt: new Date(now),
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async getStepState(runId: string, stepName: string): Promise<StepState> {
|
|
328
|
+
const key = this.stepKey(runId, stepName)
|
|
329
|
+
const data = await this.redis.hgetall(key)
|
|
330
|
+
|
|
331
|
+
if (!data.stepId) {
|
|
332
|
+
throw new Error(
|
|
333
|
+
`Step not found: runId=${runId}, stepName=${stepName}. Use insertStepState to create it.`
|
|
334
|
+
)
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return {
|
|
338
|
+
stepId: data.stepId,
|
|
339
|
+
status: data.status as any,
|
|
340
|
+
result: data.result ? JSON.parse(data.result) : undefined,
|
|
341
|
+
error: data.error ? JSON.parse(data.error) : undefined,
|
|
342
|
+
attemptCount: Number(data.attemptCount || 1),
|
|
343
|
+
retries: data.retries ? Number(data.retries) : undefined,
|
|
344
|
+
retryDelay: data.retryDelay,
|
|
345
|
+
createdAt: new Date(Number(data.createdAt!)),
|
|
346
|
+
updatedAt: new Date(Number(data.updatedAt!)),
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async getRunHistory(
|
|
351
|
+
runId: string
|
|
352
|
+
): Promise<Array<StepState & { stepName: string }>> {
|
|
353
|
+
// Find all step keys for this run to get their stepIds
|
|
354
|
+
const pattern = `${this.keyPrefix}:step:${runId}:*`
|
|
355
|
+
const stepKeys: string[] = []
|
|
356
|
+
|
|
357
|
+
// Use SCAN to find all step keys for this run
|
|
358
|
+
let cursor = '0'
|
|
359
|
+
do {
|
|
360
|
+
const [newCursor, foundKeys] = await this.redis.scan(
|
|
361
|
+
cursor,
|
|
362
|
+
'MATCH',
|
|
363
|
+
pattern,
|
|
364
|
+
'COUNT',
|
|
365
|
+
100
|
|
366
|
+
)
|
|
367
|
+
cursor = newCursor
|
|
368
|
+
stepKeys.push(...foundKeys)
|
|
369
|
+
} while (cursor !== '0')
|
|
370
|
+
|
|
371
|
+
// Fetch all history entries for all steps
|
|
372
|
+
const allHistoryEntries: Array<StepState & { stepName: string }> = []
|
|
373
|
+
|
|
374
|
+
for (const stepKey of stepKeys) {
|
|
375
|
+
const stepData = await this.redis.hgetall(stepKey)
|
|
376
|
+
if (!stepData.stepId) continue
|
|
377
|
+
|
|
378
|
+
const stepId = stepData.stepId
|
|
379
|
+
const historyKey = this.stepHistoryKey(stepId)
|
|
380
|
+
|
|
381
|
+
// Get all history entries for this step (sorted by attemptCount)
|
|
382
|
+
const historyEntries = await this.redis.zrange(historyKey, 0, -1)
|
|
383
|
+
|
|
384
|
+
for (const entryStr of historyEntries) {
|
|
385
|
+
const entry = JSON.parse(entryStr)
|
|
386
|
+
|
|
387
|
+
allHistoryEntries.push({
|
|
388
|
+
stepId: entry.stepId,
|
|
389
|
+
stepName: entry.stepName,
|
|
390
|
+
status: entry.status,
|
|
391
|
+
result: entry.result ? JSON.parse(entry.result) : undefined,
|
|
392
|
+
error: entry.error ? JSON.parse(entry.error) : undefined,
|
|
393
|
+
attemptCount: entry.attemptCount,
|
|
394
|
+
retries: entry.retries,
|
|
395
|
+
retryDelay: entry.retryDelay,
|
|
396
|
+
createdAt: new Date(entry.createdAt),
|
|
397
|
+
updatedAt: new Date(entry.createdAt), // Use createdAt for both
|
|
398
|
+
runningAt: entry.runningAt ? new Date(entry.runningAt) : undefined,
|
|
399
|
+
scheduledAt: entry.scheduledAt
|
|
400
|
+
? new Date(entry.scheduledAt)
|
|
401
|
+
: undefined,
|
|
402
|
+
succeededAt: entry.succeededAt
|
|
403
|
+
? new Date(entry.succeededAt)
|
|
404
|
+
: undefined,
|
|
405
|
+
failedAt: entry.failedAt ? new Date(entry.failedAt) : undefined,
|
|
406
|
+
})
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Sort all entries by creation time (oldest first)
|
|
411
|
+
return allHistoryEntries.sort(
|
|
412
|
+
(a, b) => a.createdAt.getTime() - b.createdAt.getTime()
|
|
413
|
+
)
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
async setStepRunning(stepId: string): Promise<void> {
|
|
417
|
+
// Extract runId and stepName from stepId (format: runId:stepName:timestamp)
|
|
418
|
+
const parts = stepId.split(':')
|
|
419
|
+
const runId = parts[0]!
|
|
420
|
+
const stepName = parts.slice(1, -1).join(':')
|
|
421
|
+
|
|
422
|
+
const now = Date.now()
|
|
423
|
+
const key = this.stepKey(runId, stepName)
|
|
424
|
+
|
|
425
|
+
// Get current attempt count and retries config
|
|
426
|
+
const data = await this.redis.hgetall(key)
|
|
427
|
+
const attemptCount = Number(data.attemptCount || 1)
|
|
428
|
+
const retries = data.retries ? Number(data.retries) : undefined
|
|
429
|
+
const retryDelay = data.retryDelay
|
|
430
|
+
|
|
431
|
+
await this.redis.hmset(
|
|
432
|
+
key,
|
|
433
|
+
'status',
|
|
434
|
+
'running',
|
|
435
|
+
'updatedAt',
|
|
436
|
+
now.toString()
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
// Update current history record to running (update in-place)
|
|
440
|
+
await this.updateCurrentHistoryRecord(
|
|
441
|
+
stepId,
|
|
442
|
+
stepName,
|
|
443
|
+
attemptCount,
|
|
444
|
+
'running',
|
|
445
|
+
undefined,
|
|
446
|
+
undefined,
|
|
447
|
+
retries,
|
|
448
|
+
retryDelay
|
|
449
|
+
)
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
async setStepScheduled(stepId: string): Promise<void> {
|
|
453
|
+
// Extract runId and stepName from stepId (format: runId:stepName:timestamp)
|
|
454
|
+
const parts = stepId.split(':')
|
|
455
|
+
const runId = parts[0]!
|
|
456
|
+
const stepName = parts.slice(1, -1).join(':')
|
|
457
|
+
|
|
458
|
+
const now = Date.now()
|
|
459
|
+
const key = this.stepKey(runId, stepName)
|
|
460
|
+
|
|
461
|
+
// Get current attempt count and retries config
|
|
462
|
+
const data = await this.redis.hgetall(key)
|
|
463
|
+
const attemptCount = Number(data.attemptCount || 1)
|
|
464
|
+
const retries = data.retries ? Number(data.retries) : undefined
|
|
465
|
+
const retryDelay = data.retryDelay
|
|
466
|
+
|
|
467
|
+
await this.redis.hmset(
|
|
468
|
+
key,
|
|
469
|
+
'status',
|
|
470
|
+
'scheduled',
|
|
471
|
+
'updatedAt',
|
|
472
|
+
now.toString()
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
// Update current history record to scheduled (update in-place)
|
|
476
|
+
await this.updateCurrentHistoryRecord(
|
|
477
|
+
stepId,
|
|
478
|
+
stepName,
|
|
479
|
+
attemptCount,
|
|
480
|
+
'scheduled',
|
|
481
|
+
undefined,
|
|
482
|
+
undefined,
|
|
483
|
+
retries,
|
|
484
|
+
retryDelay
|
|
485
|
+
)
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
async setStepResult(stepId: string, result: any): Promise<void> {
|
|
489
|
+
// Extract runId and stepName from stepId (format: runId:stepName:timestamp)
|
|
490
|
+
const parts = stepId.split(':')
|
|
491
|
+
const runId = parts[0]!
|
|
492
|
+
const stepName = parts.slice(1, -1).join(':')
|
|
493
|
+
|
|
494
|
+
const now = Date.now()
|
|
495
|
+
const key = this.stepKey(runId, stepName)
|
|
496
|
+
|
|
497
|
+
// Get current attempt count and retries config
|
|
498
|
+
const data = await this.redis.hgetall(key)
|
|
499
|
+
const attemptCount = Number(data.attemptCount || 1)
|
|
500
|
+
const retries = data.retries ? Number(data.retries) : undefined
|
|
501
|
+
const retryDelay = data.retryDelay
|
|
502
|
+
|
|
503
|
+
await this.redis.hmset(
|
|
504
|
+
key,
|
|
505
|
+
'status',
|
|
506
|
+
'succeeded',
|
|
507
|
+
'result',
|
|
508
|
+
JSON.stringify(result),
|
|
509
|
+
'updatedAt',
|
|
510
|
+
now.toString()
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
// Remove error field if it exists
|
|
514
|
+
await this.redis.hdel(key, 'error')
|
|
515
|
+
|
|
516
|
+
// Update current history record to succeeded (update in-place)
|
|
517
|
+
await this.updateCurrentHistoryRecord(
|
|
518
|
+
stepId,
|
|
519
|
+
stepName,
|
|
520
|
+
attemptCount,
|
|
521
|
+
'succeeded',
|
|
522
|
+
result,
|
|
523
|
+
undefined,
|
|
524
|
+
retries,
|
|
525
|
+
retryDelay
|
|
526
|
+
)
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
async setStepError(stepId: string, error: Error): Promise<void> {
|
|
530
|
+
// Extract runId and stepName from stepId (format: runId:stepName:timestamp)
|
|
531
|
+
const parts = stepId.split(':')
|
|
532
|
+
const runId = parts[0]!
|
|
533
|
+
const stepName = parts.slice(1, -1).join(':')
|
|
534
|
+
|
|
535
|
+
const now = Date.now()
|
|
536
|
+
const key = this.stepKey(runId, stepName)
|
|
537
|
+
|
|
538
|
+
// Get current attempt count and retries config
|
|
539
|
+
const data = await this.redis.hgetall(key)
|
|
540
|
+
const attemptCount = Number(data.attemptCount || 1)
|
|
541
|
+
const retries = data.retries ? Number(data.retries) : undefined
|
|
542
|
+
const retryDelay = data.retryDelay
|
|
543
|
+
|
|
544
|
+
const serializedError: SerializedError = {
|
|
545
|
+
message: error.message,
|
|
546
|
+
stack: error.stack,
|
|
547
|
+
code: (error as any).code,
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
await this.redis.hmset(
|
|
551
|
+
key,
|
|
552
|
+
'status',
|
|
553
|
+
'failed',
|
|
554
|
+
'error',
|
|
555
|
+
JSON.stringify(serializedError),
|
|
556
|
+
'updatedAt',
|
|
557
|
+
now.toString()
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
// Remove result field if it exists
|
|
561
|
+
await this.redis.hdel(key, 'result')
|
|
562
|
+
|
|
563
|
+
// Update current history record to failed (update in-place)
|
|
564
|
+
await this.updateCurrentHistoryRecord(
|
|
565
|
+
stepId,
|
|
566
|
+
stepName,
|
|
567
|
+
attemptCount,
|
|
568
|
+
'failed',
|
|
569
|
+
undefined,
|
|
570
|
+
serializedError,
|
|
571
|
+
retries,
|
|
572
|
+
retryDelay
|
|
573
|
+
)
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
async withRunLock<T>(id: string, fn: () => Promise<T>): Promise<T> {
|
|
577
|
+
const lockKey = this.lockKey(id)
|
|
578
|
+
const lockValue = randomUUID()
|
|
579
|
+
const maxRetries = 10
|
|
580
|
+
const retryDelay = 100 // ms
|
|
581
|
+
|
|
582
|
+
// Try to acquire lock with retries
|
|
583
|
+
for (let i = 0; i < maxRetries; i++) {
|
|
584
|
+
const acquired = await this.redis.set(
|
|
585
|
+
lockKey,
|
|
586
|
+
lockValue,
|
|
587
|
+
'PX',
|
|
588
|
+
this.lockTTL,
|
|
589
|
+
'NX'
|
|
590
|
+
)
|
|
591
|
+
|
|
592
|
+
if (acquired === 'OK') {
|
|
593
|
+
try {
|
|
594
|
+
// Lock acquired, execute function
|
|
595
|
+
return await fn()
|
|
596
|
+
} finally {
|
|
597
|
+
// Release lock using Lua script to ensure we only delete our own lock
|
|
598
|
+
await this.redis.eval(
|
|
599
|
+
`if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end`,
|
|
600
|
+
1,
|
|
601
|
+
lockKey,
|
|
602
|
+
lockValue
|
|
603
|
+
)
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// Lock not acquired, wait and retry
|
|
608
|
+
await new Promise((resolve) => setTimeout(resolve, retryDelay))
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
throw new Error(
|
|
612
|
+
`Failed to acquire lock for run ${id} after ${maxRetries} retries`
|
|
613
|
+
)
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
async createRetryAttempt(
|
|
617
|
+
stepId: string,
|
|
618
|
+
status: 'pending' | 'running'
|
|
619
|
+
): Promise<StepState> {
|
|
620
|
+
// TODO: If status is 'running', we need to set the running_at timestamp in history
|
|
621
|
+
|
|
622
|
+
// Extract runId and stepName from stepId (format: runId:stepName:timestamp)
|
|
623
|
+
const parts = stepId.split(':')
|
|
624
|
+
const runId = parts[0]!
|
|
625
|
+
const stepName = parts.slice(1, -1).join(':')
|
|
626
|
+
|
|
627
|
+
const now = Date.now()
|
|
628
|
+
const key = this.stepKey(runId, stepName)
|
|
629
|
+
|
|
630
|
+
// Get current attempt count and retries config
|
|
631
|
+
const data = await this.redis.hgetall(key)
|
|
632
|
+
const currentAttempt = Number(data.attemptCount || 1)
|
|
633
|
+
const newAttemptCount = currentAttempt + 1
|
|
634
|
+
const retries = data.retries ? Number(data.retries) : undefined
|
|
635
|
+
const retryDelay = data.retryDelay
|
|
636
|
+
|
|
637
|
+
// Reset step to pending for retry (keeps result/error for visibility)
|
|
638
|
+
await this.redis.hmset(
|
|
639
|
+
key,
|
|
640
|
+
'status',
|
|
641
|
+
status,
|
|
642
|
+
'attemptCount',
|
|
643
|
+
newAttemptCount.toString(),
|
|
644
|
+
'updatedAt',
|
|
645
|
+
now.toString()
|
|
646
|
+
)
|
|
647
|
+
|
|
648
|
+
// Insert NEW history record for retry attempt
|
|
649
|
+
await this.saveStepHistory(
|
|
650
|
+
stepId,
|
|
651
|
+
stepName,
|
|
652
|
+
newAttemptCount,
|
|
653
|
+
'pending',
|
|
654
|
+
undefined,
|
|
655
|
+
undefined,
|
|
656
|
+
retries,
|
|
657
|
+
retryDelay
|
|
658
|
+
)
|
|
659
|
+
|
|
660
|
+
return {
|
|
661
|
+
stepId: data.stepId!,
|
|
662
|
+
status: 'pending',
|
|
663
|
+
result: data.result ? JSON.parse(data.result) : undefined,
|
|
664
|
+
error: data.error ? JSON.parse(data.error) : undefined,
|
|
665
|
+
attemptCount: newAttemptCount,
|
|
666
|
+
retries: retries,
|
|
667
|
+
retryDelay: retryDelay,
|
|
668
|
+
createdAt: new Date(Number(data.createdAt!)),
|
|
669
|
+
updatedAt: new Date(now),
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
async close(): Promise<void> {
|
|
674
|
+
if (this.ownsConnection) {
|
|
675
|
+
await this.redis.quit()
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../../../tsconfig.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"rootDir": ".",
|
|
5
|
+
"module": "Node18",
|
|
6
|
+
"outDir": "dist",
|
|
7
|
+
"target": "esnext",
|
|
8
|
+
"declaration": true
|
|
9
|
+
},
|
|
10
|
+
"include": ["src/**/*.ts"],
|
|
11
|
+
"exclude": ["**/*.test.ts", "node_modules"],
|
|
12
|
+
"references": [
|
|
13
|
+
{
|
|
14
|
+
"path": "../../core/tsconfig.json"
|
|
15
|
+
}
|
|
16
|
+
]
|
|
17
|
+
}
|