@qianxude/tem 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,9 +1,12 @@
1
1
  {
2
2
  "name": "@qianxude/tem",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
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",
7
+ "bin": {
8
+ "tem": "./src/cli/index.ts"
9
+ },
7
10
  "exports": {
8
11
  ".": "./src/index.ts"
9
12
  },
@@ -27,6 +30,7 @@
27
30
  "lint": "oxlint",
28
31
  "lint:file": "oxlint",
29
32
  "dev": "bun --watch src/index.ts",
33
+ "cli": "bun ./src/cli/index.ts",
30
34
  "publish:pkg": "bun publish --access public",
31
35
  "version:patch": "./scripts/version.sh patch",
32
36
  "version:minor": "./scripts/version.sh minor",
@@ -0,0 +1,141 @@
1
+ import { Database } from '../../database/index.js';
2
+ import * as i from '../../interfaces/index.js';
3
+ import { formatTimestamp, truncate } from '../utils/format.js';
4
+ import { renderTable } from '../utils/table.js';
5
+
6
+ interface TaskRow {
7
+ id: string;
8
+ batch_id: string | null;
9
+ batch_code: string | null;
10
+ type: string;
11
+ status: i.TaskStatus;
12
+ attempt: number;
13
+ max_attempt: number;
14
+ created_at: string;
15
+ completed_at: string | null;
16
+ error: string | null;
17
+ }
18
+
19
+ function openDatabase(dbPath: string): Database {
20
+ return new Database({ path: dbPath, busyTimeout: 5000 });
21
+ }
22
+
23
+ function buildTaskQuery(
24
+ db: Database,
25
+ filters: {
26
+ batchCode?: string;
27
+ status?: string;
28
+ type?: string;
29
+ limit: number;
30
+ }
31
+ ): TaskRow[] {
32
+ const conditions: string[] = [];
33
+ const params: (string | number)[] = [];
34
+
35
+ if (filters.batchCode) {
36
+ conditions.push('b.code = ?');
37
+ params.push(filters.batchCode);
38
+ }
39
+
40
+ if (filters.status) {
41
+ conditions.push('t.status = ?');
42
+ params.push(filters.status);
43
+ }
44
+
45
+ if (filters.type) {
46
+ conditions.push('t.type = ?');
47
+ params.push(filters.type);
48
+ }
49
+
50
+ const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
51
+
52
+ const sql = `
53
+ SELECT
54
+ t.id,
55
+ t.batch_id,
56
+ b.code as batch_code,
57
+ t.type,
58
+ t.status,
59
+ t.attempt,
60
+ t.max_attempt,
61
+ t.created_at,
62
+ t.completed_at,
63
+ t.error
64
+ FROM task t
65
+ LEFT JOIN batch b ON b.id = t.batch_id
66
+ ${whereClause}
67
+ ORDER BY t.created_at DESC
68
+ LIMIT ?
69
+ `;
70
+
71
+ params.push(filters.limit);
72
+
73
+ return db.query<TaskRow>(sql, params);
74
+ }
75
+
76
+ export async function listCommand(
77
+ dbPath: string,
78
+ flags: Record<string, string | boolean>
79
+ ): Promise<void> {
80
+ const batchCode = String(flags.batch || '');
81
+ const status = String(flags.status || '');
82
+ const type = String(flags.type || '');
83
+ const limit = parseInt(String(flags.limit || '100'), 10) || 100;
84
+
85
+ // Validate status if provided
86
+ const validStatuses: i.TaskStatus[] = ['pending', 'running', 'completed', 'failed'];
87
+ if (status && !validStatuses.includes(status as i.TaskStatus)) {
88
+ console.error(
89
+ `Error: Invalid status "${status}". Valid values: ${validStatuses.join(', ')}`
90
+ );
91
+ process.exit(2);
92
+ }
93
+
94
+ const db = openDatabase(dbPath);
95
+
96
+ try {
97
+ const tasks = buildTaskQuery(db, {
98
+ batchCode: batchCode || undefined,
99
+ status: status || undefined,
100
+ type: type || undefined,
101
+ limit,
102
+ });
103
+
104
+ if (tasks.length === 0) {
105
+ console.log('No tasks found matching the criteria.');
106
+ return;
107
+ }
108
+
109
+ console.log(`Found ${tasks.length} task(s)`);
110
+ console.log();
111
+
112
+ const rows = tasks.map((t) => ({
113
+ id: t.id.slice(0, 22),
114
+ batch: t.batch_code || '-',
115
+ type: truncate(t.type, 20),
116
+ status: t.status,
117
+ attempt: `${t.attempt}/${t.max_attempt}`,
118
+ created: formatTimestamp(t.created_at).slice(0, 19),
119
+ completed: t.completed_at ? formatTimestamp(t.completed_at).slice(0, 19) : '-',
120
+ error: truncate(t.error, 30),
121
+ }));
122
+
123
+ console.log(
124
+ renderTable(
125
+ [
126
+ { header: 'ID', key: 'id', width: 24 },
127
+ { header: 'Batch', key: 'batch', width: 16 },
128
+ { header: 'Type', key: 'type', width: 22 },
129
+ { header: 'Status', key: 'status', width: 10 },
130
+ { header: 'Attempt', key: 'attempt', width: 8, align: 'right' },
131
+ { header: 'Created', key: 'created', width: 20 },
132
+ { header: 'Completed', key: 'completed', width: 20 },
133
+ { header: 'Error', key: 'error', width: 32 },
134
+ ],
135
+ rows
136
+ )
137
+ );
138
+ } finally {
139
+ db.close();
140
+ }
141
+ }
@@ -0,0 +1,453 @@
1
+ import { Database } from '../../database/index.js';
2
+ import {
3
+ formatDuration,
4
+ formatTimestamp,
5
+ formatNumber,
6
+ formatPercent,
7
+ truncate,
8
+ } from '../utils/format.js';
9
+ import { renderTable, renderKeyValue } from '../utils/table.js';
10
+
11
+ export interface BatchSummary {
12
+ id: string;
13
+ code: string;
14
+ type: string;
15
+ created_at: string;
16
+ completed_at: string | null;
17
+ total: number;
18
+ pending: number;
19
+ running: number;
20
+ completed: number;
21
+ failed: number;
22
+ }
23
+
24
+ export interface TaskTiming {
25
+ avg_time: number;
26
+ min_time: number;
27
+ max_time: number;
28
+ }
29
+
30
+ export interface ErrorPattern {
31
+ error: string;
32
+ count: number;
33
+ }
34
+
35
+ export interface StuckTask {
36
+ id: string;
37
+ type: string;
38
+ claimed_at: string;
39
+ attempt: number;
40
+ }
41
+
42
+ function openDatabase(dbPath: string): Database {
43
+ return new Database({ path: dbPath, busyTimeout: 5000 });
44
+ }
45
+
46
+ function getBatchSummary(db: Database, batchId: string): BatchSummary | null {
47
+ const rows = db.query<BatchSummary>(
48
+ `SELECT
49
+ b.id,
50
+ b.code,
51
+ b.type,
52
+ b.created_at,
53
+ b.completed_at,
54
+ COUNT(t.id) as total,
55
+ SUM(CASE WHEN t.status = 'pending' THEN 1 ELSE 0 END) as pending,
56
+ SUM(CASE WHEN t.status = 'running' THEN 1 ELSE 0 END) as running,
57
+ SUM(CASE WHEN t.status = 'completed' THEN 1 ELSE 0 END) as completed,
58
+ SUM(CASE WHEN t.status = 'failed' THEN 1 ELSE 0 END) as failed
59
+ FROM batch b
60
+ LEFT JOIN task t ON t.batch_id = b.id
61
+ WHERE b.id = ?
62
+ GROUP BY b.id`,
63
+ [batchId]
64
+ );
65
+ return rows[0] || null;
66
+ }
67
+
68
+ function getBatchByCode(db: Database, code: string): { id: string } | null {
69
+ const rows = db.query<{ id: string }>(
70
+ 'SELECT id FROM batch WHERE code = ?',
71
+ [code]
72
+ );
73
+ return rows[0] || null;
74
+ }
75
+
76
+ function getLatestBatch(db: Database): { id: string; code: string } | null {
77
+ const rows = db.query<{ id: string; code: string }>(
78
+ 'SELECT id, code FROM batch ORDER BY created_at DESC LIMIT 1'
79
+ );
80
+ return rows[0] || null;
81
+ }
82
+
83
+ function getAllBatchesSummary(db: Database): BatchSummary[] {
84
+ return db.query<BatchSummary>(
85
+ `SELECT
86
+ b.id,
87
+ b.code,
88
+ b.type,
89
+ b.created_at,
90
+ b.completed_at,
91
+ COUNT(t.id) as total,
92
+ SUM(CASE WHEN t.status = 'pending' THEN 1 ELSE 0 END) as pending,
93
+ SUM(CASE WHEN t.status = 'running' THEN 1 ELSE 0 END) as running,
94
+ SUM(CASE WHEN t.status = 'completed' THEN 1 ELSE 0 END) as completed,
95
+ SUM(CASE WHEN t.status = 'failed' THEN 1 ELSE 0 END) as failed
96
+ FROM batch b
97
+ LEFT JOIN task t ON t.batch_id = b.id
98
+ GROUP BY b.id
99
+ ORDER BY b.created_at DESC`
100
+ );
101
+ }
102
+
103
+ function getTaskTiming(db: Database, batchId: string): TaskTiming | null {
104
+ const rows = db.query<TaskTiming>(
105
+ `SELECT
106
+ AVG(
107
+ unixepoch(completed_at) - unixepoch(created_at)
108
+ ) * 1000 as avg_time,
109
+ MIN(
110
+ unixepoch(completed_at) - unixepoch(created_at)
111
+ ) * 1000 as min_time,
112
+ MAX(
113
+ unixepoch(completed_at) - unixepoch(created_at)
114
+ ) * 1000 as max_time
115
+ FROM task
116
+ WHERE batch_id = ?
117
+ AND status = 'completed'
118
+ AND completed_at IS NOT NULL`,
119
+ [batchId]
120
+ );
121
+ return rows[0] || null;
122
+ }
123
+
124
+ function getErrorPatterns(
125
+ db: Database,
126
+ batchId: string,
127
+ limit: number
128
+ ): ErrorPattern[] {
129
+ return db.query<ErrorPattern>(
130
+ `SELECT
131
+ error,
132
+ COUNT(*) as count
133
+ FROM task
134
+ WHERE batch_id = ?
135
+ AND status = 'failed'
136
+ AND error IS NOT NULL
137
+ GROUP BY error
138
+ ORDER BY count DESC
139
+ LIMIT ?`,
140
+ [batchId, limit]
141
+ );
142
+ }
143
+
144
+ function getRetryStats(
145
+ db: Database,
146
+ batchId: string
147
+ ): { total_retries: number; tasks_with_retries: number } | null {
148
+ const rows = db.query<{ total_retries: number; tasks_with_retries: number }>(
149
+ `SELECT
150
+ SUM(attempt) as total_retries,
151
+ COUNT(CASE WHEN attempt > 1 THEN 1 END) as tasks_with_retries
152
+ FROM task
153
+ WHERE batch_id = ?
154
+ AND status = 'completed'`,
155
+ [batchId]
156
+ );
157
+ return rows[0] || null;
158
+ }
159
+
160
+ function getStuckTasks(db: Database, batchId: string): StuckTask[] {
161
+ // Tasks that have been running for more than 5 minutes
162
+ return db.query<StuckTask>(
163
+ `SELECT
164
+ id,
165
+ type,
166
+ claimed_at,
167
+ attempt
168
+ FROM task
169
+ WHERE batch_id = ?
170
+ AND status = 'running'
171
+ AND claimed_at < datetime('now', '-5 minutes')`,
172
+ [batchId]
173
+ );
174
+ }
175
+
176
+ function determineBatchStatus(summary: BatchSummary): string {
177
+ if (summary.failed > 0 && summary.pending === 0 && summary.running === 0) {
178
+ return 'failed';
179
+ }
180
+ if (summary.pending === 0 && summary.running === 0) {
181
+ return 'completed';
182
+ }
183
+ if (summary.running > 0) {
184
+ return 'running';
185
+ }
186
+ return 'pending';
187
+ }
188
+
189
+ function printBatchSummary(summary: BatchSummary): void {
190
+ const status = determineBatchStatus(summary);
191
+
192
+ const overviewData = [
193
+ { key: 'Batch Code', value: summary.code },
194
+ { key: 'Type', value: summary.type },
195
+ { key: 'Status', value: status },
196
+ { key: 'Created', value: formatTimestamp(summary.created_at) },
197
+ { key: 'Completed', value: formatTimestamp(summary.completed_at) },
198
+ ];
199
+
200
+ if (summary.completed_at) {
201
+ const created = new Date(summary.created_at).getTime();
202
+ const completed = new Date(summary.completed_at).getTime();
203
+ overviewData.push({ key: 'Duration', value: formatDuration(completed - created) });
204
+ }
205
+
206
+ console.log('Overview');
207
+ console.log(renderKeyValue(overviewData));
208
+ console.log();
209
+
210
+ // Status breakdown
211
+ const total = summary.total || 1; // Avoid division by zero
212
+ const breakdownData = [
213
+ {
214
+ status: 'Total',
215
+ count: formatNumber(summary.total),
216
+ percent: '100%',
217
+ },
218
+ {
219
+ status: 'Pending',
220
+ count: formatNumber(summary.pending),
221
+ percent: formatPercent(summary.pending, total),
222
+ },
223
+ {
224
+ status: 'Running',
225
+ count: formatNumber(summary.running),
226
+ percent: formatPercent(summary.running, total),
227
+ },
228
+ {
229
+ status: 'Completed',
230
+ count: formatNumber(summary.completed),
231
+ percent: formatPercent(summary.completed, total),
232
+ },
233
+ {
234
+ status: 'Failed',
235
+ count: formatNumber(summary.failed),
236
+ percent: formatPercent(summary.failed, total),
237
+ },
238
+ ];
239
+
240
+ console.log('Status Breakdown');
241
+ console.log(
242
+ renderTable(
243
+ [
244
+ { header: 'Status', key: 'status', width: 12 },
245
+ { header: 'Count', key: 'count', width: 10, align: 'right' },
246
+ { header: 'Percent', key: 'percent', width: 10, align: 'right' },
247
+ ],
248
+ breakdownData
249
+ )
250
+ );
251
+ }
252
+
253
+ function printDetailedReport(
254
+ db: Database,
255
+ summary: BatchSummary,
256
+ limitErrors: number
257
+ ): void {
258
+ printBatchSummary(summary);
259
+
260
+ // Timing analysis
261
+ const timing = getTaskTiming(db, summary.id);
262
+ if (timing && timing.avg_time !== null) {
263
+ console.log();
264
+ console.log('Timing Analysis');
265
+ const timingData = [
266
+ {
267
+ key: 'Avg Task Time',
268
+ value: formatDuration(Math.round(timing.avg_time)),
269
+ },
270
+ {
271
+ key: 'Min Task Time',
272
+ value: formatDuration(Math.round(timing.min_time)),
273
+ },
274
+ {
275
+ key: 'Max Task Time',
276
+ value: formatDuration(Math.round(timing.max_time)),
277
+ },
278
+ ];
279
+
280
+ // Calculate throughput
281
+ if (summary.completed > 0 && timing.avg_time > 0) {
282
+ const tasksPerSecond = 1000 / timing.avg_time;
283
+ timingData.push({
284
+ key: 'Throughput',
285
+ value: `${tasksPerSecond.toFixed(2)} tasks/sec`,
286
+ });
287
+ }
288
+
289
+ console.log(renderKeyValue(timingData));
290
+ }
291
+
292
+ // Failure analysis
293
+ if (summary.failed > 0) {
294
+ console.log();
295
+ console.log('Failure Analysis');
296
+ const errors = getErrorPatterns(db, summary.id, limitErrors);
297
+ if (errors.length > 0) {
298
+ console.log(
299
+ renderTable(
300
+ [
301
+ { header: 'Count', key: 'count', width: 8, align: 'right' },
302
+ { header: 'Error', key: 'error' },
303
+ ],
304
+ errors.map((e) => ({
305
+ count: formatNumber(e.count),
306
+ error: truncate(e.error, 80),
307
+ }))
308
+ )
309
+ );
310
+ } else {
311
+ console.log('No error details available.');
312
+ }
313
+ }
314
+
315
+ // Retry analysis
316
+ const retryStats = getRetryStats(db, summary.id);
317
+ if (retryStats && retryStats.tasks_with_retries > 0) {
318
+ console.log();
319
+ console.log('Retry Analysis');
320
+ const retryData = [
321
+ {
322
+ key: 'Tasks with Retries',
323
+ value: formatNumber(retryStats.tasks_with_retries),
324
+ },
325
+ {
326
+ key: 'Total Retry Attempts',
327
+ value: formatNumber(retryStats.total_retries),
328
+ },
329
+ {
330
+ key: 'Retry Success Rate',
331
+ value: formatPercent(
332
+ retryStats.tasks_with_retries,
333
+ summary.completed
334
+ ),
335
+ },
336
+ ];
337
+ console.log(renderKeyValue(retryData));
338
+ }
339
+
340
+ // Stuck task detection
341
+ const stuckTasks = getStuckTasks(db, summary.id);
342
+ if (stuckTasks.length > 0) {
343
+ console.log();
344
+ console.log('Stuck Task Detection (running > 5 minutes)');
345
+ console.log(
346
+ renderTable(
347
+ [
348
+ { header: 'ID', key: 'id', width: 24 },
349
+ { header: 'Type', key: 'type', width: 20 },
350
+ { header: 'Claimed At', key: 'claimed_at', width: 24 },
351
+ { header: 'Attempt', key: 'attempt', width: 8, align: 'right' },
352
+ ],
353
+ stuckTasks.map((t) => ({
354
+ id: t.id.slice(0, 22),
355
+ type: t.type,
356
+ claimed_at: formatTimestamp(t.claimed_at),
357
+ attempt: t.attempt,
358
+ }))
359
+ )
360
+ );
361
+ }
362
+ }
363
+
364
+ function printAllBatchesSummary(db: Database): void {
365
+ const batches = getAllBatchesSummary(db);
366
+
367
+ if (batches.length === 0) {
368
+ console.log('No batches found in database.');
369
+ return;
370
+ }
371
+
372
+ console.log(`Found ${batches.length} batch(es)`);
373
+ console.log();
374
+
375
+ const rows = batches.map((b) => {
376
+ const status = determineBatchStatus(b);
377
+ return {
378
+ code: b.code,
379
+ type: b.type,
380
+ status,
381
+ created: formatTimestamp(b.created_at).slice(0, 19),
382
+ total: formatNumber(b.total),
383
+ pending: formatNumber(b.pending),
384
+ running: formatNumber(b.running),
385
+ completed: formatNumber(b.completed),
386
+ failed: formatNumber(b.failed),
387
+ };
388
+ });
389
+
390
+ console.log(
391
+ renderTable(
392
+ [
393
+ { header: 'Code', key: 'code', width: 20 },
394
+ { header: 'Type', key: 'type', width: 16 },
395
+ { header: 'Status', key: 'status', width: 12 },
396
+ { header: 'Created', key: 'created', width: 20 },
397
+ { header: 'Total', key: 'total', width: 8, align: 'right' },
398
+ { header: 'Pend', key: 'pending', width: 6, align: 'right' },
399
+ { header: 'Run', key: 'running', width: 5, align: 'right' },
400
+ { header: 'Done', key: 'completed', width: 6, align: 'right' },
401
+ { header: 'Fail', key: 'failed', width: 5, align: 'right' },
402
+ ],
403
+ rows
404
+ )
405
+ );
406
+ }
407
+
408
+ export async function reportCommand(
409
+ dbPath: string,
410
+ batchCode: string | undefined,
411
+ flags: Record<string, string | boolean>
412
+ ): Promise<void> {
413
+ const limitErrors = parseInt(String(flags['limit-errors'] || '10'), 10) || 10;
414
+
415
+ const db = openDatabase(dbPath);
416
+
417
+ try {
418
+ if (batchCode) {
419
+ // Detailed report for specific batch
420
+ const batch = getBatchByCode(db, batchCode);
421
+ if (!batch) {
422
+ console.error(`Error: Batch "${batchCode}" not found`);
423
+ process.exit(1);
424
+ }
425
+ const summary = getBatchSummary(db, batch.id);
426
+ if (!summary) {
427
+ console.error(`Error: Could not load summary for batch "${batchCode}"`);
428
+ process.exit(1);
429
+ }
430
+ printDetailedReport(db, summary, limitErrors);
431
+ } else if (flags.latest) {
432
+ // Report for latest batch
433
+ const batch = getLatestBatch(db);
434
+ if (!batch) {
435
+ console.error('Error: No batches found in database');
436
+ process.exit(1);
437
+ }
438
+ const summary = getBatchSummary(db, batch.id);
439
+ if (!summary) {
440
+ console.error('Error: Could not load summary for latest batch');
441
+ process.exit(1);
442
+ }
443
+ console.log(`Latest batch: ${batch.code}`);
444
+ console.log();
445
+ printDetailedReport(db, summary, limitErrors);
446
+ } else {
447
+ // Summary of all batches
448
+ printAllBatchesSummary(db);
449
+ }
450
+ } finally {
451
+ db.close();
452
+ }
453
+ }