@pikku/redis 0.12.20 → 0.12.23

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.
@@ -1,1025 +0,0 @@
1
- import type { SerializedError } from '@pikku/core/errors'
2
- import { PikkuWorkflowService } from '@pikku/core/workflow'
3
- import type {
4
- WorkflowPlannedStep,
5
- WorkflowQueueOptions,
6
- WorkflowRun,
7
- WorkflowRunWire,
8
- StepState,
9
- StepStatus,
10
- WorkflowStatus,
11
- WorkflowVersionStatus,
12
- } from '@pikku/core/workflow'
13
- import { Redis, type RedisOptions } from 'ioredis'
14
- import { randomUUID } from 'crypto'
15
-
16
- /**
17
- * Redis-based implementation of WorkflowStateService
18
- *
19
- * Stores workflow run state and step state in Redis with row-level locking.
20
- *
21
- * @example
22
- * ```typescript
23
- * const redis = new Redis('redis://localhost:6379')
24
- * const workflowService = new RedisWorkflowService(redis, 'workflows')
25
- * ```
26
- */
27
- export class RedisWorkflowService extends PikkuWorkflowService {
28
- private redis: Redis
29
- private keyPrefix: string
30
- private ownsConnection: boolean
31
- private lockTTL = 30000 // Lock TTL in milliseconds (30 seconds)
32
-
33
- /**
34
- * @param connectionOrConfig - ioredis Redis instance, RedisOptions config, or connection string
35
- * @param keyPrefix - Redis key prefix (default: 'workflows')
36
- */
37
- constructor(
38
- connectionOrConfig: Redis | RedisOptions | string | undefined,
39
- keyPrefix = 'workflows',
40
- options: WorkflowQueueOptions = {}
41
- ) {
42
- super(options)
43
- this.keyPrefix = keyPrefix
44
-
45
- // An already-connected client is recognised by the commands it exposes
46
- // rather than by `instanceof Redis`, so a compatible client that is not an
47
- // ioredis subclass — a test double, a wrapper — is used as given instead of
48
- // being silently ignored in favour of a fresh connection to localhost.
49
- if (
50
- typeof connectionOrConfig === 'object' &&
51
- connectionOrConfig !== null &&
52
- 'hgetall' in connectionOrConfig &&
53
- 'hset' in connectionOrConfig
54
- ) {
55
- this.redis = connectionOrConfig as Redis
56
- this.ownsConnection = false
57
- } else {
58
- this.redis = new Redis(connectionOrConfig as any)
59
- this.ownsConnection = true
60
- }
61
- }
62
-
63
- /**
64
- * Initialize the service (no-op for Redis, always ready)
65
- */
66
- public async init(): Promise<void> {
67
- // Redis doesn't require schema initialization
68
- await this.redis.ping()
69
- }
70
-
71
- private runKey(runId: string): string {
72
- return `${this.keyPrefix}:run:${runId}`
73
- }
74
-
75
- private stepKey(runId: string, stepName: string): string {
76
- return `${this.keyPrefix}:step:${runId}:${stepName}`
77
- }
78
-
79
- private stepHistoryKey(stepId: string): string {
80
- return `${this.keyPrefix}:step-history:${stepId}`
81
- }
82
-
83
- private lockKey(runId: string): string {
84
- return `${this.keyPrefix}:lock:${runId}`
85
- }
86
-
87
- private stepLockKey(runId: string, stepName: string): string {
88
- return `${this.keyPrefix}:step-lock:${runId}:${stepName}`
89
- }
90
-
91
- /**
92
- * Save a step history entry (creates new entry)
93
- */
94
- private async saveStepHistory(
95
- stepId: string,
96
- stepName: string,
97
- attemptCount: number,
98
- status: 'pending' | 'running' | 'scheduled' | 'succeeded' | 'failed',
99
- result?: any,
100
- error?: SerializedError,
101
- retries?: number,
102
- retryDelay?: string
103
- ): Promise<void> {
104
- const historyKey = this.stepHistoryKey(stepId)
105
- const now = Date.now()
106
-
107
- const entry: any = {
108
- stepId,
109
- stepName,
110
- attemptCount,
111
- status,
112
- result: result !== undefined ? JSON.stringify(result) : undefined,
113
- error: error !== undefined ? JSON.stringify(error) : undefined,
114
- retries,
115
- retryDelay,
116
- createdAt: now,
117
- }
118
-
119
- // Add status-specific timestamp
120
- switch (status) {
121
- case 'running':
122
- entry.runningAt = now
123
- break
124
- case 'scheduled':
125
- entry.scheduledAt = now
126
- break
127
- case 'succeeded':
128
- entry.succeededAt = now
129
- break
130
- case 'failed':
131
- entry.failedAt = now
132
- break
133
- }
134
-
135
- // Remove undefined fields
136
- Object.keys(entry).forEach(
137
- (key) => entry[key] === undefined && delete entry[key]
138
- )
139
-
140
- // Store in sorted set with attemptCount as score for ordering
141
- await this.redis.zadd(historyKey, attemptCount, JSON.stringify(entry))
142
- }
143
-
144
- /**
145
- * Update the current history entry in-place (for state transitions within same attempt)
146
- */
147
- private async updateCurrentHistoryRecord(
148
- stepId: string,
149
- stepName: string,
150
- attemptCount: number,
151
- status: 'running' | 'scheduled' | 'succeeded' | 'failed',
152
- result?: any,
153
- error?: SerializedError,
154
- retries?: number,
155
- retryDelay?: string
156
- ): Promise<void> {
157
- const historyKey = this.stepHistoryKey(stepId)
158
-
159
- // Get current history entry for this attempt
160
- const historyEntries = await this.redis.zrangebyscore(
161
- historyKey,
162
- attemptCount,
163
- attemptCount
164
- )
165
-
166
- if (historyEntries.length === 0) {
167
- // No existing entry - this shouldn't happen, but create one if missing
168
- await this.saveStepHistory(
169
- stepId,
170
- stepName,
171
- attemptCount,
172
- status,
173
- result,
174
- error,
175
- retries,
176
- retryDelay
177
- )
178
- return
179
- }
180
-
181
- // Parse existing entry
182
- const existingEntry = JSON.parse(historyEntries[0]!)
183
- const now = Date.now()
184
-
185
- // Update the entry with new status and keep original createdAt
186
- const updatedEntry: any = {
187
- ...existingEntry,
188
- status,
189
- result: result !== undefined ? JSON.stringify(result) : undefined,
190
- error: error !== undefined ? JSON.stringify(error) : undefined,
191
- }
192
-
193
- // Add status-specific timestamp
194
- switch (status) {
195
- case 'running':
196
- updatedEntry.runningAt = now
197
- break
198
- case 'scheduled':
199
- updatedEntry.scheduledAt = now
200
- break
201
- case 'succeeded':
202
- updatedEntry.succeededAt = now
203
- break
204
- case 'failed':
205
- updatedEntry.failedAt = now
206
- break
207
- }
208
-
209
- // Remove old entry and add updated one (Redis sorted set will replace if score is same)
210
- await this.redis.zremrangebyscore(historyKey, attemptCount, attemptCount)
211
- await this.redis.zadd(
212
- historyKey,
213
- attemptCount,
214
- JSON.stringify(updatedEntry)
215
- )
216
- }
217
-
218
- protected async createRunImpl(
219
- workflowName: string,
220
- input: any,
221
- inline: boolean,
222
- graphHash: string,
223
- wire: WorkflowRunWire,
224
- options?: {
225
- deterministic?: boolean
226
- plannedSteps?: WorkflowPlannedStep[]
227
- }
228
- ): Promise<string> {
229
- const id = randomUUID()
230
- const now = Date.now()
231
-
232
- const key = this.runKey(id)
233
-
234
- await this.redis.hmset(
235
- key,
236
- 'id',
237
- id,
238
- 'workflow',
239
- workflowName,
240
- 'status',
241
- 'running',
242
- 'input',
243
- JSON.stringify(input),
244
- 'inline',
245
- inline ? 'true' : 'false',
246
- 'graphHash',
247
- graphHash,
248
- 'deterministic',
249
- options?.deterministic ? 'true' : 'false',
250
- 'plannedSteps',
251
- options?.plannedSteps ? JSON.stringify(options.plannedSteps) : '',
252
- 'wire',
253
- JSON.stringify(wire),
254
- 'createdAt',
255
- now.toString(),
256
- 'updatedAt',
257
- now.toString()
258
- )
259
-
260
- return id
261
- }
262
-
263
- async getRun(id: string): Promise<WorkflowRun | null> {
264
- const key = this.runKey(id)
265
- const data = await this.redis.hgetall(key)
266
-
267
- if (!data.id) {
268
- return null
269
- }
270
-
271
- return {
272
- id: data.id!,
273
- workflow: data.workflow!,
274
- status: data.status! as WorkflowStatus,
275
- input: JSON.parse(data.input!),
276
- output: data.output ? JSON.parse(data.output) : undefined,
277
- error: data.error ? JSON.parse(data.error) : undefined,
278
- inline: data.inline === 'true' ? true : undefined,
279
- graphHash: data.graphHash || undefined,
280
- deterministic: data.deterministic === 'true' ? true : undefined,
281
- plannedSteps: data.plannedSteps
282
- ? JSON.parse(data.plannedSteps)
283
- : undefined,
284
- wire: data.wire ? JSON.parse(data.wire) : { type: 'unknown' },
285
- createdAt: new Date(Number(data.createdAt!)),
286
- updatedAt: new Date(Number(data.updatedAt!)),
287
- }
288
- }
289
-
290
- protected async updateRunStatusImpl(
291
- id: string,
292
- status: WorkflowStatus,
293
- output?: any,
294
- error?: SerializedError
295
- ): Promise<void> {
296
- const now = Date.now()
297
- const key = this.runKey(id)
298
-
299
- const fields: Record<string, string> = {
300
- status,
301
- updatedAt: now.toString(),
302
- }
303
-
304
- if (output !== undefined) {
305
- fields.output = JSON.stringify(output)
306
- }
307
-
308
- if (error !== undefined) {
309
- fields.error = JSON.stringify(error)
310
- }
311
-
312
- await this.redis.hmset(key, fields)
313
- }
314
-
315
- protected async insertStepStateImpl(
316
- runId: string,
317
- stepName: string,
318
- rpcName: string | null,
319
- data: any,
320
- stepOptions?: { retries?: number; retryDelay?: string | number },
321
- fromStepName?: string
322
- ): Promise<StepState> {
323
- const now = Date.now()
324
- const stepId = `${runId}:${stepName}:${now}`
325
- const key = this.stepKey(runId, stepName)
326
-
327
- const fields: Record<string, string> = {
328
- stepId,
329
- data: JSON.stringify(data),
330
- status: 'pending',
331
- attemptCount: '1',
332
- createdAt: now.toString(),
333
- updatedAt: now.toString(),
334
- }
335
-
336
- if (rpcName !== null) {
337
- fields.rpcName = rpcName
338
- }
339
-
340
- if (stepOptions?.retries !== undefined) {
341
- fields.retries = stepOptions.retries.toString()
342
- }
343
-
344
- if (stepOptions?.retryDelay !== undefined) {
345
- fields.retryDelay = stepOptions.retryDelay.toString()
346
- }
347
-
348
- if (fromStepName !== undefined) {
349
- fields.fromStepName = fromStepName
350
- }
351
-
352
- await this.redis.hmset(key, fields)
353
-
354
- // Save initial history entry
355
- await this.saveStepHistory(
356
- stepId,
357
- stepName,
358
- 1,
359
- 'pending',
360
- undefined,
361
- undefined,
362
- stepOptions?.retries,
363
- stepOptions?.retryDelay?.toString()
364
- )
365
-
366
- return {
367
- stepId,
368
- status: 'pending',
369
- rpcName,
370
- attemptCount: 1,
371
- retries: stepOptions?.retries,
372
- retryDelay:
373
- stepOptions?.retryDelay !== undefined
374
- ? stepOptions.retryDelay.toString()
375
- : undefined,
376
- fromStepName,
377
- createdAt: new Date(now),
378
- updatedAt: new Date(now),
379
- }
380
- }
381
-
382
- async getStepState(runId: string, stepName: string): Promise<StepState> {
383
- const key = this.stepKey(runId, stepName)
384
- const data = await this.redis.hgetall(key)
385
-
386
- if (!data.stepId) {
387
- throw new Error(
388
- `Step not found: runId=${runId}, stepName=${stepName}. Use insertStepState to create it.`
389
- )
390
- }
391
-
392
- return {
393
- stepId: data.stepId,
394
- status: data.status as any,
395
- rpcName: data.rpcName ?? null,
396
- result: data.result ? JSON.parse(data.result) : undefined,
397
- error: data.error ? JSON.parse(data.error) : undefined,
398
- attemptCount: Number(data.attemptCount || 1),
399
- retries: data.retries ? Number(data.retries) : undefined,
400
- retryDelay: data.retryDelay,
401
- fromStepName: data.fromStepName || undefined,
402
- createdAt: new Date(Number(data.createdAt!)),
403
- updatedAt: new Date(Number(data.updatedAt!)),
404
- }
405
- }
406
-
407
- async getRunHistory(
408
- runId: string
409
- ): Promise<Array<StepState & { stepName: string }>> {
410
- // Find all step keys for this run to get their stepIds
411
- const pattern = `${this.keyPrefix}:step:${runId}:*`
412
- const stepKeys: string[] = []
413
-
414
- // Use SCAN to find all step keys for this run
415
- let cursor = '0'
416
- do {
417
- const [newCursor, foundKeys] = await this.redis.scan(
418
- cursor,
419
- 'MATCH',
420
- pattern,
421
- 'COUNT',
422
- 100
423
- )
424
- cursor = newCursor
425
- stepKeys.push(...foundKeys)
426
- } while (cursor !== '0')
427
-
428
- // Fetch all history entries for all steps
429
- const allHistoryEntries: Array<StepState & { stepName: string }> = []
430
-
431
- for (const stepKey of stepKeys) {
432
- const stepData = await this.redis.hgetall(stepKey)
433
- if (!stepData.stepId) continue
434
-
435
- const stepId = stepData.stepId
436
- const historyKey = this.stepHistoryKey(stepId)
437
-
438
- // Get all history entries for this step (sorted by attemptCount)
439
- const historyEntries = await this.redis.zrange(historyKey, 0, '-1')
440
-
441
- for (const entryStr of historyEntries) {
442
- const entry = JSON.parse(entryStr)
443
-
444
- allHistoryEntries.push({
445
- stepId: entry.stepId,
446
- stepName: entry.stepName,
447
- // Provenance lives on the step row, not the per-attempt history entry.
448
- fromStepName: stepData.fromStepName || undefined,
449
- status: entry.status,
450
- result: entry.result ? JSON.parse(entry.result) : undefined,
451
- error: entry.error ? JSON.parse(entry.error) : undefined,
452
- attemptCount: entry.attemptCount,
453
- retries: entry.retries,
454
- retryDelay: entry.retryDelay,
455
- createdAt: new Date(entry.createdAt),
456
- updatedAt: new Date(entry.createdAt), // Use createdAt for both
457
- runningAt: entry.runningAt ? new Date(entry.runningAt) : undefined,
458
- scheduledAt: entry.scheduledAt
459
- ? new Date(entry.scheduledAt)
460
- : undefined,
461
- succeededAt: entry.succeededAt
462
- ? new Date(entry.succeededAt)
463
- : undefined,
464
- failedAt: entry.failedAt ? new Date(entry.failedAt) : undefined,
465
- })
466
- }
467
- }
468
-
469
- // Sort all entries by creation time (oldest first)
470
- return allHistoryEntries.sort(
471
- (a, b) => a.createdAt.getTime() - b.createdAt.getTime()
472
- )
473
- }
474
-
475
- protected async setStepRunningImpl(stepId: string): Promise<void> {
476
- // Extract runId and stepName from stepId (format: runId:stepName:timestamp)
477
- const parts = stepId.split(':')
478
- const runId = parts[0]!
479
- const stepName = parts.slice(1, -1).join(':')
480
-
481
- const now = Date.now()
482
- const key = this.stepKey(runId, stepName)
483
-
484
- // Get current attempt count and retries config
485
- const data = await this.redis.hgetall(key)
486
- const attemptCount = Number(data.attemptCount || 1)
487
- const retries = data.retries ? Number(data.retries) : undefined
488
- const retryDelay = data.retryDelay
489
-
490
- await this.redis.hmset(
491
- key,
492
- 'status',
493
- 'running',
494
- 'updatedAt',
495
- now.toString()
496
- )
497
-
498
- // Update current history record to running (update in-place)
499
- await this.updateCurrentHistoryRecord(
500
- stepId,
501
- stepName,
502
- attemptCount,
503
- 'running',
504
- undefined,
505
- undefined,
506
- retries,
507
- retryDelay
508
- )
509
- }
510
-
511
- protected async setStepScheduledImpl(stepId: string): Promise<void> {
512
- // Extract runId and stepName from stepId (format: runId:stepName:timestamp)
513
- const parts = stepId.split(':')
514
- const runId = parts[0]!
515
- const stepName = parts.slice(1, -1).join(':')
516
-
517
- const now = Date.now()
518
- const key = this.stepKey(runId, stepName)
519
-
520
- // Get current attempt count and retries config
521
- const data = await this.redis.hgetall(key)
522
- const attemptCount = Number(data.attemptCount || 1)
523
- const retries = data.retries ? Number(data.retries) : undefined
524
- const retryDelay = data.retryDelay
525
-
526
- await this.redis.hmset(
527
- key,
528
- 'status',
529
- 'scheduled',
530
- 'updatedAt',
531
- now.toString()
532
- )
533
-
534
- // Update current history record to scheduled (update in-place)
535
- await this.updateCurrentHistoryRecord(
536
- stepId,
537
- stepName,
538
- attemptCount,
539
- 'scheduled',
540
- undefined,
541
- undefined,
542
- retries,
543
- retryDelay
544
- )
545
- }
546
-
547
- protected async setStepChildRunIdImpl(
548
- stepId: string,
549
- childRunId: string
550
- ): Promise<void> {
551
- const parts = stepId.split(':')
552
- const runId = parts[0]!
553
- const stepName = parts.slice(1, -1).join(':')
554
- const key = this.stepKey(runId, stepName)
555
- await this.redis.hmset(
556
- key,
557
- 'childRunId',
558
- childRunId,
559
- 'updatedAt',
560
- Date.now().toString()
561
- )
562
- }
563
-
564
- protected async setStepResultImpl(
565
- stepId: string,
566
- result: any
567
- ): Promise<void> {
568
- // Extract runId and stepName from stepId (format: runId:stepName:timestamp)
569
- const parts = stepId.split(':')
570
- const runId = parts[0]!
571
- const stepName = parts.slice(1, -1).join(':')
572
-
573
- const now = Date.now()
574
- const key = this.stepKey(runId, stepName)
575
-
576
- // Get current attempt count and retries config
577
- const data = await this.redis.hgetall(key)
578
- const attemptCount = Number(data.attemptCount || 1)
579
- const retries = data.retries ? Number(data.retries) : undefined
580
- const retryDelay = data.retryDelay
581
-
582
- await this.redis.hmset(
583
- key,
584
- 'status',
585
- 'succeeded',
586
- 'result',
587
- JSON.stringify(result),
588
- 'updatedAt',
589
- now.toString()
590
- )
591
-
592
- // Remove error field if it exists
593
- await this.redis.hdel(key, 'error')
594
-
595
- // Update current history record to succeeded (update in-place)
596
- await this.updateCurrentHistoryRecord(
597
- stepId,
598
- stepName,
599
- attemptCount,
600
- 'succeeded',
601
- result,
602
- undefined,
603
- retries,
604
- retryDelay
605
- )
606
- }
607
-
608
- protected async setStepErrorImpl(
609
- stepId: string,
610
- error: Error
611
- ): Promise<void> {
612
- // Extract runId and stepName from stepId (format: runId:stepName:timestamp)
613
- const parts = stepId.split(':')
614
- const runId = parts[0]!
615
- const stepName = parts.slice(1, -1).join(':')
616
-
617
- const now = Date.now()
618
- const key = this.stepKey(runId, stepName)
619
-
620
- // Get current attempt count and retries config
621
- const data = await this.redis.hgetall(key)
622
- const attemptCount = Number(data.attemptCount || 1)
623
- const retries = data.retries ? Number(data.retries) : undefined
624
- const retryDelay = data.retryDelay
625
-
626
- const serializedError: SerializedError = {
627
- message: error.message,
628
- stack: error.stack,
629
- code: (error as any).code,
630
- }
631
-
632
- await this.redis.hmset(
633
- key,
634
- 'status',
635
- 'failed',
636
- 'error',
637
- JSON.stringify(serializedError),
638
- 'updatedAt',
639
- now.toString()
640
- )
641
-
642
- // Remove result field if it exists
643
- await this.redis.hdel(key, 'result')
644
-
645
- // Update current history record to failed (update in-place)
646
- await this.updateCurrentHistoryRecord(
647
- stepId,
648
- stepName,
649
- attemptCount,
650
- 'failed',
651
- undefined,
652
- serializedError,
653
- retries,
654
- retryDelay
655
- )
656
- }
657
-
658
- private async withLock<T>(
659
- lockKey: string,
660
- errorMessage: string,
661
- fn: () => Promise<T>
662
- ): Promise<T> {
663
- const lockValue = randomUUID()
664
- const maxRetries = 10
665
- const retryDelay = 100 // ms
666
-
667
- // Try to acquire lock with retries
668
- for (let i = 0; i < maxRetries; i++) {
669
- const acquired = await this.redis.set(
670
- lockKey,
671
- lockValue,
672
- 'PX',
673
- this.lockTTL,
674
- 'NX'
675
- )
676
-
677
- if (acquired === 'OK') {
678
- try {
679
- // Lock acquired, execute function
680
- return await fn()
681
- } finally {
682
- // Release lock using Lua script to ensure we only delete our own lock
683
- await this.redis.eval(
684
- `if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end`,
685
- 1,
686
- lockKey,
687
- lockValue
688
- )
689
- }
690
- }
691
-
692
- // Lock not acquired, wait and retry
693
- await new Promise((resolve) => setTimeout(resolve, retryDelay))
694
- }
695
-
696
- throw new Error(`${errorMessage} after ${maxRetries} retries`)
697
- }
698
-
699
- async withRunLock<T>(id: string, fn: () => Promise<T>): Promise<T> {
700
- return this.withLock(
701
- this.lockKey(id),
702
- `Failed to acquire lock for run ${id}`,
703
- fn
704
- )
705
- }
706
-
707
- async withStepLock<T>(
708
- runId: string,
709
- stepName: string,
710
- fn: () => Promise<T>
711
- ): Promise<T> {
712
- return this.withLock(
713
- this.stepLockKey(runId, stepName),
714
- `Failed to acquire step lock for run ${runId}, step ${stepName}`,
715
- fn
716
- )
717
- }
718
-
719
- protected async createRetryAttemptImpl(
720
- stepId: string,
721
- status: 'pending' | 'running'
722
- ): Promise<StepState> {
723
- // TODO: If status is 'running', we need to set the running_at timestamp in history
724
-
725
- // Extract runId and stepName from stepId (format: runId:stepName:timestamp)
726
- const parts = stepId.split(':')
727
- const runId = parts[0]!
728
- const stepName = parts.slice(1, -1).join(':')
729
-
730
- const now = Date.now()
731
- const key = this.stepKey(runId, stepName)
732
-
733
- // Get current attempt count and retries config
734
- const data = await this.redis.hgetall(key)
735
- const currentAttempt = Number(data.attemptCount || 1)
736
- const newAttemptCount = currentAttempt + 1
737
- const retries = data.retries ? Number(data.retries) : undefined
738
- const retryDelay = data.retryDelay
739
-
740
- // Reset step to pending for retry (keeps result/error for visibility)
741
- await this.redis.hmset(
742
- key,
743
- 'status',
744
- status,
745
- 'attemptCount',
746
- newAttemptCount.toString(),
747
- 'updatedAt',
748
- now.toString()
749
- )
750
-
751
- // Insert NEW history record for retry attempt
752
- await this.saveStepHistory(
753
- stepId,
754
- stepName,
755
- newAttemptCount,
756
- 'pending',
757
- undefined,
758
- undefined,
759
- retries,
760
- retryDelay
761
- )
762
-
763
- return {
764
- stepId: data.stepId!,
765
- status: 'pending',
766
- rpcName: data.rpcName ?? null,
767
- result: data.result ? JSON.parse(data.result) : undefined,
768
- error: data.error ? JSON.parse(data.error) : undefined,
769
- attemptCount: newAttemptCount,
770
- retries: retries,
771
- retryDelay: retryDelay,
772
- fromStepName: data.fromStepName || undefined,
773
- createdAt: new Date(Number(data.createdAt!)),
774
- updatedAt: new Date(now),
775
- }
776
- }
777
-
778
- // ============================================================================
779
- // Workflow Graph Methods
780
- // ============================================================================
781
-
782
- async getCompletedGraphState(runId: string): Promise<{
783
- completedNodeIds: string[]
784
- failedNodeIds: string[]
785
- branchKeys: Record<string, string>
786
- }> {
787
- const completedNodeIds: string[] = []
788
- const failedNodeIds: string[] = []
789
- const branchKeys: Record<string, string> = {}
790
-
791
- const pattern = `${this.keyPrefix}:step:${runId}:*`
792
- let cursor = '0'
793
-
794
- do {
795
- const [newCursor, foundKeys] = await this.redis.scan(
796
- cursor,
797
- 'MATCH',
798
- pattern,
799
- 'COUNT',
800
- 100
801
- )
802
- cursor = newCursor
803
-
804
- for (const key of foundKeys) {
805
- const data = await this.redis.hmget(
806
- key,
807
- 'status',
808
- 'branchTaken',
809
- 'attemptCount',
810
- 'retries'
811
- )
812
- const [status, branchTaken, attemptCount, retries] = data
813
-
814
- const parts = key.split(':')
815
- const stepIndex = parts.lastIndexOf('step')
816
- if (stepIndex === -1 || stepIndex + 1 >= parts.length) continue
817
- if (parts[stepIndex + 1] !== runId) continue
818
- const nodeId = parts.slice(stepIndex + 2).join(':')
819
-
820
- if (status === 'succeeded') {
821
- completedNodeIds.push(nodeId)
822
- if (branchTaken) {
823
- branchKeys[nodeId] = branchTaken
824
- }
825
- } else if (status === 'failed') {
826
- const maxAttempts = (parseInt(retries || '0', 10) || 0) + 1
827
- const attempts = parseInt(attemptCount || '0', 10) || 0
828
- if (attempts >= maxAttempts) {
829
- failedNodeIds.push(nodeId)
830
- }
831
- }
832
- }
833
- } while (cursor !== '0')
834
-
835
- return { completedNodeIds, failedNodeIds, branchKeys }
836
- }
837
-
838
- async getStepInstances(
839
- runId: string
840
- ): Promise<
841
- Array<{ stepName: string; status: StepStatus; fromStepName?: string }>
842
- > {
843
- const instances: Array<{
844
- stepName: string
845
- status: StepStatus
846
- fromStepName?: string
847
- }> = []
848
- const pattern = `${this.keyPrefix}:step:${runId}:*`
849
- let cursor = '0'
850
- do {
851
- const [newCursor, foundKeys] = await this.redis.scan(
852
- cursor,
853
- 'MATCH',
854
- pattern,
855
- 'COUNT',
856
- 100
857
- )
858
- cursor = newCursor
859
- for (const key of foundKeys) {
860
- const [status, fromStepName] = await this.redis.hmget(
861
- key,
862
- 'status',
863
- 'fromStepName'
864
- )
865
- const parts = key.split(':')
866
- const stepIndex = parts.lastIndexOf('step')
867
- if (stepIndex === -1 || parts[stepIndex + 1] !== runId) continue
868
- instances.push({
869
- stepName: parts.slice(stepIndex + 2).join(':'),
870
- status: status as StepStatus,
871
- fromStepName: fromStepName || undefined,
872
- })
873
- }
874
- } while (cursor !== '0')
875
- return instances
876
- }
877
-
878
- async getNodeResults(
879
- runId: string,
880
- nodeIds: string[]
881
- ): Promise<Record<string, any>> {
882
- if (nodeIds.length === 0) return {}
883
-
884
- const results: Record<string, any> = {}
885
-
886
- for (const nodeId of nodeIds) {
887
- const key = this.stepKey(runId, nodeId)
888
- const data = await this.redis.hmget(key, 'status', 'result')
889
- const [status, result] = data
890
-
891
- if (status === 'succeeded' && result) {
892
- results[nodeId] = JSON.parse(result)
893
- }
894
- }
895
-
896
- return results
897
- }
898
-
899
- protected async setBranchTakenImpl(
900
- stepId: string,
901
- branchKey: string
902
- ): Promise<void> {
903
- // Extract runId and stepName from stepId (format: runId:stepName:timestamp)
904
- const parts = stepId.split(':')
905
- const runId = parts[0]!
906
- const stepName = parts.slice(1, -1).join(':')
907
-
908
- const now = Date.now()
909
- const key = this.stepKey(runId, stepName)
910
-
911
- await this.redis.hmset(
912
- key,
913
- 'branchTaken',
914
- branchKey,
915
- 'updatedAt',
916
- now.toString()
917
- )
918
- }
919
-
920
- /**
921
- * Hash holding one field per state variable of a run.
922
- *
923
- * The whole state used to be a single JSON blob on the run hash, which made
924
- * every write a read-modify-write: two parallel branches each setting their
925
- * own variable would both read the blob, add their key, and write it back,
926
- * so whichever wrote second silently dropped the other's variable.
927
- */
928
- private runStateKey(runId: string): string {
929
- return `${this.keyPrefix}:run-state:${runId}`
930
- }
931
-
932
- protected async updateRunStateImpl(
933
- runId: string,
934
- name: string,
935
- value: unknown
936
- ): Promise<void> {
937
- // HSET touches only the field it is named, so concurrent writers to
938
- // different variables no longer race, and nothing has to parse and
939
- // re-encode the values it is not writing.
940
- await this.redis.hset(
941
- this.runStateKey(runId),
942
- name,
943
- JSON.stringify(value) ?? 'null'
944
- )
945
- await this.redis.hset(
946
- this.runKey(runId),
947
- 'updatedAt',
948
- Date.now().toString()
949
- )
950
- }
951
-
952
- async getRunState(runId: string): Promise<Record<string, unknown>> {
953
- // The blob is what a run started before the per-key layout still holds; a
954
- // field written since wins, so a run in flight across the deploy keeps
955
- // everything it had.
956
- const blob = await this.redis.hget(this.runKey(runId), 'state')
957
- const state: Record<string, unknown> = blob ? JSON.parse(blob) : {}
958
-
959
- const fields = await this.redis.hgetall(this.runStateKey(runId))
960
- for (const [name, value] of Object.entries(fields)) {
961
- state[name] = JSON.parse(value)
962
- }
963
- return state
964
- }
965
-
966
- private versionKey(name: string, graphHash: string): string {
967
- return `${this.keyPrefix}:version:${name}:${graphHash}`
968
- }
969
-
970
- protected async upsertWorkflowVersionImpl(
971
- name: string,
972
- graphHash: string,
973
- graph: any,
974
- source: string,
975
- status?: WorkflowVersionStatus
976
- ): Promise<void> {
977
- const key = this.versionKey(name, graphHash)
978
- const exists = await this.redis.exists(key)
979
- if (!exists) {
980
- await this.redis.hmset(
981
- key,
982
- 'workflowName',
983
- name,
984
- 'graphHash',
985
- graphHash,
986
- 'graph',
987
- JSON.stringify(graph),
988
- 'source',
989
- source,
990
- 'status',
991
- status ?? 'active',
992
- 'createdAt',
993
- Date.now().toString()
994
- )
995
- }
996
- }
997
-
998
- protected async updateWorkflowVersionStatusImpl(
999
- name: string,
1000
- graphHash: string,
1001
- status: WorkflowVersionStatus
1002
- ): Promise<void> {
1003
- const key = this.versionKey(name, graphHash)
1004
- await this.redis.hset(key, 'status', status)
1005
- }
1006
-
1007
- async getWorkflowVersion(
1008
- name: string,
1009
- graphHash: string
1010
- ): Promise<{ graph: any; source: string } | null> {
1011
- const key = this.versionKey(name, graphHash)
1012
- const data = await this.redis.hgetall(key)
1013
- if (!data.graph) return null
1014
- return {
1015
- graph: JSON.parse(data.graph),
1016
- source: data.source!,
1017
- }
1018
- }
1019
-
1020
- async close(): Promise<void> {
1021
- if (this.ownsConnection) {
1022
- await this.redis.quit()
1023
- }
1024
- }
1025
- }