Files
pipeline/backend-node/src/services/collector/centralMqttForwarder.ts
T
chpark 77d35220b1
Build and Push Images / build-and-push (push) Has been cancelled
feat(edge): 디지털 트윈용 장비 메타(IP/Protocol) IDC TimescaleDB 적재 + 송신 페이로드 보강
- 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>
2026-05-07 15:38:38 +09:00

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();
});
});
}