@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.
@@ -0,0 +1,237 @@
1
+ // A MySQL GTID set: which transactions (per source) are already
2
+ // executed. String form matches @@gtid_executed / Previous_gtids, e.g.
3
+ // 3E11FA47-71CA-11E1-9E33-C80AA9429562:1-5:11-18,
4
+ // 2C256447-3F0D-431B-9A25-BDDF1F1F6EF6:1-27
5
+ // MySQL 8.3+ GTIDs may carry a tag; a (uuid, tag) pair is its own
6
+ // source, printed merged into the uuid's block after the untagged
7
+ // intervals: 'uuid:1-5:tag_a:1:tag_b:1' (tags lowercase, sorted).
8
+ // Intervals are stored internally as [start, end] inclusive, kept sorted
9
+ // and coalesced. GNOs are JavaScript Numbers: MySQL allows up to 2^63-1,
10
+ // but real-world transaction counts sit comfortably inside 2^53.
11
+ class GtidSet {
12
+ constructor() {
13
+ // uuid (lowercase, dashed) -> Map of tag ('' = untagged) ->
14
+ // array of [start, end] inclusive
15
+ this._sids = new Map();
16
+ }
17
+
18
+ // Accepts '', 'uuid:1-5', 'uuid:1-5:8,uuid2:3',
19
+ // 'uuid:1-5:tag_a:1:tag_b:2-3' (whitespace tolerated). Within a uuid
20
+ // block a non-numeric item is a tag naming the source of the interval
21
+ // items that follow it, per the Gtid_set::add_gtid_text grammar.
22
+ static parse(text) {
23
+ const set = new GtidSet();
24
+ if (!text) {
25
+ return set;
26
+ }
27
+ for (const entry of String(text).split(',')) {
28
+ const parts = entry.trim().split(':');
29
+ if (parts.length < 2) {
30
+ throw new Error(`Invalid GTID set entry: '${entry.trim()}'`);
31
+ }
32
+ const uuid = normaliseUuid(parts[0]);
33
+ let tag = '';
34
+ let intervals = 0;
35
+ // A tag names the interval items that follow it, so a tag section
36
+ // with none is malformed (the leading untagged section may be
37
+ // empty: 'uuid:tag_a:1' has no untagged intervals)
38
+ let sectionEmpty = false;
39
+ for (const item of parts.slice(1)) {
40
+ const trimmed = item.trim();
41
+ if (TAG_REGEX.test(trimmed)) {
42
+ if (sectionEmpty) {
43
+ throw new Error(`GTID tag without intervals: '${entry.trim()}'`);
44
+ }
45
+ tag = trimmed.toLowerCase();
46
+ sectionEmpty = true;
47
+ continue;
48
+ }
49
+ const bounds = trimmed.split('-');
50
+ const start = parsePositiveInt(bounds[0], entry);
51
+ const end = bounds.length > 1 ?
52
+ parsePositiveInt(bounds[1], entry) : start;
53
+ if (bounds.length > 2 || end < start) {
54
+ throw new Error(`Invalid GTID interval: '${entry.trim()}'`);
55
+ }
56
+ set._addInterval(uuid, tag, start, end);
57
+ sectionEmpty = false;
58
+ intervals++;
59
+ }
60
+ if (intervals === 0 || sectionEmpty) {
61
+ throw new Error(`Invalid GTID set entry: '${entry.trim()}'`);
62
+ }
63
+ }
64
+ return set;
65
+ }
66
+
67
+ // Adds a single transaction from a 'uuid:gno' or 'uuid:tag:gno' string
68
+ add(gtid) {
69
+ const { uuid, tag, gno } = parseSingleGtid(gtid);
70
+ this._addInterval(uuid, tag, gno, gno);
71
+ }
72
+
73
+ // Whether a single transaction ('uuid:gno' or 'uuid:tag:gno', e.g. an
74
+ // event.gtid) is already contained in the set. null/undefined (an
75
+ // anonymous transaction's event.gtid) is never contained; a malformed
76
+ // string throws, as it would corrupt rather than answer.
77
+ contains(gtid) {
78
+ if (gtid === undefined || gtid === null) {
79
+ return false;
80
+ }
81
+ const { uuid, tag, gno } = parseSingleGtid(gtid);
82
+ const tags = this._sids.get(uuid);
83
+ const intervals = tags && tags.get(tag);
84
+ if (!intervals) {
85
+ return false;
86
+ }
87
+ return intervals.some(([start, end]) => gno >= start && gno <= end);
88
+ }
89
+
90
+ // Adds [start, end] inclusive for a (uuid, tag) source, keeping
91
+ // intervals sorted and coalesced
92
+ _addInterval(uuid, tag, start, end) {
93
+ let tags = this._sids.get(uuid);
94
+ if (!tags) {
95
+ tags = new Map();
96
+ this._sids.set(uuid, tags);
97
+ }
98
+ const existing = tags.get(tag) || [];
99
+ existing.push([start, end]);
100
+ existing.sort((a, b) => a[0] - b[0]);
101
+ const merged = [existing[0]];
102
+ for (let i = 1; i < existing.length; i++) {
103
+ const last = merged[merged.length - 1];
104
+ const next = existing[i];
105
+ if (next[0] <= last[1] + 1) {
106
+ last[1] = Math.max(last[1], next[1]);
107
+ } else {
108
+ merged.push(next);
109
+ }
110
+ }
111
+ tags.set(tag, merged);
112
+ }
113
+
114
+ isEmpty() {
115
+ return this._sids.size === 0;
116
+ }
117
+
118
+ // Sorted (uuid, then tag, untagged first) flat list of
119
+ // [uuid, tag, intervals] entries: the order both the text form and
120
+ // the wire encoding require
121
+ _sortedEntries() {
122
+ return [...this._sids.entries()]
123
+ .sort((a, b) => a[0] < b[0] ? -1 : 1)
124
+ .flatMap(([uuid, tags]) => [...tags.entries()]
125
+ .sort((a, b) => a[0] < b[0] ? -1 : 1)
126
+ .map(([tag, intervals]) => [uuid, tag, intervals]));
127
+ }
128
+
129
+ toString() {
130
+ let text = '';
131
+ let lastUuid = null;
132
+ for (const [uuid, tag, intervals] of this._sortedEntries()) {
133
+ const ranges = intervals
134
+ .map(([start, end]) => start === end ? `${start}` : `${start}-${end}`)
135
+ .join(':');
136
+ if (uuid === lastUuid) {
137
+ text += `:${tag}:${ranges}`;
138
+ } else {
139
+ text += (text ? ',' : '') + uuid +
140
+ (tag ? `:${tag}` : '') + `:${ranges}`;
141
+ lastUuid = uuid;
142
+ }
143
+ }
144
+ return text;
145
+ }
146
+
147
+ // Wire encoding used by COM_BINLOG_DUMP_GTID (and Previous_gtids).
148
+ // Untagged sets use the classic layout accepted by every server
149
+ // version: n_sids u64le, then per sid: 16-byte binary UUID,
150
+ // n_intervals u64le, then per interval: start u64le, end-exclusive
151
+ // u64le. A set containing any tagged source switches the whole
152
+ // payload to the tagged format (MySQL 8.3+, sql/rpl_gtid_set.cc
153
+ // encode_nsids_format): the first u64 becomes
154
+ // (1 << 56) | (n_entries << 8) | 1, and every entry carries a tag
155
+ // field (one length byte = length << 1, then the lowercase tag
156
+ // bytes; 0x00 for untagged) between the UUID and the interval count.
157
+ // Servers before 8.3 reject the tagged format as malformed.
158
+ encode() {
159
+ const entries = this._sortedEntries();
160
+ const tagged = entries.some(([, tag]) => tag !== '');
161
+ let length = 8;
162
+ for (const [, tag, intervals] of entries) {
163
+ length += 16 + (tagged ? 1 + tag.length : 0) + 8 +
164
+ intervals.length * 16;
165
+ }
166
+ const buffer = Buffer.alloc(length);
167
+ let offset = 0;
168
+ const nSidsField = tagged ?
169
+ (1n << 56n) | (BigInt(entries.length) << 8n) | 1n :
170
+ BigInt(entries.length);
171
+ offset = buffer.writeBigUInt64LE(nSidsField, offset);
172
+ for (const [uuid, tag, intervals] of entries) {
173
+ offset += Buffer.from(uuid.replace(/-/g, ''), 'hex')
174
+ .copy(buffer, offset);
175
+ if (tagged) {
176
+ offset = buffer.writeUInt8(tag.length << 1, offset);
177
+ offset += buffer.write(tag, offset, 'ascii');
178
+ }
179
+ offset = buffer.writeBigUInt64LE(BigInt(intervals.length), offset);
180
+ for (const [start, end] of intervals) {
181
+ offset = buffer.writeBigUInt64LE(BigInt(start), offset);
182
+ offset = buffer.writeBigUInt64LE(BigInt(end) + 1n, offset);
183
+ }
184
+ }
185
+ return buffer;
186
+ }
187
+ }
188
+
189
+ const UUID_REGEX =
190
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
191
+
192
+ // Tag grammar per libs/mysql/gtid/tag.cpp: a letter or underscore, then
193
+ // letters, digits or underscores, at most 32 characters, case-folded to
194
+ // lowercase. Cannot be confused with an interval (those start with a
195
+ // digit).
196
+ const TAG_REGEX = /^[a-zA-Z_][a-zA-Z0-9_]{0,31}$/;
197
+
198
+ function parseSingleGtid(gtid) {
199
+ const parts = String(gtid).split(':');
200
+ if (parts.length !== 2 && parts.length !== 3) {
201
+ throw new Error(`Invalid GTID: '${gtid}'`);
202
+ }
203
+ const uuid = normaliseUuid(parts[0]);
204
+ let tag = '';
205
+ if (parts.length === 3) {
206
+ const candidate = parts[1].trim();
207
+ if (!TAG_REGEX.test(candidate)) {
208
+ throw new Error(`Invalid GTID tag in '${gtid}'`);
209
+ }
210
+ tag = candidate.toLowerCase();
211
+ }
212
+ const gno = parsePositiveInt(parts[parts.length - 1].trim(), gtid);
213
+ return { uuid, tag, gno };
214
+ }
215
+
216
+ function normaliseUuid(text) {
217
+ const uuid = text.trim().toLowerCase();
218
+ if (!UUID_REGEX.test(uuid)) {
219
+ throw new Error(`Invalid GTID source UUID: '${text.trim()}'`);
220
+ }
221
+ return uuid;
222
+ }
223
+
224
+ function parsePositiveInt(text, context) {
225
+ // Decimal digits only, per the MySQL GTID grammar: Number() alone would
226
+ // also accept '1e3', '0x10' or '1.0' and silently reinterpret them
227
+ if (!/^\d+$/.test(text)) {
228
+ throw new Error(`Invalid GTID number in '${context}': '${text}'`);
229
+ }
230
+ const value = Number(text);
231
+ if (!Number.isSafeInteger(value) || value < 1) {
232
+ throw new Error(`Invalid GTID number in '${context}': '${text}'`);
233
+ }
234
+ return value;
235
+ }
236
+
237
+ export { GtidSet };
@@ -0,0 +1,107 @@
1
+ // A MariaDB GTID position: the last transaction processed per replication
2
+ // domain. String form matches @@gtid_binlog_pos / @@gtid_current_pos /
3
+ // @slave_connect_state, e.g. '0-1-1234,1-3-45'. Unlike MySQL GTID sets
4
+ // there are no intervals: within a domain the binlog order is total, so a
5
+ // single domain-server-sequence triple identifies everything before it,
6
+ // and a position holds at most one entry per domain. The server resumes a
7
+ // client by skipping event groups in each listed domain until it passes
8
+ // the given sequence number from the given server id; domains not listed
9
+ // are streamed from the beginning.
10
+ class MariadbGtidPosition {
11
+ constructor() {
12
+ // domainId (number) -> { domainId, serverId, seqNo }
13
+ this._domains = new Map();
14
+ }
15
+
16
+ // Accepts '' or 'D-S-N[,D-S-N...]' (whitespace tolerated). Rejects two
17
+ // entries for one domain, as the server would (ER_DUPLICATE_GTID_DOMAIN)
18
+ static parse(text) {
19
+ const position = new MariadbGtidPosition();
20
+ if (!text) {
21
+ return position;
22
+ }
23
+ for (const entry of String(text).split(',')) {
24
+ const parts = entry.trim().split('-');
25
+ if (parts.length !== 3) {
26
+ throw new Error(`Invalid MariaDB GTID: '${entry.trim()}'`);
27
+ }
28
+ const domainId = parseUInt32(parts[0], entry);
29
+ const serverId = parseUInt32(parts[1], entry);
30
+ const seqNo = parseSeqNo(parts[2], entry);
31
+ if (position._domains.has(domainId)) {
32
+ throw new Error(
33
+ `Duplicate replication domain ${domainId} in GTID position ` +
34
+ `'${text}'`);
35
+ }
36
+ position._domains.set(domainId, { domainId, serverId, seqNo });
37
+ }
38
+ return position;
39
+ }
40
+
41
+ // Builds the position implied by a GTID_LIST_EVENT: the event may hold
42
+ // several entries per domain (one per server id); the last entry of a
43
+ // domain's run is the most recent, so plain per-domain overwrite in
44
+ // event order yields the right watermark
45
+ static fromGtidList(entries) {
46
+ const position = new MariadbGtidPosition();
47
+ for (const entry of entries) {
48
+ position.add(entry);
49
+ }
50
+ return position;
51
+ }
52
+
53
+ // Records { domainId, serverId, seqNo } as the last transaction
54
+ // processed in its domain. Deliberately an overwrite, not a max: after
55
+ // a failover within a domain the newest transaction can carry a lower
56
+ // sequence number under a different server id, and "last processed" is
57
+ // what the server's skip rule keys on
58
+ add(gtid) {
59
+ this._domains.set(gtid.domainId, {
60
+ domainId: gtid.domainId,
61
+ serverId: gtid.serverId,
62
+ seqNo: gtid.seqNo,
63
+ });
64
+ }
65
+
66
+ isEmpty() {
67
+ return this._domains.size === 0;
68
+ }
69
+
70
+ toString() {
71
+ return [...this._domains.values()]
72
+ .sort((a, b) => a.domainId - b.domainId)
73
+ .map(({ domainId, serverId, seqNo }) =>
74
+ `${domainId}-${serverId}-${seqNo}`)
75
+ .join(',');
76
+ }
77
+ }
78
+
79
+ function parseUInt32(text, context) {
80
+ // Decimal digits only: Number() alone would also accept '1e3' or '0x10'
81
+ if (!/^\d+$/.test(text)) {
82
+ throw new Error(`Invalid MariaDB GTID in '${context}': '${text}'`);
83
+ }
84
+ const value = Number(text);
85
+ if (!Number.isSafeInteger(value) || value > 0xffffffff) {
86
+ throw new Error(`Invalid MariaDB GTID in '${context}': '${text}'`);
87
+ }
88
+ return value;
89
+ }
90
+
91
+ function parseSeqNo(text, context) {
92
+ if (!/^\d+$/.test(text)) {
93
+ throw new Error(`Invalid MariaDB GTID in '${context}': '${text}'`);
94
+ }
95
+ // seq_no is u64; keep the Number-or-exact-string convention used for
96
+ // other 64-bit values
97
+ const value = Number(text);
98
+ if (Number.isSafeInteger(value)) {
99
+ return value;
100
+ }
101
+ if (BigInt(text) > 0xffffffffffffffffn) {
102
+ throw new Error(`Invalid MariaDB GTID in '${context}': '${text}'`);
103
+ }
104
+ return text;
105
+ }
106
+
107
+ export { MariadbGtidPosition };
@@ -17,7 +17,7 @@ export default function initBinlogPacketClass(zongji) {
17
17
 
18
18
  const timestamp = parser.parseUnsignedNumber(4) * 1000;
19
19
  const eventType = parser.parseUnsignedNumber(1);
20
- parser.parseUnsignedNumber(4); // serverId
20
+ const serverId = parser.parseUnsignedNumber(4);
21
21
  const eventLength = parser.parseUnsignedNumber(4);
22
22
  const nextPosition = parser.parseUnsignedNumber(4);
23
23
  parser.parseUnsignedNumber(2); // flags
@@ -27,7 +27,13 @@ export default function initBinlogPacketClass(zongji) {
27
27
  nextPosition: nextPosition,
28
28
  size: eventLength - BinlogPacket.Length,
29
29
  eventType: eventType,
30
+ // MariaDB GTID events need it: the GTID's server component lives
31
+ // in the common header
32
+ serverId: serverId,
30
33
  };
34
+ // Exposed for bookkeeping on events that are filtered out before
35
+ // being parsed (the event object never exists for those)
36
+ this.nextPosition = nextPosition;
31
37
 
32
38
  const EventClass = getEventClass(eventType);
33
39
  this.eventName = EventClass.name;
package/lib/rows_event.js CHANGED
@@ -1,10 +1,26 @@
1
1
  import { BinlogEvent } from './binlog_event.js';
2
2
  import * as Common from './common.js';
3
+ import { Parser } from './reader.js';
3
4
 
4
5
  const Version2Events = [
5
6
  0x1e, // WRITE_ROWS_EVENT_V2,
6
7
  0x1f, // UPDATE_ROWS_EVENT_V2,
7
8
  0x20, // DELETE_ROWS_EVENT_V2
9
+ 0xa9, // WRITE_ROWS_COMPRESSED_EVENT (MariaDB, v2 layout)
10
+ 0xaa, // UPDATE_ROWS_COMPRESSED_EVENT
11
+ 0xab, // DELETE_ROWS_COMPRESSED_EVENT
12
+ ];
13
+
14
+ // MariaDB log_bin_compress=ON variants: identical to the corresponding
15
+ // plain event through the columns-present bitmap(s); the row-image area
16
+ // (from the first row's null bitmap) is a compressed-payload envelope
17
+ const CompressedEvents = [
18
+ 0xa6, // WRITE_ROWS_COMPRESSED_EVENT_V1
19
+ 0xa7, // UPDATE_ROWS_COMPRESSED_EVENT_V1
20
+ 0xa8, // DELETE_ROWS_COMPRESSED_EVENT_V1
21
+ 0xa9, // WRITE_ROWS_COMPRESSED_EVENT
22
+ 0xaa, // UPDATE_ROWS_COMPRESSED_EVENT
23
+ 0xab, // DELETE_ROWS_COMPRESSED_EVENT
8
24
  ];
9
25
 
10
26
  const CHECKSUM_SIZE = 4;
@@ -72,20 +88,36 @@ class RowsEvent extends BinlogEvent {
72
88
  this.columns_present_bitmap2 = parser.parseBuffer(columnsPresentBitmapSize);
73
89
  }
74
90
 
75
- if (this.useChecksum) {
91
+ let rowsParser = parser;
92
+ let trimChecksum = this.useChecksum;
93
+ if (CompressedEvents.indexOf(options.eventType) !== -1) {
94
+ // Inflate the row-image area and read the rows from the inflated
95
+ // bytes; the event's CRC covers the compressed bytes and stays on
96
+ // the original parser
97
+ const end =
98
+ parser._packetEnd - (this.useChecksum ? CHECKSUM_SIZE : 0);
99
+ const inflated = Common.uncompressBinlogEventPayload(
100
+ parser.parseBuffer(end - parser._offset));
101
+ parser._offset = parser._packetEnd;
102
+ rowsParser = new Parser();
103
+ rowsParser.append(inflated);
104
+ trimChecksum = false;
105
+ }
106
+
107
+ if (trimChecksum) {
76
108
  // Ignore the checksum at the end of this packet
77
- parser._packetEnd -= CHECKSUM_SIZE;
109
+ rowsParser._packetEnd -= CHECKSUM_SIZE;
78
110
  }
79
111
 
80
112
  this.rows = [];
81
- while (!parser.reachedPacketEnd()) {
82
- this.rows.push(this._fetchOneRow(parser));
113
+ while (!rowsParser.reachedPacketEnd()) {
114
+ this.rows.push(this._fetchOneRow(rowsParser));
83
115
  }
84
116
 
85
- if (this.useChecksum) {
117
+ if (trimChecksum) {
86
118
  // Skip past the checksum at the end of the packet
87
- parser._packetEnd += CHECKSUM_SIZE;
88
- parser._offset += CHECKSUM_SIZE;
119
+ rowsParser._packetEnd += CHECKSUM_SIZE;
120
+ rowsParser._offset += CHECKSUM_SIZE;
89
121
  }
90
122
  }
91
123
  }