@vlasky/zongji 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,13 @@
1
1
  import { EventEmitter } from 'events';
2
2
  import initBinlogPacketClass from '../packet/binlog.js';
3
3
  import { Parser } from '../reader.js';
4
+ import { GtidSet } from '../gtid_set.js';
4
5
 
5
6
  const BINLOG_DUMP_COMMAND = 0x12;
7
+ // Auto-position dump: the server locates the starting binlog itself and
8
+ // skips transactions already in the supplied GTID set (requires
9
+ // gtid_mode=ON server-side)
10
+ const BINLOG_DUMP_GTID_COMMAND = 0x1e;
6
11
 
7
12
  class Command extends EventEmitter {
8
13
  constructor() {
@@ -75,6 +80,16 @@ class SimplePacket {
75
80
  this.writeInt8(0);
76
81
  }
77
82
 
83
+ writeUInt64(value) {
84
+ this.buffer.writeBigUInt64LE(BigInt(value), this.offset);
85
+ this.offset += 8;
86
+ }
87
+
88
+ writeBuffer(value) {
89
+ value.copy(this.buffer, this.offset);
90
+ this.offset += value.length;
91
+ }
92
+
78
93
  writeHeader(sequenceId) {
79
94
  const offset = this.offset;
80
95
  this.offset = 0;
@@ -95,20 +110,56 @@ export default function initBinlogClass(zongji) {
95
110
 
96
111
  start(packet, connection) {
97
112
  const options = zongji.get([
98
- 'serverId', 'position', 'filename', 'nonBlock',
113
+ 'serverId', 'position', 'filename', 'nonBlock', 'gtidSet',
114
+ 'requestAnnotateRows',
99
115
  ]);
100
- const binlogPos = options.position || 4;
101
116
  const serverId = options.serverId || 1;
102
- const flags = options.nonBlock ? 1 : 0;
103
- const filename = options.filename || '';
104
-
105
- const length = 16 + Buffer.byteLength(filename, 'utf8');
106
- const outPacket = new SimplePacket(length);
107
- outPacket.writeInt8(BINLOG_DUMP_COMMAND);
108
- outPacket.writeInt32(binlogPos);
109
- outPacket.writeInt16(flags);
110
- outPacket.writeInt32(serverId);
111
- outPacket.writeNullTerminatedString(filename, 'utf8');
117
+ // Only BINLOG_DUMP_NON_BLOCK (0x01) exists server-side; the
118
+ // BINLOG_THROUGH_* flags from the retired protocol docs were never
119
+ // read by any MySQL server, and 0x02 now means "send v2 heartbeats"
120
+ let flags = options.nonBlock ? 1 : 0;
121
+ // MariaDB reuses 0x02 as BINLOG_SEND_ANNOTATE_ROWS_EVENT: without
122
+ // it a capability>=2 client never receives ANNOTATE_ROWS events.
123
+ // Never set it for MySQL, where the same bit requests v2 heartbeats
124
+ if (zongji.isMariaDb && options.requestAnnotateRows) {
125
+ flags |= 2;
126
+ }
127
+
128
+ let outPacket;
129
+ // MariaDB has no COM_BINLOG_DUMP_GTID (it answers 0x1e with
130
+ // ER_UNKNOWN_COM_ERROR): its GTID resume state was already sent via
131
+ // SET @slave_connect_state, and the plain dump below then has its
132
+ // filename/position ignored by the server
133
+ if (options.gtidSet != null && !zongji.isMariaDb) {
134
+ // COM_BINLOG_DUMP_GTID: empty filename and position 4 tell the
135
+ // server to locate the first binlog file not fully contained in
136
+ // the set; transactions in the set are skipped server-side.
137
+ // Layout (all integers little-endian): flags(2), serverId(4),
138
+ // filename length(4) + filename, position(8), set length(4) +
139
+ // encoded set. An empty set encodes as eight zero bytes and
140
+ // requests the server's complete history.
141
+ const gtidData = GtidSet.parse(options.gtidSet).encode();
142
+ outPacket = new SimplePacket(4 + 1 + 2 + 4 + 4 + 8 + 4 +
143
+ gtidData.length);
144
+ outPacket.writeInt8(BINLOG_DUMP_GTID_COMMAND);
145
+ outPacket.writeInt16(flags);
146
+ outPacket.writeInt32(serverId);
147
+ outPacket.writeInt32(0); // empty filename
148
+ outPacket.writeUInt64(4);
149
+ outPacket.writeInt32(gtidData.length);
150
+ outPacket.writeBuffer(gtidData);
151
+ } else {
152
+ const binlogPos = options.position || 4;
153
+ const filename = options.filename || '';
154
+
155
+ outPacket = new SimplePacket(
156
+ 16 + Buffer.byteLength(filename, 'utf8'));
157
+ outPacket.writeInt8(BINLOG_DUMP_COMMAND);
158
+ outPacket.writeInt32(binlogPos);
159
+ outPacket.writeInt16(flags);
160
+ outPacket.writeInt32(serverId);
161
+ outPacket.writeNullTerminatedString(filename, 'utf8');
162
+ }
112
163
  connection.writePacket(outPacket);
113
164
  return Binlog.prototype.binlogData;
114
165
  }
@@ -141,36 +192,140 @@ export default function initBinlogClass(zongji) {
141
192
  let event;
142
193
  let error;
143
194
 
195
+ // Guards resume-pair bookkeeping below against a stale Command:
196
+ // after stop()+start() a packet buffered on the old connection
197
+ // must not mutate the new stream's options
198
+ const isCurrent = !zongji.stopped && zongji.connection === connection;
199
+
144
200
  // Track the current transaction GTID before event filtering, so
145
201
  // subsequent events can carry event.gtid even when 'gtid' events
146
- // themselves are excluded by includeEvents
147
- const isGtidEvent =
148
- eventName === 'gtid' || eventName === 'anonymousgtid';
149
- if (isGtidEvent) {
202
+ // themselves are excluded by includeEvents. When executed-set
203
+ // tracking is active, commit markers and Previous_gtids also need
204
+ // parsing before filtering. Rotate events are parsed pre-filter
205
+ // too: the (filename, position) resume pair must stay coherent
206
+ // even when 'rotate' is excluded by includeEvents, otherwise
207
+ // later filtered events would advance the position into the new
208
+ // file while the filename still named the old one.
209
+ const isGtidEvent = eventName === 'gtid' ||
210
+ eventName === 'anonymousgtid' || eventName === 'mariadbgtid';
211
+ const isRotate = eventName === 'rotate';
212
+ // Commit markers are always parsed pre-filter: they detach the
213
+ // in-flight GTID from subsequent events and end the resume
214
+ // position freeze below, whatever includeEvents says
215
+ const isCommitCandidate = eventName === 'xid' ||
216
+ eventName === 'query' || eventName === 'xaprepare';
217
+ const isTrackedEvent = isGtidEvent || isRotate ||
218
+ isCommitCandidate ||
219
+ ((zongji._executedGtids !== null || zongji._seedGtidsFromStream) &&
220
+ (eventName === 'previousgtids' ||
221
+ eventName === 'mariadbgtidlist'));
222
+
223
+ if (isCurrent && eventName === 'tablemap') {
224
+ // A multi-table statement writes all its TableMaps before
225
+ // any row event, so no position between the first TableMap
226
+ // and the commit marker is safe to resume from: with the
227
+ // later TableMaps unseen, their rows would be silently
228
+ // dropped. Freeze the resume position here; the commit
229
+ // marker (or, defensively, the next transaction's GTID
230
+ // event or a rotate, once parsed) unfreezes it, so a
231
+ // persisted position always replays whole transactions
232
+ // (at-least-once).
233
+ zongji._positionFrozen = true;
234
+ }
235
+
236
+ // Set when this event definitively closes the current transaction;
237
+ // its GTID is detached only after the event itself is delivered
238
+ let closesTransaction = false;
239
+ if (isTrackedEvent) {
150
240
  try {
151
241
  event = binlogPacket.getEvent();
152
- // Anonymous transactions have no usable GTID; do not let a
153
- // previous transaction's GTID leak onto their events
154
- zongji._currentGtid =
155
- eventName === 'gtid' ? event.gtid : undefined;
242
+ // All GTID/position bookkeeping is gated on isCurrent: after
243
+ // stop()+start() a stale packet must not fold a previous
244
+ // stream's GTID (possibly of the other server flavour) into
245
+ // the new tracker state
246
+ if (isCurrent) {
247
+ if (isGtidEvent || isRotate) {
248
+ // A successfully parsed group start or file boundary
249
+ // means any open transaction ended; a parse failure
250
+ // leaves the position freeze in place (stale positions
251
+ // are recoverable, premature advances drop rows)
252
+ zongji._positionFrozen = false;
253
+ }
254
+ if (isGtidEvent) {
255
+ // Anonymous transactions have no usable GTID; do not let
256
+ // a previous transaction's GTID leak onto their events
257
+ zongji._currentGtid =
258
+ eventName === 'anonymousgtid' ? undefined : event.gtid;
259
+ }
260
+ if (isRotate) {
261
+ // The payload position is the first event of the NEW
262
+ // file: the only value coherent with binlogName (the
263
+ // header nextPosition refers to the OLD file, 0 for the
264
+ // artificial rotate at dump start)
265
+ zongji.options.filename = event.binlogName;
266
+ zongji.options.position = event.position;
267
+ }
268
+ closesTransaction =
269
+ zongji._trackGtidProgress(eventName, event);
270
+ if (closesTransaction) {
271
+ // Unfrozen before the position updates below, so the
272
+ // commit event's own end position is adopted
273
+ zongji._positionFrozen = false;
274
+ }
275
+ }
156
276
  } catch (err) {
157
- zongji._currentGtid = undefined;
277
+ // A GTID that cannot be parsed must not label other events;
278
+ // an unparseable commit marker may have ended the current
279
+ // transaction, so its GTID must not linger either. The
280
+ // position freeze deliberately stays: the failed event might
281
+ // equally be mid-transaction, and a frozen position is safe
282
+ // (extra redelivery) where a premature advance drops rows
283
+ if (isCurrent && (isGtidEvent || isCommitCandidate)) {
284
+ zongji._currentGtid = undefined;
285
+ }
158
286
  error = err;
159
287
  }
160
288
  }
161
289
 
162
- if (zongji._skipEvent(eventName)) {
290
+ // A parse failure in a tracked event corrupts GTID/position
291
+ // bookkeeping, so it must surface even when the event itself is
292
+ // filtered out
293
+ if (zongji._skipEvent(eventName) && !error) {
294
+ // Filtered events must still advance the resume position, under
295
+ // the same rules as delivered ones (never inside a frozen
296
+ // transaction span, never from a rotate header, never adopting
297
+ // MariaDB's zero end_log_pos). On MariaDB only commits and
298
+ // standalone events carry a real end position, so filtering out
299
+ // 'xid'/'query' would otherwise freeze options.position at the
300
+ // start point.
301
+ if (isCurrent && eventName !== 'tablemap' && !isRotate &&
302
+ !zongji._positionFrozen && binlogPacket.nextPosition > 0) {
303
+ zongji.options.position = binlogPacket.nextPosition;
304
+ }
305
+ if (closesTransaction) {
306
+ zongji._currentGtid = undefined;
307
+ }
163
308
  return Binlog.prototype.binlogData;
164
309
  }
165
310
 
166
- if (!isGtidEvent && !error) {
311
+ if (!isTrackedEvent && !error) {
167
312
  try {
168
313
  event = binlogPacket.getEvent();
169
314
  } catch (err) {
170
315
  error = err;
171
316
  }
172
317
  }
173
- this._callback.call(this, error, event);
318
+ // The commit event itself still carries the transaction's GTID;
319
+ // whatever follows before the next GTID event belongs to no
320
+ // transaction. The finally preserves that invariant even if a
321
+ // 'binlog' handler throws while the commit event is delivered
322
+ try {
323
+ this._callback.call(this, error, event);
324
+ } finally {
325
+ if (closesTransaction) {
326
+ zongji._currentGtid = undefined;
327
+ }
328
+ }
174
329
  }
175
330
 
176
331
  return Binlog.prototype.binlogData;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@vlasky/zongji",
3
- "version": "0.7.0",
4
- "description": "MySQL binlog-based change data capture (CDC) for Node.js",
3
+ "version": "0.8.0",
4
+ "description": "MySQL and MariaDB binlog-based change data capture (CDC) for Node.js",
5
5
  "type": "module",
6
6
  "main": "index.js",
7
7
  "types": "index.d.ts",
@@ -24,7 +24,10 @@
24
24
  },
25
25
  "keywords": [
26
26
  "mysql",
27
- "binlog"
27
+ "mariadb",
28
+ "binlog",
29
+ "cdc",
30
+ "replication"
28
31
  ],
29
32
  "author": "Vlad Lasky <github@vladlasky.com> (https://github.com/vlasky)",
30
33
  "contributors": [