@vlasky/zongji 0.6.1 → 0.7.1

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
@@ -7,23 +7,18 @@ const ConnectionConfigMap = {
7
7
  'Pool': obj => obj.config.connectionConfig,
8
8
  };
9
9
 
10
- const TableInfoQueryTemplate = `SELECT
10
+ const TableInfoQuery = `SELECT
11
11
  COLUMN_NAME, COLLATION_NAME, CHARACTER_SET_NAME,
12
12
  COLUMN_COMMENT, COLUMN_TYPE
13
- FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA='%s' AND TABLE_NAME='%s'
13
+ FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA=? AND TABLE_NAME=?
14
14
  ORDER BY ORDINAL_POSITION`;
15
15
 
16
- // Simple format function to replace %s placeholders
17
- function formatSql(template, ...args) {
18
- let i = 0;
19
- return template.replace(/%s/g, () => args[i++]);
20
- }
21
-
22
16
  class ZongJi extends EventEmitter {
23
17
  constructor(dsn) {
24
18
  super();
25
19
 
26
20
  this._pendingErrors = [];
21
+ this._pendingErrorTimer = null;
27
22
  this.on('newListener', (event) => {
28
23
  if (event !== 'error' || this._pendingErrors.length === 0) {
29
24
  return;
@@ -39,8 +34,12 @@ class ZongJi extends EventEmitter {
39
34
  this._filters({});
40
35
  this.ctrlCallbacks = [];
41
36
  this.tableMap = {};
37
+ this._warnedUnsupported = new Set();
38
+ this._currentGtid = undefined;
42
39
  this.ready = false;
43
40
  this.stopped = false;
41
+ this._starting = false;
42
+ this._startEpoch = 0;
44
43
  this.useChecksum = false;
45
44
 
46
45
  this._dsn = dsn;
@@ -54,7 +53,12 @@ class ZongJi extends EventEmitter {
54
53
  const createConnection = (options) => {
55
54
  const emitError = (err) => {
56
55
  if (this.listenerCount('error') === 0) {
56
+ // Buffer errors that fire before the caller attaches a listener
57
+ // (typically within the same tick as construction/start). If no
58
+ // listener ever appears, fall back to EventEmitter's default
59
+ // behaviour (throw) rather than swallowing failures silently.
57
60
  this._pendingErrors.push(err);
61
+ this._schedulePendingErrorCheck();
58
62
  return;
59
63
  }
60
64
  this.emit('error', err);
@@ -95,6 +99,23 @@ class ZongJi extends EventEmitter {
95
99
  this.connection = createConnection(binlogDsn);
96
100
  }
97
101
 
102
+ _schedulePendingErrorCheck() {
103
+ if (this._pendingErrorTimer) {
104
+ return;
105
+ }
106
+ this._pendingErrorTimer = setImmediate(() => {
107
+ this._pendingErrorTimer = null;
108
+ if (this.listenerCount('error') > 0 || this._pendingErrors.length === 0) {
109
+ return;
110
+ }
111
+ const pending = this._pendingErrors.slice();
112
+ this._pendingErrors.length = 0;
113
+ // No 'error' listener was attached within a macrotask; re-emit so
114
+ // Node's unhandled 'error' semantics apply (throws)
115
+ pending.forEach(err => this.emit('error', err));
116
+ });
117
+ }
118
+
98
119
  _isChecksumEnabled(next) {
99
120
  const SelectChecksumParamSql = 'select @@GLOBAL.binlog_checksum as checksum';
100
121
  const SetChecksumSql = 'set @master_binlog_checksum=@@global.binlog_checksum';
@@ -184,19 +205,28 @@ class ZongJi extends EventEmitter {
184
205
  }
185
206
 
186
207
  _fetchTableInfo(tableMapEvent, next) {
187
- const sql = formatSql(TableInfoQueryTemplate,
188
- tableMapEvent.schemaName, tableMapEvent.tableName);
189
-
190
208
  if (!this.ctrlConnection ||
191
209
  this.ctrlConnection.state === 'disconnected' ||
192
210
  this.ctrlConnection._fatalError ||
193
211
  this.ctrlConnection._protocolError ||
194
212
  this.ctrlConnection._closing) {
213
+ // The binlog connection stays paused, so processing has halted.
214
+ // During stop() that is expected; otherwise it must not be silent.
215
+ if (!this.stopped) {
216
+ this.emit('error', new Error(
217
+ 'Control connection unavailable while fetching column metadata ' +
218
+ 'for ' + tableMapEvent.schemaName + '.' + tableMapEvent.tableName +
219
+ '. Binlog processing has halted; call stop() then start() to resume.'));
220
+ }
195
221
  return;
196
222
  }
197
223
 
224
+ const params = [tableMapEvent.schemaName, tableMapEvent.tableName];
198
225
  try {
199
- this.ctrlConnection.query(sql, (err, rows) => {
226
+ // execute() uses a server-side prepared statement: parameters are sent
227
+ // out-of-band (never spliced into SQL text) and the statement is cached
228
+ // per connection, so repeated metadata lookups avoid re-parsing.
229
+ this.ctrlConnection.execute(TableInfoQuery, params, (err, rows) => {
200
230
  if (err) {
201
231
  // Errors should be emitted
202
232
  this.emit('error', err);
@@ -234,12 +264,14 @@ class ZongJi extends EventEmitter {
234
264
  filename,
235
265
  position,
236
266
  startAtEnd,
267
+ nonBlock,
237
268
  } = {}) {
238
269
  this.options = {
239
270
  serverId,
240
271
  filename,
241
272
  position,
242
273
  startAtEnd,
274
+ nonBlock,
243
275
  };
244
276
  }
245
277
 
@@ -257,6 +289,28 @@ class ZongJi extends EventEmitter {
257
289
  includeSchema,
258
290
  excludeSchema,
259
291
  };
292
+
293
+ // Precompiled lookups so per-event filtering is O(1). Only own keys of
294
+ // the schema objects are considered (no prototype leakage).
295
+ const compileSchemaFilter = (schema) => {
296
+ if (schema === undefined || schema === null) {
297
+ return undefined;
298
+ }
299
+ const compiled = new Map();
300
+ for (const database of Object.keys(schema)) {
301
+ const tables = schema[database];
302
+ compiled.set(database, tables === true ?
303
+ true : new Set(Array.isArray(tables) ? tables : []));
304
+ }
305
+ return compiled;
306
+ };
307
+
308
+ this._includeEventsSet = includeEvents === undefined ?
309
+ undefined : new Set(Array.isArray(includeEvents) ? includeEvents : []);
310
+ this._excludeEventsSet =
311
+ new Set(Array.isArray(excludeEvents) ? excludeEvents : []);
312
+ this._includeSchemaMap = compileSchemaFilter(includeSchema);
313
+ this._excludeSchemaMap = compileSchemaFilter(excludeSchema) || new Map();
260
314
  }
261
315
 
262
316
  get(name) {
@@ -289,6 +343,27 @@ class ZongJi extends EventEmitter {
289
343
  return;
290
344
  }
291
345
 
346
+ // A duplicate start() while one is already initialising is ignored -
347
+ // the first call completes - but its filters are applied, exactly as
348
+ // in the already-running branch above: filters are snapshotted, and
349
+ // re-calling start() is the documented way to update them, so updates
350
+ // made during the initialisation window must not be lost. Stream
351
+ // options (filename/position/serverId) still come from the first
352
+ // call. If stop() intervened, this start() instead proceeds as a
353
+ // restart: the epoch below makes the stale initialisation chain
354
+ // abort, so exactly one binlog dump command is ever enqueued.
355
+ if (this._starting && !this.stopped) {
356
+ this._filters(options);
357
+ return;
358
+ }
359
+ this._starting = true;
360
+ this._startEpoch += 1;
361
+ const epoch = this._startEpoch;
362
+
363
+ // A resumed stream must not attribute early events to a GTID seen
364
+ // before the restart
365
+ this._currentGtid = undefined;
366
+
292
367
  this.stopped = false;
293
368
 
294
369
  if (!this.connection || !this.ctrlConnection) {
@@ -330,6 +405,16 @@ class ZongJi extends EventEmitter {
330
405
  });
331
406
  };
332
407
 
408
+ // Attach the current transaction's GTID (tracked at the packet layer,
409
+ // even when 'gtid' events are filtered out). Gtid/AnonymousGtid events
410
+ // keep their own parsed value.
411
+ const attachGtid = (event) => {
412
+ if (!('gtid' in event)) {
413
+ event.gtid = this._currentGtid;
414
+ }
415
+ return event;
416
+ };
417
+
333
418
  const binlogHandler = (error, event) => {
334
419
  if (error) {
335
420
  return this.emit('error', error);
@@ -347,9 +432,20 @@ class ZongJi extends EventEmitter {
347
432
  if (!tableMap || tableMap.tableName !== event.tableName || tableMap.columns.length !== event.columnCount) {
348
433
  if (!this.connection) return;
349
434
  this.connection.pause();
435
+ attachGtid(event);
350
436
  this._fetchTableInfo(event, () => {
351
- // merge the column info with metadata
352
- event.updateColumnInfo();
437
+ try {
438
+ // merge the column info with metadata
439
+ event.updateColumnInfo();
440
+ } catch (err) {
441
+ // Schema drift between binlog write and metadata fetch:
442
+ // drop the cached entry (subsequent row events for this
443
+ // table id are skipped rather than misdecoded) and report
444
+ delete this.tableMap[event.tableId];
445
+ this.emit('error', err);
446
+ if (this.connection) this.connection.resume();
447
+ return;
448
+ }
353
449
  this.emit('binlog', event);
354
450
  if (this.connection) this.connection.resume();
355
451
  });
@@ -358,13 +454,32 @@ class ZongJi extends EventEmitter {
358
454
  break;
359
455
  }
360
456
  case 'Rotate':
361
- if (this.options.filename !== event.binlogName) {
362
- this.options.filename = event.binlogName;
363
- }
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;
364
465
  break;
365
466
  }
366
- this.options.position = event.nextPosition;
367
- this.emit('binlog', event);
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.
478
+ const typeName = event.getTypeName();
479
+ if (typeName !== 'TableMap' && typeName !== 'Rotate') {
480
+ this.options.position = event.nextPosition;
481
+ }
482
+ this.emit('binlog', attachGtid(event));
368
483
  };
369
484
 
370
485
  let promises = [new Promise(testChecksum)];
@@ -375,7 +490,13 @@ class ZongJi extends EventEmitter {
375
490
 
376
491
  Promise.all(promises)
377
492
  .then(() => {
378
- // Check if stop() was called while promises were pending
493
+ // Abort if a newer start() superseded this one (after a stop())
494
+ // while promises were pending
495
+ if (epoch !== this._startEpoch) {
496
+ return;
497
+ }
498
+ this._starting = false;
499
+ // Abort if stop() was called while promises were pending
379
500
  if (this.stopped) {
380
501
  return;
381
502
  }
@@ -384,8 +505,9 @@ class ZongJi extends EventEmitter {
384
505
  this.ready = true;
385
506
  this.emit('ready');
386
507
 
387
- // Final check right before addCommand - connection may have been destroyed
388
- if (this.stopped) {
508
+ // Final check right before addCommand - connection may have been
509
+ // destroyed or this start() superseded by a ready handler
510
+ if (this.stopped || epoch !== this._startEpoch) {
389
511
  return;
390
512
  }
391
513
 
@@ -413,6 +535,10 @@ class ZongJi extends EventEmitter {
413
535
  this.connection.addCommand(new this.BinlogClass(binlogHandler));
414
536
  })
415
537
  .catch(err => {
538
+ if (epoch !== this._startEpoch) {
539
+ return;
540
+ }
541
+ this._starting = false;
416
542
  this.emit('error', err);
417
543
  });
418
544
 
@@ -427,10 +553,25 @@ class ZongJi extends EventEmitter {
427
553
  return;
428
554
  }
429
555
 
556
+ // Errors emitted by a connection we are deliberately destroying are
557
+ // teardown noise (e.g. in-flight queries failing with
558
+ // ERR_STREAM_WRITE_AFTER_END); do not forward them to the caller
559
+ const silenceErrors = (conn) => {
560
+ conn.removeAllListeners('error');
561
+ conn.on('error', () => {});
562
+ };
563
+
564
+ // Capture the connections this stop() owns: finish() may fire from an
565
+ // async callback after a subsequent start() has already created
566
+ // replacement connections, and must not touch those
567
+ const ctrlConnection = this.ctrlConnection;
568
+ const ctrlToClose = this.ctrlConnectionOwner ? ctrlConnection : null;
569
+
430
570
  // Binary log connection does not end with destroy()
431
571
  let connectionThreadId = null;
432
572
  if (this.connection) {
433
573
  connectionThreadId = this.connection.threadId;
574
+ silenceErrors(this.connection);
434
575
  this.connection.destroy();
435
576
  // @ts-ignore - internal mysql2 API
436
577
  if (this.connection.stream && typeof this.connection.stream.unref === 'function') {
@@ -443,24 +584,29 @@ class ZongJi extends EventEmitter {
443
584
  const finish = () => {
444
585
  if (finished) return;
445
586
  finished = true;
446
- if (this.ctrlConnectionOwner && this.ctrlConnection) {
447
- this.ctrlConnection.destroy();
587
+ if (ctrlToClose) {
588
+ silenceErrors(ctrlToClose);
589
+ ctrlToClose.destroy();
448
590
  // @ts-ignore - internal mysql2 API
449
- if (this.ctrlConnection.stream && typeof this.ctrlConnection.stream.unref === 'function') {
591
+ if (ctrlToClose.stream && typeof ctrlToClose.stream.unref === 'function') {
450
592
  // @ts-ignore - internal mysql2 API
451
- this.ctrlConnection.stream.unref();
593
+ ctrlToClose.stream.unref();
594
+ }
595
+ if (this.ctrlConnection === ctrlToClose) {
596
+ this.ctrlConnection = null;
452
597
  }
453
- this.ctrlConnection = null;
454
598
  }
455
599
  this.emit('stopped');
456
600
  };
457
601
 
458
- if (!this.ctrlConnection ||
459
- this.ctrlConnection.state === 'disconnected' ||
460
- this.ctrlConnection._fatalError ||
461
- this.ctrlConnection._protocolError ||
462
- this.ctrlConnection._closing) {
463
- this.ctrlConnection = null;
602
+ if (!ctrlConnection ||
603
+ ctrlConnection.state === 'disconnected' ||
604
+ ctrlConnection._fatalError ||
605
+ ctrlConnection._protocolError ||
606
+ ctrlConnection._closing) {
607
+ if (this.ctrlConnection === ctrlConnection) {
608
+ this.ctrlConnection = null;
609
+ }
464
610
  return finish();
465
611
  }
466
612
 
@@ -470,7 +616,7 @@ class ZongJi extends EventEmitter {
470
616
 
471
617
  const killTimeout = setTimeout(finish, 1000);
472
618
  try {
473
- this.ctrlConnection.query(
619
+ ctrlConnection.query(
474
620
  'KILL ' + connectionThreadId,
475
621
  () => {
476
622
  clearTimeout(killTimeout);
@@ -483,44 +629,40 @@ class ZongJi extends EventEmitter {
483
629
  }
484
630
  }
485
631
 
632
+ // Emit an error the first time an undecodable event type arrives so that
633
+ // dropped row changes (e.g. compressed transactions) are never silent.
634
+ _warnUnsupportedEvent(EventClass) {
635
+ if (this._warnedUnsupported.has(EventClass.name)) {
636
+ return;
637
+ }
638
+ this._warnedUnsupported.add(EventClass.name);
639
+ this.emit('error', new Error(EventClass.unsupportedReason));
640
+ }
641
+
486
642
  // It includes every events by default.
487
643
  _skipEvent(name) {
488
- const includes = this.filters.includeEvents;
489
- const excludes = this.filters.excludeEvents;
490
-
491
- let included = (includes === undefined) ||
492
- (Array.isArray(includes) && (includes.indexOf(name) > -1));
493
- let excluded = Array.isArray(excludes) && (excludes.indexOf(name) > -1);
494
-
495
- return excluded || !included;
644
+ if (this._excludeEventsSet.has(name)) {
645
+ return true;
646
+ }
647
+ return this._includeEventsSet !== undefined &&
648
+ !this._includeEventsSet.has(name);
496
649
  }
497
650
 
498
651
  // It doesn't skip any schema by default.
499
652
  _skipSchema(database, table) {
500
- const includes = this.filters.includeSchema;
501
- const excludes = this.filters.excludeSchema || {};
502
-
503
- let included = (includes === undefined) ||
504
- (
505
- (database in includes) &&
506
- (
507
- includes[database] === true ||
508
- (
509
- Array.isArray(includes[database]) &&
510
- includes[database].indexOf(table) > -1
511
- )
512
- )
513
- );
514
- let excluded = (database in excludes) &&
515
- (
516
- excludes[database] === true ||
517
- (
518
- Array.isArray(excludes[database]) &&
519
- excludes[database].indexOf(table) > -1
520
- )
521
- );
653
+ const excludeEntry = this._excludeSchemaMap.get(database);
654
+ if (excludeEntry === true ||
655
+ (excludeEntry instanceof Set && excludeEntry.has(table))) {
656
+ return true;
657
+ }
522
658
 
523
- return excluded || !included;
659
+ if (this._includeSchemaMap === undefined) {
660
+ return false;
661
+ }
662
+ const includeEntry = this._includeSchemaMap.get(database);
663
+ const included = includeEntry === true ||
664
+ (includeEntry instanceof Set && includeEntry.has(table));
665
+ return !included;
524
666
  }
525
667
  }
526
668
 
@@ -261,6 +261,16 @@ class TableMap extends BinlogEvent {
261
261
  const tableMap = this.tableMap[this.tableId];
262
262
 
263
263
  const columnSchemas = tableMap.columnSchemas;
264
+ // Schema drift: the table was altered between this binlog event being
265
+ // written and the metadata fetch. Fail diagnosably rather than with a
266
+ // bare TypeError from indexing missing columns.
267
+ if (!columnSchemas || columnSchemas.length < this.columnCount) {
268
+ throw new Error(
269
+ `Table ${this.schemaName}.${this.tableName} schema changed between ` +
270
+ `binlog event and metadata fetch: the event has ${this.columnCount} ` +
271
+ 'columns, fetched metadata has ' +
272
+ `${columnSchemas ? columnSchemas.length : 0}`);
273
+ }
264
274
  const columns = [];
265
275
  for (let j = 0; j < this.columnCount; j++) {
266
276
  columns.push({
@@ -364,6 +374,43 @@ class Unknown extends BinlogEvent {
364
374
  }
365
375
  }
366
376
 
377
+ /* MySQL 8.0.20+ wraps whole transactions in a single compressed event when
378
+ * binlog_transaction_compression=ON. The embedded row events are
379
+ * zstd-compressed and zongji cannot decode them, so the row changes they
380
+ * carry would be silently lost. The first occurrence emits an error via
381
+ * ZongJi#_warnUnsupportedEvent (see unsupportedReason below).
382
+ */
383
+ class TransactionPayload extends BinlogEvent {
384
+ constructor(parser, options, zongji) {
385
+ super(parser, options);
386
+ skipEventRemainder(parser, zongji);
387
+ }
388
+
389
+ static unsupportedReason =
390
+ 'Server sent a TRANSACTION_PAYLOAD_EVENT (binlog_transaction_compression=ON). ' +
391
+ 'zongji cannot decode compressed transactions, so their row events are NOT ' +
392
+ 'being emitted. Set binlog_transaction_compression=OFF on the server to ' +
393
+ 'receive these changes.';
394
+ }
395
+
396
+ /* MySQL 8.0+ emits partial JSON row updates when
397
+ * binlog_row_value_options=PARTIAL_JSON. The JSON diff format is not
398
+ * decodable by zongji, so these updates would be silently lost. The first
399
+ * occurrence emits an error via ZongJi#_warnUnsupportedEvent.
400
+ */
401
+ class PartialUpdateRows extends BinlogEvent {
402
+ constructor(parser, options, zongji) {
403
+ super(parser, options);
404
+ skipEventRemainder(parser, zongji);
405
+ }
406
+
407
+ static unsupportedReason =
408
+ 'Server sent a PARTIAL_UPDATE_ROWS_EVENT (binlog_row_value_options=PARTIAL_JSON). ' +
409
+ 'zongji cannot decode partial JSON updates, so these UPDATE row events are ' +
410
+ 'NOT being emitted. Clear binlog_row_value_options on the server to receive ' +
411
+ 'these changes.';
412
+ }
413
+
367
414
  export {
368
415
  BinlogEvent,
369
416
  Rotate,
@@ -375,5 +422,7 @@ export {
375
422
  Gtid,
376
423
  AnonymousGtid,
377
424
  PreviousGtids,
425
+ TransactionPayload,
426
+ PartialUpdateRows,
378
427
  Unknown
379
428
  };
package/lib/code_map.js CHANGED
@@ -37,7 +37,13 @@ const CodeEvent = [
37
37
  'DELETE_ROWS_EVENT_V2',
38
38
  'GTID_LOG_EVENT',
39
39
  'ANONYMOUS_GTID_LOG_EVENT',
40
- 'PREVIOUS_GTIDS_LOG_EVENT'
40
+ 'PREVIOUS_GTIDS_LOG_EVENT',
41
+ 'TRANSACTION_CONTEXT_EVENT',
42
+ 'VIEW_CHANGE_EVENT',
43
+ 'XA_PREPARE_LOG_EVENT',
44
+ 'PARTIAL_UPDATE_ROWS_EVENT',
45
+ 'TRANSACTION_PAYLOAD_EVENT',
46
+ 'HEARTBEAT_LOG_EVENT_V2'
41
47
  ];
42
48
 
43
49
  const EventClass = {
@@ -50,6 +56,8 @@ const EventClass = {
50
56
  GTID_LOG_EVENT: events.Gtid,
51
57
  ANONYMOUS_GTID_LOG_EVENT: events.AnonymousGtid,
52
58
  PREVIOUS_GTIDS_LOG_EVENT: events.PreviousGtids,
59
+ TRANSACTION_PAYLOAD_EVENT: events.TransactionPayload,
60
+ PARTIAL_UPDATE_ROWS_EVENT: events.PartialUpdateRows,
53
61
 
54
62
  TABLE_MAP_EVENT: events.TableMap,
55
63
  DELETE_ROWS_EVENT_V1: rowsEvents.DeleteRows,