@vlasky/zongji 0.7.1 → 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.
package/index.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import mysql from 'mysql2';
2
2
  import { EventEmitter } from 'events';
3
3
  import initBinlogClass from './lib/sequence/binlog.js';
4
+ import { GtidSet } from './lib/gtid_set.js';
5
+ import { MariadbGtidPosition } from './lib/mariadb_gtid.js';
4
6
 
5
7
  const ConnectionConfigMap = {
6
8
  'Connection': obj => obj.config,
@@ -36,11 +38,18 @@ class ZongJi extends EventEmitter {
36
38
  this.tableMap = {};
37
39
  this._warnedUnsupported = new Set();
38
40
  this._currentGtid = undefined;
41
+ this._executedGtids = null;
42
+ this._pendingGtid = undefined;
43
+ this._positionFrozen = false;
44
+ this._seedGtidsFromStream = false;
45
+ this._startFromGtids = false;
39
46
  this.ready = false;
40
47
  this.stopped = false;
41
48
  this._starting = false;
42
49
  this._startEpoch = 0;
43
50
  this.useChecksum = false;
51
+ this.serverVersion = undefined;
52
+ this.isMariaDb = false;
44
53
 
45
54
  this._dsn = dsn;
46
55
  this.ctrlConnection = null;
@@ -192,6 +201,110 @@ class ZongJi extends EventEmitter {
192
201
  });
193
202
  }
194
203
 
204
+ // The transactions this instance knows to be processed: the start()
205
+ // seed plus every transaction whose commit has been observed. Persist
206
+ // it and pass to start({ gtidSet }) to resume, including on a different
207
+ // server in the same replication topology. A MySQL GTID set
208
+ // ('uuid:1-5,...') or a MariaDB GTID position ('0-1-1234,...')
209
+ // depending on the connected server. Undefined when no exact seed was
210
+ // available (see start()).
211
+ get gtidSet() {
212
+ return this._executedGtids ? this._executedGtids.toString() : undefined;
213
+ }
214
+
215
+ // The GTID of the transaction whose events are currently being
216
+ // delivered (what event.gtid is for them); undefined between
217
+ // transactions. Only coherent when read synchronously inside a
218
+ // 'binlog' handler: checkpointing code should use event.gtid.
219
+ get lastGtid() {
220
+ return this._currentGtid;
221
+ }
222
+
223
+ // Called from the packet layer (before event filtering) for
224
+ // GTID-relevant events. A transaction's GTID enters the executed set
225
+ // only once its commit marker has been seen (Xid, or a Query event
226
+ // other than BEGIN, e.g. DDL or COMMIT), so a persisted zongji.gtidSet
227
+ // never claims a transaction whose row events were still in flight.
228
+ // Returns true when the event definitively closes the current
229
+ // transaction, so the caller can stop attaching its GTID to
230
+ // subsequent events.
231
+ _trackGtidProgress(eventName, event) {
232
+ const fold = () => {
233
+ if (this._pendingGtid !== undefined && this._executedGtids) {
234
+ this._executedGtids.add(this._pendingGtid);
235
+ }
236
+ this._pendingGtid = undefined;
237
+ };
238
+
239
+ switch (eventName) {
240
+ case 'gtid':
241
+ // A new transaction implies the previous one committed, so this
242
+ // also covers commit markers hidden by event filtering
243
+ fold();
244
+ this._pendingGtid = event.gtid;
245
+ break;
246
+ case 'anonymousgtid':
247
+ fold();
248
+ break;
249
+ case 'xid':
250
+ fold();
251
+ return true;
252
+ case 'xaprepare':
253
+ // Ends the group's event delivery on both flavours, but must NOT
254
+ // fold: the prepared transaction commits later under the XA
255
+ // COMMIT statement's own GTID, and the pending GTID folds when
256
+ // that (or any next) GTID event arrives
257
+ return true;
258
+ case 'query': {
259
+ // Fold only on definite commit markers. Anything else (BEGIN,
260
+ // XA START/END, DDL, SAVEPOINT, ...) must not fold: claiming a
261
+ // transaction early risks losing its remaining events on resume,
262
+ // whereas not folding merely delays the checkpoint until the
263
+ // next transaction's GTID arrives (at-least-once redelivery).
264
+ const query = event.query.trim().toUpperCase();
265
+ if (query === 'COMMIT' || query === 'ROLLBACK' ||
266
+ query.startsWith('XA COMMIT') ||
267
+ query.startsWith('XA ROLLBACK')) {
268
+ fold();
269
+ return true;
270
+ }
271
+ break;
272
+ }
273
+ case 'previousgtids':
274
+ // When dumping from the start of a binlog file, its Previous_gtids
275
+ // event is the exact "everything before this point" seed
276
+ if (this._seedGtidsFromStream && this._executedGtids === null) {
277
+ try {
278
+ this._executedGtids = GtidSet.parse(event.gtidSet);
279
+ } catch {
280
+ // Leave unseeded; gtidSet stays undefined
281
+ }
282
+ }
283
+ break;
284
+ case 'mariadbgtid':
285
+ // As with 'gtid': a new event group implies the previous one
286
+ // completed. This also closes standalone groups (DDL), which have
287
+ // no commit marker at all
288
+ fold();
289
+ this._pendingGtid = {
290
+ domainId: event.domainId,
291
+ serverId: event.serverId,
292
+ seqNo: event.seqNo,
293
+ };
294
+ break;
295
+ case 'mariadbgtidlist':
296
+ // At the start of a binlog file this is the exact "everything
297
+ // before this point" seed, MariaDB's Previous_gtids analogue.
298
+ // Fake mid-stream lists on GTID connects never seed: the position
299
+ // is already non-null then
300
+ if (this._seedGtidsFromStream && this._executedGtids === null) {
301
+ this._executedGtids = MariadbGtidPosition.fromGtidList(event.gtids);
302
+ }
303
+ break;
304
+ }
305
+ return false;
306
+ }
307
+
195
308
  _findBinlogEnd(next) {
196
309
  this.ctrlConnection.query('SHOW BINARY LOGS', (err, rows) => {
197
310
  if (err) {
@@ -264,14 +377,18 @@ class ZongJi extends EventEmitter {
264
377
  filename,
265
378
  position,
266
379
  startAtEnd,
380
+ gtidSet,
267
381
  nonBlock,
382
+ requestAnnotateRows,
268
383
  } = {}) {
269
384
  this.options = {
270
385
  serverId,
271
386
  filename,
272
387
  position,
273
388
  startAtEnd,
389
+ gtidSet,
274
390
  nonBlock,
391
+ requestAnnotateRows,
275
392
  };
276
393
  }
277
394
 
@@ -361,8 +478,38 @@ class ZongJi extends EventEmitter {
361
478
  const epoch = this._startEpoch;
362
479
 
363
480
  // A resumed stream must not attribute early events to a GTID seen
364
- // before the restart
481
+ // before the restart, nor inherit a position freeze from a
482
+ // transaction the previous stream was in the middle of
365
483
  this._currentGtid = undefined;
484
+ this._pendingGtid = undefined;
485
+ this._positionFrozen = false;
486
+
487
+ // Executed-GTID-set tracking (drives the zongji.gtidSet checkpoint).
488
+ // Exact seeds: an explicit start set, the server's gtid_executed for
489
+ // startAtEnd (fetched below), or the stream's first Previous_gtids
490
+ // event when dumping from the start of a binlog file. An arbitrary
491
+ // mid-file file+position start has no exact seed, so gtidSet stays
492
+ // undefined there.
493
+ this._executedGtids = null;
494
+ this._seedGtidsFromStream = false;
495
+ this._startFromGtids = options.gtidSet != null;
496
+ if (this._startFromGtids) {
497
+ // The flavour is sniffed from the format (a MySQL set always
498
+ // contains ':', a MariaDB position never does; '' is valid for
499
+ // either) and verified against the server after detection
500
+ try {
501
+ this._executedGtids = String(options.gtidSet).includes(':')
502
+ ? GtidSet.parse(options.gtidSet)
503
+ : MariadbGtidPosition.parse(options.gtidSet);
504
+ } catch (err) {
505
+ this._starting = false;
506
+ this.emit('error', err);
507
+ return;
508
+ }
509
+ } else if (!options.startAtEnd &&
510
+ (options.position === undefined || options.position <= 4)) {
511
+ this._seedGtidsFromStream = true;
512
+ }
366
513
 
367
514
  this.stopped = false;
368
515
 
@@ -373,6 +520,28 @@ class ZongJi extends EventEmitter {
373
520
  this._options(options);
374
521
  this._filters(options);
375
522
 
523
+ // Server flavour steers the rest of the preamble: MariaDB has no
524
+ // gtid_mode/gtid_executed (its GTIDs are domain-server-sequence
525
+ // watermarks, always on) and needs its own dump-request dialect.
526
+ // SELECT VERSION() is used rather than the handshake packet because
527
+ // MariaDB prefixes the handshake version with "5.5.5-" for old-client
528
+ // compatibility, while VERSION() always reports e.g.
529
+ // "11.8.8-MariaDB-ubu2404-log".
530
+ const detectServer = (resolve, reject) => {
531
+ this.ctrlConnection.query('SELECT VERSION() AS version', (err, rows) => {
532
+ // Never overwrite state belonging to a newer start()
533
+ if (epoch !== this._startEpoch) {
534
+ return resolve();
535
+ }
536
+ if (err) {
537
+ return reject(err);
538
+ }
539
+ this.serverVersion = rows[0].version;
540
+ this.isMariaDb = /mariadb/i.test(this.serverVersion);
541
+ resolve();
542
+ });
543
+ };
544
+
376
545
  const testChecksum = (resolve, reject) => {
377
546
  this._isChecksumEnabled((err, checksumEnabled) => {
378
547
  if (err) {
@@ -388,6 +557,10 @@ class ZongJi extends EventEmitter {
388
557
 
389
558
  const findBinlogEnd = (resolve, reject) => {
390
559
  this._findBinlogEnd((err, result) => {
560
+ // As above: never mutate options for a superseded start()
561
+ if (epoch !== this._startEpoch) {
562
+ return resolve();
563
+ }
391
564
  if (err) {
392
565
  return reject(err);
393
566
  }
@@ -405,6 +578,30 @@ class ZongJi extends EventEmitter {
405
578
  });
406
579
  };
407
580
 
581
+ // For startAtEnd the server's own executed set is the exact seed for
582
+ // zongji.gtidSet ("everything up to now"); transactions racing between
583
+ // this query and the dump start are streamed and merge idempotently
584
+ const seedGtidsFromServer = (resolve, reject) => {
585
+ this.ctrlConnection.query(
586
+ 'SELECT @@GLOBAL.gtid_executed AS gtidExecuted', (err, rows) => {
587
+ // A stale seed must not overwrite state belonging to a newer
588
+ // start() that superseded this one while the query was in flight
589
+ if (epoch !== this._startEpoch) {
590
+ return resolve();
591
+ }
592
+ if (err) {
593
+ return reject(err);
594
+ }
595
+ try {
596
+ this._executedGtids =
597
+ GtidSet.parse(rows[0].gtidExecuted.replace(/\s/g, ''));
598
+ } catch (parseErr) {
599
+ return reject(parseErr);
600
+ }
601
+ resolve();
602
+ });
603
+ };
604
+
408
605
  // Attach the current transaction's GTID (tracked at the packet layer,
409
606
  // even when 'gtid' events are filtered out). Gtid/AnonymousGtid events
410
607
  // keep their own parsed value.
@@ -428,6 +625,24 @@ class ZongJi extends EventEmitter {
428
625
 
429
626
  switch (event.getTypeName()) {
430
627
  case 'TableMap': {
628
+ // On MariaDB, classic temporal codes may hide 5.3 "hires"
629
+ // columns that no binlog metadata (FULL included) can reveal;
630
+ // such tables must take the INFORMATION_SCHEMA path below
631
+ if (event.hasSelfDescribingMetadata() &&
632
+ !(this.isMariaDb && event.hasAmbiguousTemporalColumns())) {
633
+ // MySQL 8.0+ with binlog_row_metadata=FULL: the event itself
634
+ // carries complete column metadata as of binlog write time, so
635
+ // no INFORMATION_SCHEMA round-trip (and no connection pause) is
636
+ // needed. Rebuilt on every TableMap event, so ALTER TABLE never
637
+ // leaves stale columns behind.
638
+ this.tableMap[event.tableId] = {
639
+ columnSchemas: event.buildColumnSchemas(),
640
+ parentSchema: event.schemaName,
641
+ tableName: event.tableName,
642
+ };
643
+ event.updateColumnInfo();
644
+ break;
645
+ }
431
646
  const tableMap = this.tableMap[event.tableId];
432
647
  if (!tableMap || tableMap.tableName !== event.tableName || tableMap.columns.length !== event.columnCount) {
433
648
  if (!this.connection) return;
@@ -453,42 +668,144 @@ class ZongJi extends EventEmitter {
453
668
  }
454
669
  break;
455
670
  }
456
- case 'Rotate':
457
- // The payload position is the first event of the NEW file: the
458
- // only value coherent with binlogName. The header nextPosition
459
- // refers to the OLD file (0 for the artificial rotate at dump
460
- // start), so rotates are excluded from the generic update below;
461
- // persisting (new filename, old-file offset) would corrupt the
462
- // resume point until the next non-rotate event repaired it.
463
- this.options.filename = event.binlogName;
464
- this.options.position = event.position;
465
- break;
466
671
  }
467
- // Never advance the resume position past a TableMap: a consumer
468
- // persisting options.position could otherwise resume in the gap
469
- // between a TableMap and its row events, and with no cached table
470
- // metadata those rows would be silently dropped. Holding position
471
- // back means the TableMap is replayed before its rows on resume;
472
- // rows already seen may be re-emitted (at-least-once), which is
473
- // recoverable where dropping is not. A narrower window remains for
474
- // multi-table statements (all TableMaps precede all row events, so
475
- // emitting the first table's rows advances past the later
476
- // TableMaps); the complete fix is advancing only at transaction
477
- // boundaries, tracked pre-filter.
672
+ // Rotate events update the (filename, position) resume pair at the
673
+ // packet layer (lib/sequence/binlog.js), before event filtering, so
674
+ // the pair stays coherent even when 'rotate' is excluded by
675
+ // includeEvents. They remain excluded from the generic update below:
676
+ // the header nextPosition refers to the OLD file (0 for the
677
+ // artificial rotate at dump start), and persisting (new filename,
678
+ // old-file offset) would corrupt the resume point.
679
+ // Never advance the resume position into a transaction's
680
+ // TableMap-to-commit span: a consumer persisting options.position
681
+ // could otherwise resume between a TableMap and row events that
682
+ // depend on it, and with no cached table metadata those rows would
683
+ // be silently dropped (a multi-table statement writes all its
684
+ // TableMaps before any row event, so even a position after one
685
+ // table's rows is inside the danger zone). The packet layer
686
+ // freezes the position at the first TableMap and unfreezes it at
687
+ // the commit marker; on resume the whole transaction is replayed,
688
+ // so rows already seen may be re-emitted (at-least-once), which is
689
+ // recoverable where dropping is not.
690
+ // MariaDB writes end_log_pos=0 on every event inside a transaction
691
+ // (TableMap, row events); only commits and standalone events carry a
692
+ // real position. Adopting a zero would corrupt the resume point.
478
693
  const typeName = event.getTypeName();
479
- if (typeName !== 'TableMap' && typeName !== 'Rotate') {
694
+ if (typeName !== 'TableMap' && typeName !== 'Rotate' &&
695
+ !this._positionFrozen && event.nextPosition > 0) {
480
696
  this.options.position = event.nextPosition;
481
697
  }
482
698
  this.emit('binlog', attachGtid(event));
483
699
  };
484
700
 
485
- let promises = [new Promise(testChecksum)];
701
+ // Announce MariaDB slave capability 4 ("understands GTID events") on
702
+ // the binlog connection before the dump command is enqueued. Without
703
+ // it the server rewrites its own events for pre-GTID clients: GTID
704
+ // events become literal BEGIN Query events and standalone
705
+ // GTID/checkpoint events become dummy Query events, so no GTID
706
+ // information ever reaches the stream.
707
+ const setMariaDbCapability = (resolve, reject) => {
708
+ this.connection.query('SET @mariadb_slave_capability=4', (err) => {
709
+ if (epoch !== this._startEpoch) {
710
+ return resolve();
711
+ }
712
+ if (err) {
713
+ return reject(err);
714
+ }
715
+ resolve();
716
+ });
717
+ };
486
718
 
487
- if (this.options.startAtEnd) {
488
- promises.push(new Promise(findBinlogEnd));
489
- }
719
+ // MariaDB has no COM_BINLOG_DUMP_GTID: the resume position travels in
720
+ // the @slave_connect_state session variable and the dump command is a
721
+ // plain COM_BINLOG_DUMP whose filename/position the server then
722
+ // ignores. An empty position means "from the oldest binlog available"
723
+ const setMariaDbConnectState = (resolve, reject) => {
724
+ // Validated as digits/dashes/commas by the parser, so safe to inline
725
+ const position = this._executedGtids.toString();
726
+ this.connection.query(
727
+ `SET @slave_connect_state='${position}'`, (err) => {
728
+ if (epoch !== this._startEpoch) {
729
+ return resolve();
730
+ }
731
+ if (err) {
732
+ return reject(err);
733
+ }
734
+ resolve();
735
+ });
736
+ };
490
737
 
491
- Promise.all(promises)
738
+ // MariaDB's analogue of the gtid_executed seed below: the last GTID
739
+ // per domain in the binlog or applied by a slave thread, whichever is
740
+ // most recent (matches what a real replica would send on connect)
741
+ const seedMariaDbGtidsFromServer = (resolve, reject) => {
742
+ this.ctrlConnection.query(
743
+ 'SELECT @@GLOBAL.gtid_current_pos AS gtidPos', (err, rows) => {
744
+ if (epoch !== this._startEpoch) {
745
+ return resolve();
746
+ }
747
+ if (err) {
748
+ return reject(err);
749
+ }
750
+ try {
751
+ this._executedGtids =
752
+ MariadbGtidPosition.parse(rows[0].gtidPos.trim());
753
+ } catch (parseErr) {
754
+ return reject(parseErr);
755
+ }
756
+ resolve();
757
+ });
758
+ };
759
+
760
+ // Detection must complete before the rest of the preamble because the
761
+ // seed query below is MySQL-only
762
+ new Promise(detectServer)
763
+ .then(() => {
764
+ if (this._startFromGtids) {
765
+ // The start set was parsed by format before the server flavour
766
+ // was known; an empty position is valid for either flavour,
767
+ // anything else must match the connected server
768
+ const isMariaDbPosition =
769
+ this._executedGtids instanceof MariadbGtidPosition;
770
+ if (isMariaDbPosition !== this.isMariaDb) {
771
+ if (this._executedGtids.isEmpty()) {
772
+ this._executedGtids = this.isMariaDb
773
+ ? new MariadbGtidPosition()
774
+ : new GtidSet();
775
+ } else if (this.isMariaDb) {
776
+ throw new Error(
777
+ 'gtidSet is a MySQL GTID set but the server is MariaDB; ' +
778
+ 'pass a MariaDB GTID position instead ' +
779
+ '(domain-server-sequence, e.g. \'0-1-1234\')');
780
+ } else {
781
+ throw new Error(
782
+ 'gtidSet is a MariaDB GTID position but the server is ' +
783
+ 'MySQL; pass a MySQL GTID set instead ' +
784
+ '(e.g. \'uuid:1-5\')');
785
+ }
786
+ }
787
+ }
788
+
789
+ let promises = [new Promise(testChecksum)];
790
+
791
+ if (this.isMariaDb) {
792
+ promises.push(new Promise(setMariaDbCapability));
793
+ if (this._startFromGtids) {
794
+ promises.push(new Promise(setMariaDbConnectState));
795
+ }
796
+ }
797
+
798
+ if (this.options.startAtEnd) {
799
+ promises.push(new Promise(findBinlogEnd));
800
+ if (this._executedGtids === null) {
801
+ promises.push(new Promise(this.isMariaDb
802
+ ? seedMariaDbGtidsFromServer
803
+ : seedGtidsFromServer));
804
+ }
805
+ }
806
+
807
+ return Promise.all(promises);
808
+ })
492
809
  .then(() => {
493
810
  // Abort if a newer start() superseded this one (after a stop())
494
811
  // while promises were pending
@@ -667,3 +984,8 @@ class ZongJi extends EventEmitter {
667
984
  }
668
985
 
669
986
  export default ZongJi;
987
+ // The GTID set model behind zongji.gtidSet, exported so consumers can
988
+ // parse persisted sets and test membership (e.g. "was this event.gtid
989
+ // already covered by my snapshot?") without their own parser. Handles
990
+ // tagged GTIDs (MySQL 8.3+).
991
+ export { GtidSet };