Skip to content

Commit 2e1e4c3

Browse files
authored
Merge pull request #20 from happy-game/replication
feat(gaussdb-node): 增加 OpenGauss 逻辑复制支持
2 parents 1b94cda + 2e4cfe6 commit 2e1e4c3

15 files changed

Lines changed: 818 additions & 136 deletions

File tree

packages/gaussdb-node/esm/index.mjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ export const escapeIdentifier = gaussdb.escapeIdentifier
1212
export const escapeLiteral = gaussdb.escapeLiteral
1313
export const Result = gaussdb.Result
1414
export const TypeOverrides = gaussdb.TypeOverrides
15+
export const LogicalReplicationService = gaussdb.LogicalReplicationService
16+
export const MppdbDecodingPlugin = gaussdb.MppdbDecodingPlugin
1517

1618
// Also export the defaults
1719
export const defaults = gaussdb.defaults

packages/gaussdb-node/lib/index.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ const utils = require('./utils')
88
const Pool = require('gaussdb-pool')
99
const TypeOverrides = require('./type-overrides')
1010
const { DatabaseError } = require('gaussdb-protocol')
11+
const { LogicalReplicationService, MppdbDecodingPlugin } = require('./logical-replication')
12+
1113
const { escapeIdentifier, escapeLiteral } = require('./utils')
1214

1315
const poolFactory = (Client) => {
@@ -28,6 +30,8 @@ const GAUSSDB = function (clientConstructor) {
2830
this.types = require('pg-types')
2931
this.DatabaseError = DatabaseError
3032
this.TypeOverrides = TypeOverrides
33+
this.LogicalReplicationService = LogicalReplicationService
34+
this.MppdbDecodingPlugin = MppdbDecodingPlugin
3135
this.escapeIdentifier = escapeIdentifier
3236
this.escapeLiteral = escapeLiteral
3337
this.Result = Result
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
'use strict'
2+
3+
const LogicalReplicationService = require('./logical-replication-service')
4+
const MppdbDecodingPlugin = require('./mppdb-decoding-plugin')
5+
6+
module.exports = {
7+
LogicalReplicationService,
8+
MppdbDecodingPlugin,
9+
}
Lines changed: 335 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,335 @@
1+
/*
2+
* Copyright (c) 2025 happy-game
3+
*
4+
* This source code is derived from and/or based on:
5+
* pg-logical-replication - Copyright (c) 2025 Kibae Shin
6+
*
7+
* Licensed under the MIT License.
8+
*/
9+
'use strict'
10+
const EventEmitter = require('events').EventEmitter
11+
const Client = require('../client')
12+
const { BufferReader } = require('gaussdb-protocol')
13+
const POSTGRES_EPOCH_MS = 946684800000
14+
const MAX_UINT64 = { hi: 0x7fffffff, lo: 0xffffffff }
15+
16+
class LogicalReplicationService extends EventEmitter {
17+
constructor(clientConfig, config) {
18+
super()
19+
this._lastLsn = null
20+
this._lastReceive = null
21+
this._lastFlushed = null
22+
this._lastApplied = null
23+
this._client = null
24+
this._connection = null
25+
this._stop = true
26+
// Flow control (backpressure) queue
27+
this._messageQueue = []
28+
this._processing = false
29+
this._lastStandbyStatusUpdatedTime = 0
30+
this._checkStandbyStatusTimer = null
31+
this.clientConfig = clientConfig
32+
this.config = {
33+
acknowledge: Object.assign(
34+
{
35+
// If the value is false, acknowledge must be done manually. Default: true
36+
auto: true,
37+
// Acknowledge is performed every set time (sec). If 0, do not do it. Default: 10
38+
timeoutSeconds: 10,
39+
},
40+
(config && config.acknowledge) || {}
41+
),
42+
// Flow control (backpressure) configuration.
43+
// When enabled, the stream will be paused until the data handler completes,
44+
// preventing memory overflow when processing is slower than the incoming message rate.
45+
flowControl: Object.assign(
46+
{
47+
// If true, pause the stream until the data handler completes. Default: false
48+
enabled: false,
49+
},
50+
(config && config.flowControl) || {}
51+
),
52+
}
53+
}
54+
55+
lastLsn() {
56+
return this._lastLsn || '0/00000000'
57+
}
58+
59+
async stop() {
60+
this._stop = true
61+
// Clear flow control queue
62+
this._messageQueue = []
63+
this._processing = false
64+
if (this._connection) {
65+
this._connection.removeAllListeners()
66+
this._connection = null
67+
}
68+
if (this._client) {
69+
this._client.removeAllListeners()
70+
await this._client.end()
71+
this._client = null
72+
}
73+
this._checkStandbyStatus(false)
74+
return this
75+
}
76+
77+
/**
78+
* @param plugin One of [MppdbDecodingPlugin, ]
79+
* @param slotName
80+
* @param uptoLsn
81+
*/
82+
async subscribe(plugin, slotName, uptoLsn) {
83+
try {
84+
const [client, connection] = await this._createClient()
85+
this._lastLsn = uptoLsn || this._lastLsn
86+
87+
// check replicationStart
88+
connection.once('replicationStart', () => {
89+
this._stop = false
90+
this.emit('start')
91+
this._checkStandbyStatus(true)
92+
})
93+
94+
connection.on('copyData', (msg) => {
95+
const buffer = msg && msg.chunk ? msg.chunk : msg
96+
if (!buffer || buffer.length === 0) return
97+
this._handleCopyData(plugin, buffer)
98+
})
99+
return plugin.start(client, slotName, this._lastLsn || '0/00000000')
100+
} catch (e) {
101+
await this.stop()
102+
this.emit('error', e)
103+
throw e
104+
}
105+
}
106+
107+
/**
108+
* OpenGauss uses a 65-byte little-endian Standby Status Update packet,
109+
* different from PostgreSQL's 34-byte big-endian format.
110+
* @param lsn
111+
* @param ping Request server to respond
112+
*/
113+
async acknowledge(lsn, ping) {
114+
if (this._stop || !this._connection) return false
115+
const received = lsn ? parseLsn(lsn) : this._lastReceive || { hi: 0, lo: 0 }
116+
const flushed = this._lastFlushed || received
117+
const applied = this._lastApplied || received
118+
this._lastStandbyStatusUpdatedTime = Date.now()
119+
120+
// Timestamp as microseconds since midnight 2000-01-01
121+
const nowMicros = (Date.now() - POSTGRES_EPOCH_MS) * 1000
122+
const timeHi = Math.floor(nowMicros / 0x100000000)
123+
const timeLo = Math.floor(nowMicros - timeHi * 0x100000000)
124+
125+
const response = Buffer.alloc(65)
126+
let offset = 0
127+
response[offset++] = 0x72 // 'r'
128+
offset = writeUInt64LE(response, offset, MAX_UINT64) // sendTime (unused, set to max)
129+
offset = writeUInt64LE(response, offset, received) // Last WAL received
130+
offset = writeUInt64LE(response, offset, flushed) // Last WAL flushed to disk
131+
offset = writeUInt64LE(response, offset, MAX_UINT64) // flushTime (unused, set to max)
132+
offset = writeUInt64LE(response, offset, applied) // Last WAL applied
133+
response.writeUInt32LE(0xffffffff, offset) // applyTime lo (unused)
134+
offset += 4
135+
response.writeUInt32LE(0xffffffff, offset) // applyTime hi (unused)
136+
offset += 4
137+
offset = writeUInt64LE(response, offset, { hi: timeHi >>> 0, lo: timeLo >>> 0 }) // client timestamp
138+
// If 1, requests server to respond immediately - can be used to verify connectivity
139+
response[offset++] = ping ? 1 : received.hi === 0 && received.lo === 0 ? 1 : 0
140+
response.writeUInt32LE(0, offset) // xlogFlushLocation (unused)
141+
offset += 4
142+
response[offset++] = 1 // peer_role
143+
response[offset++] = 1 // peer_state
144+
response[offset++] = 1 // sender_sent_location flag
145+
this._connection.sendCopyFromChunk(response)
146+
return true
147+
}
148+
149+
setFlushedLsn(lsn) {
150+
this._lastFlushed = parseLsn(lsn)
151+
}
152+
153+
setAppliedLsn(lsn) {
154+
this._lastApplied = parseLsn(lsn)
155+
}
156+
157+
async _createClient() {
158+
await this.stop()
159+
this._client = new Client(Object.assign({}, this.clientConfig, { replication: 'database' }))
160+
await this._client.connect()
161+
this._connection = this._client.connection
162+
this._client.on('error', (e) => this.emit('error', e))
163+
return [this._client, this._connection]
164+
}
165+
166+
_handleCopyData(plugin, buffer) {
167+
const tag = buffer[0]
168+
if (tag !== 0x77 && tag !== 0x6b) {
169+
return
170+
}
171+
if (tag === 0x77) {
172+
// XLogData: OpenGauss uses big-endian LSN in header (same as PostgreSQL)
173+
const reader = new BufferReader(1, 'be')
174+
reader.setBuffer(1, buffer)
175+
const start = reader.uint64Parts()
176+
const lsn = formatLsn(start.hi, start.lo)
177+
this._updateLastReceive(start)
178+
const parsed = plugin.parse(buffer.slice(25))
179+
if (Array.isArray(parsed)) {
180+
for (const item of parsed) {
181+
const itemLsn = item && item.lsn ? item.lsn : lsn
182+
this._enqueue(itemLsn, item)
183+
}
184+
} else {
185+
this._enqueue(lsn, parsed)
186+
}
187+
}
188+
if (tag === 0x6b) {
189+
// Primary keepalive message: OpenGauss uses little-endian and includes extra server mode/state fields
190+
const reader = new BufferReader(1, 'le')
191+
reader.setBuffer(1, buffer)
192+
const server = reader.uint64Parts()
193+
if (!this._lastReceive || compareLsn(server, this._lastReceive) > 0) {
194+
this._updateLastReceive(server)
195+
}
196+
reader.setBuffer(17, buffer)
197+
const serverClock = reader.uint64Parts()
198+
const timestamp = serverClockToTimestamp(serverClock)
199+
const shouldRespond = buffer[25] === 1
200+
this.emit('heartbeat', formatLsn(server.hi, server.lo), timestamp, shouldRespond)
201+
if (shouldRespond) {
202+
this.acknowledge(this._lastLsn, true)
203+
}
204+
}
205+
}
206+
207+
async _acknowledge(lsn) {
208+
if (!this.config.acknowledge.auto) return
209+
this.emit('acknowledge', lsn)
210+
await this.acknowledge(lsn)
211+
}
212+
213+
_enqueue(lsn, data) {
214+
this._lastLsn = lsn
215+
this._updateLastReceive(parseLsn(lsn))
216+
if (!this.config.flowControl.enabled) {
217+
this.emit('data', lsn, data)
218+
this._acknowledge(lsn)
219+
return
220+
}
221+
this._messageQueue.push({ lsn, data })
222+
this._processQueue()
223+
}
224+
225+
/**
226+
* Process messages in the queue sequentially with backpressure support.
227+
* Pauses the stream while processing and resumes when the queue is empty.
228+
*/
229+
_processQueue() {
230+
if (this._processing || this._stop) return
231+
this._processing = true
232+
233+
// Pause the stream to prevent buffer overflow
234+
if (this._connection && this._connection.stream && this._connection.stream.pause) {
235+
this._connection.stream.pause()
236+
}
237+
238+
const processNext = async () => {
239+
while (this._messageQueue.length > 0 && !this._stop) {
240+
const message = this._messageQueue.shift()
241+
try {
242+
// Wait for all listeners to complete (supports async handlers)
243+
await this._emitAsync('data', message.lsn, message.data)
244+
await this._acknowledge(message.lsn)
245+
} catch (e) {
246+
this.emit('error', e)
247+
}
248+
}
249+
this._processing = false
250+
251+
// Resume the stream when queue is empty
252+
if (!this._stop && this._connection && this._connection.stream && this._connection.stream.resume) {
253+
this._connection.stream.resume()
254+
}
255+
}
256+
processNext()
257+
}
258+
259+
async _emitAsync(event, ...args) {
260+
const listeners = this.listeners(event)
261+
for (const listener of listeners) {
262+
await listener(...args)
263+
}
264+
}
265+
266+
_checkStandbyStatus(enable) {
267+
if (this._checkStandbyStatusTimer) {
268+
clearInterval(this._checkStandbyStatusTimer)
269+
this._checkStandbyStatusTimer = null
270+
}
271+
if (this.config.acknowledge.timeoutSeconds > 0 && enable) {
272+
this._checkStandbyStatusTimer = setInterval(async () => {
273+
if (this._stop) return
274+
if (
275+
this._lastLsn &&
276+
Date.now() - this._lastStandbyStatusUpdatedTime > this.config.acknowledge.timeoutSeconds * 1000
277+
) {
278+
await this.acknowledge(this._lastLsn)
279+
}
280+
}, 1000)
281+
}
282+
}
283+
284+
_updateLastReceive(parts) {
285+
this._lastReceive = { hi: parts.hi >>> 0, lo: parts.lo >>> 0 }
286+
this._lastLsn = formatLsn(this._lastReceive.hi, this._lastReceive.lo)
287+
}
288+
}
289+
290+
function writeUInt64LE(buffer, offset, parts) {
291+
buffer.writeUInt32LE(parts.lo >>> 0, offset)
292+
buffer.writeUInt32LE(parts.hi >>> 0, offset + 4)
293+
return offset + 8
294+
}
295+
296+
function parseLsn(lsn) {
297+
if (!lsn) return { hi: 0, lo: 0 }
298+
const parts = String(lsn).split('/')
299+
if (parts.length !== 2) return { hi: 0, lo: 0 }
300+
const hi = parseInt(parts[0], 16)
301+
const lo = parseInt(parts[1], 16)
302+
return { hi: hi >>> 0, lo: lo >>> 0 }
303+
}
304+
305+
function pad8(value) {
306+
const hex = (value >>> 0).toString(16).toUpperCase()
307+
return ('00000000' + hex).slice(-8)
308+
}
309+
310+
function formatLsn(hi, lo) {
311+
return `${pad8(hi)}/${pad8(lo)}`
312+
}
313+
314+
function compareLsn(a, b) {
315+
if (a.hi !== b.hi) return a.hi > b.hi ? 1 : -1
316+
if (a.lo === b.lo) return 0
317+
return a.lo > b.lo ? 1 : -1
318+
}
319+
320+
function serverClockToTimestamp(parts) {
321+
const micros = uint64ToNumberOrString(parts)
322+
if (typeof micros !== 'number') return null
323+
return Math.floor(micros / 1000) + POSTGRES_EPOCH_MS
324+
}
325+
326+
function uint64ToNumberOrString(parts) {
327+
const hi = parts.hi >>> 0
328+
const lo = parts.lo >>> 0
329+
if (hi <= 0x1fffff) {
330+
return hi * 0x100000000 + lo
331+
}
332+
return `0x${pad8(hi)}${pad8(lo)}`
333+
}
334+
335+
module.exports = LogicalReplicationService

0 commit comments

Comments
 (0)