spindb 0.69.0 → 0.69.2
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/README.md +1 -0
- package/dist/cli/commands/create.js +29 -0
- package/dist/cli/commands/create.js.map +1 -1
- package/dist/cli/commands/restore.js +58 -2
- package/dist/cli/commands/restore.js.map +1 -1
- package/dist/config/version.js +1 -1
- package/dist/engines/postgresql/drop-database-sql.js +94 -0
- package/dist/engines/postgresql/drop-database-sql.js.map +1 -0
- package/dist/engines/postgresql/index.js +97 -29
- package/dist/engines/postgresql/index.js.map +1 -1
- package/dist/engines/postgresql/restore.js +8 -2
- package/dist/engines/postgresql/restore.js.map +1 -1
- package/dist/engines/redis/resp-client.js +484 -55
- package/dist/engines/redis/resp-client.js.map +1 -1
- package/package.json +1 -1
|
@@ -46,7 +46,12 @@ function parseReply(buf, offset) {
|
|
|
46
46
|
case 0x2b: // '+' simple string
|
|
47
47
|
return { value: line, offset: lineEnd };
|
|
48
48
|
case 0x2d: // '-' error
|
|
49
|
-
|
|
49
|
+
// A bare `-\r\n` carries no text at all. An Error with an empty message
|
|
50
|
+
// is indistinguishable from "no error" once it is serialized, so name it.
|
|
51
|
+
return {
|
|
52
|
+
value: new Error(line || 'The server returned an empty error reply'),
|
|
53
|
+
offset: lineEnd,
|
|
54
|
+
};
|
|
50
55
|
case 0x3a: // ':' integer
|
|
51
56
|
return { value: Number(line), offset: lineEnd };
|
|
52
57
|
case 0x24: {
|
|
@@ -79,6 +84,13 @@ function parseReply(buf, offset) {
|
|
|
79
84
|
throw new Error(`Unsupported RESP reply type: ${String.fromCharCode(type)}`);
|
|
80
85
|
}
|
|
81
86
|
}
|
|
87
|
+
// The first byte of every reply RESP2 can produce. Anything else means we are
|
|
88
|
+
// not talking to a Redis server on this port - a TLS record, an HTTP response,
|
|
89
|
+
// or a RESP3 reply from a server that volunteered one. Bailing out on sight is
|
|
90
|
+
// what keeps a mis-typed `redis://` against a TLS-only endpoint from hanging:
|
|
91
|
+
// those bytes carry no CRLF, so the incremental parser would otherwise wait for
|
|
92
|
+
// a line terminator that never arrives.
|
|
93
|
+
const RESP2_REPLY_TYPES = new Set([0x2b, 0x2d, 0x3a, 0x24, 0x2a]);
|
|
82
94
|
// Usernames that name no real ACL user, so AUTH must be sent in its
|
|
83
95
|
// one-argument form (`AUTH <password>`) instead of `AUTH <user> <password>`.
|
|
84
96
|
//
|
|
@@ -99,24 +111,82 @@ export function buildRespAuthArgs(password, username) {
|
|
|
99
111
|
? ['AUTH', username, password]
|
|
100
112
|
: ['AUTH', password];
|
|
101
113
|
}
|
|
114
|
+
// The handshake rejections a pasted connection string actually produces, each
|
|
115
|
+
// with the one sentence that says what to change. The server's own text is
|
|
116
|
+
// always kept - it is the ground truth - and the hint is appended.
|
|
117
|
+
const HANDSHAKE_HINTS = [
|
|
118
|
+
{
|
|
119
|
+
match: /^WRONGPASS/i,
|
|
120
|
+
hint: 'The username or password in the connection string was rejected. Copy the URL again from the provider console - a rotated credential is the usual cause.',
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
match: /^NOAUTH/i,
|
|
124
|
+
hint: 'The server wants credentials the connection string does not carry. Use the full redis://user:password@host:port URL.',
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
match: /Client sent AUTH, but no password is set/i,
|
|
128
|
+
hint: 'The connection string carries a password but the server has none configured. Drop the credentials from the URL.',
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
match: /wrong number of arguments for 'auth'/i,
|
|
132
|
+
hint: 'This server predates ACL users (Redis 5 or older), so it only accepts a password. Remove the username from the connection string.',
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
match: /DB index is out of range|SELECT is not allowed/i,
|
|
136
|
+
hint: 'The source does not have the numbered database from the end of the URL. Most managed Redis exposes only db 0 - drop the /N suffix.',
|
|
137
|
+
},
|
|
138
|
+
];
|
|
139
|
+
export function annotateHandshakeError(error) {
|
|
140
|
+
const hint = HANDSHAKE_HINTS.find((h) => h.match.test(error.message))?.hint;
|
|
141
|
+
if (!hint)
|
|
142
|
+
return error;
|
|
143
|
+
// Server messages end with a period about half the time; do not double it.
|
|
144
|
+
const text = error.message.replace(/\.\s*$/, '');
|
|
145
|
+
return new Error(`${text}. ${hint}`);
|
|
146
|
+
}
|
|
102
147
|
export class RespClient {
|
|
103
148
|
socket;
|
|
104
149
|
inbox = Buffer.alloc(0);
|
|
105
150
|
// FIFO of resolvers, one per in-flight command (pipelining-friendly).
|
|
106
151
|
queue = [];
|
|
107
152
|
fatal = null;
|
|
108
|
-
constructor(socket) {
|
|
153
|
+
constructor(socket, idleTimeoutMs) {
|
|
109
154
|
this.socket = socket;
|
|
110
155
|
socket.on('data', (chunk) => this.onData(chunk));
|
|
111
156
|
socket.on('error', (err) => this.onFatal(err));
|
|
112
157
|
socket.on('close', () => this.onFatal(new Error('Redis connection closed')));
|
|
158
|
+
if (idleTimeoutMs > 0) {
|
|
159
|
+
socket.setTimeout(idleTimeoutMs);
|
|
160
|
+
socket.on('timeout', () => this.onIdle(idleTimeoutMs));
|
|
161
|
+
}
|
|
113
162
|
}
|
|
114
163
|
onData(chunk) {
|
|
115
164
|
this.inbox =
|
|
116
165
|
this.inbox.length === 0 ? chunk : Buffer.concat([this.inbox, chunk]);
|
|
117
166
|
let offset = 0;
|
|
118
167
|
for (;;) {
|
|
119
|
-
|
|
168
|
+
if (offset < this.inbox.length &&
|
|
169
|
+
!RESP2_REPLY_TYPES.has(this.inbox[offset])) {
|
|
170
|
+
this.onFatal(new Error('The server did not answer with the Redis protocol (first reply byte ' +
|
|
171
|
+
`0x${this.inbox[offset].toString(16).padStart(2, '0')}). ` +
|
|
172
|
+
'If the source requires TLS, use rediss:// instead of redis://; ' +
|
|
173
|
+
'if it is behind an HTTP proxy or a REST-only endpoint, point at the ' +
|
|
174
|
+
'database port instead.'));
|
|
175
|
+
this.socket.destroy();
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
// A malformed reply must not escape as an uncaught exception: `onData`
|
|
179
|
+
// runs on the socket's 'data' event, so a throw here would take the whole
|
|
180
|
+
// process down mid-migration with no --json output at all.
|
|
181
|
+
let parsed;
|
|
182
|
+
try {
|
|
183
|
+
parsed = parseReply(this.inbox, offset);
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
this.onFatal(error instanceof Error ? error : new Error(String(error)));
|
|
187
|
+
this.socket.destroy();
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
120
190
|
if (!parsed)
|
|
121
191
|
break;
|
|
122
192
|
offset = parsed.offset;
|
|
@@ -130,6 +200,15 @@ export class RespClient {
|
|
|
130
200
|
}
|
|
131
201
|
this.inbox = offset === 0 ? this.inbox : this.inbox.subarray(offset);
|
|
132
202
|
}
|
|
203
|
+
// Socket inactivity. Only fatal while we are actually waiting on a reply -
|
|
204
|
+
// an idle connection between commands is normal and must not be torn down.
|
|
205
|
+
onIdle(idleTimeoutMs) {
|
|
206
|
+
if (this.queue.length === 0)
|
|
207
|
+
return;
|
|
208
|
+
this.onFatal(new Error(`The Redis server stopped responding (no reply for ${idleTimeoutMs}ms with ` +
|
|
209
|
+
`${this.queue.length} command(s) in flight)`));
|
|
210
|
+
this.socket.destroy();
|
|
211
|
+
}
|
|
133
212
|
onFatal(err) {
|
|
134
213
|
if (this.fatal)
|
|
135
214
|
return;
|
|
@@ -160,13 +239,37 @@ export class RespClient {
|
|
|
160
239
|
this.socket.write(Buffer.concat(commands.map(encodeCommand)));
|
|
161
240
|
return Promise.all(promises);
|
|
162
241
|
}
|
|
242
|
+
// Like `pipeline`, but a per-command `-ERR` comes back as an Error VALUE
|
|
243
|
+
// instead of rejecting the batch. The migration needs this to tell "every
|
|
244
|
+
// RESTORE in this batch was refused because the payload format is foreign"
|
|
245
|
+
// (recoverable - fall back to a logical copy) from "one key blew up"
|
|
246
|
+
// (a real failure), which `Promise.all` cannot express: it surfaces the first
|
|
247
|
+
// rejection and hides the rest.
|
|
248
|
+
async pipelineSettled(commands) {
|
|
249
|
+
if (this.fatal)
|
|
250
|
+
throw this.fatal;
|
|
251
|
+
const promises = commands.map(() => new Promise((resolve, reject) => {
|
|
252
|
+
this.queue.push({ resolve, reject });
|
|
253
|
+
}));
|
|
254
|
+
this.socket.write(Buffer.concat(commands.map(encodeCommand)));
|
|
255
|
+
const settled = await Promise.allSettled(promises);
|
|
256
|
+
// A socket-level failure rejects every outstanding waiter with the same
|
|
257
|
+
// Error. That is not a per-command refusal and must not be mistaken for
|
|
258
|
+
// one, so re-throw instead of handing back a list of Errors.
|
|
259
|
+
if (this.fatal)
|
|
260
|
+
throw this.fatal;
|
|
261
|
+
return settled.map((s) => s.status === 'fulfilled'
|
|
262
|
+
? s.value
|
|
263
|
+
: s.reason instanceof Error
|
|
264
|
+
? s.reason
|
|
265
|
+
: new Error(String(s.reason)));
|
|
266
|
+
}
|
|
163
267
|
close() {
|
|
164
268
|
this.socket.destroy();
|
|
165
269
|
}
|
|
166
270
|
static async connect(opts) {
|
|
271
|
+
const timeoutMs = opts.connectTimeoutMs ?? 15000;
|
|
167
272
|
const socket = await new Promise((resolve, reject) => {
|
|
168
|
-
const onError = (err) => reject(err);
|
|
169
|
-
const timeoutMs = opts.connectTimeoutMs ?? 15000;
|
|
170
273
|
const s = opts.tls
|
|
171
274
|
? tlsConnect({
|
|
172
275
|
host: opts.host,
|
|
@@ -175,24 +278,42 @@ export class RespClient {
|
|
|
175
278
|
rejectUnauthorized: opts.rejectUnauthorized ?? false,
|
|
176
279
|
})
|
|
177
280
|
: netConnect({ host: opts.host, port: opts.port });
|
|
281
|
+
// A rejected connect must not leave the socket open: nothing else holds a
|
|
282
|
+
// reference to it, so the handle would keep the process alive.
|
|
283
|
+
const onError = (err) => {
|
|
284
|
+
s.destroy();
|
|
285
|
+
reject(err);
|
|
286
|
+
};
|
|
287
|
+
const onTimeout = () => {
|
|
288
|
+
s.destroy();
|
|
289
|
+
reject(new Error(`Redis connection timed out after ${timeoutMs}ms`));
|
|
290
|
+
};
|
|
178
291
|
const onReady = () => {
|
|
292
|
+
// Both handlers have to go, not just 'error': the connect deadline is
|
|
293
|
+
// re-armed as an IDLE deadline by the constructor, and leaving this
|
|
294
|
+
// listener attached would tear the socket down on a quiet moment.
|
|
179
295
|
s.removeListener('error', onError);
|
|
296
|
+
s.removeListener('timeout', onTimeout);
|
|
180
297
|
s.setTimeout(0);
|
|
181
298
|
resolve(s);
|
|
182
299
|
};
|
|
183
|
-
s.setTimeout(timeoutMs
|
|
184
|
-
|
|
185
|
-
reject(new Error(`Redis connection timed out after ${timeoutMs}ms`));
|
|
186
|
-
});
|
|
300
|
+
s.setTimeout(timeoutMs);
|
|
301
|
+
s.once('timeout', onTimeout);
|
|
187
302
|
s.once('error', onError);
|
|
188
303
|
s.once(opts.tls ? 'secureConnect' : 'connect', onReady);
|
|
189
304
|
});
|
|
190
|
-
const client = new RespClient(socket);
|
|
191
|
-
|
|
192
|
-
|
|
305
|
+
const client = new RespClient(socket, opts.idleTimeoutMs ?? timeoutMs * 4);
|
|
306
|
+
try {
|
|
307
|
+
if (opts.password) {
|
|
308
|
+
await client.command(buildRespAuthArgs(opts.password, opts.username));
|
|
309
|
+
}
|
|
310
|
+
if (opts.database && opts.database > 0) {
|
|
311
|
+
await client.command(['SELECT', String(opts.database)]);
|
|
312
|
+
}
|
|
193
313
|
}
|
|
194
|
-
|
|
195
|
-
|
|
314
|
+
catch (error) {
|
|
315
|
+
client.close();
|
|
316
|
+
throw error instanceof Error ? annotateHandshakeError(error) : error;
|
|
196
317
|
}
|
|
197
318
|
return client;
|
|
198
319
|
}
|
|
@@ -221,10 +342,331 @@ export class RespClient {
|
|
|
221
342
|
return { cursor: nextCursor, keys };
|
|
222
343
|
}
|
|
223
344
|
}
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
//
|
|
345
|
+
// Why the fast path is not always available.
|
|
346
|
+
//
|
|
347
|
+
// DUMP payloads are stamped with the RDB version of the server that produced
|
|
348
|
+
// them, and RESTORE refuses anything its own format does not cover. That is not
|
|
349
|
+
// a "the target is too old" problem that a version bump fixes - Redis and Valkey
|
|
350
|
+
// now serialize into DIFFERENT number spaces:
|
|
351
|
+
//
|
|
352
|
+
// Redis 7.2 RDB 11 Valkey 8.0 RDB 11
|
|
353
|
+
// Redis 8.x RDB 12 Valkey 9.0 RDB 80
|
|
354
|
+
// Upstash (8.4-compat) RDB 14
|
|
355
|
+
//
|
|
356
|
+
// so a Valkey 9 source cannot RESTORE into any Redis at all, and a Redis 8 or
|
|
357
|
+
// Upstash source cannot RESTORE into Redis 7.2 or Valkey. Measured 2026-09-09
|
|
358
|
+
// against live servers of each. A serverless provider may also simply not
|
|
359
|
+
// implement DUMP. Either way the answer is the same: stop trying to move bytes
|
|
360
|
+
// and move values instead.
|
|
361
|
+
function isPayloadFormatRefusal(message) {
|
|
362
|
+
return /DUMP payload version or checksum are wrong|Bad data format/i.test(message);
|
|
363
|
+
}
|
|
364
|
+
function isCommandUnavailable(message) {
|
|
365
|
+
return /unknown command|Command is not available|unsupported command|ERR .*not supported/i.test(message);
|
|
366
|
+
}
|
|
367
|
+
export function shouldFallBackToLogicalCopy(message) {
|
|
368
|
+
return isPayloadFormatRefusal(message) || isCommandUnavailable(message);
|
|
369
|
+
}
|
|
370
|
+
// Value types the logical path knows how to read and rewrite.
|
|
371
|
+
const LOGICAL_COPY_TYPES = new Set([
|
|
372
|
+
'string',
|
|
373
|
+
'list',
|
|
374
|
+
'set',
|
|
375
|
+
'zset',
|
|
376
|
+
'hash',
|
|
377
|
+
'stream',
|
|
378
|
+
]);
|
|
379
|
+
// Elements pulled per read round-trip, and written per command. Keeps a single
|
|
380
|
+
// reply (and a single command) bounded regardless of how large one key is, which
|
|
381
|
+
// matters both for our own memory and for providers that cap response size.
|
|
382
|
+
// Kept EVEN on purpose: hashes and sorted sets are written as field/value and
|
|
383
|
+
// score/member PAIRS, and an odd chunk would split one across two commands.
|
|
384
|
+
const ELEMENT_CHUNK = 512;
|
|
385
|
+
function replyToBuffer(reply) {
|
|
386
|
+
return Buffer.isBuffer(reply) ? reply : null;
|
|
387
|
+
}
|
|
388
|
+
function replyToString(reply) {
|
|
389
|
+
if (Buffer.isBuffer(reply))
|
|
390
|
+
return reply.toString('latin1');
|
|
391
|
+
if (typeof reply === 'string')
|
|
392
|
+
return reply;
|
|
393
|
+
return '';
|
|
394
|
+
}
|
|
395
|
+
function replyToBuffers(reply) {
|
|
396
|
+
return Array.isArray(reply)
|
|
397
|
+
? reply.filter((r) => Buffer.isBuffer(r))
|
|
398
|
+
: [];
|
|
399
|
+
}
|
|
400
|
+
// A *SCAN reply: [cursor, [element, ...]].
|
|
401
|
+
function parseScanReply(reply) {
|
|
402
|
+
if (!Array.isArray(reply) || reply.length < 2) {
|
|
403
|
+
throw new Error('Unexpected SCAN reply while reading a collection');
|
|
404
|
+
}
|
|
405
|
+
return { cursor: replyToString(reply[0]), items: replyToBuffers(reply[1]) };
|
|
406
|
+
}
|
|
407
|
+
function chunk(items, size) {
|
|
408
|
+
const out = [];
|
|
409
|
+
for (let i = 0; i < items.length; i += size)
|
|
410
|
+
out.push(items.slice(i, i + size));
|
|
411
|
+
return out;
|
|
412
|
+
}
|
|
413
|
+
// The first read for a key of this type. Every one of these returns whatever
|
|
414
|
+
// fits in a single chunk, so a batch of keys can be read in ONE round-trip and
|
|
415
|
+
// only the oversized ones need a follow-up walk.
|
|
416
|
+
function firstReadCommand(type, key) {
|
|
417
|
+
switch (type) {
|
|
418
|
+
case 'string':
|
|
419
|
+
return ['GET', key];
|
|
420
|
+
case 'hash':
|
|
421
|
+
return ['HSCAN', key, '0', 'COUNT', String(ELEMENT_CHUNK)];
|
|
422
|
+
case 'set':
|
|
423
|
+
return ['SSCAN', key, '0', 'COUNT', String(ELEMENT_CHUNK)];
|
|
424
|
+
case 'zset':
|
|
425
|
+
return ['ZSCAN', key, '0', 'COUNT', String(ELEMENT_CHUNK)];
|
|
426
|
+
case 'list':
|
|
427
|
+
return ['LRANGE', key, '0', String(ELEMENT_CHUNK - 1)];
|
|
428
|
+
case 'stream':
|
|
429
|
+
return ['XRANGE', key, '-', '+', 'COUNT', String(ELEMENT_CHUNK)];
|
|
430
|
+
default:
|
|
431
|
+
return null;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
// The largest sequence a stream id can hold: both halves of a stream id are
|
|
435
|
+
// unsigned 64-bit, and Redis rolls a full sequence over into the next
|
|
436
|
+
// millisecond rather than refusing the entry.
|
|
437
|
+
const STREAM_ID_PART_MAX = 18446744073709551615n;
|
|
438
|
+
// Stream ids are `<ms>-<seq>`. XRANGE ranges are inclusive, and the exclusive
|
|
439
|
+
// `(` form only exists from Redis 6.2, so the next page starts at seq+1 of the
|
|
440
|
+
// last id we saw - a form every version understands.
|
|
441
|
+
//
|
|
442
|
+
// The arithmetic is BigInt because both halves are uint64 and a Number cannot
|
|
443
|
+
// hold one past 2^53. `Number('9007199254740993') + 1` is 9007199254740994 by
|
|
444
|
+
// luck, but `Number('18446744073709551615') + 1` is 18446744073709552000 - a
|
|
445
|
+
// value that is not the next id and is not even in the stream, so the walk
|
|
446
|
+
// would silently skip the tail of a large stream. A sequence that is already at
|
|
447
|
+
// the maximum carries into the next millisecond, exactly as Redis does when it
|
|
448
|
+
// assigns one.
|
|
449
|
+
export function nextStreamId(id) {
|
|
450
|
+
const match = /^(\d+)(?:-(\d+))?$/.exec(id);
|
|
451
|
+
if (!match) {
|
|
452
|
+
throw new Error(`Unexpected stream entry id from the source: ${id}`);
|
|
453
|
+
}
|
|
454
|
+
const ms = BigInt(match[1]);
|
|
455
|
+
const seq = match[2] === undefined ? 0n : BigInt(match[2]);
|
|
456
|
+
return seq >= STREAM_ID_PART_MAX ? `${ms + 1n}-0` : `${ms}-${seq + 1n}`;
|
|
457
|
+
}
|
|
458
|
+
function parseStreamEntries(reply) {
|
|
459
|
+
if (!Array.isArray(reply))
|
|
460
|
+
return [];
|
|
461
|
+
const entries = [];
|
|
462
|
+
for (const item of reply) {
|
|
463
|
+
if (!Array.isArray(item) || item.length < 2)
|
|
464
|
+
continue;
|
|
465
|
+
entries.push({
|
|
466
|
+
id: replyToString(item[0]),
|
|
467
|
+
fields: replyToBuffers(item[1]),
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
return entries;
|
|
471
|
+
}
|
|
472
|
+
// Finish reading a key whose first chunk came back full. `first` is that chunk.
|
|
473
|
+
async function readRemainder(src, key, type, first) {
|
|
474
|
+
switch (type) {
|
|
475
|
+
case 'string': {
|
|
476
|
+
const value = replyToBuffer(first);
|
|
477
|
+
// A key that vanished between SCAN and GET is a non-event, not an error.
|
|
478
|
+
return value ? { kind: 'string', value } : { kind: 'skip', type: 'none' };
|
|
479
|
+
}
|
|
480
|
+
case 'hash':
|
|
481
|
+
case 'set':
|
|
482
|
+
case 'zset': {
|
|
483
|
+
const { cursor, items } = parseScanReply(first);
|
|
484
|
+
const all = items;
|
|
485
|
+
let next = cursor;
|
|
486
|
+
while (next !== '0') {
|
|
487
|
+
const page = parseScanReply(await src.command([
|
|
488
|
+
type === 'hash' ? 'HSCAN' : type === 'set' ? 'SSCAN' : 'ZSCAN',
|
|
489
|
+
key,
|
|
490
|
+
next,
|
|
491
|
+
'COUNT',
|
|
492
|
+
String(ELEMENT_CHUNK),
|
|
493
|
+
]));
|
|
494
|
+
all.push(...page.items);
|
|
495
|
+
next = page.cursor;
|
|
496
|
+
}
|
|
497
|
+
return { kind: 'elements', type, items: all };
|
|
498
|
+
}
|
|
499
|
+
case 'list': {
|
|
500
|
+
const all = replyToBuffers(first);
|
|
501
|
+
// A short page means the end of the list; a full one means there may be
|
|
502
|
+
// more, so keep walking from where the last page stopped.
|
|
503
|
+
while (all.length > 0 && all.length % ELEMENT_CHUNK === 0) {
|
|
504
|
+
const page = replyToBuffers(await src.command([
|
|
505
|
+
'LRANGE',
|
|
506
|
+
key,
|
|
507
|
+
String(all.length),
|
|
508
|
+
String(all.length + ELEMENT_CHUNK - 1),
|
|
509
|
+
]));
|
|
510
|
+
if (page.length === 0)
|
|
511
|
+
break;
|
|
512
|
+
all.push(...page);
|
|
513
|
+
}
|
|
514
|
+
return { kind: 'elements', type: 'list', items: all };
|
|
515
|
+
}
|
|
516
|
+
case 'stream': {
|
|
517
|
+
const entries = parseStreamEntries(first);
|
|
518
|
+
while (entries.length > 0 && entries.length % ELEMENT_CHUNK === 0) {
|
|
519
|
+
const page = parseStreamEntries(await src.command([
|
|
520
|
+
'XRANGE',
|
|
521
|
+
key,
|
|
522
|
+
nextStreamId(entries[entries.length - 1].id),
|
|
523
|
+
'+',
|
|
524
|
+
'COUNT',
|
|
525
|
+
String(ELEMENT_CHUNK),
|
|
526
|
+
]));
|
|
527
|
+
if (page.length === 0)
|
|
528
|
+
break;
|
|
529
|
+
entries.push(...page);
|
|
530
|
+
}
|
|
531
|
+
return { kind: 'stream', entries };
|
|
532
|
+
}
|
|
533
|
+
default:
|
|
534
|
+
return { kind: 'skip', type };
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
// The commands that recreate one key on the target. A collection is DELeted
|
|
538
|
+
// first so a re-run replaces it instead of appending to it - the same
|
|
539
|
+
// replace-in-place semantics RESTORE ... REPLACE gives the fast path.
|
|
540
|
+
function writeCommands(key, contents, ttlMs) {
|
|
541
|
+
const cmds = [];
|
|
542
|
+
if (contents.kind === 'string') {
|
|
543
|
+
// SET replaces a key of ANY previous type, so it needs no DEL, and PX folds
|
|
544
|
+
// the expiry into the same command.
|
|
545
|
+
cmds.push(ttlMs > 0
|
|
546
|
+
? ['SET', key, contents.value, 'PX', String(ttlMs)]
|
|
547
|
+
: ['SET', key, contents.value]);
|
|
548
|
+
return cmds;
|
|
549
|
+
}
|
|
550
|
+
if (contents.kind === 'elements') {
|
|
551
|
+
if (contents.items.length === 0)
|
|
552
|
+
return cmds; // an empty collection cannot exist
|
|
553
|
+
cmds.push(['DEL', key]);
|
|
554
|
+
const writer = contents.type === 'hash'
|
|
555
|
+
? 'HSET'
|
|
556
|
+
: contents.type === 'set'
|
|
557
|
+
? 'SADD'
|
|
558
|
+
: contents.type === 'zset'
|
|
559
|
+
? 'ZADD'
|
|
560
|
+
: 'RPUSH';
|
|
561
|
+
// ZSCAN and HSCAN return flat member/score and field/value pairs. ZADD
|
|
562
|
+
// wants score BEFORE member, which is the order ZSCAN already yields
|
|
563
|
+
// reversed, so zsets are re-paired; the others are already in write order.
|
|
564
|
+
const args = contents.type === 'zset'
|
|
565
|
+
? contents.items.flatMap((_, i, arr) => i % 2 === 0 ? [arr[i + 1], arr[i]] : [])
|
|
566
|
+
: contents.items;
|
|
567
|
+
for (const part of chunk(args, ELEMENT_CHUNK)) {
|
|
568
|
+
cmds.push([writer, key, ...part]);
|
|
569
|
+
}
|
|
570
|
+
if (ttlMs > 0)
|
|
571
|
+
cmds.push(['PEXPIRE', key, String(ttlMs)]);
|
|
572
|
+
return cmds;
|
|
573
|
+
}
|
|
574
|
+
if (contents.kind === 'stream') {
|
|
575
|
+
if (contents.entries.length === 0)
|
|
576
|
+
return cmds;
|
|
577
|
+
cmds.push(['DEL', key]);
|
|
578
|
+
for (const entry of contents.entries) {
|
|
579
|
+
cmds.push(['XADD', key, entry.id, ...entry.fields]);
|
|
580
|
+
}
|
|
581
|
+
if (ttlMs > 0)
|
|
582
|
+
cmds.push(['PEXPIRE', key, String(ttlMs)]);
|
|
583
|
+
}
|
|
584
|
+
return cmds;
|
|
585
|
+
}
|
|
586
|
+
// Read one batch of keys off the source and rewrite them on the target with
|
|
587
|
+
// type-specific commands. Returns how many keys landed and which types had to
|
|
588
|
+
// be skipped.
|
|
589
|
+
async function copyBatchLogically(src, dst, keys) {
|
|
590
|
+
const probe = await src.pipeline(keys.flatMap((k) => [
|
|
591
|
+
['TYPE', k],
|
|
592
|
+
['PTTL', k],
|
|
593
|
+
]));
|
|
594
|
+
const types = keys.map((_, i) => replyToString(probe[i * 2]));
|
|
595
|
+
const ttls = keys.map((_, i) => {
|
|
596
|
+
const pttl = probe[i * 2 + 1];
|
|
597
|
+
return typeof pttl === 'number' && pttl > 0 ? pttl : 0;
|
|
598
|
+
});
|
|
599
|
+
// One round-trip for the first (and usually only) chunk of every key.
|
|
600
|
+
const readable = keys
|
|
601
|
+
.map((key, i) => ({ key, i, cmd: firstReadCommand(types[i], key) }))
|
|
602
|
+
.filter((r) => r.cmd !== null);
|
|
603
|
+
const firsts = readable.length > 0 ? await src.pipeline(readable.map((r) => r.cmd)) : [];
|
|
604
|
+
const skippedTypes = new Set();
|
|
605
|
+
let skipped = 0;
|
|
606
|
+
for (const type of types) {
|
|
607
|
+
// `none` means the key expired or was deleted between SCAN and TYPE.
|
|
608
|
+
if (type !== 'none' && !LOGICAL_COPY_TYPES.has(type)) {
|
|
609
|
+
skippedTypes.add(type);
|
|
610
|
+
skipped++;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
const writes = [];
|
|
614
|
+
let copied = 0;
|
|
615
|
+
for (const [n, entry] of readable.entries()) {
|
|
616
|
+
const contents = await readRemainder(src, entry.key, types[entry.i], firsts[n]);
|
|
617
|
+
if (contents.kind === 'skip')
|
|
618
|
+
continue;
|
|
619
|
+
const cmds = writeCommands(entry.key, contents, ttls[entry.i]);
|
|
620
|
+
if (cmds.length === 0)
|
|
621
|
+
continue;
|
|
622
|
+
writes.push(...cmds);
|
|
623
|
+
copied++;
|
|
624
|
+
}
|
|
625
|
+
for (const part of chunk(writes, ELEMENT_CHUNK)) {
|
|
626
|
+
await dst.pipeline(part);
|
|
627
|
+
}
|
|
628
|
+
return { copied, skipped, skippedTypes };
|
|
629
|
+
}
|
|
630
|
+
// Move one batch with DUMP/RESTORE. Returns null when the target refused the
|
|
631
|
+
// payload format (or an end does not implement the commands), which is the
|
|
632
|
+
// caller's signal to switch to the logical path for good.
|
|
633
|
+
async function copyBatchWithDumpRestore(src, dst, keys) {
|
|
634
|
+
const probe = await src.pipelineSettled(keys.flatMap((k) => [
|
|
635
|
+
['DUMP', k],
|
|
636
|
+
['PTTL', k],
|
|
637
|
+
]));
|
|
638
|
+
const restoreCmds = [];
|
|
639
|
+
for (let i = 0; i < keys.length; i++) {
|
|
640
|
+
const payload = probe[i * 2];
|
|
641
|
+
if (payload instanceof Error) {
|
|
642
|
+
// The source will not DUMP at all - nothing to retry, switch strategy.
|
|
643
|
+
if (shouldFallBackToLogicalCopy(payload.message))
|
|
644
|
+
return null;
|
|
645
|
+
throw payload;
|
|
646
|
+
}
|
|
647
|
+
const pttl = probe[i * 2 + 1];
|
|
648
|
+
// A key can vanish between SCAN and DUMP; DUMP returns null - skip it.
|
|
649
|
+
if (!Buffer.isBuffer(payload))
|
|
650
|
+
continue;
|
|
651
|
+
const ttlMs = typeof pttl === 'number' && pttl > 0 ? pttl : 0;
|
|
652
|
+
restoreCmds.push(['RESTORE', keys[i], String(ttlMs), payload, 'REPLACE']);
|
|
653
|
+
}
|
|
654
|
+
if (restoreCmds.length === 0)
|
|
655
|
+
return 0;
|
|
656
|
+
const results = await dst.pipelineSettled(restoreCmds);
|
|
657
|
+
const failures = results.filter((r) => r instanceof Error);
|
|
658
|
+
if (failures.length === 0)
|
|
659
|
+
return restoreCmds.length;
|
|
660
|
+
if (failures.every((f) => shouldFallBackToLogicalCopy(f.message)))
|
|
661
|
+
return null;
|
|
662
|
+
throw failures[0];
|
|
663
|
+
}
|
|
664
|
+
// Binary-safe keyspace copy: SCAN the source, move each batch into the target,
|
|
665
|
+
// and report progress as it goes. DUMP/RESTORE is tried first because it is
|
|
666
|
+
// exact and cheap; the moment the target refuses that payload format the copy
|
|
667
|
+
// switches - permanently, and re-doing the batch it was in the middle of - to a
|
|
668
|
+
// type-aware read/write walk that does not depend on either end's RDB version.
|
|
669
|
+
// Used by the `restore --from-url` path for redis/valkey.
|
|
228
670
|
export async function copyRedisKeyspace(source, target, options = {}) {
|
|
229
671
|
const batchSize = options.batchSize ?? 200;
|
|
230
672
|
const src = await RespClient.connect(source);
|
|
@@ -234,55 +676,42 @@ export async function copyRedisKeyspace(source, target, options = {}) {
|
|
|
234
676
|
const total = await src.dbsize();
|
|
235
677
|
dst = await RespClient.connect(target);
|
|
236
678
|
await dst.ping();
|
|
679
|
+
let strategy = options.strategy ?? 'dump-restore';
|
|
237
680
|
let cursor = '0';
|
|
238
681
|
let scanned = 0;
|
|
239
682
|
let restored = 0;
|
|
683
|
+
let skipped = 0;
|
|
684
|
+
const skippedTypes = new Set();
|
|
240
685
|
do {
|
|
241
686
|
const { cursor: next, keys } = await src.scan(cursor, batchSize);
|
|
242
687
|
cursor = next;
|
|
243
688
|
if (keys.length === 0)
|
|
244
689
|
continue;
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
for (let i = 0; i < keys.length; i++) {
|
|
252
|
-
const payload = probe[i * 2];
|
|
253
|
-
const pttl = probe[i * 2 + 1];
|
|
254
|
-
// A key can vanish between SCAN and DUMP; DUMP returns null - skip it.
|
|
255
|
-
if (!Buffer.isBuffer(payload))
|
|
690
|
+
if (strategy === 'dump-restore') {
|
|
691
|
+
const moved = await copyBatchWithDumpRestore(src, dst, keys);
|
|
692
|
+
if (moved !== null) {
|
|
693
|
+
scanned += keys.length;
|
|
694
|
+
restored += moved;
|
|
695
|
+
options.onProgress?.({ scanned, restored, total, strategy });
|
|
256
696
|
continue;
|
|
257
|
-
const ttlMs = typeof pttl === 'number' && pttl > 0 ? pttl : 0;
|
|
258
|
-
restoreCmds.push([
|
|
259
|
-
'RESTORE',
|
|
260
|
-
keys[i],
|
|
261
|
-
String(ttlMs),
|
|
262
|
-
payload,
|
|
263
|
-
'REPLACE',
|
|
264
|
-
]);
|
|
265
|
-
}
|
|
266
|
-
scanned += keys.length;
|
|
267
|
-
if (restoreCmds.length > 0) {
|
|
268
|
-
try {
|
|
269
|
-
await dst.pipeline(restoreCmds);
|
|
270
697
|
}
|
|
271
|
-
|
|
272
|
-
const message = error.message;
|
|
273
|
-
// RESTORE rejects a DUMP payload whose RDB version is newer than the
|
|
274
|
-
// target supports - i.e. the source is a newer engine version than
|
|
275
|
-
// the target. Surface that plainly instead of the raw checksum error.
|
|
276
|
-
if (/DUMP payload version|checksum are wrong/i.test(message)) {
|
|
277
|
-
throw new Error(`The target is an older engine version than the source, so its binary format is incompatible (${message}). Provision the target at the same major version as the source, or newer, and retry.`);
|
|
278
|
-
}
|
|
279
|
-
throw error;
|
|
280
|
-
}
|
|
281
|
-
restored += restoreCmds.length;
|
|
698
|
+
strategy = 'logical';
|
|
282
699
|
}
|
|
283
|
-
|
|
700
|
+
const result = await copyBatchLogically(src, dst, keys);
|
|
701
|
+
scanned += keys.length;
|
|
702
|
+
restored += result.copied;
|
|
703
|
+
skipped += result.skipped;
|
|
704
|
+
for (const t of result.skippedTypes)
|
|
705
|
+
skippedTypes.add(t);
|
|
706
|
+
options.onProgress?.({ scanned, restored, total, strategy });
|
|
284
707
|
} while (cursor !== '0');
|
|
285
|
-
return {
|
|
708
|
+
return {
|
|
709
|
+
keysCopied: restored,
|
|
710
|
+
total,
|
|
711
|
+
strategy,
|
|
712
|
+
skipped,
|
|
713
|
+
skippedTypes: [...skippedTypes].sort(),
|
|
714
|
+
};
|
|
286
715
|
}
|
|
287
716
|
finally {
|
|
288
717
|
src.close();
|