@qianxude/tem 0.4.0 → 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +196 -38
- package/package.json +2 -1
- package/src/cli/README.md +218 -0
- package/src/core/tem.ts +6 -2
- package/src/database/index.ts +11 -108
- package/src/database/schema.sql +17 -1
- package/src/interfaces/index.ts +3 -0
- package/src/mock-server/README.md +180 -13
- package/src/services/batch-interruption.ts +8 -2
package/README.md
CHANGED
|
@@ -6,6 +6,14 @@ Built for **single-process, IO-bound scenarios** where you need reliable task ex
|
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
bun add @qianxude/tem
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
9
17
|
## Features
|
|
10
18
|
|
|
11
19
|
- **SQLite Persistence** — Tasks survive process restarts using `bun:sqlite` with WAL mode
|
|
@@ -43,38 +51,37 @@ Don't use tem when you need:
|
|
|
43
51
|
```typescript
|
|
44
52
|
import { TEM } from "@qianxude/tem";
|
|
45
53
|
|
|
46
|
-
// Initialize
|
|
47
54
|
const tem = new TEM({
|
|
48
|
-
|
|
49
|
-
concurrency: 5,
|
|
50
|
-
|
|
51
|
-
rateLimit: {
|
|
52
|
-
perMinute: 60, // Respect LLM provider limits
|
|
53
|
-
perSecond: 5
|
|
54
|
-
}
|
|
55
|
+
databasePath: "./tem.db",
|
|
56
|
+
concurrency: 5,
|
|
57
|
+
pollIntervalMs: 1000,
|
|
58
|
+
rateLimit: { requests: 60, windowMs: 60000 } // 60 req/min
|
|
55
59
|
});
|
|
56
60
|
|
|
57
61
|
// Create a batch
|
|
58
62
|
const batch = await tem.batch.create({
|
|
59
|
-
code: "2026-02-15-llm-fix",
|
|
63
|
+
code: "2026-02-15-llm-fix",
|
|
60
64
|
type: "rewrite-docs"
|
|
61
65
|
});
|
|
62
66
|
|
|
63
|
-
//
|
|
64
|
-
await tem.task.
|
|
67
|
+
// Create tasks
|
|
68
|
+
await tem.task.createMany([
|
|
65
69
|
{ batchId: batch.id, type: "rewrite", payload: { docId: 1 } },
|
|
66
70
|
{ batchId: batch.id, type: "rewrite", payload: { docId: 2 } },
|
|
67
71
|
{ batchId: batch.id, type: "rewrite", payload: { docId: 3 } }
|
|
68
72
|
]);
|
|
69
73
|
|
|
70
|
-
// Register handler
|
|
71
|
-
tem.worker.register("rewrite", async (
|
|
72
|
-
const result = await callLLM(
|
|
74
|
+
// Register handler — payload is your task data, context has metadata
|
|
75
|
+
tem.worker.register("rewrite", async (payload, context) => {
|
|
76
|
+
const result = await callLLM(payload);
|
|
73
77
|
return result; // Stored in task.result
|
|
74
78
|
});
|
|
75
79
|
|
|
76
80
|
// Start processing
|
|
77
81
|
tem.worker.start();
|
|
82
|
+
|
|
83
|
+
// Stop when done
|
|
84
|
+
await tem.stop();
|
|
78
85
|
```
|
|
79
86
|
|
|
80
87
|
---
|
|
@@ -99,6 +106,111 @@ failed
|
|
|
99
106
|
|
|
100
107
|
---
|
|
101
108
|
|
|
109
|
+
## Core Concepts
|
|
110
|
+
|
|
111
|
+
- **Batch** — A named group of tasks. All recovery operations (resume, retry) work at batch level.
|
|
112
|
+
- **Task** — A unit of work with a `type`, opaque `payload`, and tracked `status`.
|
|
113
|
+
- **Worker** — Polls for pending tasks and dispatches them to registered handlers by type.
|
|
114
|
+
- **Payload** — Opaque JSON; the framework never parses it. Your handler receives it as-is.
|
|
115
|
+
- **Claim model** — Tasks are acquired atomically (`UPDATE ... WHERE status='pending'`), preventing duplicate execution.
|
|
116
|
+
|
|
117
|
+
### Task Ordering
|
|
118
|
+
|
|
119
|
+
Tasks within a batch are claimed and executed in **FIFO order** (First-In-First-Out) based on creation time.
|
|
120
|
+
When multiple tasks are pending, the task created first will be claimed first:
|
|
121
|
+
|
|
122
|
+
```typescript
|
|
123
|
+
// These tasks will be claimed in order: task1, then task2, then task3
|
|
124
|
+
await tem.task.create({ batchId: batch.id, type: "process", payload: { id: 1 } }); // task1
|
|
125
|
+
await tem.task.create({ batchId: batch.id, type: "process", payload: { id: 2 } }); // task2
|
|
126
|
+
await tem.task.create({ batchId: batch.id, type: "process", payload: { id: 3 } }); // task3
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
## Error Handling
|
|
132
|
+
|
|
133
|
+
By default, any thrown error causes the task to retry up to `defaultMaxAttempts`:
|
|
134
|
+
|
|
135
|
+
```typescript
|
|
136
|
+
tem.worker.register("process", async (payload, context) => {
|
|
137
|
+
console.log(`Attempt ${context.attempt}`);
|
|
138
|
+
const result = await callAPI(payload); // throws → auto-retry
|
|
139
|
+
return result;
|
|
140
|
+
});
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
For permanent failures that should not be retried, throw `NonRetryableError`:
|
|
144
|
+
|
|
145
|
+
```typescript
|
|
146
|
+
import { TEM, NonRetryableError } from "@qianxude/tem";
|
|
147
|
+
|
|
148
|
+
tem.worker.register("validate", async (payload) => {
|
|
149
|
+
if (!payload.id) {
|
|
150
|
+
throw new NonRetryableError("Missing required field: id");
|
|
151
|
+
// Task goes directly to 'failed', no retries
|
|
152
|
+
}
|
|
153
|
+
return process(payload);
|
|
154
|
+
});
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
## Batch Interruption
|
|
160
|
+
|
|
161
|
+
Automatically stop a batch when error thresholds are exceeded:
|
|
162
|
+
|
|
163
|
+
```typescript
|
|
164
|
+
const batch = await tem.batch.create({
|
|
165
|
+
code: "llm-run-01",
|
|
166
|
+
type: "summarize",
|
|
167
|
+
interruptionCriteria: {
|
|
168
|
+
maxErrorRate: 0.3, // Stop if >30% tasks fail
|
|
169
|
+
maxFailedTasks: 10, // Stop if >10 tasks fail
|
|
170
|
+
maxConsecutiveFailures: 5, // Stop if 5 failures in a row
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Check interruption details after the batch stops:
|
|
176
|
+
|
|
177
|
+
```typescript
|
|
178
|
+
const logs = await tem.interruption.getInterruptionLog(batchId);
|
|
179
|
+
// [{ reason, message, statsAtInterruption }]
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Manually interrupt a running batch:
|
|
183
|
+
|
|
184
|
+
```typescript
|
|
185
|
+
await tem.interruptBatch(batchId, "manual", "Stopping due to bad data");
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
## Auto-Detect Constraints
|
|
191
|
+
|
|
192
|
+
Probe an API endpoint to discover its concurrency and rate limits before running tasks:
|
|
193
|
+
|
|
194
|
+
```typescript
|
|
195
|
+
const config = await TEM.detectConstraints({
|
|
196
|
+
url: "https://api.example.com/v1/endpoint",
|
|
197
|
+
method: "POST",
|
|
198
|
+
headers: { Authorization: "Bearer " + process.env.API_KEY },
|
|
199
|
+
body: { /* minimal valid request */ },
|
|
200
|
+
timeoutMs: 30000,
|
|
201
|
+
maxConcurrencyToTest: 50,
|
|
202
|
+
rateLimitTestDurationMs: 10000,
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
const tem = new TEM({
|
|
206
|
+
databasePath: "./tasks.db",
|
|
207
|
+
concurrency: config.concurrency,
|
|
208
|
+
rateLimit: config.rateLimit,
|
|
209
|
+
});
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
---
|
|
213
|
+
|
|
102
214
|
## Recovery Patterns
|
|
103
215
|
|
|
104
216
|
### Resume After Crash
|
|
@@ -133,7 +245,7 @@ TEM
|
|
|
133
245
|
├── Worker # Execution loop with concurrency/rate limiting
|
|
134
246
|
├── ConcurrencyController # Semaphore for local concurrency
|
|
135
247
|
├── RateLimiter # Token bucket for API rate limits
|
|
136
|
-
└──
|
|
248
|
+
└── BatchInterruptionService # Auto-stop on error thresholds
|
|
137
249
|
```
|
|
138
250
|
|
|
139
251
|
### Why Claim-Based?
|
|
@@ -199,12 +311,13 @@ This ensures:
|
|
|
199
311
|
|
|
200
312
|
```typescript
|
|
201
313
|
interface TEMConfig {
|
|
202
|
-
|
|
203
|
-
concurrency?: number;
|
|
204
|
-
|
|
314
|
+
databasePath: string; // SQLite file path
|
|
315
|
+
concurrency?: number; // Default: 5
|
|
316
|
+
pollIntervalMs?: number; // Default: 1000ms
|
|
317
|
+
defaultMaxAttempts?: number; // Default: 3
|
|
205
318
|
rateLimit?: {
|
|
206
|
-
|
|
207
|
-
|
|
319
|
+
requests: number; // Number of requests
|
|
320
|
+
windowMs: number; // Time window in ms (e.g. 60000 for per-minute)
|
|
208
321
|
};
|
|
209
322
|
}
|
|
210
323
|
```
|
|
@@ -216,57 +329,71 @@ interface TEMConfig {
|
|
|
216
329
|
const batch = await tem.batch.create({
|
|
217
330
|
code: "unique-batch-code",
|
|
218
331
|
type: "batch-type",
|
|
219
|
-
metadata?: { ... }
|
|
332
|
+
metadata?: { ... },
|
|
333
|
+
interruptionCriteria?: {
|
|
334
|
+
maxErrorRate?: number;
|
|
335
|
+
maxFailedTasks?: number;
|
|
336
|
+
maxConsecutiveFailures?: number;
|
|
337
|
+
}
|
|
220
338
|
});
|
|
221
339
|
|
|
222
|
-
// Get batch
|
|
223
|
-
const batch = await tem.batch.
|
|
224
|
-
|
|
225
|
-
// List batches
|
|
226
|
-
const batches = await tem.batch.list({ type?: "..." });
|
|
340
|
+
// Get batch by ID
|
|
341
|
+
const batch = await tem.batch.getById(batchId);
|
|
227
342
|
|
|
228
343
|
// Get statistics
|
|
229
344
|
const stats = await tem.batch.getStats(batchId);
|
|
230
|
-
// { pending
|
|
345
|
+
// { pending, running, completed, failed, total }
|
|
231
346
|
|
|
232
347
|
// Resume after crash (running → pending)
|
|
233
348
|
await tem.batch.resume(batchId);
|
|
234
349
|
|
|
235
|
-
// Retry all failed (failed → pending, attempt
|
|
350
|
+
// Retry all failed (failed → pending, attempt reset)
|
|
236
351
|
await tem.batch.retryFailed(batchId);
|
|
237
352
|
```
|
|
238
353
|
|
|
239
354
|
### Task Operations
|
|
240
355
|
|
|
241
356
|
```typescript
|
|
242
|
-
//
|
|
243
|
-
await tem.task.
|
|
357
|
+
// Create single task
|
|
358
|
+
await tem.task.create({
|
|
244
359
|
batchId: string,
|
|
245
360
|
type: string,
|
|
246
361
|
payload: object,
|
|
247
|
-
|
|
362
|
+
maxAttempts?: number
|
|
248
363
|
});
|
|
249
364
|
|
|
250
|
-
// Bulk
|
|
251
|
-
await tem.task.
|
|
365
|
+
// Bulk create (single transaction)
|
|
366
|
+
await tem.task.createMany([
|
|
252
367
|
{ batchId, type, payload },
|
|
253
368
|
...
|
|
254
369
|
]);
|
|
370
|
+
|
|
371
|
+
// Get task by ID
|
|
372
|
+
const task = await tem.task.getById(taskId);
|
|
255
373
|
```
|
|
256
374
|
|
|
257
375
|
### Worker
|
|
258
376
|
|
|
259
377
|
```typescript
|
|
260
378
|
// Register handler
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
const result = await doWork(
|
|
264
|
-
return result; //
|
|
379
|
+
// payload: your task data; context: { taskId, batchId, attempt }
|
|
380
|
+
tem.worker.register("task-type", async (payload, context) => {
|
|
381
|
+
const result = await doWork(payload);
|
|
382
|
+
return result; // JSON-serialized to task.result
|
|
265
383
|
});
|
|
266
384
|
|
|
267
385
|
// Control execution
|
|
268
386
|
tem.worker.start();
|
|
269
|
-
await tem.
|
|
387
|
+
await tem.stop(); // Stops worker and closes DB
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
### NonRetryableError
|
|
391
|
+
|
|
392
|
+
```typescript
|
|
393
|
+
import { NonRetryableError } from "@qianxude/tem";
|
|
394
|
+
|
|
395
|
+
throw new NonRetryableError("reason");
|
|
396
|
+
// Task goes to 'failed' immediately, skipping remaining attempts
|
|
270
397
|
```
|
|
271
398
|
|
|
272
399
|
---
|
|
@@ -298,6 +425,37 @@ await tem.worker.stop();
|
|
|
298
425
|
|
|
299
426
|
---
|
|
300
427
|
|
|
428
|
+
## Mock Server
|
|
429
|
+
|
|
430
|
+
Tem includes a built-in mock HTTP server for testing task execution under various constraints. Use it to simulate APIs with:
|
|
431
|
+
|
|
432
|
+
- **Concurrency limits** — Test how your tasks handle 503 errors
|
|
433
|
+
- **Rate limiting** — Verify retry behavior against 429 responses
|
|
434
|
+
- **Error simulation** — Test resilience with configurable failure rates
|
|
435
|
+
|
|
436
|
+
See [src/mock-server/README.md](src/mock-server/README.md) for detailed documentation.
|
|
437
|
+
|
|
438
|
+
---
|
|
439
|
+
|
|
440
|
+
## CLI
|
|
441
|
+
|
|
442
|
+
Tem includes a CLI for batch diagnostics and monitoring:
|
|
443
|
+
|
|
444
|
+
```sh
|
|
445
|
+
# Generate diagnostic report
|
|
446
|
+
tem report ./tem.db my-batch
|
|
447
|
+
|
|
448
|
+
# List failed tasks
|
|
449
|
+
tem list ./tem.db --batch my-batch --status failed
|
|
450
|
+
|
|
451
|
+
# Watch batch progress in real-time
|
|
452
|
+
tem watch ./tem.db --latest
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
See [src/cli/README.md](src/cli/README.md) for full documentation.
|
|
456
|
+
|
|
457
|
+
---
|
|
458
|
+
|
|
301
459
|
## License
|
|
302
460
|
|
|
303
461
|
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@qianxude/tem",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.3",
|
|
4
4
|
"description": "A lightweight task execution engine for IO-bound workloads with SQLite persistence, retry, and rate limiting",
|
|
5
5
|
"module": "src/index.ts",
|
|
6
6
|
"type": "module",
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"scripts": {
|
|
23
23
|
"typecheck": "tsc --noEmit",
|
|
24
24
|
"test": "bun test",
|
|
25
|
+
"coverage": "bun test --coverage",
|
|
25
26
|
"test:integration": "bun test tests/integration/*.test.ts",
|
|
26
27
|
"test:mock-server": "bun test tests/integration/mock-server.test.ts",
|
|
27
28
|
"test:simple-tasks": "bun test tests/integration/tem-with-mock-server.test.ts",
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# tem CLI
|
|
2
|
+
|
|
3
|
+
Command-line interface for batch diagnostics and monitoring.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
The CLI is included with the tem package:
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
bun add @qianxude/tem
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
You can run it directly with bun:
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
bun run src/cli/index.ts <command> [options]
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Or install globally:
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
bun link
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
tem <command> [options]
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Commands
|
|
32
|
+
|
|
33
|
+
### `report`
|
|
34
|
+
|
|
35
|
+
Generate a diagnostic report for batches.
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
tem report <db-path> [batch-code]
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
**Arguments:**
|
|
42
|
+
|
|
43
|
+
- `db-path` - Path to the SQLite database file (required)
|
|
44
|
+
- `batch-code` - Specific batch code to report on (optional)
|
|
45
|
+
|
|
46
|
+
**Options:**
|
|
47
|
+
|
|
48
|
+
- `--latest` - Report on the most recently created batch
|
|
49
|
+
- `--limit-errors N` - Show top N error patterns (default: 10)
|
|
50
|
+
|
|
51
|
+
**Examples:**
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
# Summary report for all batches
|
|
55
|
+
tem report ./tem.db
|
|
56
|
+
|
|
57
|
+
# Detailed report for specific batch
|
|
58
|
+
tem report ./tem.db my-batch-code
|
|
59
|
+
|
|
60
|
+
# Report on latest batch
|
|
61
|
+
tem report ./tem.db --latest
|
|
62
|
+
|
|
63
|
+
# Show top 20 error patterns
|
|
64
|
+
tem report ./tem.db my-batch-code --limit-errors 20
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
**Report includes:**
|
|
68
|
+
|
|
69
|
+
- Batch overview (code, type, status, timestamps, duration)
|
|
70
|
+
- Status breakdown with counts and percentages
|
|
71
|
+
- Timing analysis (avg/min/max task times, throughput)
|
|
72
|
+
- Error patterns for failed tasks
|
|
73
|
+
- Retry analysis statistics
|
|
74
|
+
- Detection of stuck tasks (running > 5 minutes)
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
### `list`
|
|
79
|
+
|
|
80
|
+
List tasks with filtering options.
|
|
81
|
+
|
|
82
|
+
```sh
|
|
83
|
+
tem list <db-path>
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
**Arguments:**
|
|
87
|
+
|
|
88
|
+
- `db-path` - Path to the SQLite database file (required)
|
|
89
|
+
|
|
90
|
+
**Options:**
|
|
91
|
+
|
|
92
|
+
- `--batch <code>` - Filter by batch code
|
|
93
|
+
- `--status <status>` - Filter by status: `pending`, `running`, `completed`, or `failed`
|
|
94
|
+
- `--type <type>` - Filter by task type
|
|
95
|
+
- `--limit <n>` - Limit results (default: 100)
|
|
96
|
+
|
|
97
|
+
**Examples:**
|
|
98
|
+
|
|
99
|
+
```sh
|
|
100
|
+
# List all tasks (up to 100)
|
|
101
|
+
tem list ./tem.db
|
|
102
|
+
|
|
103
|
+
# List failed tasks from a specific batch
|
|
104
|
+
tem list ./tem.db --batch my-batch --status failed
|
|
105
|
+
|
|
106
|
+
# List pending tasks of a specific type
|
|
107
|
+
tem list ./tem.db --status pending --type rewrite --limit 20
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
**Output columns:**
|
|
111
|
+
|
|
112
|
+
- ID - Task UUID
|
|
113
|
+
- Batch - Batch code
|
|
114
|
+
- Type - Task type
|
|
115
|
+
- Status - Current status
|
|
116
|
+
- Attempts - Current attempt / max attempts
|
|
117
|
+
- Created - Timestamp
|
|
118
|
+
- Completed - Completion timestamp
|
|
119
|
+
- Error - Truncated error message (if failed)
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
### `watch`
|
|
124
|
+
|
|
125
|
+
Monitor a running batch in real-time.
|
|
126
|
+
|
|
127
|
+
```sh
|
|
128
|
+
tem watch <db-path> [batch-code]
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
**Arguments:**
|
|
132
|
+
|
|
133
|
+
- `db-path` - Path to the SQLite database file (required)
|
|
134
|
+
- `batch-code` - Specific batch code to watch (optional if using `--latest`)
|
|
135
|
+
|
|
136
|
+
**Options:**
|
|
137
|
+
|
|
138
|
+
- `--latest` - Watch the most recently created batch
|
|
139
|
+
- `--interval N` - Refresh interval in seconds (default: 5)
|
|
140
|
+
- `--timeout N` - Maximum watch time in seconds (default: 3600)
|
|
141
|
+
- `--no-clear` - Don't clear screen between updates
|
|
142
|
+
|
|
143
|
+
**Examples:**
|
|
144
|
+
|
|
145
|
+
```sh
|
|
146
|
+
# Watch the latest batch
|
|
147
|
+
tem watch ./tem.db --latest
|
|
148
|
+
|
|
149
|
+
# Watch specific batch with 10-second refresh
|
|
150
|
+
tem watch ./tem.db my-batch-code --interval 10
|
|
151
|
+
|
|
152
|
+
# Watch for up to 5 minutes
|
|
153
|
+
tem watch ./tem.db --latest --timeout 300
|
|
154
|
+
|
|
155
|
+
# Watch without clearing screen (for logging)
|
|
156
|
+
tem watch ./tem.db --latest --no-clear
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
**Watch display includes:**
|
|
160
|
+
|
|
161
|
+
- Visual progress bar
|
|
162
|
+
- Batch status with color coding:
|
|
163
|
+
- 🟢 Green - Completed
|
|
164
|
+
- 🔴 Red - Failed
|
|
165
|
+
- 🟡 Yellow - Running
|
|
166
|
+
- 🔵 Cyan - Pending
|
|
167
|
+
- Real-time statistics (pending, running, completed, failed, total)
|
|
168
|
+
- Throughput and ETA
|
|
169
|
+
- Recent errors (last 3)
|
|
170
|
+
- Stuck task warnings (> 5 minutes running)
|
|
171
|
+
|
|
172
|
+
Press `Ctrl+C` to stop watching. A final report is displayed when the batch completes.
|
|
173
|
+
|
|
174
|
+
---
|
|
175
|
+
|
|
176
|
+
## Exit Codes
|
|
177
|
+
|
|
178
|
+
| Code | Meaning |
|
|
179
|
+
|------|---------|
|
|
180
|
+
| 0 | Success |
|
|
181
|
+
| 1 | Runtime error (database issues, batch not found, timeout) |
|
|
182
|
+
| 2 | Usage error (missing arguments, invalid commands/options) |
|
|
183
|
+
| 130 | Interrupted by user (SIGINT) |
|
|
184
|
+
|
|
185
|
+
---
|
|
186
|
+
|
|
187
|
+
## Global Options
|
|
188
|
+
|
|
189
|
+
- `--help, -h` - Show help message for any command
|
|
190
|
+
|
|
191
|
+
## Common Workflows
|
|
192
|
+
|
|
193
|
+
### Debug a failing batch
|
|
194
|
+
|
|
195
|
+
```sh
|
|
196
|
+
# Watch the batch in one terminal
|
|
197
|
+
tem watch ./tem.db my-batch --latest
|
|
198
|
+
|
|
199
|
+
# In another terminal, list failed tasks
|
|
200
|
+
tem list ./tem.db --batch my-batch --status failed
|
|
201
|
+
|
|
202
|
+
# Generate detailed report
|
|
203
|
+
tem report ./tem.db my-batch --limit-errors 20
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
### Monitor a long-running job
|
|
207
|
+
|
|
208
|
+
```sh
|
|
209
|
+
# Watch with longer interval to reduce database queries
|
|
210
|
+
tem watch ./tem.db my-batch --interval 30 --timeout 7200
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
### Quick status check
|
|
214
|
+
|
|
215
|
+
```sh
|
|
216
|
+
# Summary of all batches
|
|
217
|
+
tem report ./tem.db
|
|
218
|
+
```
|
package/src/core/tem.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
type DetectOptions,
|
|
7
7
|
type DetectedConfig,
|
|
8
8
|
} from '../utils/auto-detect.js';
|
|
9
|
+
import type { BatchInterruptionReason, BatchInterruptionCriteria } from '../interfaces/index.js';
|
|
9
10
|
|
|
10
11
|
export type { DetectOptions, DetectedConfig };
|
|
11
12
|
|
|
@@ -30,6 +31,9 @@ export interface TEMConfig {
|
|
|
30
31
|
|
|
31
32
|
// Optional: Specific batch ID to process (if set, only processes this batch)
|
|
32
33
|
batchId?: string;
|
|
34
|
+
|
|
35
|
+
// Default interruption criteria for all batches (batch-level overrides these)
|
|
36
|
+
defaultInterruptionCriteria?: BatchInterruptionCriteria;
|
|
33
37
|
}
|
|
34
38
|
|
|
35
39
|
export class TEM {
|
|
@@ -84,7 +88,7 @@ export class TEM {
|
|
|
84
88
|
// Initialize services
|
|
85
89
|
this.batch = new BatchService(this.database);
|
|
86
90
|
this.task = new TaskService(this.database);
|
|
87
|
-
this.interruption = new BatchInterruptionService(this.database, this.batch);
|
|
91
|
+
this.interruption = new BatchInterruptionService(this.database, this.batch, config.defaultInterruptionCriteria);
|
|
88
92
|
|
|
89
93
|
// Initialize worker with config
|
|
90
94
|
const workerConfig: WorkerConfig = {
|
|
@@ -116,7 +120,7 @@ export class TEM {
|
|
|
116
120
|
*/
|
|
117
121
|
async interruptBatch(
|
|
118
122
|
batchId: string,
|
|
119
|
-
reason?:
|
|
123
|
+
reason?: BatchInterruptionReason,
|
|
120
124
|
message?: string
|
|
121
125
|
): Promise<void> {
|
|
122
126
|
await this.interruption.interrupt(
|
package/src/database/index.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { Database as SQLiteDatabase, type SQLQueryBindings } from 'bun:sqlite';
|
|
2
|
+
import { readFileSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
2
4
|
import * as i from '../interfaces/index.js';
|
|
3
5
|
|
|
4
6
|
export interface DatabaseOptions {
|
|
@@ -13,121 +15,22 @@ export class Database implements i.DatabaseConnection {
|
|
|
13
15
|
this.db = new SQLiteDatabase(options.path);
|
|
14
16
|
|
|
15
17
|
// Enable WAL mode for better concurrency
|
|
16
|
-
this.db.
|
|
18
|
+
this.db.run('PRAGMA journal_mode = WAL;');
|
|
17
19
|
|
|
18
20
|
// Set busy timeout for concurrent access safety (default 5 seconds)
|
|
19
21
|
const timeout = options.busyTimeout ?? 5000;
|
|
20
|
-
this.db.
|
|
22
|
+
this.db.run(`PRAGMA busy_timeout = ${timeout};`);
|
|
21
23
|
|
|
22
|
-
//
|
|
23
|
-
this.
|
|
24
|
+
// Initialize schema
|
|
25
|
+
this.initSchema();
|
|
24
26
|
}
|
|
25
27
|
|
|
26
|
-
private
|
|
27
|
-
//
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
31
|
-
name TEXT NOT NULL UNIQUE,
|
|
32
|
-
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
33
|
-
)
|
|
34
|
-
`);
|
|
28
|
+
private initSchema(): void {
|
|
29
|
+
// Read schema from file
|
|
30
|
+
const schemaPath = join(import.meta.dirname, 'schema.sql');
|
|
31
|
+
const schema = readFileSync(schemaPath, 'utf-8');
|
|
35
32
|
|
|
36
|
-
|
|
37
|
-
const migrations = [
|
|
38
|
-
{ name: '001_initial_schema', apply: () => this.applyInitialSchema() },
|
|
39
|
-
{ name: '002_batch_interruption', apply: () => this.applyBatchInterruptionMigration() },
|
|
40
|
-
];
|
|
41
|
-
|
|
42
|
-
for (const migration of migrations) {
|
|
43
|
-
const migrationCount = this.db
|
|
44
|
-
.query('SELECT COUNT(*) as count FROM _migration WHERE name = $name')
|
|
45
|
-
.get({ $name: migration.name }) as { count: number };
|
|
46
|
-
|
|
47
|
-
if (migrationCount.count === 0) {
|
|
48
|
-
migration.apply();
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
private applyInitialSchema(): void {
|
|
54
|
-
const schema = `
|
|
55
|
-
-- Batch: Groups of related tasks
|
|
56
|
-
CREATE TABLE IF NOT EXISTS batch (
|
|
57
|
-
id TEXT PRIMARY KEY,
|
|
58
|
-
code TEXT NOT NULL UNIQUE,
|
|
59
|
-
type TEXT NOT NULL,
|
|
60
|
-
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
61
|
-
completed_at DATETIME,
|
|
62
|
-
metadata TEXT
|
|
63
|
-
);
|
|
64
|
-
|
|
65
|
-
-- Task: Individual units of work
|
|
66
|
-
CREATE TABLE IF NOT EXISTS task (
|
|
67
|
-
id TEXT PRIMARY KEY,
|
|
68
|
-
batch_id TEXT REFERENCES batch(id) ON DELETE CASCADE,
|
|
69
|
-
type TEXT NOT NULL,
|
|
70
|
-
status TEXT NOT NULL CHECK(status IN ('pending', 'running', 'completed', 'failed')),
|
|
71
|
-
payload TEXT NOT NULL,
|
|
72
|
-
result TEXT,
|
|
73
|
-
error TEXT,
|
|
74
|
-
attempt INTEGER NOT NULL DEFAULT 0,
|
|
75
|
-
max_attempt INTEGER NOT NULL DEFAULT 3,
|
|
76
|
-
claimed_at DATETIME,
|
|
77
|
-
completed_at DATETIME,
|
|
78
|
-
version INTEGER NOT NULL DEFAULT 0,
|
|
79
|
-
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
80
|
-
);
|
|
81
|
-
|
|
82
|
-
-- Indexes for performance
|
|
83
|
-
CREATE INDEX IF NOT EXISTS idx_batch_code ON batch(code);
|
|
84
|
-
CREATE INDEX IF NOT EXISTS idx_batch_type ON batch(type);
|
|
85
|
-
CREATE INDEX IF NOT EXISTS idx_task_batch_id ON task(batch_id);
|
|
86
|
-
CREATE INDEX IF NOT EXISTS idx_task_status ON task(status);
|
|
87
|
-
CREATE INDEX IF NOT EXISTS idx_task_type ON task(type);
|
|
88
|
-
CREATE INDEX IF NOT EXISTS idx_task_claim ON task(status, claimed_at);
|
|
89
|
-
CREATE INDEX IF NOT EXISTS idx_task_pending ON task(status, created_at) WHERE status = 'pending';
|
|
90
|
-
`;
|
|
91
|
-
|
|
92
|
-
this.transaction(() => {
|
|
93
|
-
this.db.exec(schema);
|
|
94
|
-
this.db
|
|
95
|
-
.query('INSERT INTO _migration (name) VALUES ($name)')
|
|
96
|
-
.run({ $name: '001_initial_schema' });
|
|
97
|
-
});
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
private applyBatchInterruptionMigration(): void {
|
|
101
|
-
const migration = `
|
|
102
|
-
-- Add status to batch table
|
|
103
|
-
ALTER TABLE batch ADD COLUMN status TEXT NOT NULL DEFAULT 'active'
|
|
104
|
-
CHECK(status IN ('active', 'interrupted', 'completed'));
|
|
105
|
-
|
|
106
|
-
-- Add interruption criteria storage (JSON)
|
|
107
|
-
ALTER TABLE batch ADD COLUMN interruption_criteria TEXT;
|
|
108
|
-
|
|
109
|
-
-- Index for quickly finding active batches
|
|
110
|
-
CREATE INDEX IF NOT EXISTS idx_batch_status ON batch(status);
|
|
111
|
-
|
|
112
|
-
-- New table: interruption log
|
|
113
|
-
CREATE TABLE IF NOT EXISTS batch_interrupt_log (
|
|
114
|
-
id TEXT PRIMARY KEY,
|
|
115
|
-
batch_id TEXT NOT NULL REFERENCES batch(id) ON DELETE CASCADE,
|
|
116
|
-
reason TEXT NOT NULL,
|
|
117
|
-
message TEXT NOT NULL,
|
|
118
|
-
stats_snapshot TEXT NOT NULL, -- JSON of BatchStats
|
|
119
|
-
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
120
|
-
);
|
|
121
|
-
|
|
122
|
-
CREATE INDEX IF NOT EXISTS idx_interrupt_log_batch_id ON batch_interrupt_log(batch_id);
|
|
123
|
-
`;
|
|
124
|
-
|
|
125
|
-
this.transaction(() => {
|
|
126
|
-
this.db.exec(migration);
|
|
127
|
-
this.db
|
|
128
|
-
.query('INSERT INTO _migration (name) VALUES ($name)')
|
|
129
|
-
.run({ $name: '002_batch_interruption' });
|
|
130
|
-
});
|
|
33
|
+
this.db.run(schema);
|
|
131
34
|
}
|
|
132
35
|
|
|
133
36
|
query<T = unknown>(sql: string, params?: SQLQueryBindings[]): T[] {
|
package/src/database/schema.sql
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
-- TEM Database Schema
|
|
2
2
|
-- SQLite with WAL mode
|
|
3
|
+
-- Complete schema - single source of truth for new databases
|
|
3
4
|
|
|
4
5
|
-- Migration tracking
|
|
5
6
|
CREATE TABLE IF NOT EXISTS _migration (
|
|
@@ -13,9 +14,12 @@ CREATE TABLE IF NOT EXISTS batch (
|
|
|
13
14
|
id TEXT PRIMARY KEY,
|
|
14
15
|
code TEXT NOT NULL UNIQUE,
|
|
15
16
|
type TEXT NOT NULL,
|
|
17
|
+
status TEXT NOT NULL DEFAULT 'active'
|
|
18
|
+
CHECK(status IN ('active', 'interrupted', 'completed')),
|
|
16
19
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
17
20
|
completed_at DATETIME,
|
|
18
|
-
metadata TEXT -- JSON object
|
|
21
|
+
metadata TEXT, -- JSON object
|
|
22
|
+
interruption_criteria TEXT -- JSON object
|
|
19
23
|
);
|
|
20
24
|
|
|
21
25
|
-- Task: Individual units of work
|
|
@@ -35,11 +39,23 @@ CREATE TABLE IF NOT EXISTS task (
|
|
|
35
39
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
36
40
|
);
|
|
37
41
|
|
|
42
|
+
-- Interruption log: Records batch interruption events
|
|
43
|
+
CREATE TABLE IF NOT EXISTS batch_interrupt_log (
|
|
44
|
+
id TEXT PRIMARY KEY,
|
|
45
|
+
batch_id TEXT NOT NULL REFERENCES batch(id) ON DELETE CASCADE,
|
|
46
|
+
reason TEXT NOT NULL,
|
|
47
|
+
message TEXT NOT NULL,
|
|
48
|
+
stats_snapshot TEXT NOT NULL, -- JSON of BatchStats
|
|
49
|
+
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
50
|
+
);
|
|
51
|
+
|
|
38
52
|
-- Indexes for performance
|
|
39
53
|
CREATE INDEX IF NOT EXISTS idx_batch_code ON batch(code);
|
|
40
54
|
CREATE INDEX IF NOT EXISTS idx_batch_type ON batch(type);
|
|
55
|
+
CREATE INDEX IF NOT EXISTS idx_batch_status ON batch(status);
|
|
41
56
|
CREATE INDEX IF NOT EXISTS idx_task_batch_id ON task(batch_id);
|
|
42
57
|
CREATE INDEX IF NOT EXISTS idx_task_status ON task(status);
|
|
43
58
|
CREATE INDEX IF NOT EXISTS idx_task_type ON task(type);
|
|
44
59
|
CREATE INDEX IF NOT EXISTS idx_task_claim ON task(status, claimed_at);
|
|
45
60
|
CREATE INDEX IF NOT EXISTS idx_task_pending ON task(status, created_at) WHERE status = 'pending';
|
|
61
|
+
CREATE INDEX IF NOT EXISTS idx_interrupt_log_batch_id ON batch_interrupt_log(batch_id);
|
package/src/interfaces/index.ts
CHANGED
|
@@ -105,6 +105,9 @@ export interface TEMConfig {
|
|
|
105
105
|
|
|
106
106
|
// Polling
|
|
107
107
|
pollIntervalMs: number;
|
|
108
|
+
|
|
109
|
+
// Default interruption criteria for all batches (batch-level overrides these)
|
|
110
|
+
defaultInterruptionCriteria?: BatchInterruptionCriteria;
|
|
108
111
|
}
|
|
109
112
|
|
|
110
113
|
// ============================================================================
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
# Mock Server
|
|
2
2
|
|
|
3
|
-
A lightweight HTTP server for simulating external API services with configurable concurrency
|
|
3
|
+
A lightweight HTTP server for simulating external API services with configurable concurrency, rate limiting, and error simulation. Used for testing TEM's task execution capabilities under various load conditions.
|
|
4
4
|
|
|
5
5
|
## Overview
|
|
6
6
|
|
|
7
7
|
The mock server provides a controlled environment to test how TEM handles:
|
|
8
8
|
|
|
9
|
-
- **Concurrency limits**
|
|
10
|
-
- **Rate limiting**
|
|
11
|
-
- **
|
|
9
|
+
- **Concurrency limits** — Simulate services that reject requests when too many are in flight (503 errors)
|
|
10
|
+
- **Rate limiting** — Test backoff and retry behavior against rate-limited endpoints (429 errors)
|
|
11
|
+
- **Error simulation** — Verify resilience with configurable random failure rates
|
|
12
|
+
- **Processing delays** — Verify timeout handling and async processing
|
|
12
13
|
|
|
13
14
|
## Architecture
|
|
14
15
|
|
|
@@ -30,6 +31,8 @@ The mock server provides a controlled environment to test how TEM handles:
|
|
|
30
31
|
| Component | File | Purpose |
|
|
31
32
|
|-----------|------|---------|
|
|
32
33
|
| `startMockServer` | `server.ts` | Server lifecycle management |
|
|
34
|
+
| `createMockService` | `server.ts` | Client helper to create services |
|
|
35
|
+
| `createErrorSimulation` | `server.ts` | Helper to create error simulation config |
|
|
33
36
|
| `createRouter` | `router.ts` | HTTP routing and request handling |
|
|
34
37
|
| `MockService` | `service.ts` | Per-service concurrency and rate limiting |
|
|
35
38
|
| `RejectingRateLimiter` | `service.ts` | Token bucket rate limiter with immediate reject |
|
|
@@ -49,7 +52,8 @@ startMockServer({
|
|
|
49
52
|
defaultService: {
|
|
50
53
|
maxConcurrency: 3,
|
|
51
54
|
rateLimit: { limit: 10, windowMs: 1000 },
|
|
52
|
-
delayMs: [10, 50]
|
|
55
|
+
delayMs: [10, 50],
|
|
56
|
+
errorSimulation: { rate: 0.1, statusCode: 500 }
|
|
53
57
|
}
|
|
54
58
|
});
|
|
55
59
|
```
|
|
@@ -61,10 +65,7 @@ Dynamic service creation and management. Each service has its own concurrency/ra
|
|
|
61
65
|
**Use case:** Complex tests with multiple services having different constraints.
|
|
62
66
|
|
|
63
67
|
```typescript
|
|
64
|
-
startMockServer({
|
|
65
|
-
port: 8080,
|
|
66
|
-
mode: 'multi'
|
|
67
|
-
});
|
|
68
|
+
startMockServer({ port: 8080, mode: 'multi' });
|
|
68
69
|
|
|
69
70
|
// Create services dynamically via HTTP API
|
|
70
71
|
```
|
|
@@ -75,7 +76,7 @@ startMockServer({
|
|
|
75
76
|
|
|
76
77
|
| Method | Path | Description |
|
|
77
78
|
|--------|------|-------------|
|
|
78
|
-
| `GET` | `/` | Access the default service |
|
|
79
|
+
| `GET` / `POST` | `/` | Access the default service |
|
|
79
80
|
| `POST` | `/shutdown` | Shutdown the server |
|
|
80
81
|
|
|
81
82
|
### Multi Mode Endpoints
|
|
@@ -84,7 +85,7 @@ startMockServer({
|
|
|
84
85
|
|--------|------|-------------|
|
|
85
86
|
| `POST` | `/service/:name` | Create or replace a service |
|
|
86
87
|
| `DELETE` | `/service/:name` | Delete a service |
|
|
87
|
-
| `GET` | `/mock/:name` | Access a service |
|
|
88
|
+
| `GET` / `POST` | `/mock/:name` | Access a service |
|
|
88
89
|
| `POST` | `/shutdown` | Shutdown the server |
|
|
89
90
|
|
|
90
91
|
### Create Service (Multi Mode Only)
|
|
@@ -99,7 +100,12 @@ Content-Type: application/json
|
|
|
99
100
|
"limit": 10, // Requests per window (required)
|
|
100
101
|
"windowMs": 1000 // Window size in ms (required)
|
|
101
102
|
},
|
|
102
|
-
"delayMs": [10, 200]
|
|
103
|
+
"delayMs": [10, 200], // [min, max] processing delay (optional, default: [10, 200])
|
|
104
|
+
"errorSimulation": { // Optional error simulation config
|
|
105
|
+
"rate": 0.1, // Error rate 0-1 (10% = 0.1)
|
|
106
|
+
"statusCode": 503, // HTTP status to return (default: 500)
|
|
107
|
+
"errorMessage": "simulated_error" // Error message (default: "internal_server_error")
|
|
108
|
+
}
|
|
103
109
|
}
|
|
104
110
|
```
|
|
105
111
|
|
|
@@ -114,11 +120,30 @@ Content-Type: application/json
|
|
|
114
120
|
}
|
|
115
121
|
```
|
|
116
122
|
|
|
123
|
+
### Delete Service (Multi Mode Only)
|
|
124
|
+
|
|
125
|
+
```http
|
|
126
|
+
DELETE /service/:name
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
**Response:**
|
|
130
|
+
```http
|
|
131
|
+
HTTP/1.1 200 OK
|
|
132
|
+
Content-Type: application/json
|
|
133
|
+
|
|
134
|
+
{
|
|
135
|
+
"service": "test1",
|
|
136
|
+
"status": "deleted"
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
117
140
|
### Access Service
|
|
118
141
|
|
|
119
142
|
```http
|
|
120
143
|
GET /mock/:name # Multi mode
|
|
144
|
+
POST /mock/:name # Multi mode (body ignored)
|
|
121
145
|
GET / # Single mode
|
|
146
|
+
POST / # Single mode (body ignored)
|
|
122
147
|
```
|
|
123
148
|
|
|
124
149
|
**Success Response (200):**
|
|
@@ -152,6 +177,8 @@ Content-Type: application/json
|
|
|
152
177
|
}
|
|
153
178
|
```
|
|
154
179
|
|
|
180
|
+
The server will stop accepting new connections and exit the process after a brief delay.
|
|
181
|
+
|
|
155
182
|
## Error Responses
|
|
156
183
|
|
|
157
184
|
| Status | Error Code | Description |
|
|
@@ -163,6 +190,21 @@ Content-Type: application/json
|
|
|
163
190
|
| `429` | `rate_limit_exceeded` | Rate limit reached |
|
|
164
191
|
| `503` | `concurrency_limit_exceeded` | Concurrency limit reached |
|
|
165
192
|
|
|
193
|
+
### Error Simulation
|
|
194
|
+
|
|
195
|
+
When `errorSimulation` is configured, requests may randomly fail with:
|
|
196
|
+
|
|
197
|
+
```http
|
|
198
|
+
HTTP/1.1 500 Internal Server Error // Or configured statusCode
|
|
199
|
+
Content-Type: application/json
|
|
200
|
+
|
|
201
|
+
{
|
|
202
|
+
"error": "internal_server_error" // Or configured errorMessage
|
|
203
|
+
}
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
The error is checked **before** acquiring concurrency/rate limit resources, so simulated errors don't count against limits.
|
|
207
|
+
|
|
166
208
|
## Configuration
|
|
167
209
|
|
|
168
210
|
### ServerConfig
|
|
@@ -185,6 +227,21 @@ interface ServiceConfig {
|
|
|
185
227
|
windowMs: number; // Window duration in milliseconds
|
|
186
228
|
};
|
|
187
229
|
delayMs: [number, number]; // [min, max] simulated processing delay
|
|
230
|
+
errorSimulation?: { // Optional error simulation
|
|
231
|
+
rate: number; // Error rate 0-1
|
|
232
|
+
statusCode?: number; // HTTP status code (default: 500)
|
|
233
|
+
errorMessage?: string; // Error message (default: "internal_server_error")
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
### ErrorSimulationConfig
|
|
239
|
+
|
|
240
|
+
```typescript
|
|
241
|
+
interface ErrorSimulationConfig {
|
|
242
|
+
rate: number; // Error rate 0-1 (e.g., 0.1 = 10% error rate)
|
|
243
|
+
statusCode?: number; // HTTP status code to return (default: 500)
|
|
244
|
+
errorMessage?: string; // Error message (default: "internal_server_error")
|
|
188
245
|
}
|
|
189
246
|
```
|
|
190
247
|
|
|
@@ -315,6 +372,29 @@ console.log(r3.status); // 429
|
|
|
315
372
|
console.log(await r3.json()); // { error: 'rate_limit_exceeded' }
|
|
316
373
|
```
|
|
317
374
|
|
|
375
|
+
### Testing Error Simulation
|
|
376
|
+
|
|
377
|
+
```typescript
|
|
378
|
+
import { startMockServer, stopMockServer, createErrorSimulation } from './src/mock-server';
|
|
379
|
+
|
|
380
|
+
startMockServer({
|
|
381
|
+
port: 8080,
|
|
382
|
+
mode: 'single',
|
|
383
|
+
defaultService: {
|
|
384
|
+
maxConcurrency: 10,
|
|
385
|
+
rateLimit: { limit: 100, windowMs: 1000 },
|
|
386
|
+
delayMs: [10, 50],
|
|
387
|
+
errorSimulation: createErrorSimulation(0.3, 503, "service_unavailable")
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
// Approximately 30% of requests will fail with 503
|
|
392
|
+
for (let i = 0; i < 10; i++) {
|
|
393
|
+
const res = await fetch('http://localhost:8080/');
|
|
394
|
+
console.log(res.status); // Mix of 200 and 503
|
|
395
|
+
}
|
|
396
|
+
```
|
|
397
|
+
|
|
318
398
|
## Rate Limiting Algorithm
|
|
319
399
|
|
|
320
400
|
The mock server uses a **token bucket** algorithm for rate limiting:
|
|
@@ -332,15 +412,31 @@ Concurrency is tracked per-service:
|
|
|
332
412
|
- Decrements when request completes (in `finally` block)
|
|
333
413
|
- If `currentConcurrency >= maxConcurrency`, new requests get 503
|
|
334
414
|
|
|
415
|
+
## Error Simulation
|
|
416
|
+
|
|
417
|
+
Error simulation is checked before acquiring resources:
|
|
418
|
+
|
|
419
|
+
```typescript
|
|
420
|
+
// Pseudocode
|
|
421
|
+
if (Math.random() < errorSimulation.rate) {
|
|
422
|
+
return errorResponse; // Doesn't consume concurrency/rate limit tokens
|
|
423
|
+
}
|
|
424
|
+
// Continue with normal request handling...
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
This ensures that simulated errors don't deplete your concurrency slots or rate limit budget.
|
|
428
|
+
|
|
335
429
|
## Programmatic API
|
|
336
430
|
|
|
431
|
+
### Server Lifecycle
|
|
432
|
+
|
|
337
433
|
```typescript
|
|
338
434
|
import { startMockServer, stopMockServer, getServerState } from './src/mock-server';
|
|
339
435
|
|
|
340
436
|
// Start server
|
|
341
437
|
startMockServer(config: ServerConfig): void
|
|
342
438
|
|
|
343
|
-
// Stop server programmatically
|
|
439
|
+
// Stop server programmatically (for testing)
|
|
344
440
|
stopMockServer(): void
|
|
345
441
|
|
|
346
442
|
// Get current state (for testing)
|
|
@@ -350,3 +446,74 @@ getServerState(): {
|
|
|
350
446
|
hasDefaultService: boolean;
|
|
351
447
|
}
|
|
352
448
|
```
|
|
449
|
+
|
|
450
|
+
### Client Helpers
|
|
451
|
+
|
|
452
|
+
```typescript
|
|
453
|
+
import { createMockService, createErrorSimulation } from './src/mock-server';
|
|
454
|
+
|
|
455
|
+
// Create a service programmatically (wraps HTTP call)
|
|
456
|
+
createMockService(
|
|
457
|
+
name: string,
|
|
458
|
+
config: CreateServiceRequest,
|
|
459
|
+
mockUrl?: string // defaults to http://localhost:19999
|
|
460
|
+
): Promise<Response>
|
|
461
|
+
|
|
462
|
+
// Create error simulation config with validation
|
|
463
|
+
createErrorSimulation(
|
|
464
|
+
rate: number, // 0-1 error rate
|
|
465
|
+
statusCode?: number, // HTTP status (default: 500)
|
|
466
|
+
errorMessage?: string // Error message
|
|
467
|
+
): ErrorSimulationConfig
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
Example using client helpers:
|
|
471
|
+
|
|
472
|
+
```typescript
|
|
473
|
+
import { createMockService, createErrorSimulation } from './src/mock-server';
|
|
474
|
+
|
|
475
|
+
// Create service with error simulation
|
|
476
|
+
await createMockService('flaky-api', {
|
|
477
|
+
maxConcurrency: 5,
|
|
478
|
+
rateLimit: { limit: 10, windowMs: 1000 },
|
|
479
|
+
delayMs: [50, 100],
|
|
480
|
+
errorSimulation: createErrorSimulation(0.2, 503)
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
// Use the service
|
|
484
|
+
const res = await fetch('http://localhost:19999/mock/flaky-api');
|
|
485
|
+
```
|
|
486
|
+
|
|
487
|
+
## Testing with TEM
|
|
488
|
+
|
|
489
|
+
The mock server is designed to integrate seamlessly with TEM for testing retry and error handling:
|
|
490
|
+
|
|
491
|
+
```typescript
|
|
492
|
+
import { TEM } from '@qianxude/tem';
|
|
493
|
+
import { startMockServer, createMockService, createErrorSimulation } from './src/mock-server';
|
|
494
|
+
|
|
495
|
+
// Start mock server with flaky service
|
|
496
|
+
startMockServer({ port: 19999, mode: 'multi' });
|
|
497
|
+
|
|
498
|
+
await createMockService('api', {
|
|
499
|
+
maxConcurrency: 3,
|
|
500
|
+
rateLimit: { limit: 10, windowMs: 1000 },
|
|
501
|
+
errorSimulation: createErrorSimulation(0.2) // 20% failure rate
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
// Configure TEM to match mock server limits
|
|
505
|
+
const tem = new TEM({
|
|
506
|
+
databasePath: ':memory:',
|
|
507
|
+
concurrency: 3,
|
|
508
|
+
rateLimit: { requests: 10, windowMs: 1000 }
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
// Register handler that calls mock server
|
|
512
|
+
tem.worker.register('test', async (payload) => {
|
|
513
|
+
const res = await fetch('http://localhost:19999/mock/api');
|
|
514
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
515
|
+
return res.json();
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
// TEM's retry mechanism will handle the 20% failure rate
|
|
519
|
+
```
|
|
@@ -13,7 +13,8 @@ export interface BatchInterruptionRow {
|
|
|
13
13
|
export class BatchInterruptionService implements i.BatchInterruptionService {
|
|
14
14
|
constructor(
|
|
15
15
|
private db: Database,
|
|
16
|
-
private batchService: BatchService
|
|
16
|
+
private batchService: BatchService,
|
|
17
|
+
private defaultCriteria?: i.BatchInterruptionCriteria
|
|
17
18
|
) {}
|
|
18
19
|
|
|
19
20
|
/**
|
|
@@ -30,13 +31,18 @@ export class BatchInterruptionService implements i.BatchInterruptionService {
|
|
|
30
31
|
}
|
|
31
32
|
): Promise<boolean> {
|
|
32
33
|
// Fetch batch with its interruption criteria
|
|
33
|
-
const { batch, criteria } = await this.batchService.getWithCriteria(batchId);
|
|
34
|
+
const { batch, criteria: batchCriteria } = await this.batchService.getWithCriteria(batchId);
|
|
34
35
|
|
|
35
36
|
// If already interrupted or completed, no need to check
|
|
36
37
|
if (batch.status !== 'active') {
|
|
37
38
|
return false;
|
|
38
39
|
}
|
|
39
40
|
|
|
41
|
+
// Merge criteria: TEM-level (default) overrides batch-level
|
|
42
|
+
const criteria: i.BatchInterruptionCriteria | undefined = batchCriteria || this.defaultCriteria
|
|
43
|
+
? { ...batchCriteria, ...this.defaultCriteria }
|
|
44
|
+
: undefined;
|
|
45
|
+
|
|
40
46
|
// If no criteria set, never interrupt
|
|
41
47
|
if (!criteria) {
|
|
42
48
|
return false;
|