Skip to content

Commit ef0d19b

Browse files
committed
feat(satellite): implement job management system with heartbeat job
1 parent 3fdb93d commit ef0d19b

File tree

5 files changed

+509
-7
lines changed

5 files changed

+509
-7
lines changed

.vscode/settings.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"github.copilot.chat.commitMessageGeneration.instructions": [
33
{
4-
"text": "Generate commit messages following the Conventional Commits specification with MANDATORY scope for monorepo. Use this structure:\n\n<type>(<scope>): <description>\n\nRules:\n1. **SCOPE IS MANDATORY** - Must be one of: frontend, backend, gateway, shared, all\n2. **Type must be one of**: feat, fix, docs, style, refactor, perf, test, build, ci, chore\n3. **Description**: Imperative mood, no period at end, max 72 characters\n4. **Examples**:\n - feat(frontend): add dark mode toggle\n - fix(backend): resolve database connection issue\n - chore(gateway): update dependencies\n - docs(all): update README installation guide\n - refactor(shared): extract common utilities\n\nAlways include the scope to ensure proper changelog filtering per service. Use 'all' scope only for changes affecting multiple services or project-wide changes."
4+
"text": "Generate commit messages following the Conventional Commits specification with MANDATORY scope for monorepo. Use this structure:\n\n<type>(<scope>): <description>\n\nRules:\n1. **SCOPE IS MANDATORY** - Must be one of: frontend, backend, satellite, shared, all, ci, deps\n2. **Type must be one of**: feat, fix, docs, style, refactor, perf, test, build, ci, chore\n3. **Description**: Imperative mood, no period at end, max 72 characters\n4. **Examples**:\n - feat(frontend): add dark mode toggle\n - fix(backend): resolve database connection issue\n - feat(satellite): implement MCP server process management\n - chore(deps): update dependencies\n - docs(all): update README installation guide\n - refactor(shared): extract common utilities\n\nAlways include the scope to ensure proper changelog filtering per service. Use 'all' scope only for changes affecting multiple services or project-wide changes."
55
}
66
],
77
"github.copilot.chat.codeGeneration.useInstructionFiles": true
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
import { FastifyBaseLogger } from 'fastify';
2+
3+
/**
4+
* Base Job Interface
5+
*
6+
* All recurring jobs must implement this interface to be managed by the JobManager.
7+
*/
8+
export interface Job {
9+
/**
10+
* Unique job name for logging and tracking
11+
*/
12+
readonly name: string;
13+
14+
/**
15+
* Job execution interval in milliseconds
16+
*/
17+
readonly interval: number;
18+
19+
/**
20+
* Start the job
21+
*/
22+
start(): void;
23+
24+
/**
25+
* Stop the job
26+
*/
27+
stop(): void;
28+
29+
/**
30+
* Check if job is currently running
31+
*/
32+
isRunning(): boolean;
33+
34+
/**
35+
* Get job execution statistics
36+
*/
37+
getStats(): JobStats;
38+
}
39+
40+
/**
41+
* Job execution statistics
42+
*/
43+
export interface JobStats {
44+
name: string;
45+
isRunning: boolean;
46+
executionCount: number;
47+
lastExecution?: Date;
48+
nextExecution?: Date;
49+
averageExecutionTime?: number;
50+
errorCount: number;
51+
}
52+
53+
/**
54+
* Abstract Base Job Class
55+
*
56+
* Provides common functionality for interval-based jobs.
57+
* Subclasses must implement the execute() method.
58+
*/
59+
export abstract class BaseJob implements Job {
60+
public readonly name: string;
61+
public readonly interval: number;
62+
protected logger: FastifyBaseLogger;
63+
protected intervalHandle?: NodeJS.Timeout;
64+
protected running: boolean = false;
65+
protected executionCount: number = 0;
66+
protected errorCount: number = 0;
67+
protected lastExecution?: Date;
68+
protected executionTimes: number[] = [];
69+
70+
constructor(name: string, interval: number, logger: FastifyBaseLogger) {
71+
this.name = name;
72+
this.interval = interval;
73+
this.logger = logger;
74+
}
75+
76+
/**
77+
* Start the job with immediate first execution
78+
*/
79+
start(): void {
80+
if (this.running) {
81+
this.logger.warn({
82+
operation: 'job_already_running',
83+
job_name: this.name
84+
}, `Job "${this.name}" is already running`);
85+
return;
86+
}
87+
88+
this.logger.info({
89+
operation: 'job_start',
90+
job_name: this.name,
91+
interval_ms: this.interval,
92+
interval_seconds: Math.round(this.interval / 1000)
93+
}, `Starting job "${this.name}" (${Math.round(this.interval / 1000)}s interval)`);
94+
95+
this.running = true;
96+
97+
// Execute immediately on start
98+
this.executeJob();
99+
100+
// Set up recurring execution
101+
this.intervalHandle = setInterval(() => {
102+
this.executeJob();
103+
}, this.interval);
104+
}
105+
106+
/**
107+
* Stop the job
108+
*/
109+
stop(): void {
110+
if (!this.running) {
111+
this.logger.warn({
112+
operation: 'job_already_stopped',
113+
job_name: this.name
114+
}, `Job "${this.name}" is not running`);
115+
return;
116+
}
117+
118+
if (this.intervalHandle) {
119+
clearInterval(this.intervalHandle);
120+
this.intervalHandle = undefined;
121+
}
122+
123+
this.running = false;
124+
125+
this.logger.info({
126+
operation: 'job_stop',
127+
job_name: this.name,
128+
total_executions: this.executionCount,
129+
total_errors: this.errorCount
130+
}, `Stopped job "${this.name}" (${this.executionCount} executions, ${this.errorCount} errors)`);
131+
}
132+
133+
/**
134+
* Check if job is running
135+
*/
136+
isRunning(): boolean {
137+
return this.running;
138+
}
139+
140+
/**
141+
* Get job statistics
142+
*/
143+
getStats(): JobStats {
144+
const avgTime = this.executionTimes.length > 0
145+
? this.executionTimes.reduce((a, b) => a + b, 0) / this.executionTimes.length
146+
: undefined;
147+
148+
const nextExecution = this.running && this.lastExecution
149+
? new Date(this.lastExecution.getTime() + this.interval)
150+
: undefined;
151+
152+
return {
153+
name: this.name,
154+
isRunning: this.running,
155+
executionCount: this.executionCount,
156+
lastExecution: this.lastExecution,
157+
nextExecution,
158+
averageExecutionTime: avgTime,
159+
errorCount: this.errorCount
160+
};
161+
}
162+
163+
/**
164+
* Execute the job with error handling and metrics
165+
*/
166+
private async executeJob(): Promise<void> {
167+
const startTime = Date.now();
168+
169+
try {
170+
this.executionCount++;
171+
this.lastExecution = new Date();
172+
173+
this.logger.debug({
174+
operation: 'job_execute_start',
175+
job_name: this.name,
176+
execution_number: this.executionCount
177+
}, `Executing job "${this.name}" (#${this.executionCount})`);
178+
179+
await this.execute();
180+
181+
const executionTime = Date.now() - startTime;
182+
183+
// Keep last 10 execution times for average calculation
184+
this.executionTimes.push(executionTime);
185+
if (this.executionTimes.length > 10) {
186+
this.executionTimes.shift();
187+
}
188+
189+
this.logger.debug({
190+
operation: 'job_execute_success',
191+
job_name: this.name,
192+
execution_number: this.executionCount,
193+
execution_time_ms: executionTime
194+
}, `Job "${this.name}" completed in ${executionTime}ms`);
195+
196+
} catch (error) {
197+
this.errorCount++;
198+
const errorMessage = error instanceof Error ? error.message : String(error);
199+
const executionTime = Date.now() - startTime;
200+
201+
this.logger.error({
202+
operation: 'job_execute_error',
203+
job_name: this.name,
204+
execution_number: this.executionCount,
205+
error_count: this.errorCount,
206+
execution_time_ms: executionTime,
207+
error: errorMessage
208+
}, `Job "${this.name}" failed: ${errorMessage}`);
209+
}
210+
}
211+
212+
/**
213+
* Execute method to be implemented by subclasses
214+
*/
215+
protected abstract execute(): Promise<void>;
216+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { Job, JobStats } from './base-job';
2+
import { HeartbeatService } from '../services/heartbeat-service';
3+
4+
/**
5+
* Heartbeat Job
6+
*
7+
* Wraps the existing HeartbeatService to integrate with the job management system.
8+
* The HeartbeatService handles all heartbeat logic and reporting.
9+
*/
10+
export class HeartbeatJob implements Job {
11+
public readonly name = 'heartbeat';
12+
public readonly interval = 30000; // 30 seconds
13+
14+
constructor(private heartbeatService: HeartbeatService) {}
15+
16+
/**
17+
* Start the heartbeat service
18+
*/
19+
start(): void {
20+
this.heartbeatService.start();
21+
}
22+
23+
/**
24+
* Stop the heartbeat service
25+
*/
26+
stop(): void {
27+
this.heartbeatService.stop();
28+
}
29+
30+
/**
31+
* Check if heartbeat service is running
32+
*/
33+
isRunning(): boolean {
34+
return this.heartbeatService.getStatus().isRunning;
35+
}
36+
37+
/**
38+
* Get heartbeat job statistics
39+
*/
40+
getStats(): JobStats {
41+
const status = this.heartbeatService.getStatus();
42+
43+
return {
44+
name: this.name,
45+
isRunning: status.isRunning,
46+
executionCount: status.heartbeatCount,
47+
errorCount: 0, // HeartbeatService doesn't expose error count yet
48+
lastExecution: undefined, // Could be tracked in HeartbeatService
49+
nextExecution: status.isRunning
50+
? new Date(Date.now() + this.interval)
51+
: undefined,
52+
averageExecutionTime: undefined // Could be tracked in HeartbeatService
53+
};
54+
}
55+
}

0 commit comments

Comments
 (0)