spindb 0.69.1 → 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.
@@ -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
- return { value: new Error(line), offset: lineEnd };
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
- const parsed = parseReply(this.inbox, offset);
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
- s.destroy();
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
- if (opts.password) {
192
- await client.command(buildRespAuthArgs(opts.password, opts.username));
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
- if (opts.database && opts.database > 0) {
195
- await client.command(['SELECT', String(opts.database)]);
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
- // Binary-safe keyspace copy: SCAN the source, pipeline DUMP+PTTL per batch, and
225
- // RESTORE (with TTL, REPLACE) into the target. Preserves every type and TTL
226
- // exactly because DUMP/RESTORE moves Redis's own serialization. Used by the
227
- // `restore --from-url` path for redis/valkey. Returns the number of keys copied.
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
- // DUMP + PTTL for every key in the batch, in one round-trip.
246
- const probe = await src.pipeline(keys.flatMap((k) => [
247
- ['DUMP', k],
248
- ['PTTL', k],
249
- ]));
250
- const restoreCmds = [];
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
- catch (error) {
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
- options.onProgress?.({ scanned, restored, total });
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 { keysCopied: restored, total };
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();