flowfn 0.0.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/dist/index.d.mts +1305 -0
- package/dist/index.d.ts +1305 -0
- package/dist/index.js +3180 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +3088 -0
- package/dist/index.mjs.map +1 -0
- package/docs/API.md +801 -0
- package/docs/USAGE.md +619 -0
- package/package.json +75 -0
- package/src/adapters/base.ts +46 -0
- package/src/adapters/memory.ts +183 -0
- package/src/adapters/postgres/index.ts +383 -0
- package/src/adapters/postgres/postgres.test.ts +100 -0
- package/src/adapters/postgres/schema.ts +110 -0
- package/src/adapters/redis.test.ts +124 -0
- package/src/adapters/redis.ts +331 -0
- package/src/core/flow-fn.test.ts +70 -0
- package/src/core/flow-fn.ts +198 -0
- package/src/core/metrics.ts +198 -0
- package/src/core/scheduler.test.ts +80 -0
- package/src/core/scheduler.ts +154 -0
- package/src/index.ts +57 -0
- package/src/monitoring/health.ts +261 -0
- package/src/patterns/backoff.ts +30 -0
- package/src/patterns/batching.ts +248 -0
- package/src/patterns/circuit-breaker.test.ts +52 -0
- package/src/patterns/circuit-breaker.ts +52 -0
- package/src/patterns/priority.ts +146 -0
- package/src/patterns/rate-limit.ts +290 -0
- package/src/patterns/retry.test.ts +62 -0
- package/src/queue/batch.test.ts +35 -0
- package/src/queue/dependencies.test.ts +33 -0
- package/src/queue/dlq.ts +222 -0
- package/src/queue/job.ts +67 -0
- package/src/queue/queue.ts +243 -0
- package/src/queue/types.ts +153 -0
- package/src/queue/worker.ts +66 -0
- package/src/storage/event-log.ts +205 -0
- package/src/storage/job-storage.ts +206 -0
- package/src/storage/workflow-storage.ts +182 -0
- package/src/stream/stream.ts +194 -0
- package/src/stream/types.ts +81 -0
- package/src/utils/hashing.ts +29 -0
- package/src/utils/id-generator.ts +109 -0
- package/src/utils/serialization.ts +142 -0
- package/src/utils/time.ts +167 -0
- package/src/workflow/advanced.test.ts +43 -0
- package/src/workflow/events.test.ts +39 -0
- package/src/workflow/types.ts +132 -0
- package/src/workflow/workflow.test.ts +55 -0
- package/src/workflow/workflow.ts +422 -0
- package/tests/dlq.test.ts +205 -0
- package/tests/health.test.ts +228 -0
- package/tests/integration.test.ts +253 -0
- package/tests/stream.test.ts +233 -0
- package/tests/workflow.test.ts +286 -0
- package/tsconfig.json +17 -0
- package/tsup.config.ts +10 -0
- package/vitest.config.ts +15 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { FlowAdapter } from "../adapters/base.js";
|
|
2
|
+
import { MemoryAdapter } from "../adapters/memory.js";
|
|
3
|
+
import { Queue, QueueOptions } from "../queue/types.js";
|
|
4
|
+
import { QueueImpl } from "../queue/queue.js";
|
|
5
|
+
import { Stream, StreamOptions } from "../stream/types.js";
|
|
6
|
+
import { StreamImpl } from "../stream/stream.js";
|
|
7
|
+
import { Workflow, WorkflowBuilder } from "../workflow/types.js";
|
|
8
|
+
import { WorkflowBuilderImpl } from "../workflow/workflow.js";
|
|
9
|
+
import { Scheduler } from "./scheduler.js";
|
|
10
|
+
import { MetricsManager } from "./metrics.js";
|
|
11
|
+
import {
|
|
12
|
+
HealthCheckerImpl,
|
|
13
|
+
HealthStatus,
|
|
14
|
+
MemoryEventTracker,
|
|
15
|
+
EventTracker,
|
|
16
|
+
} from "../monitoring/health.js";
|
|
17
|
+
import EventEmitter from "eventemitter3";
|
|
18
|
+
|
|
19
|
+
export interface FlowFnConfig {
|
|
20
|
+
adapter:
|
|
21
|
+
| FlowAdapter
|
|
22
|
+
| "memory"
|
|
23
|
+
| "redis"
|
|
24
|
+
| "postgres"
|
|
25
|
+
| "d1"
|
|
26
|
+
| "sqs"
|
|
27
|
+
| "kafka";
|
|
28
|
+
namespace?: string;
|
|
29
|
+
defaultJobOptions?: any;
|
|
30
|
+
defaultQueueOptions?: QueueOptions;
|
|
31
|
+
defaultStreamOptions?: StreamOptions;
|
|
32
|
+
telemetry?: {
|
|
33
|
+
enabled: boolean;
|
|
34
|
+
provider?: "opentelemetry" | "custom";
|
|
35
|
+
};
|
|
36
|
+
onError?: (error: Error, context: any) => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface FlowFn {
|
|
40
|
+
queue<T = any>(name: string, options?: QueueOptions): Queue<T>;
|
|
41
|
+
listQueues(): Promise<string[]>;
|
|
42
|
+
|
|
43
|
+
stream<T = any>(name: string, options?: StreamOptions): Stream<T>;
|
|
44
|
+
listStreams(): Promise<string[]>;
|
|
45
|
+
|
|
46
|
+
workflow<T = any>(name: string): WorkflowBuilder<T>;
|
|
47
|
+
listWorkflows(): Promise<Workflow[]>;
|
|
48
|
+
|
|
49
|
+
scheduler(): Scheduler;
|
|
50
|
+
|
|
51
|
+
metrics: MetricsManager;
|
|
52
|
+
healthCheck(): Promise<HealthStatus>;
|
|
53
|
+
getEventTracker(): EventTracker;
|
|
54
|
+
|
|
55
|
+
on(event: string, handler: (...args: any[]) => void): void;
|
|
56
|
+
off(event: string, handler: (...args: any[]) => void): void;
|
|
57
|
+
|
|
58
|
+
close(): Promise<void>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export class FlowFnImpl extends EventEmitter implements FlowFn {
|
|
62
|
+
private adapter: FlowAdapter;
|
|
63
|
+
private queues: Map<string, Queue> = new Map();
|
|
64
|
+
private streams: Map<string, Stream> = new Map();
|
|
65
|
+
private _metrics: MetricsManager;
|
|
66
|
+
private _scheduler: Scheduler;
|
|
67
|
+
private healthChecker: HealthCheckerImpl;
|
|
68
|
+
private eventTracker: EventTracker;
|
|
69
|
+
private startTime: number;
|
|
70
|
+
|
|
71
|
+
constructor(config: FlowFnConfig) {
|
|
72
|
+
super();
|
|
73
|
+
this.startTime = Date.now();
|
|
74
|
+
|
|
75
|
+
if (config.adapter === "memory") {
|
|
76
|
+
this.adapter = new MemoryAdapter();
|
|
77
|
+
} else if (typeof config.adapter === "string") {
|
|
78
|
+
// Placeholder for other string-based adapter inits
|
|
79
|
+
throw new Error(
|
|
80
|
+
`Adapter ${config.adapter} not automatically initialized yet. Pass an instance.`
|
|
81
|
+
);
|
|
82
|
+
} else {
|
|
83
|
+
this.adapter = config.adapter;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
this._metrics = new MetricsManager(this.adapter);
|
|
87
|
+
this._scheduler = new Scheduler();
|
|
88
|
+
this.healthChecker = new HealthCheckerImpl();
|
|
89
|
+
this.eventTracker = new MemoryEventTracker();
|
|
90
|
+
|
|
91
|
+
// Add custom health checks
|
|
92
|
+
this.setupHealthChecks();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private setupHealthChecks(): void {
|
|
96
|
+
// Queue health check
|
|
97
|
+
this.healthChecker.addCheck("queues", async () => {
|
|
98
|
+
const queueCount = this.queues.size;
|
|
99
|
+
return {
|
|
100
|
+
name: "queues",
|
|
101
|
+
status: "pass",
|
|
102
|
+
details: {
|
|
103
|
+
count: queueCount,
|
|
104
|
+
active: Array.from(this.queues.keys()),
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// Adapter health check
|
|
110
|
+
this.healthChecker.addCheck("adapter", async () => {
|
|
111
|
+
try {
|
|
112
|
+
// Simple ping check
|
|
113
|
+
await this.adapter.getQueueStats("health-check");
|
|
114
|
+
return {
|
|
115
|
+
name: "adapter",
|
|
116
|
+
status: "pass",
|
|
117
|
+
message: "Adapter responding",
|
|
118
|
+
};
|
|
119
|
+
} catch (error) {
|
|
120
|
+
return {
|
|
121
|
+
name: "adapter",
|
|
122
|
+
status: "fail",
|
|
123
|
+
message: (error as Error).message,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
queue<T = any>(name: string, options?: QueueOptions): Queue<T> {
|
|
130
|
+
if (!this.queues.has(name)) {
|
|
131
|
+
this.queues.set(name, new QueueImpl<T>(name, this.adapter, options));
|
|
132
|
+
}
|
|
133
|
+
return this.queues.get(name) as Queue<T>;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async listQueues(): Promise<string[]> {
|
|
137
|
+
return Array.from(this.queues.keys());
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
stream<T = any>(name: string, options?: StreamOptions): Stream<T> {
|
|
141
|
+
if (!this.streams.has(name)) {
|
|
142
|
+
this.streams.set(name, new StreamImpl<T>(name, this.adapter, options));
|
|
143
|
+
}
|
|
144
|
+
return this.streams.get(name) as Stream<T>;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async listStreams(): Promise<string[]> {
|
|
148
|
+
return Array.from(this.streams.keys());
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
workflow<T = any>(name: string): WorkflowBuilder<T> {
|
|
152
|
+
return new WorkflowBuilderImpl<T>(name, this.adapter);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async listWorkflows(): Promise<Workflow[]> {
|
|
156
|
+
return [];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
scheduler(): Scheduler {
|
|
160
|
+
return this._scheduler;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
get metrics(): MetricsManager {
|
|
164
|
+
return this._metrics;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async healthCheck(): Promise<HealthStatus> {
|
|
168
|
+
const health = await this.healthChecker.check();
|
|
169
|
+
|
|
170
|
+
// Track health check event
|
|
171
|
+
this.eventTracker.track({
|
|
172
|
+
type: "health.check",
|
|
173
|
+
category: "system",
|
|
174
|
+
severity: health.healthy ? "info" : "warn",
|
|
175
|
+
message: health.healthy ? "System healthy" : "System unhealthy",
|
|
176
|
+
metadata: { checksCount: health.checks.length },
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
return health;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Get event tracker
|
|
184
|
+
*/
|
|
185
|
+
getEventTracker(): EventTracker {
|
|
186
|
+
return this.eventTracker;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async close(): Promise<void> {
|
|
190
|
+
await this.adapter.cleanup();
|
|
191
|
+
for (const q of this.queues.values()) await q.close();
|
|
192
|
+
for (const s of this.streams.values()) await s.close();
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function createFlow(config: FlowFnConfig): FlowFn {
|
|
197
|
+
return new FlowFnImpl(config);
|
|
198
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { FlowAdapter } from "../adapters/base.js";
|
|
2
|
+
|
|
3
|
+
export interface MetricDataPoint {
|
|
4
|
+
timestamp: number;
|
|
5
|
+
value: number;
|
|
6
|
+
tags?: Record<string, string>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface TimeSeriesMetrics {
|
|
10
|
+
dataPoints: MetricDataPoint[];
|
|
11
|
+
|
|
12
|
+
// Aggregations
|
|
13
|
+
min: number;
|
|
14
|
+
max: number;
|
|
15
|
+
avg: number;
|
|
16
|
+
sum: number;
|
|
17
|
+
count: number;
|
|
18
|
+
p50?: number;
|
|
19
|
+
p95?: number;
|
|
20
|
+
p99?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class MetricsManager {
|
|
24
|
+
private adapter: FlowAdapter;
|
|
25
|
+
private metrics: Map<string, MetricDataPoint[]> = new Map();
|
|
26
|
+
private maxDataPoints = 1000;
|
|
27
|
+
|
|
28
|
+
constructor(adapter: FlowAdapter) {
|
|
29
|
+
this.adapter = adapter;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Record a metric data point
|
|
34
|
+
*/
|
|
35
|
+
record(name: string, value: number, tags?: Record<string, string>): void {
|
|
36
|
+
const key = this.getMetricKey(name, tags);
|
|
37
|
+
if (!this.metrics.has(key)) {
|
|
38
|
+
this.metrics.set(key, []);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const dataPoints = this.metrics.get(key)!;
|
|
42
|
+
dataPoints.push({
|
|
43
|
+
timestamp: Date.now(),
|
|
44
|
+
value,
|
|
45
|
+
tags,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// Trim if exceeds max
|
|
49
|
+
if (dataPoints.length > this.maxDataPoints) {
|
|
50
|
+
this.metrics.set(key, dataPoints.slice(-this.maxDataPoints));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Get time series for a metric
|
|
56
|
+
*/
|
|
57
|
+
getTimeSeries(
|
|
58
|
+
name: string,
|
|
59
|
+
options?: {
|
|
60
|
+
tags?: Record<string, string>;
|
|
61
|
+
since?: number;
|
|
62
|
+
limit?: number;
|
|
63
|
+
}
|
|
64
|
+
): TimeSeriesMetrics | null {
|
|
65
|
+
const key = this.getMetricKey(name, options?.tags);
|
|
66
|
+
let dataPoints = this.metrics.get(key) || [];
|
|
67
|
+
|
|
68
|
+
if (options?.since) {
|
|
69
|
+
dataPoints = dataPoints.filter((dp) => dp.timestamp >= options.since!);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (options?.limit) {
|
|
73
|
+
dataPoints = dataPoints.slice(-options.limit);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (dataPoints.length === 0) {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return this.calculateAggregations(dataPoints);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private calculateAggregations(
|
|
84
|
+
dataPoints: MetricDataPoint[]
|
|
85
|
+
): TimeSeriesMetrics {
|
|
86
|
+
const values = dataPoints.map((dp) => dp.value).sort((a, b) => a - b);
|
|
87
|
+
const sum = values.reduce((a, b) => a + b, 0);
|
|
88
|
+
const count = values.length;
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
dataPoints,
|
|
92
|
+
min: values[0],
|
|
93
|
+
max: values[count - 1],
|
|
94
|
+
avg: sum / count,
|
|
95
|
+
sum,
|
|
96
|
+
count,
|
|
97
|
+
p50: this.percentile(values, 50),
|
|
98
|
+
p95: this.percentile(values, 95),
|
|
99
|
+
p99: this.percentile(values, 99),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private percentile(sorted: number[], p: number): number {
|
|
104
|
+
const index = Math.ceil((sorted.length * p) / 100) - 1;
|
|
105
|
+
return sorted[Math.max(0, index)];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private getMetricKey(name: string, tags?: Record<string, string>): string {
|
|
109
|
+
if (!tags) return name;
|
|
110
|
+
const tagStr = Object.entries(tags)
|
|
111
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
112
|
+
.map(([k, v]) => `${k}:${v}`)
|
|
113
|
+
.join(",");
|
|
114
|
+
return `${name}{${tagStr}}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async getQueueMetrics(name: string): Promise<any> {
|
|
118
|
+
const stats = await this.adapter.getQueueStats(name);
|
|
119
|
+
|
|
120
|
+
// Get throughput from recorded metrics
|
|
121
|
+
const throughputMetrics = this.getTimeSeries("queue.throughput", {
|
|
122
|
+
tags: { queue: name },
|
|
123
|
+
since: Date.now() - 60000, // Last minute
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const durationMetrics = this.getTimeSeries("queue.job.duration", {
|
|
127
|
+
tags: { queue: name },
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
...stats,
|
|
132
|
+
throughput: throughputMetrics?.avg || 0,
|
|
133
|
+
avgDuration: durationMetrics?.avg || 0,
|
|
134
|
+
p95Duration: durationMetrics?.p95 || 0,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async getStreamMetrics(name: string): Promise<any> {
|
|
139
|
+
const info = await this.adapter.getStreamInfo(name);
|
|
140
|
+
|
|
141
|
+
const throughputMetrics = this.getTimeSeries("stream.throughput", {
|
|
142
|
+
tags: { stream: name },
|
|
143
|
+
since: Date.now() - 1000, // Last second
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
...info,
|
|
148
|
+
lag: 0,
|
|
149
|
+
throughput: throughputMetrics?.avg || 0,
|
|
150
|
+
avgLatency: 0,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async getWorkflowMetrics(name: string): Promise<any> {
|
|
155
|
+
const metricsData = this.getTimeSeries("workflow.executions", {
|
|
156
|
+
tags: { workflow: name },
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
totalExecutions: metricsData?.count || 0,
|
|
161
|
+
running: 0,
|
|
162
|
+
completed: 0,
|
|
163
|
+
failed: 0,
|
|
164
|
+
successRate: 0,
|
|
165
|
+
avgDuration: metricsData?.avg || 0,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async getSystemMetrics(): Promise<any> {
|
|
170
|
+
const memUsage =
|
|
171
|
+
typeof process !== "undefined" && process.memoryUsage
|
|
172
|
+
? process.memoryUsage().heapUsed
|
|
173
|
+
: 0;
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
queues: 0,
|
|
177
|
+
streams: 0,
|
|
178
|
+
workflows: 0,
|
|
179
|
+
workers: 0,
|
|
180
|
+
memoryUsage: memUsage,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Clear old metrics
|
|
186
|
+
*/
|
|
187
|
+
cleanup(maxAge: number): void {
|
|
188
|
+
const now = Date.now();
|
|
189
|
+
for (const [key, dataPoints] of this.metrics.entries()) {
|
|
190
|
+
const filtered = dataPoints.filter((dp) => now - dp.timestamp <= maxAge);
|
|
191
|
+
if (filtered.length === 0) {
|
|
192
|
+
this.metrics.delete(key);
|
|
193
|
+
} else {
|
|
194
|
+
this.metrics.set(key, filtered);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import { Scheduler } from './scheduler.js';
|
|
3
|
+
|
|
4
|
+
describe('Scheduler', () => {
|
|
5
|
+
let scheduler: Scheduler;
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
scheduler = new Scheduler();
|
|
9
|
+
vi.useFakeTimers();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
afterEach(async () => {
|
|
13
|
+
await scheduler.close();
|
|
14
|
+
vi.useRealTimers();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('should schedule a task using cron pattern', async () => {
|
|
18
|
+
let count = 0;
|
|
19
|
+
// Every second
|
|
20
|
+
await scheduler.schedule('test', '* * * * * *', async () => {
|
|
21
|
+
count++;
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const list = await scheduler.list();
|
|
25
|
+
expect(list).toHaveLength(1);
|
|
26
|
+
expect(list[0].name).toBe('test');
|
|
27
|
+
|
|
28
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
29
|
+
expect(count).toBe(1);
|
|
30
|
+
|
|
31
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
32
|
+
expect(count).toBe(2);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should schedule a task using "every" interval', async () => {
|
|
36
|
+
let count = 0;
|
|
37
|
+
await scheduler.schedule('repeat', { every: 100 }, async () => {
|
|
38
|
+
count++;
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
42
|
+
expect(count).toBe(1);
|
|
43
|
+
|
|
44
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
45
|
+
expect(count).toBe(2);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('should respect limits', async () => {
|
|
49
|
+
let count = 0;
|
|
50
|
+
await scheduler.schedule('limited', { every: 100, limit: 2 }, async () => {
|
|
51
|
+
count++;
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
55
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
56
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
57
|
+
|
|
58
|
+
expect(count).toBe(2);
|
|
59
|
+
const list = await scheduler.list();
|
|
60
|
+
expect(list).toHaveLength(0);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('should pause and resume', async () => {
|
|
64
|
+
let count = 0;
|
|
65
|
+
await scheduler.schedule('pause-test', { every: 100 }, async () => {
|
|
66
|
+
count++;
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
70
|
+
expect(count).toBe(1);
|
|
71
|
+
|
|
72
|
+
await scheduler.pause('pause-test');
|
|
73
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
74
|
+
expect(count).toBe(1);
|
|
75
|
+
|
|
76
|
+
await scheduler.resume('pause-test');
|
|
77
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
78
|
+
expect(count).toBe(2);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import parser from 'cron-parser';
|
|
2
|
+
|
|
3
|
+
export interface ScheduleOptions {
|
|
4
|
+
pattern?: string;
|
|
5
|
+
timezone?: string;
|
|
6
|
+
data?: any;
|
|
7
|
+
startDate?: Date;
|
|
8
|
+
endDate?: Date;
|
|
9
|
+
limit?: number;
|
|
10
|
+
every?: number; // ms
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class Scheduler {
|
|
14
|
+
private tasks: Map<string, {
|
|
15
|
+
pattern: string | ScheduleOptions;
|
|
16
|
+
handler: Function;
|
|
17
|
+
nextRun: number;
|
|
18
|
+
timer?: NodeJS.Timeout;
|
|
19
|
+
count: number;
|
|
20
|
+
paused: boolean;
|
|
21
|
+
}> = new Map();
|
|
22
|
+
|
|
23
|
+
async schedule(name: string, pattern: string | ScheduleOptions, handler: Function): Promise<void> {
|
|
24
|
+
await this.cancel(name);
|
|
25
|
+
|
|
26
|
+
const options: ScheduleOptions = typeof pattern === 'string' ? { pattern } : pattern;
|
|
27
|
+
const now = Date.now();
|
|
28
|
+
let nextRun = 0;
|
|
29
|
+
|
|
30
|
+
if (options.pattern) {
|
|
31
|
+
const interval = parser.parseExpression(options.pattern, {
|
|
32
|
+
currentDate: options.startDate || new Date(),
|
|
33
|
+
tz: options.timezone
|
|
34
|
+
});
|
|
35
|
+
nextRun = interval.next().getTime();
|
|
36
|
+
} else if (options.every) {
|
|
37
|
+
nextRun = (options.startDate?.getTime() || now) + options.every;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
this.tasks.set(name, {
|
|
41
|
+
pattern,
|
|
42
|
+
handler,
|
|
43
|
+
nextRun,
|
|
44
|
+
count: 0,
|
|
45
|
+
paused: false
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
this.plan(name);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async repeat(name: string, options: any, handler: Function): Promise<void> {
|
|
52
|
+
return this.schedule(name, options, handler);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private plan(name: string) {
|
|
56
|
+
const task = this.tasks.get(name);
|
|
57
|
+
if (!task || task.paused) return;
|
|
58
|
+
|
|
59
|
+
const now = Date.now();
|
|
60
|
+
const delay = Math.max(0, task.nextRun - now);
|
|
61
|
+
|
|
62
|
+
if (task.timer) clearTimeout(task.timer);
|
|
63
|
+
|
|
64
|
+
task.timer = setTimeout(async () => {
|
|
65
|
+
if (task.paused) return;
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const options: ScheduleOptions = typeof task.pattern === 'string' ? { pattern: task.pattern } : task.pattern;
|
|
69
|
+
await task.handler(options.data);
|
|
70
|
+
task.count++;
|
|
71
|
+
|
|
72
|
+
// Check limits
|
|
73
|
+
if (options.limit && task.count >= options.limit) {
|
|
74
|
+
this.tasks.delete(name);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Plan next run
|
|
79
|
+
if (options.pattern) {
|
|
80
|
+
const interval = parser.parseExpression(options.pattern, {
|
|
81
|
+
currentDate: new Date(),
|
|
82
|
+
tz: options.timezone
|
|
83
|
+
});
|
|
84
|
+
task.nextRun = interval.next().getTime();
|
|
85
|
+
} else if (options.every) {
|
|
86
|
+
task.nextRun = Date.now() + options.every;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (options.endDate && task.nextRun > options.endDate.getTime()) {
|
|
90
|
+
this.tasks.delete(name);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
this.plan(name);
|
|
95
|
+
} catch (err) {
|
|
96
|
+
console.error(`Scheduler task "${name}" failed:`, err);
|
|
97
|
+
// Still plan next run? Usually yes.
|
|
98
|
+
this.plan(name);
|
|
99
|
+
}
|
|
100
|
+
}, delay);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async list(): Promise<any[]> {
|
|
104
|
+
return Array.from(this.tasks.entries()).map(([name, task]) => ({
|
|
105
|
+
name,
|
|
106
|
+
nextRun: new Date(task.nextRun),
|
|
107
|
+
count: task.count,
|
|
108
|
+
paused: task.paused
|
|
109
|
+
}));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async cancel(name: string): Promise<void> {
|
|
113
|
+
const task = this.tasks.get(name);
|
|
114
|
+
if (task) {
|
|
115
|
+
if (task.timer) clearTimeout(task.timer);
|
|
116
|
+
this.tasks.delete(name);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async pause(name: string): Promise<void> {
|
|
121
|
+
const task = this.tasks.get(name);
|
|
122
|
+
if (task) {
|
|
123
|
+
task.paused = true;
|
|
124
|
+
if (task.timer) clearTimeout(task.timer);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async resume(name: string): Promise<void> {
|
|
129
|
+
const task = this.tasks.get(name);
|
|
130
|
+
if (task && task.paused) {
|
|
131
|
+
task.paused = false;
|
|
132
|
+
// Recalculate next run if it passed while paused?
|
|
133
|
+
// For cron, we just find the next occurrence from now.
|
|
134
|
+
const options: ScheduleOptions = typeof task.pattern === 'string' ? { pattern: task.pattern } : task.pattern;
|
|
135
|
+
if (options.pattern) {
|
|
136
|
+
const interval = parser.parseExpression(options.pattern, {
|
|
137
|
+
currentDate: new Date(),
|
|
138
|
+
tz: options.timezone
|
|
139
|
+
});
|
|
140
|
+
task.nextRun = interval.next().getTime();
|
|
141
|
+
} else if (options.every) {
|
|
142
|
+
task.nextRun = Date.now() + options.every;
|
|
143
|
+
}
|
|
144
|
+
this.plan(name);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async close(): Promise<void> {
|
|
149
|
+
for (const task of this.tasks.values()) {
|
|
150
|
+
if (task.timer) clearTimeout(task.timer);
|
|
151
|
+
}
|
|
152
|
+
this.tasks.clear();
|
|
153
|
+
}
|
|
154
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export * from "./core/flow-fn.js";
|
|
2
|
+
export * from "./core/scheduler.js";
|
|
3
|
+
export * from "./core/metrics.js";
|
|
4
|
+
|
|
5
|
+
export * from "./queue/types.js";
|
|
6
|
+
export * from "./queue/worker.js";
|
|
7
|
+
export * from "./queue/dlq.js";
|
|
8
|
+
export * from "./stream/types.js";
|
|
9
|
+
export * from "./workflow/types.js";
|
|
10
|
+
|
|
11
|
+
export * from "./adapters/base.js";
|
|
12
|
+
export * from "./adapters/memory.js";
|
|
13
|
+
export * from "./adapters/redis.js";
|
|
14
|
+
export * from "./adapters/postgres/index.js";
|
|
15
|
+
|
|
16
|
+
export * from "./patterns/backoff.js";
|
|
17
|
+
export * from "./patterns/circuit-breaker.js";
|
|
18
|
+
export * from "./patterns/rate-limit.js";
|
|
19
|
+
export {
|
|
20
|
+
batch,
|
|
21
|
+
chunk,
|
|
22
|
+
processBatches,
|
|
23
|
+
BatchWriter,
|
|
24
|
+
batchByKey,
|
|
25
|
+
BatchAccumulator,
|
|
26
|
+
type BatchOptions as BatchingOptions,
|
|
27
|
+
} from "./patterns/batching.js";
|
|
28
|
+
export * from "./patterns/priority.js";
|
|
29
|
+
|
|
30
|
+
export * from "./monitoring/health.js";
|
|
31
|
+
|
|
32
|
+
export * from "./utils/hashing.js";
|
|
33
|
+
export * from "./utils/id-generator.js";
|
|
34
|
+
export {
|
|
35
|
+
toMilliseconds,
|
|
36
|
+
fromMilliseconds,
|
|
37
|
+
formatDuration,
|
|
38
|
+
sleep,
|
|
39
|
+
sleepDuration,
|
|
40
|
+
timeout,
|
|
41
|
+
now,
|
|
42
|
+
delayUntil,
|
|
43
|
+
isPast,
|
|
44
|
+
isFuture,
|
|
45
|
+
addDuration,
|
|
46
|
+
parseDuration,
|
|
47
|
+
type Duration as TimeDuration,
|
|
48
|
+
} from "./utils/time.js";
|
|
49
|
+
export * from "./utils/serialization.js";
|
|
50
|
+
|
|
51
|
+
export * from "./storage/job-storage.js";
|
|
52
|
+
export {
|
|
53
|
+
MemoryWorkflowStorage,
|
|
54
|
+
WorkflowStorage,
|
|
55
|
+
type ListOptions as StorageListOptions,
|
|
56
|
+
} from "./storage/workflow-storage.js";
|
|
57
|
+
export * from "./storage/event-log.js";
|