77d35220b1
Build and Push Images / build-and-push (push) Has been cancelled
- edgeDeviceConfigReporter 신규: pipeline_device_connections + pipeline_equipment 조인해 IDC TimescaleDB의 edge_device_config_1 에 5분 주기로 적재 (DISTINCT ON 으로 최신값 조회용) - app.ts: 부팅 시 startEdgeDeviceConfigReporter, SIGTERM/SIGINT 시 graceful stop 추가 - CollectedData 에 host/port 필드 추가, collectDevice 에서 device row 값 채움 - centralMqttForwarder.buildPayload 에 protocol/host/port 포함 (IDC 컨슈머가 활용) - 312 마이그레이션(fleet_edge_raw_data host/port 컬럼) 등록 (sql 파일은 .gitignore 로 미동봉, OPS 절차로 적용) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
448 lines
14 KiB
TypeScript
448 lines
14 KiB
TypeScript
/**
|
|
* Central MQTT Forwarder
|
|
*
|
|
* Pipeline이 수집한 데이터를 IDC 중앙 EMQX로 전송.
|
|
* 스피폭스 엣지의 `kafka-to-central-mqtt` 포워더(Python) Node.js 포팅.
|
|
*
|
|
* 토픽: dt/v1/data/{company_id}/{edge_id} (QoS 1, MQTTv5)
|
|
* 하트비트: dt/v1/status/{company_id}/{edge_id}
|
|
*
|
|
* 설계:
|
|
* - 설정은 central_mqtt_forwarder_config 테이블에서 조회 (company_code 단위로 1개)
|
|
* - 여러 고객사를 한 파이프라인 인스턴스가 처리 가능
|
|
* - 배치 (batch_size / batch_timeout_ms)
|
|
* - 실패 시 retry_queue 테이블에 persist
|
|
* - 통계는 central_mqtt_forwarder_stats 에 주기 업데이트
|
|
*/
|
|
|
|
import mqtt, { MqttClient, IClientOptions } from "mqtt";
|
|
import { query } from "../../database/db";
|
|
import { logger } from "../../utils/logger";
|
|
import { PasswordEncryption } from "../../utils/passwordEncryption";
|
|
import type { CollectedData } from "./deviceCollectorService";
|
|
|
|
// ─── 타입 ──────────────────────────────────────────
|
|
|
|
interface ForwarderConfig {
|
|
id: number;
|
|
config_name: string;
|
|
company_code: string;
|
|
company_id: string;
|
|
edge_id: string;
|
|
broker_host: string;
|
|
broker_port: number;
|
|
username: string | null;
|
|
password_encrypted: string | null;
|
|
use_tls: string;
|
|
client_id_prefix: string | null;
|
|
topic_pattern: string;
|
|
status_topic_pattern: string;
|
|
batch_size: number;
|
|
batch_timeout_ms: number;
|
|
heartbeat_interval_sec: number;
|
|
qos: number;
|
|
is_enabled: string;
|
|
}
|
|
|
|
interface ForwarderInstance {
|
|
config: ForwarderConfig;
|
|
client: MqttClient | null;
|
|
buffer: CollectedData[];
|
|
flushTimer: NodeJS.Timeout | null;
|
|
heartbeatTimer: NodeJS.Timeout | null;
|
|
stats: {
|
|
messagesForwarded: number;
|
|
messagesFailed: number;
|
|
messagesDropped: number;
|
|
batchesSent: number;
|
|
lastPublishedAt: Date | null;
|
|
startedAt: Date;
|
|
isConnected: boolean;
|
|
reconnectAttempts: number;
|
|
lastError: string | null;
|
|
lastErrorAt: Date | null;
|
|
};
|
|
}
|
|
|
|
// ─── 전역 인스턴스 맵 (company_code 기준) ───────────
|
|
|
|
const instances = new Map<number, ForwarderInstance>();
|
|
|
|
// ─── 시작/중지 ──────────────────────────────────────
|
|
|
|
export async function startAllEnabled(): Promise<void> {
|
|
const configs = await query<ForwarderConfig>(
|
|
`SELECT * FROM central_mqtt_forwarder_config WHERE is_enabled = 'Y'`
|
|
);
|
|
logger.info(`[CentralForwarder] 활성 설정 ${configs.length}개 시작`);
|
|
for (const cfg of configs) {
|
|
await startForwarder(cfg).catch(err =>
|
|
logger.error(`[CentralForwarder] 시작 실패 (id=${cfg.id}): ${(err as Error).message}`)
|
|
);
|
|
}
|
|
}
|
|
|
|
export async function stopAll(): Promise<void> {
|
|
for (const id of Array.from(instances.keys())) {
|
|
await stopForwarder(id);
|
|
}
|
|
}
|
|
|
|
export async function startForwarder(cfgOrId: ForwarderConfig | number): Promise<void> {
|
|
const config: ForwarderConfig =
|
|
typeof cfgOrId === "number" ? await loadConfig(cfgOrId) : cfgOrId;
|
|
|
|
if (instances.has(config.id)) {
|
|
logger.warn(`[CentralForwarder] 이미 실행 중: id=${config.id}`);
|
|
return;
|
|
}
|
|
|
|
const decryptedPw = config.password_encrypted
|
|
? tryDecrypt(config.password_encrypted)
|
|
: undefined;
|
|
|
|
const clientId = `${config.client_id_prefix || "pipeline-forwarder"}-${config.edge_id}-${Date.now()
|
|
.toString(36)
|
|
.slice(-6)}`;
|
|
|
|
const url = `${config.use_tls === "Y" ? "mqtts" : "mqtt"}://${config.broker_host}:${config.broker_port}`;
|
|
|
|
const opts: IClientOptions = {
|
|
clientId,
|
|
username: config.username || undefined,
|
|
password: decryptedPw,
|
|
reconnectPeriod: 5000,
|
|
connectTimeout: 10000,
|
|
clean: true,
|
|
protocolVersion: 5,
|
|
};
|
|
|
|
const client = mqtt.connect(url, opts);
|
|
|
|
const instance: ForwarderInstance = {
|
|
config,
|
|
client,
|
|
buffer: [],
|
|
flushTimer: null,
|
|
heartbeatTimer: null,
|
|
stats: {
|
|
messagesForwarded: 0,
|
|
messagesFailed: 0,
|
|
messagesDropped: 0,
|
|
batchesSent: 0,
|
|
lastPublishedAt: null,
|
|
startedAt: new Date(),
|
|
isConnected: false,
|
|
reconnectAttempts: 0,
|
|
lastError: null,
|
|
lastErrorAt: null,
|
|
},
|
|
};
|
|
instances.set(config.id, instance);
|
|
|
|
client.on("connect", () => {
|
|
instance.stats.isConnected = true;
|
|
logger.info(`[CentralForwarder] 연결됨: ${url} (config=${config.config_name})`);
|
|
persistStats(instance).catch(() => {});
|
|
// 접속 즉시 재시도 큐 드레인
|
|
drainRetryQueue(instance).catch(err =>
|
|
logger.warn(`[CentralForwarder] 재시도 큐 드레인 실패: ${(err as Error).message}`)
|
|
);
|
|
});
|
|
|
|
client.on("reconnect", () => {
|
|
instance.stats.reconnectAttempts++;
|
|
});
|
|
|
|
client.on("close", () => {
|
|
instance.stats.isConnected = false;
|
|
});
|
|
|
|
client.on("error", err => {
|
|
instance.stats.lastError = err.message;
|
|
instance.stats.lastErrorAt = new Date();
|
|
logger.error(`[CentralForwarder] 연결 오류: ${err.message}`);
|
|
});
|
|
|
|
// 배치 flush 타이머
|
|
instance.flushTimer = setInterval(() => {
|
|
flushBuffer(instance).catch(() => {});
|
|
}, config.batch_timeout_ms);
|
|
|
|
// 하트비트 타이머
|
|
instance.heartbeatTimer = setInterval(() => {
|
|
sendHeartbeat(instance).catch(() => {});
|
|
}, config.heartbeat_interval_sec * 1000);
|
|
|
|
// 통계 주기 저장
|
|
setInterval(() => persistStats(instance).catch(() => {}), 30_000);
|
|
}
|
|
|
|
export async function stopForwarder(configId: number): Promise<void> {
|
|
const inst = instances.get(configId);
|
|
if (!inst) return;
|
|
|
|
if (inst.flushTimer) clearInterval(inst.flushTimer);
|
|
if (inst.heartbeatTimer) clearInterval(inst.heartbeatTimer);
|
|
|
|
// 남은 버퍼 밀어내기
|
|
await flushBuffer(inst).catch(() => {});
|
|
|
|
if (inst.client) {
|
|
await new Promise<void>(resolve => {
|
|
inst.client!.end(false, {}, () => resolve());
|
|
});
|
|
}
|
|
instances.delete(configId);
|
|
await persistStats(inst).catch(() => {});
|
|
logger.info(`[CentralForwarder] 중지: config_id=${configId}`);
|
|
}
|
|
|
|
// ─── 데이터 인입 ───────────────────────────────────
|
|
|
|
/** deviceCollectorService가 이 함수를 호출해 포워딩 파이프라인에 데이터 투입 */
|
|
export async function ingest(data: CollectedData): Promise<void> {
|
|
// 회사별 인스턴스 찾기 (company_code 매칭)
|
|
for (const inst of instances.values()) {
|
|
const cfg = inst.config;
|
|
if (cfg.company_code !== "*" && cfg.company_code !== data.companyCode) continue;
|
|
|
|
inst.buffer.push(data);
|
|
|
|
if (inst.buffer.length >= cfg.batch_size) {
|
|
await flushBuffer(inst);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function flushBuffer(inst: ForwarderInstance): Promise<void> {
|
|
if (inst.buffer.length === 0) return;
|
|
const cfg = inst.config;
|
|
const batch = inst.buffer.splice(0, inst.buffer.length);
|
|
|
|
if (!inst.client || !inst.stats.isConnected) {
|
|
// 연결 안 되어 있으면 retry_queue에 쌓아두기
|
|
await enqueueRetry(cfg.id, batch, cfg).catch(err =>
|
|
logger.error(`[CentralForwarder] 재시도 큐 저장 실패: ${(err as Error).message}`)
|
|
);
|
|
return;
|
|
}
|
|
|
|
for (const data of batch) {
|
|
const topic = renderTopic(cfg.topic_pattern, cfg, data);
|
|
const payload = buildPayload(cfg, data);
|
|
|
|
try {
|
|
await publishAsync(inst.client, topic, payload, cfg.qos as 0 | 1 | 2);
|
|
inst.stats.messagesForwarded++;
|
|
inst.stats.lastPublishedAt = new Date();
|
|
} catch (err) {
|
|
inst.stats.messagesFailed++;
|
|
await enqueueRetry(cfg.id, [data], cfg).catch(() => {
|
|
inst.stats.messagesDropped++;
|
|
});
|
|
logger.warn(`[CentralForwarder] publish 실패: ${(err as Error).message}`);
|
|
}
|
|
}
|
|
|
|
inst.stats.batchesSent++;
|
|
}
|
|
|
|
async function sendHeartbeat(inst: ForwarderInstance): Promise<void> {
|
|
if (!inst.client || !inst.stats.isConnected) return;
|
|
const cfg = inst.config;
|
|
const topic = cfg.status_topic_pattern
|
|
.replace("{company_id}", cfg.company_id)
|
|
.replace("{edge_id}", cfg.edge_id);
|
|
|
|
const payload = JSON.stringify({
|
|
status: "online",
|
|
timestamp: new Date().toISOString(),
|
|
company_id: cfg.company_id,
|
|
edge_id: cfg.edge_id,
|
|
stats: {
|
|
forwarded: inst.stats.messagesForwarded,
|
|
failed: inst.stats.messagesFailed,
|
|
dropped: inst.stats.messagesDropped,
|
|
batches_sent: inst.stats.batchesSent,
|
|
reconnect_attempts: inst.stats.reconnectAttempts,
|
|
},
|
|
});
|
|
|
|
try {
|
|
await publishAsync(inst.client, topic, payload, cfg.qos as 0 | 1 | 2);
|
|
} catch (err) {
|
|
logger.debug(`[CentralForwarder] heartbeat 실패: ${(err as Error).message}`);
|
|
}
|
|
}
|
|
|
|
// ─── 재시도 큐 ─────────────────────────────────────
|
|
|
|
async function enqueueRetry(
|
|
configId: number,
|
|
items: CollectedData[],
|
|
cfg: ForwarderConfig
|
|
): Promise<void> {
|
|
if (items.length === 0) return;
|
|
|
|
const values: unknown[] = [];
|
|
const placeholders: string[] = [];
|
|
items.forEach((data, idx) => {
|
|
const base = idx * 3;
|
|
const topic = renderTopic(cfg.topic_pattern, cfg, data);
|
|
const payload = buildPayload(cfg, data);
|
|
values.push(configId, topic, payload);
|
|
placeholders.push(`($${base + 1}, $${base + 2}, $${base + 3}::jsonb)`);
|
|
});
|
|
|
|
await query(
|
|
`INSERT INTO central_mqtt_forwarder_retry_queue (config_id, topic, payload)
|
|
VALUES ${placeholders.join(", ")}`,
|
|
values
|
|
);
|
|
}
|
|
|
|
async function drainRetryQueue(inst: ForwarderInstance): Promise<void> {
|
|
if (!inst.client || !inst.stats.isConnected) return;
|
|
|
|
// 한 번에 최대 500건씩 처리
|
|
const rows = await query<{ id: number; topic: string; payload: string }>(
|
|
`SELECT id, topic, payload::text AS payload
|
|
FROM central_mqtt_forwarder_retry_queue
|
|
WHERE config_id = $1
|
|
ORDER BY enqueued_at
|
|
LIMIT 500`,
|
|
[inst.config.id]
|
|
);
|
|
|
|
for (const row of rows) {
|
|
try {
|
|
await publishAsync(inst.client, row.topic, row.payload, inst.config.qos as 0 | 1 | 2);
|
|
await query(`DELETE FROM central_mqtt_forwarder_retry_queue WHERE id = $1`, [row.id]);
|
|
inst.stats.messagesForwarded++;
|
|
} catch (err) {
|
|
await query(
|
|
`UPDATE central_mqtt_forwarder_retry_queue
|
|
SET retry_count = retry_count + 1, last_attempt = NOW(), last_error = $2
|
|
WHERE id = $1`,
|
|
[row.id, (err as Error).message]
|
|
);
|
|
return; // 하나라도 실패하면 중단 — 재연결 후 다시 시도
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── 통계 저장 ─────────────────────────────────────
|
|
|
|
async function persistStats(inst: ForwarderInstance): Promise<void> {
|
|
const s = inst.stats;
|
|
await query(
|
|
`INSERT INTO central_mqtt_forwarder_stats
|
|
(config_id, started_at, last_published_at, messages_forwarded, messages_failed,
|
|
messages_dropped, batches_sent, last_error, last_error_at, is_connected,
|
|
reconnect_attempts, updated_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NOW())
|
|
ON CONFLICT (config_id) DO UPDATE SET
|
|
started_at = EXCLUDED.started_at,
|
|
last_published_at = EXCLUDED.last_published_at,
|
|
messages_forwarded = EXCLUDED.messages_forwarded,
|
|
messages_failed = EXCLUDED.messages_failed,
|
|
messages_dropped = EXCLUDED.messages_dropped,
|
|
batches_sent = EXCLUDED.batches_sent,
|
|
last_error = EXCLUDED.last_error,
|
|
last_error_at = EXCLUDED.last_error_at,
|
|
is_connected = EXCLUDED.is_connected,
|
|
reconnect_attempts = EXCLUDED.reconnect_attempts,
|
|
updated_at = NOW()`,
|
|
[
|
|
inst.config.id,
|
|
s.startedAt,
|
|
s.lastPublishedAt,
|
|
s.messagesForwarded,
|
|
s.messagesFailed,
|
|
s.messagesDropped,
|
|
s.batchesSent,
|
|
s.lastError,
|
|
s.lastErrorAt,
|
|
s.isConnected ? "Y" : "N",
|
|
s.reconnectAttempts,
|
|
]
|
|
);
|
|
}
|
|
|
|
export function getRuntimeStatus() {
|
|
return Array.from(instances.values()).map(i => ({
|
|
config_id: i.config.id,
|
|
config_name: i.config.config_name,
|
|
company_code: i.config.company_code,
|
|
edge_id: i.config.edge_id,
|
|
broker: `${i.config.broker_host}:${i.config.broker_port}`,
|
|
connected: i.stats.isConnected,
|
|
buffered: i.buffer.length,
|
|
...i.stats,
|
|
}));
|
|
}
|
|
|
|
// ─── 유틸 ─────────────────────────────────────────
|
|
|
|
async function loadConfig(id: number): Promise<ForwarderConfig> {
|
|
const rows = await query<ForwarderConfig>(
|
|
`SELECT * FROM central_mqtt_forwarder_config WHERE id = $1`,
|
|
[id]
|
|
);
|
|
if (!rows.length) throw new Error(`forwarder config ${id} 없음`);
|
|
return rows[0];
|
|
}
|
|
|
|
function tryDecrypt(encrypted: string): string | undefined {
|
|
try {
|
|
return PasswordEncryption.decrypt(encrypted);
|
|
} catch {
|
|
logger.warn(`[CentralForwarder] 비밀번호 복호화 실패 — 원본 사용`);
|
|
return encrypted;
|
|
}
|
|
}
|
|
|
|
function renderTopic(
|
|
pattern: string,
|
|
cfg: ForwarderConfig,
|
|
data: CollectedData
|
|
): string {
|
|
return pattern
|
|
.replace("{company_id}", cfg.company_id)
|
|
.replace("{edge_id}", cfg.edge_id)
|
|
.replace("{connection_id}", String(data.connectionId));
|
|
}
|
|
|
|
function buildPayload(cfg: ForwarderConfig, data: CollectedData): string {
|
|
return JSON.stringify({
|
|
timestamp: data.timestamp,
|
|
edge_id: cfg.edge_id,
|
|
device_id: String(data.connectionId),
|
|
connection_name: data.connectionName,
|
|
protocol: data.protocol,
|
|
host: data.host,
|
|
port: data.port,
|
|
tags: data.tags,
|
|
priority: 2,
|
|
company_id: cfg.company_id,
|
|
plc_state: data.plcState,
|
|
error_message: data.errorMessage,
|
|
forwarded_at: new Date().toISOString(),
|
|
});
|
|
}
|
|
|
|
function publishAsync(
|
|
client: MqttClient,
|
|
topic: string,
|
|
payload: string,
|
|
qos: 0 | 1 | 2
|
|
): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
client.publish(topic, payload, { qos }, err => {
|
|
if (err) return reject(err);
|
|
resolve();
|
|
});
|
|
});
|
|
}
|