@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.
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
 
@@ -343,12 +460,17 @@ class ZongJi extends EventEmitter {
343
460
  return;
344
461
  }
345
462
 
346
- // A duplicate start() while one is already initialising is ignored;
347
- // the first call completes. But if stop() intervened, this start()
348
- // must proceed as a restart: the epoch below makes the stale
349
- // initialisation chain abort, so exactly one binlog dump command is
350
- // ever enqueued.
463
+ // A duplicate start() while one is already initialising is ignored -
464
+ // the first call completes - but its filters are applied, exactly as
465
+ // in the already-running branch above: filters are snapshotted, and
466
+ // re-calling start() is the documented way to update them, so updates
467
+ // made during the initialisation window must not be lost. Stream
468
+ // options (filename/position/serverId) still come from the first
469
+ // call. If stop() intervened, this start() instead proceeds as a
470
+ // restart: the epoch below makes the stale initialisation chain
471
+ // abort, so exactly one binlog dump command is ever enqueued.
351
472
  if (this._starting && !this.stopped) {
473
+ this._filters(options);
352
474
  return;
353
475
  }
354
476
  this._starting = true;
@@ -356,8 +478,38 @@ class ZongJi extends EventEmitter {
356
478
  const epoch = this._startEpoch;
357
479
 
358
480
  // A resumed stream must not attribute early events to a GTID seen
359
- // before the restart
481
+ // before the restart, nor inherit a position freeze from a
482
+ // transaction the previous stream was in the middle of
360
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
+ }
361
513
 
362
514
  this.stopped = false;
363
515
 
@@ -368,6 +520,28 @@ class ZongJi extends EventEmitter {
368
520
  this._options(options);
369
521
  this._filters(options);
370
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
+
371
545
  const testChecksum = (resolve, reject) => {
372
546
  this._isChecksumEnabled((err, checksumEnabled) => {
373
547
  if (err) {
@@ -383,6 +557,10 @@ class ZongJi extends EventEmitter {
383
557
 
384
558
  const findBinlogEnd = (resolve, reject) => {
385
559
  this._findBinlogEnd((err, result) => {
560
+ // As above: never mutate options for a superseded start()
561
+ if (epoch !== this._startEpoch) {
562
+ return resolve();
563
+ }
386
564
  if (err) {
387
565
  return reject(err);
388
566
  }
@@ -400,6 +578,30 @@ class ZongJi extends EventEmitter {
400
578
  });
401
579
  };
402
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
+
403
605
  // Attach the current transaction's GTID (tracked at the packet layer,
404
606
  // even when 'gtid' events are filtered out). Gtid/AnonymousGtid events
405
607
  // keep their own parsed value.
@@ -423,14 +625,42 @@ class ZongJi extends EventEmitter {
423
625
 
424
626
  switch (event.getTypeName()) {
425
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
+ }
426
646
  const tableMap = this.tableMap[event.tableId];
427
647
  if (!tableMap || tableMap.tableName !== event.tableName || tableMap.columns.length !== event.columnCount) {
428
648
  if (!this.connection) return;
429
649
  this.connection.pause();
430
650
  attachGtid(event);
431
651
  this._fetchTableInfo(event, () => {
432
- // merge the column info with metadata
433
- event.updateColumnInfo();
652
+ try {
653
+ // merge the column info with metadata
654
+ event.updateColumnInfo();
655
+ } catch (err) {
656
+ // Schema drift between binlog write and metadata fetch:
657
+ // drop the cached entry (subsequent row events for this
658
+ // table id are skipped rather than misdecoded) and report
659
+ delete this.tableMap[event.tableId];
660
+ this.emit('error', err);
661
+ if (this.connection) this.connection.resume();
662
+ return;
663
+ }
434
664
  this.emit('binlog', event);
435
665
  if (this.connection) this.connection.resume();
436
666
  });
@@ -438,23 +668,144 @@ class ZongJi extends EventEmitter {
438
668
  }
439
669
  break;
440
670
  }
441
- case 'Rotate':
442
- if (this.options.filename !== event.binlogName) {
443
- this.options.filename = event.binlogName;
444
- }
445
- break;
446
671
  }
447
- this.options.position = event.nextPosition;
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.
693
+ const typeName = event.getTypeName();
694
+ if (typeName !== 'TableMap' && typeName !== 'Rotate' &&
695
+ !this._positionFrozen && event.nextPosition > 0) {
696
+ this.options.position = event.nextPosition;
697
+ }
448
698
  this.emit('binlog', attachGtid(event));
449
699
  };
450
700
 
451
- 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
+ };
452
718
 
453
- if (this.options.startAtEnd) {
454
- promises.push(new Promise(findBinlogEnd));
455
- }
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
+ };
737
+
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
+ };
456
759
 
457
- Promise.all(promises)
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
+ })
458
809
  .then(() => {
459
810
  // Abort if a newer start() superseded this one (after a stop())
460
811
  // while promises were pending
@@ -633,3 +984,8 @@ class ZongJi extends EventEmitter {
633
984
  }
634
985
 
635
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 };