Files
Web_BLS_Heartbeat_Server/test/dualWrite.test.js
XuJiacheng 43fa7505e5 feat: 新增 G4 热表独立双写能力
- 新增配置项以支持旧/新明细表的独立写入开关及目标表名。
- 重构 DatabaseManager,抽象通用批量 COPY 写入内核,支持不同目标表的复用。
- 新增双明细写入编排器,支持旧/新表独立执行、重试及 fallback。
- 调整 HeartbeatProcessor.processBatch(),确保 room_status 独立执行。
- 错误表仅记录新表写入失败,旧表失败不再写入错误表。
- 重新定义消费暂停策略,基于当前启用的关键 sink 判断。
- 补充按 sink 维度的统计项与启动日志。

新增 G4 热表相关的数据库规范与处理逻辑,确保系统在双写模式下的稳定性与可扩展性。
2026-03-09 15:49:12 +08:00

398 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from 'node:assert/strict';
import { HeartbeatProcessor } from '../src/processor/heartbeatProcessor.js';
import { DatabaseManager } from '../src/db/databaseManager.js';
// ---- 辅助 ----
const buildBasePayload = () => ({
ts_ms: 1700000000123,
hotel_id: 1,
room_id: '101',
device_id: 'dev1',
ip: '10.0.0.1',
power_state: 1,
guest_type: 0,
cardless_state: 0,
service_mask: 7,
pms_state: 1,
carbon_state: 0,
device_count: 1,
comm_seq: 1,
});
function makeDualResult({ legacyEnabled = true, legacySuccess = true, legacyConn = false,
g4HotEnabled = false, g4HotSuccess = true, g4HotConn = false,
insertedCount = 1, failedRecords = [] } = {}) {
return {
legacy: {
enabled: legacyEnabled, success: legacySuccess, insertedCount: legacySuccess ? insertedCount : 0,
failedRecords: legacySuccess ? [] : failedRecords, error: legacySuccess ? null : new Error('legacy fail'),
isConnectionError: legacyConn, batchError: legacySuccess ? null : new Error('legacy fail'),
},
g4Hot: {
enabled: g4HotEnabled, success: g4HotSuccess, insertedCount: g4HotSuccess ? insertedCount : 0,
failedRecords: g4HotSuccess ? [] : failedRecords, error: g4HotSuccess ? null : new Error('g4hot fail'),
isConnectionError: g4HotConn, batchError: g4HotSuccess ? null : new Error('g4hot fail'),
},
};
}
function buildMockDb(overrides = {}) {
return {
config: {
legacyHeartbeatEnabled: true,
g4HotHeartbeatEnabled: false,
roomStatusEnabled: true,
legacyTable: 'heartbeat.heartbeat_events',
g4HotTable: 'heartbeat.heartbeat_events_g4_hot',
...overrides.config,
},
insertHeartbeatEventsDual: overrides.insertHeartbeatEventsDual ?? (async () => makeDualResult()),
insertHeartbeatEventsErrors: overrides.insertHeartbeatEventsErrors ?? (async () => {}),
upsertRoomStatus: overrides.upsertRoomStatus ?? (async () => ({ rowCount: 1 })),
insertHeartbeatEvents: overrides.insertHeartbeatEvents ?? (async () => ({ insertedCount: 1 })),
checkConnection: overrides.checkConnection ?? (async () => true),
};
}
function buildProcessor(dbOverrides = {}, processorConfig = {}) {
const db = buildMockDb(dbOverrides);
return new HeartbeatProcessor(
{ batchSize: 1, batchTimeout: 1000, ...processorConfig },
db
);
}
// ---- 测试 ----
describe('Dual-write: 仅旧表开启', () => {
it('should call insertHeartbeatEventsDual and succeed', async () => {
let captured = null;
const processor = buildProcessor({
config: { legacyHeartbeatEnabled: true, g4HotHeartbeatEnabled: false },
insertHeartbeatEventsDual: async (events) => {
captured = events;
return makeDualResult({ legacyEnabled: true, g4HotEnabled: false });
},
});
const msg = { value: Buffer.from(JSON.stringify(buildBasePayload()), 'utf8') };
const res = await processor.processMessage(msg);
assert.deepEqual(res, { insertedCount: 1 });
assert.ok(Array.isArray(captured));
assert.equal(captured.length, 1);
});
});
describe('Dual-write: 仅新表开启', () => {
it('should call insertHeartbeatEventsDual with g4hot enabled', async () => {
let dualCalled = false;
const processor = buildProcessor({
config: { legacyHeartbeatEnabled: false, g4HotHeartbeatEnabled: true },
insertHeartbeatEventsDual: async (events) => {
dualCalled = true;
return makeDualResult({ legacyEnabled: false, g4HotEnabled: true });
},
});
const msg = { value: Buffer.from(JSON.stringify(buildBasePayload()), 'utf8') };
await processor.processMessage(msg);
assert.ok(dualCalled);
});
});
describe('Dual-write: 旧新双开', () => {
it('should call insertHeartbeatEventsDual with both enabled', async () => {
let dualCalled = false;
const processor = buildProcessor({
config: { legacyHeartbeatEnabled: true, g4HotHeartbeatEnabled: true },
insertHeartbeatEventsDual: async () => {
dualCalled = true;
return makeDualResult({ legacyEnabled: true, g4HotEnabled: true });
},
});
const msg = { value: Buffer.from(JSON.stringify(buildBasePayload()), 'utf8') };
await processor.processMessage(msg);
assert.ok(dualCalled);
});
});
describe('Dual-write: 旧新双关room_status 仍执行', () => {
it('should still call upsertRoomStatus when both detail writes are off', async () => {
let roomStatusCalled = false;
const processor = buildProcessor({
config: { legacyHeartbeatEnabled: false, g4HotHeartbeatEnabled: false, roomStatusEnabled: true },
insertHeartbeatEventsDual: async () => makeDualResult({ legacyEnabled: false, g4HotEnabled: false }),
upsertRoomStatus: async () => { roomStatusCalled = true; return { rowCount: 1 }; },
});
const msg = { value: Buffer.from(JSON.stringify(buildBasePayload()), 'utf8') };
await processor.processMessage(msg);
// 等待异步 room_status 调用完成
await new Promise(r => setTimeout(r, 50));
assert.ok(roomStatusCalled);
});
});
describe('Dual-write: 旧成功新失败', () => {
it('should continue and only write g4hot failures to error table', async () => {
let errorTablePayload = null;
const failedRecord = { error: new Error('g4hot column mismatch'), record: buildBasePayload() };
const processor = buildProcessor({
config: { legacyHeartbeatEnabled: true, g4HotHeartbeatEnabled: true },
insertHeartbeatEventsDual: async () => makeDualResult({
legacyEnabled: true, legacySuccess: true,
g4HotEnabled: true, g4HotSuccess: false, failedRecords: [failedRecord],
}),
insertHeartbeatEventsErrors: async (payload) => { errorTablePayload = payload; },
});
const msg = { value: Buffer.from(JSON.stringify(buildBasePayload()), 'utf8') };
await processor.processMessage(msg);
await new Promise(r => setTimeout(r, 50));
assert.ok(errorTablePayload);
assert.equal(errorTablePayload.length, 1);
assert.equal(errorTablePayload[0].error_code, 'g4hot_write_failed');
});
});
describe('Dual-write: 旧失败新成功', () => {
it('should NOT write legacy failures to error table', async () => {
let errorTableCalled = false;
const failedRecord = { error: new Error('legacy column mismatch'), record: buildBasePayload() };
const processor = buildProcessor({
config: { legacyHeartbeatEnabled: true, g4HotHeartbeatEnabled: true },
insertHeartbeatEventsDual: async () => makeDualResult({
legacyEnabled: true, legacySuccess: false, failedRecords: [failedRecord],
g4HotEnabled: true, g4HotSuccess: true,
}),
insertHeartbeatEventsErrors: async () => { errorTableCalled = true; },
});
const msg = { value: Buffer.from(JSON.stringify(buildBasePayload()), 'utf8') };
await processor.processMessage(msg);
await new Promise(r => setTimeout(r, 50));
assert.equal(errorTableCalled, false);
});
});
describe('Dual-write: 双失败', () => {
it('should only write g4hot failures to error table, not legacy failures', async () => {
let errorTablePayload = null;
const failedRecord = { error: new Error('fail'), record: buildBasePayload() };
const processor = buildProcessor({
config: { legacyHeartbeatEnabled: true, g4HotHeartbeatEnabled: true },
insertHeartbeatEventsDual: async () => makeDualResult({
legacyEnabled: true, legacySuccess: false, failedRecords: [failedRecord],
g4HotEnabled: true, g4HotSuccess: false, failedRecords: [failedRecord],
}),
insertHeartbeatEventsErrors: async (payload) => { errorTablePayload = payload; },
});
const msg = { value: Buffer.from(JSON.stringify(buildBasePayload()), 'utf8') };
await processor.processMessage(msg);
await new Promise(r => setTimeout(r, 50));
// 错误表只包含 g4hot 失败
assert.ok(errorTablePayload);
assert.equal(errorTablePayload[0].error_code, 'g4hot_write_failed');
});
});
describe('Dual-write: room_status 始终执行', () => {
it('should call upsertRoomStatus regardless of detail write success/failure', async () => {
let roomStatusCalled = false;
const processor = buildProcessor({
config: { legacyHeartbeatEnabled: true, g4HotHeartbeatEnabled: false, roomStatusEnabled: true },
insertHeartbeatEventsDual: async () => makeDualResult({
legacyEnabled: true, legacySuccess: false,
failedRecords: [{ error: new Error('fail'), record: buildBasePayload() }],
}),
upsertRoomStatus: async () => { roomStatusCalled = true; return { rowCount: 1 }; },
});
const msg = { value: Buffer.from(JSON.stringify(buildBasePayload()), 'utf8') };
await processor.processMessage(msg);
await new Promise(r => setTimeout(r, 50));
assert.ok(roomStatusCalled);
});
});
describe('Dual-write: 暂停消费策略', () => {
it('should NOT pause when both detail writes disabled but room_status enabled', async () => {
let paused = false;
const processor = buildProcessor({
config: { legacyHeartbeatEnabled: false, g4HotHeartbeatEnabled: false, roomStatusEnabled: true },
insertHeartbeatEventsDual: async () => makeDualResult({ legacyEnabled: false, g4HotEnabled: false }),
});
processor.onDbOffline = () => { paused = true; };
const msg = { value: Buffer.from(JSON.stringify(buildBasePayload()), 'utf8') };
await processor.processMessage(msg);
assert.equal(paused, false);
});
it('should pause when only legacy enabled and legacy has connection error', () => {
const processor = buildProcessor({});
const result = processor._shouldPauseConsumption(
{ enabled: true, isConnectionError: true },
{ enabled: false, isConnectionError: false }
);
assert.ok(result);
});
it('should NOT pause in dual-write when only g4hot has connection error', async () => {
let paused = false;
const processor = buildProcessor({
config: { legacyHeartbeatEnabled: true, g4HotHeartbeatEnabled: true },
insertHeartbeatEventsDual: async () => makeDualResult({
legacyEnabled: true, legacySuccess: true,
g4HotEnabled: true, g4HotSuccess: false, g4HotConn: true,
}),
});
processor.onDbOffline = () => { paused = true; };
const msg = { value: Buffer.from(JSON.stringify(buildBasePayload()), 'utf8') };
await processor.processMessage(msg);
assert.equal(paused, false);
});
it('should pause in dual-write when both have connection errors', () => {
const processor = buildProcessor({});
const result = processor._shouldPauseConsumption(
{ enabled: true, isConnectionError: true },
{ enabled: true, isConnectionError: true }
);
assert.ok(result);
});
it('should NOT pause when only g4hot has connection error in dual mode', () => {
const processor = buildProcessor({});
const result = processor._shouldPauseConsumption(
{ enabled: true, isConnectionError: false },
{ enabled: true, isConnectionError: true }
);
assert.equal(result, false);
});
it('should pause when only g4hot enabled and g4hot has connection error', () => {
const processor = buildProcessor({});
const result = processor._shouldPauseConsumption(
{ enabled: false, isConnectionError: false },
{ enabled: true, isConnectionError: true }
);
assert.ok(result);
});
});
describe('DatabaseManager: _g4HotToRowValues', () => {
it('unpacks svc booleans from service_mask', () => {
const dm = new DatabaseManager({ host: 'x', port: 5432, user: 'x', password: 'x', database: 'x' });
const e = { ...buildBasePayload(), service_mask: 7 }; // bits 0,1,2 set
const values = dm._g4HotToRowValues(e);
const cols = dm._getG4HotColumns();
const svc01Idx = cols.indexOf('svc_01');
assert.equal(values[svc01Idx], true); // bit 0
assert.equal(values[svc01Idx + 1], true); // bit 1 (svc_02)
assert.equal(values[svc01Idx + 2], true); // bit 2 (svc_03)
assert.equal(values[svc01Idx + 3], false); // bit 3 (svc_04)
assert.equal(values[svc01Idx + 63], false); // svc_64
});
it('unpacks array fields into _1, _2, _residual', () => {
const dm = new DatabaseManager({ host: 'x', port: 5432, user: 'x', password: 'x', database: 'x' });
const e = {
...buildBasePayload(),
air_address: ['ac1', 'ac2', 'ac3'],
state: [1, 2, 3],
elec_address: ['e1'],
voltage: [220.5],
};
const values = dm._g4HotToRowValues(e);
const cols = dm._getG4HotColumns();
const airAddr1Idx = cols.indexOf('air_address_1');
assert.equal(values[airAddr1Idx], 'ac1');
assert.equal(values[airAddr1Idx + 1], 'ac2'); // air_address_2
assert.deepEqual(values[airAddr1Idx + 2], ['ac3']); // air_address_residual
const state1Idx = cols.indexOf('state_1');
assert.equal(values[state1Idx], 1);
assert.equal(values[state1Idx + 1], 2);
assert.deepEqual(values[state1Idx + 2], [3]);
const elecAddr1Idx = cols.indexOf('elec_address_1');
assert.equal(values[elecAddr1Idx], 'e1');
assert.equal(values[elecAddr1Idx + 1], null); // only 1 element
assert.equal(values[elecAddr1Idx + 2], null); // no residual
});
it('produces correct column count', () => {
const dm = new DatabaseManager({ host: 'x', port: 5432, user: 'x', password: 'x', database: 'x' });
const e = buildBasePayload();
const cols = dm._getG4HotColumns();
const values = dm._g4HotToRowValues(e);
assert.equal(values.length, cols.length);
});
it('writes power helper fields as null temporarily', () => {
const dm = new DatabaseManager({ host: 'x', port: 5432, user: 'x', password: 'x', database: 'x' });
const e = {
...buildBasePayload(),
extra: {
power_carbon_on: 1.5,
power_carbon_off: 2.5,
power_person_exist: 3.5,
power_person_left: 4.5,
},
};
const cols = dm._getG4HotColumns();
const values = dm._g4HotToRowValues(e);
assert.equal(values[cols.indexOf('power_carbon_on')], null);
assert.equal(values[cols.indexOf('power_carbon_off')], null);
assert.equal(values[cols.indexOf('power_person_exist')], null);
assert.equal(values[cols.indexOf('power_person_left')], null);
});
it('generates lowercase guid without hyphens when missing', () => {
const dm = new DatabaseManager({ host: 'x', port: 5432, user: 'x', password: 'x', database: 'x' });
const e = buildBasePayload();
const cols = dm._getG4HotColumns();
const values = dm._g4HotToRowValues(e);
const guid = values[cols.indexOf('guid')];
assert.match(guid, /^[0-9a-f]{32}$/);
});
it('normalizes provided guid to lowercase without hyphens', () => {
const dm = new DatabaseManager({ host: 'x', port: 5432, user: 'x', password: 'x', database: 'x' });
const e = { ...buildBasePayload(), guid: 'A0B1C2D3-E4F5-6789-ABCD-EF0123456789' };
const cols = dm._getG4HotColumns();
const values = dm._g4HotToRowValues(e);
const guid = values[cols.indexOf('guid')];
assert.equal(guid, 'a0b1c2d3e4f56789abcdef0123456789');
});
});
describe('DatabaseManager: _formatPgCol', () => {
it('formats boolean values correctly', () => {
const dm = new DatabaseManager({ host: 'x', port: 5432, user: 'x', password: 'x', database: 'x' });
assert.equal(dm._formatPgCol(true), 't');
assert.equal(dm._formatPgCol(false), 'f');
assert.equal(dm._formatPgCol(null), '\\N');
});
});
describe('DatabaseManager: insertHeartbeatEventsDual', () => {
it('returns empty results when both targets disabled', async () => {
const dm = new DatabaseManager({
host: 'x', port: 5432, user: 'x', password: 'x', database: 'x',
legacyHeartbeatEnabled: false, g4HotHeartbeatEnabled: false,
});
const result = await dm.insertHeartbeatEventsDual([buildBasePayload()]);
assert.equal(result.legacy.enabled, false);
assert.equal(result.g4Hot.enabled, false);
assert.equal(result.legacy.insertedCount, 0);
assert.equal(result.g4Hot.insertedCount, 0);
});
});