@qianxude/tem 0.2.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/LICENSE +21 -0
- package/README.md +303 -0
- package/package.json +42 -0
- package/src/core/index.ts +4 -0
- package/src/core/tem.ts +100 -0
- package/src/core/worker.ts +168 -0
- package/src/database/index.ts +114 -0
- package/src/database/schema.sql +45 -0
- package/src/index.ts +19 -0
- package/src/interfaces/index.ts +186 -0
- package/src/mock-server/README.md +352 -0
- package/src/mock-server/index.ts +3 -0
- package/src/mock-server/router.ts +235 -0
- package/src/mock-server/server.ts +148 -0
- package/src/mock-server/service.ts +122 -0
- package/src/mock-server/types.ts +62 -0
- package/src/services/batch.ts +121 -0
- package/src/services/index.ts +2 -0
- package/src/services/task.ts +176 -0
- package/src/utils/auto-detect.ts +487 -0
- package/src/utils/batch-monitor.ts +52 -0
- package/src/utils/concurrency.ts +44 -0
- package/src/utils/index.ts +9 -0
- package/src/utils/rate-limiter.ts +54 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import type * as i from './types';
|
|
2
|
+
import { MockService } from './service';
|
|
3
|
+
import { createRouter } from './router';
|
|
4
|
+
|
|
5
|
+
const DEFAULT_MOCK_URL = 'http://localhost:19999';
|
|
6
|
+
|
|
7
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
8
|
+
type BunServer = any;
|
|
9
|
+
|
|
10
|
+
interface ServerState {
|
|
11
|
+
services: Map<string, MockService>;
|
|
12
|
+
mode: i.ServerMode;
|
|
13
|
+
defaultService: MockService | null;
|
|
14
|
+
server: BunServer | null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const globalState: ServerState = {
|
|
18
|
+
services: new Map(),
|
|
19
|
+
mode: 'multi',
|
|
20
|
+
defaultService: null,
|
|
21
|
+
server: null,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Start the mock server with the given configuration.
|
|
26
|
+
*/
|
|
27
|
+
export function startMockServer(config: i.ServerConfig): void {
|
|
28
|
+
// Reset state for fresh start
|
|
29
|
+
globalState.services.clear();
|
|
30
|
+
globalState.mode = config.mode ?? 'multi';
|
|
31
|
+
globalState.defaultService = null;
|
|
32
|
+
|
|
33
|
+
// Setup default service for single mode
|
|
34
|
+
if (globalState.mode === 'single') {
|
|
35
|
+
if (!config.defaultService) {
|
|
36
|
+
throw new Error('defaultService is required in single mode');
|
|
37
|
+
}
|
|
38
|
+
globalState.defaultService = new MockService('default', config.defaultService);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Create router with state
|
|
42
|
+
const router = createRouter({
|
|
43
|
+
services: globalState.services,
|
|
44
|
+
getMode: () => globalState.mode,
|
|
45
|
+
getDefaultService: () => globalState.defaultService,
|
|
46
|
+
shutdownFn: () => shutdown(),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// Start server
|
|
50
|
+
globalState.server = Bun.serve({
|
|
51
|
+
port: config.port,
|
|
52
|
+
fetch: router,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
console.log(`[INFO] Mock server started on port ${config.port} (${globalState.mode} mode)`);
|
|
56
|
+
|
|
57
|
+
if (globalState.mode === 'single') {
|
|
58
|
+
console.log(`[INFO] Default service configured with maxConcurrency=${config.defaultService!.maxConcurrency}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Shutdown the server gracefully.
|
|
64
|
+
*/
|
|
65
|
+
function shutdown(): void {
|
|
66
|
+
console.log('[INFO] Shutting down mock server...');
|
|
67
|
+
|
|
68
|
+
if (globalState.server) {
|
|
69
|
+
globalState.server.stop();
|
|
70
|
+
globalState.server = null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
globalState.services.clear();
|
|
74
|
+
globalState.defaultService = null;
|
|
75
|
+
|
|
76
|
+
console.log('[INFO] Mock server stopped');
|
|
77
|
+
|
|
78
|
+
// Exit process
|
|
79
|
+
process.exit(0);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Get the current server state (for testing).
|
|
84
|
+
*/
|
|
85
|
+
export function getServerState(): {
|
|
86
|
+
services: Map<string, MockService>;
|
|
87
|
+
mode: i.ServerMode;
|
|
88
|
+
hasDefaultService: boolean;
|
|
89
|
+
} {
|
|
90
|
+
return {
|
|
91
|
+
services: globalState.services,
|
|
92
|
+
mode: globalState.mode,
|
|
93
|
+
hasDefaultService: globalState.defaultService !== null,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Stop the server programmatically (for testing).
|
|
99
|
+
*/
|
|
100
|
+
export function stopMockServer(): void {
|
|
101
|
+
if (globalState.server) {
|
|
102
|
+
globalState.server.stop();
|
|
103
|
+
globalState.server = null;
|
|
104
|
+
}
|
|
105
|
+
globalState.services.clear();
|
|
106
|
+
globalState.defaultService = null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Client helper to create a configured mock service on the mock server.
|
|
111
|
+
* @param name - Unique service name/identifier
|
|
112
|
+
* @param config - Service configuration (concurrency, rate limit, delay)
|
|
113
|
+
* @param mockUrl - Base URL of the mock server (defaults to localhost:19999)
|
|
114
|
+
* @returns The Response object from the fetch call
|
|
115
|
+
*/
|
|
116
|
+
export async function createMockService(
|
|
117
|
+
name: string,
|
|
118
|
+
config: i.CreateServiceRequest,
|
|
119
|
+
mockUrl: string = DEFAULT_MOCK_URL
|
|
120
|
+
): Promise<Response> {
|
|
121
|
+
const res = await fetch(`${mockUrl}/service/${name}`, {
|
|
122
|
+
method: 'POST',
|
|
123
|
+
headers: { 'Content-Type': 'application/json' },
|
|
124
|
+
body: JSON.stringify(config),
|
|
125
|
+
});
|
|
126
|
+
return res;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Helper to create error simulation config with validation.
|
|
131
|
+
* @param rate - Error rate 0-1
|
|
132
|
+
* @param statusCode - HTTP status code (default: 500)
|
|
133
|
+
* @param errorMessage - Error message
|
|
134
|
+
*/
|
|
135
|
+
export function createErrorSimulation(
|
|
136
|
+
rate: number,
|
|
137
|
+
statusCode?: number,
|
|
138
|
+
errorMessage?: string
|
|
139
|
+
): i.ErrorSimulationConfig {
|
|
140
|
+
if (rate < 0 || rate > 1) {
|
|
141
|
+
throw new Error('Error rate must be between 0 and 1');
|
|
142
|
+
}
|
|
143
|
+
return {
|
|
144
|
+
rate,
|
|
145
|
+
statusCode,
|
|
146
|
+
errorMessage,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type * as i from './types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Rate limiter with tryAcquire pattern for immediate reject/allow decision.
|
|
5
|
+
* Uses token bucket algorithm.
|
|
6
|
+
*/
|
|
7
|
+
class RejectingRateLimiter {
|
|
8
|
+
private tokens: number;
|
|
9
|
+
private lastRefill: number;
|
|
10
|
+
|
|
11
|
+
constructor(private limit: number, private windowMs: number) {
|
|
12
|
+
this.tokens = limit;
|
|
13
|
+
this.lastRefill = Date.now();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Try to acquire a token. Returns immediately with success/failure.
|
|
18
|
+
*/
|
|
19
|
+
tryAcquire(): boolean {
|
|
20
|
+
const now = Date.now();
|
|
21
|
+
this.refill(now);
|
|
22
|
+
|
|
23
|
+
if (this.tokens >= 1) {
|
|
24
|
+
this.tokens--;
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
private refill(now: number): void {
|
|
32
|
+
const elapsedMs = now - this.lastRefill;
|
|
33
|
+
const tokensToAdd = (elapsedMs / this.windowMs) * this.limit;
|
|
34
|
+
|
|
35
|
+
if (tokensToAdd > 0) {
|
|
36
|
+
this.tokens = Math.min(this.limit, this.tokens + tokensToAdd);
|
|
37
|
+
this.lastRefill = now;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Mock service with concurrency and rate limiting.
|
|
44
|
+
*/
|
|
45
|
+
export class MockService {
|
|
46
|
+
private currentConcurrency = 0;
|
|
47
|
+
private rateLimiter: RejectingRateLimiter;
|
|
48
|
+
|
|
49
|
+
constructor(
|
|
50
|
+
public name: string,
|
|
51
|
+
private config: i.ServiceConfig
|
|
52
|
+
) {
|
|
53
|
+
this.rateLimiter = new RejectingRateLimiter(
|
|
54
|
+
config.rateLimit.limit,
|
|
55
|
+
config.rateLimit.windowMs
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Try to acquire both concurrency slot and rate limit token.
|
|
61
|
+
* Returns immediately with result - no waiting.
|
|
62
|
+
*/
|
|
63
|
+
tryAcquire(): i.TryAcquireResult {
|
|
64
|
+
// Check concurrency first
|
|
65
|
+
if (this.currentConcurrency >= this.config.maxConcurrency) {
|
|
66
|
+
return { allowed: false, error: 'concurrency' };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Then check rate limit
|
|
70
|
+
if (!this.rateLimiter.tryAcquire()) {
|
|
71
|
+
return { allowed: false, error: 'rateLimit' };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Both passed - acquire concurrency
|
|
75
|
+
this.currentConcurrency++;
|
|
76
|
+
return { allowed: true };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Release a concurrency slot.
|
|
81
|
+
*/
|
|
82
|
+
release(): void {
|
|
83
|
+
if (this.currentConcurrency > 0) {
|
|
84
|
+
this.currentConcurrency--;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Get random delay within configured range.
|
|
90
|
+
*/
|
|
91
|
+
getDelay(): number {
|
|
92
|
+
const [min, max] = this.config.delayMs;
|
|
93
|
+
return Math.floor(Math.random() * (max - min + 1)) + min;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Get current concurrency count.
|
|
98
|
+
*/
|
|
99
|
+
getCurrentConcurrency(): number {
|
|
100
|
+
return this.currentConcurrency;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Check if a random service error should be simulated.
|
|
105
|
+
* Returns the error config if error should occur, null otherwise.
|
|
106
|
+
*/
|
|
107
|
+
checkRandomError(): { statusCode: number; errorMessage: string } | null {
|
|
108
|
+
const errorConfig = this.config.errorSimulation;
|
|
109
|
+
if (!errorConfig || errorConfig.rate <= 0) {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (Math.random() < errorConfig.rate) {
|
|
114
|
+
return {
|
|
115
|
+
statusCode: errorConfig.statusCode ?? 500,
|
|
116
|
+
errorMessage: errorConfig.errorMessage ?? 'internal_server_error',
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Mock Server Types
|
|
2
|
+
// Import as: import * as i from './types'
|
|
3
|
+
|
|
4
|
+
export type ServerMode = 'single' | 'multi';
|
|
5
|
+
|
|
6
|
+
export interface RateLimitConfig {
|
|
7
|
+
limit: number;
|
|
8
|
+
windowMs: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ErrorSimulationConfig {
|
|
12
|
+
rate: number; // Error rate 0-1 (e.g., 0.1 = 10% error rate)
|
|
13
|
+
statusCode?: number; // HTTP status code to return (default: 500)
|
|
14
|
+
errorMessage?: string; // Error message (default: "internal_server_error")
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ServiceConfig {
|
|
18
|
+
maxConcurrency: number;
|
|
19
|
+
rateLimit: RateLimitConfig;
|
|
20
|
+
delayMs: [number, number]; // [min, max]
|
|
21
|
+
errorSimulation?: ErrorSimulationConfig; // Optional error simulation
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ServerConfig {
|
|
25
|
+
port: number;
|
|
26
|
+
mode?: ServerMode;
|
|
27
|
+
defaultService?: ServiceConfig;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface CreateServiceRequest {
|
|
31
|
+
maxConcurrency: number;
|
|
32
|
+
rateLimit: RateLimitConfig;
|
|
33
|
+
delayMs?: [number, number]; // [min, max], defaults to [10, 200]
|
|
34
|
+
errorSimulation?: ErrorSimulationConfig; // Optional error simulation
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ServiceResponse {
|
|
38
|
+
service: string;
|
|
39
|
+
status: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface MockResponse {
|
|
43
|
+
requestId: string;
|
|
44
|
+
meta: {
|
|
45
|
+
ts: number;
|
|
46
|
+
rt: number;
|
|
47
|
+
};
|
|
48
|
+
data: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface ErrorResponse {
|
|
52
|
+
error: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface ShutdownResponse {
|
|
56
|
+
status: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface TryAcquireResult {
|
|
60
|
+
allowed: boolean;
|
|
61
|
+
error?: 'concurrency' | 'rateLimit';
|
|
62
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import * as i from '../interfaces/index.js';
|
|
2
|
+
import type { Database } from '../database/index.js';
|
|
3
|
+
|
|
4
|
+
export interface BatchRow {
|
|
5
|
+
id: string;
|
|
6
|
+
code: string;
|
|
7
|
+
type: string;
|
|
8
|
+
created_at: string;
|
|
9
|
+
completed_at: string | null;
|
|
10
|
+
metadata: string | null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function rowToBatch(row: BatchRow): i.Batch {
|
|
14
|
+
return {
|
|
15
|
+
id: row.id,
|
|
16
|
+
code: row.code,
|
|
17
|
+
type: row.type,
|
|
18
|
+
createdAt: new Date(row.created_at),
|
|
19
|
+
completedAt: row.completed_at ? new Date(row.completed_at) : null,
|
|
20
|
+
metadata: row.metadata ? (JSON.parse(row.metadata) as Record<string, unknown>) : null,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class BatchService implements i.BatchService {
|
|
25
|
+
constructor(private db: Database) {}
|
|
26
|
+
|
|
27
|
+
async create(input: i.CreateBatchInput): Promise<i.Batch> {
|
|
28
|
+
const id = crypto.randomUUID();
|
|
29
|
+
const now = new Date().toISOString();
|
|
30
|
+
|
|
31
|
+
this.db.run(
|
|
32
|
+
`INSERT INTO batch (id, code, type, created_at, metadata)
|
|
33
|
+
VALUES (?, ?, ?, ?, ?)`,
|
|
34
|
+
[id, input.code, input.type, now, input.metadata ? JSON.stringify(input.metadata) : null]
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const rows = this.db.query<BatchRow>('SELECT * FROM batch WHERE id = ?', [id]);
|
|
38
|
+
const row = rows[0];
|
|
39
|
+
if (row === undefined) {
|
|
40
|
+
throw new Error('Failed to create batch');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return rowToBatch(row);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async getById(id: string): Promise<i.Batch | null> {
|
|
47
|
+
const rows = this.db.query<BatchRow>('SELECT * FROM batch WHERE id = ?', [id]);
|
|
48
|
+
const row = rows[0];
|
|
49
|
+
if (row === undefined) return null;
|
|
50
|
+
return rowToBatch(row);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async getByCode(code: string): Promise<i.Batch | null> {
|
|
54
|
+
const rows = this.db.query<BatchRow>('SELECT * FROM batch WHERE code = ?', [code]);
|
|
55
|
+
const row = rows[0];
|
|
56
|
+
if (row === undefined) return null;
|
|
57
|
+
return rowToBatch(row);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async complete(id: string): Promise<void> {
|
|
61
|
+
const now = new Date().toISOString();
|
|
62
|
+
this.db.run(
|
|
63
|
+
`UPDATE batch SET completed_at = ? WHERE id = ?`,
|
|
64
|
+
[now, id]
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async list(filter?: { type?: string }): Promise<i.Batch[]> {
|
|
69
|
+
let sql = 'SELECT * FROM batch';
|
|
70
|
+
const params: string[] = [];
|
|
71
|
+
|
|
72
|
+
if (filter?.type) {
|
|
73
|
+
sql += ' WHERE type = ?';
|
|
74
|
+
params.push(filter.type);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
sql += ' ORDER BY created_at DESC';
|
|
78
|
+
|
|
79
|
+
const rows = this.db.query<BatchRow>(sql, params);
|
|
80
|
+
return rows.map(rowToBatch);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async getStats(id: string): Promise<i.BatchStats> {
|
|
84
|
+
const result = this.db.query<{ status: i.TaskStatus; count: number }>(
|
|
85
|
+
`SELECT status, COUNT(*) as count FROM task WHERE batch_id = ? GROUP BY status`,
|
|
86
|
+
[id]
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
const stats: i.BatchStats = {
|
|
90
|
+
batchId: id,
|
|
91
|
+
total: 0,
|
|
92
|
+
pending: 0,
|
|
93
|
+
running: 0,
|
|
94
|
+
completed: 0,
|
|
95
|
+
failed: 0,
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
for (const row of result) {
|
|
99
|
+
stats[row.status] = row.count;
|
|
100
|
+
stats.total += row.count;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return stats;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async resume(id: string): Promise<number> {
|
|
107
|
+
const result = this.db.run(
|
|
108
|
+
`UPDATE task SET status = 'pending' WHERE batch_id = ? AND status = 'running'`,
|
|
109
|
+
[id]
|
|
110
|
+
);
|
|
111
|
+
return result.changes ?? 0;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async retryFailed(id: string): Promise<number> {
|
|
115
|
+
const result = this.db.run(
|
|
116
|
+
`UPDATE task SET status = 'pending', attempt = 0 WHERE batch_id = ? AND status = 'failed'`,
|
|
117
|
+
[id]
|
|
118
|
+
);
|
|
119
|
+
return result.changes ?? 0;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import * as i from '../interfaces/index.js';
|
|
2
|
+
import type { Database } from '../database/index.js';
|
|
3
|
+
|
|
4
|
+
export interface TaskRow {
|
|
5
|
+
id: string;
|
|
6
|
+
batch_id: string | null;
|
|
7
|
+
type: string;
|
|
8
|
+
status: i.TaskStatus;
|
|
9
|
+
payload: string;
|
|
10
|
+
result: string | null;
|
|
11
|
+
error: string | null;
|
|
12
|
+
attempt: number;
|
|
13
|
+
max_attempt: number;
|
|
14
|
+
claimed_at: string | null;
|
|
15
|
+
completed_at: string | null;
|
|
16
|
+
version: number;
|
|
17
|
+
created_at: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function rowToTask(row: TaskRow): i.Task {
|
|
21
|
+
return {
|
|
22
|
+
id: row.id,
|
|
23
|
+
batchId: row.batch_id,
|
|
24
|
+
type: row.type,
|
|
25
|
+
status: row.status,
|
|
26
|
+
payload: row.payload,
|
|
27
|
+
result: row.result,
|
|
28
|
+
error: row.error,
|
|
29
|
+
attempt: row.attempt,
|
|
30
|
+
maxAttempt: row.max_attempt,
|
|
31
|
+
claimedAt: row.claimed_at ? new Date(row.claimed_at) : null,
|
|
32
|
+
completedAt: row.completed_at ? new Date(row.completed_at) : null,
|
|
33
|
+
version: row.version,
|
|
34
|
+
createdAt: new Date(row.created_at),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class TaskService implements i.TaskService {
|
|
39
|
+
constructor(private db: Database) {}
|
|
40
|
+
|
|
41
|
+
async create(input: i.CreateTaskInput): Promise<i.Task> {
|
|
42
|
+
const id = crypto.randomUUID();
|
|
43
|
+
const now = new Date().toISOString();
|
|
44
|
+
const maxAttempt = input.maxAttempt ?? 3;
|
|
45
|
+
|
|
46
|
+
this.db.run(
|
|
47
|
+
`INSERT INTO task (id, batch_id, type, status, payload, max_attempt, created_at)
|
|
48
|
+
VALUES (?, ?, ?, 'pending', ?, ?, ?)`,
|
|
49
|
+
[
|
|
50
|
+
id,
|
|
51
|
+
input.batchId ?? null,
|
|
52
|
+
input.type,
|
|
53
|
+
JSON.stringify(input.payload),
|
|
54
|
+
maxAttempt,
|
|
55
|
+
now,
|
|
56
|
+
]
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
const rows = this.db.query<TaskRow>('SELECT * FROM task WHERE id = ?', [id]);
|
|
60
|
+
const row = rows[0];
|
|
61
|
+
if (row === undefined) {
|
|
62
|
+
throw new Error('Failed to create task');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return rowToTask(row);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async createMany(inputs: i.CreateTaskInput[]): Promise<i.Task[]> {
|
|
69
|
+
const tasks: i.Task[] = [];
|
|
70
|
+
|
|
71
|
+
this.db.transaction(() => {
|
|
72
|
+
for (const input of inputs) {
|
|
73
|
+
const id = crypto.randomUUID();
|
|
74
|
+
const now = new Date().toISOString();
|
|
75
|
+
const maxAttempt = input.maxAttempt ?? 3;
|
|
76
|
+
|
|
77
|
+
this.db.run(
|
|
78
|
+
`INSERT INTO task (id, batch_id, type, status, payload, max_attempt, created_at)
|
|
79
|
+
VALUES (?, ?, ?, 'pending', ?, ?, ?)`,
|
|
80
|
+
[
|
|
81
|
+
id,
|
|
82
|
+
input.batchId ?? null,
|
|
83
|
+
input.type,
|
|
84
|
+
JSON.stringify(input.payload),
|
|
85
|
+
maxAttempt,
|
|
86
|
+
now,
|
|
87
|
+
]
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
const rows = this.db.query<TaskRow>('SELECT * FROM task WHERE id = ?', [id]);
|
|
91
|
+
const row = rows[0];
|
|
92
|
+
if (row === undefined) {
|
|
93
|
+
throw new Error('Failed to create task');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
tasks.push(rowToTask(row));
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
return tasks;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async getById(id: string): Promise<i.Task | null> {
|
|
104
|
+
const rows = this.db.query<TaskRow>('SELECT * FROM task WHERE id = ?', [id]);
|
|
105
|
+
const row = rows[0];
|
|
106
|
+
if (row === undefined) return null;
|
|
107
|
+
return rowToTask(row);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async claim(batchId?: string): Promise<i.Task | null> {
|
|
111
|
+
const now = new Date().toISOString();
|
|
112
|
+
|
|
113
|
+
// Atomic claim using UPDATE ... WHERE status='pending'
|
|
114
|
+
// This ensures no duplicate execution even with concurrent async operations
|
|
115
|
+
const claimed = this.db.query<TaskRow>(
|
|
116
|
+
`UPDATE task
|
|
117
|
+
SET status = 'running',
|
|
118
|
+
claimed_at = ?,
|
|
119
|
+
version = version + 1,
|
|
120
|
+
attempt = attempt + 1
|
|
121
|
+
WHERE id = (
|
|
122
|
+
SELECT id FROM task
|
|
123
|
+
WHERE status = 'pending'
|
|
124
|
+
AND (batch_id = ? OR ? IS NULL)
|
|
125
|
+
ORDER BY created_at
|
|
126
|
+
LIMIT 1
|
|
127
|
+
)
|
|
128
|
+
AND status = 'pending'
|
|
129
|
+
RETURNING *`,
|
|
130
|
+
[now, batchId ?? null, batchId ?? null]
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
const row = claimed[0];
|
|
134
|
+
if (row === undefined) return null;
|
|
135
|
+
return rowToTask(row);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async complete(id: string, result: unknown): Promise<void> {
|
|
139
|
+
const now = new Date().toISOString();
|
|
140
|
+
|
|
141
|
+
this.db.run(
|
|
142
|
+
`UPDATE task
|
|
143
|
+
SET status = 'completed',
|
|
144
|
+
result = ?,
|
|
145
|
+
completed_at = ?,
|
|
146
|
+
version = version + 1
|
|
147
|
+
WHERE id = ?`,
|
|
148
|
+
[JSON.stringify(result), now, id]
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async fail(id: string, error: string): Promise<void> {
|
|
153
|
+
const now = new Date().toISOString();
|
|
154
|
+
|
|
155
|
+
this.db.run(
|
|
156
|
+
`UPDATE task
|
|
157
|
+
SET status = 'failed',
|
|
158
|
+
error = ?,
|
|
159
|
+
completed_at = ?,
|
|
160
|
+
version = version + 1
|
|
161
|
+
WHERE id = ?`,
|
|
162
|
+
[error, now, id]
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async retry(id: string): Promise<void> {
|
|
167
|
+
this.db.run(
|
|
168
|
+
`UPDATE task
|
|
169
|
+
SET status = 'pending',
|
|
170
|
+
claimed_at = NULL,
|
|
171
|
+
version = version + 1
|
|
172
|
+
WHERE id = ?`,
|
|
173
|
+
[id]
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
}
|