@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.
@@ -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
+ }