spindb 0.69.1 → 0.69.4

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,386 @@ 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
+ // The TARGET is full: it has a `maxmemory` and the copy reached it.
371
+ //
372
+ // This is NOT a fallback case. Neither strategy can write a key the server has
373
+ // refused to store, so retrying with the logical path would only walk into the
374
+ // same wall more slowly. It is also the one copy failure whose fix belongs to
375
+ // the operator rather than to spindb, so it gets said plainly instead of being
376
+ // handed over as a bare server code.
377
+ //
378
+ // `MISCONF` refuses writes identically from the caller's side and leaves the
379
+ // same half-populated keyspace, so it is handled here too - but its CAUSE is
380
+ // persistence, not memory, and telling the operator to raise `maxmemory` sends
381
+ // them at the wrong setting. The two are classified apart and worded apart.
382
+ function isTargetOutOfMemory(message) {
383
+ return /^(?:\(error\)\s*)?OOM\b/m.test(message.trim());
384
+ }
385
+ function isTargetPersistenceFailure(message) {
386
+ return /^(?:\(error\)\s*)?MISCONF\b/m.test(message.trim());
387
+ }
388
+ /** Either way the target refused the write and the copy is partial. */
389
+ function isTargetWriteRefusal(message) {
390
+ return isTargetOutOfMemory(message) || isTargetPersistenceFailure(message);
391
+ }
392
+ /**
393
+ * Restate a target-side write refusal in terms the person running the copy can
394
+ * act on, leaving every other error exactly as the server sent it.
395
+ */
396
+ export function describeTargetWriteFailure(error) {
397
+ const raw = error.message.trim();
398
+ if (isTargetOutOfMemory(error.message)) {
399
+ return new Error(`The target database is out of memory and refused the write, so only part ` +
400
+ `of the keyspace was copied. Its maxmemory is smaller than the source's ` +
401
+ `data. Raise the target's memory limit, or reduce what is being copied, ` +
402
+ `then run the copy again. (${raw})`);
403
+ }
404
+ if (isTargetPersistenceFailure(error.message)) {
405
+ return new Error(`The target database refused the write because its RDB/AOF persistence is ` +
406
+ `failing (MISCONF), so only part of the keyspace was copied. Check the ` +
407
+ `target server's disk space and its stop-writes-on-bgsave-error / save ` +
408
+ `settings, fix the background save, then run the copy again. (${raw})`);
409
+ }
410
+ return error;
411
+ }
412
+ // Value types the logical path knows how to read and rewrite.
413
+ const LOGICAL_COPY_TYPES = new Set([
414
+ 'string',
415
+ 'list',
416
+ 'set',
417
+ 'zset',
418
+ 'hash',
419
+ 'stream',
420
+ ]);
421
+ // Elements pulled per read round-trip, and written per command. Keeps a single
422
+ // reply (and a single command) bounded regardless of how large one key is, which
423
+ // matters both for our own memory and for providers that cap response size.
424
+ // Kept EVEN on purpose: hashes and sorted sets are written as field/value and
425
+ // score/member PAIRS, and an odd chunk would split one across two commands.
426
+ const ELEMENT_CHUNK = 512;
427
+ function replyToBuffer(reply) {
428
+ return Buffer.isBuffer(reply) ? reply : null;
429
+ }
430
+ function replyToString(reply) {
431
+ if (Buffer.isBuffer(reply))
432
+ return reply.toString('latin1');
433
+ if (typeof reply === 'string')
434
+ return reply;
435
+ return '';
436
+ }
437
+ function replyToBuffers(reply) {
438
+ return Array.isArray(reply)
439
+ ? reply.filter((r) => Buffer.isBuffer(r))
440
+ : [];
441
+ }
442
+ // A *SCAN reply: [cursor, [element, ...]].
443
+ function parseScanReply(reply) {
444
+ if (!Array.isArray(reply) || reply.length < 2) {
445
+ throw new Error('Unexpected SCAN reply while reading a collection');
446
+ }
447
+ return { cursor: replyToString(reply[0]), items: replyToBuffers(reply[1]) };
448
+ }
449
+ function chunk(items, size) {
450
+ const out = [];
451
+ for (let i = 0; i < items.length; i += size)
452
+ out.push(items.slice(i, i + size));
453
+ return out;
454
+ }
455
+ // The first read for a key of this type. Every one of these returns whatever
456
+ // fits in a single chunk, so a batch of keys can be read in ONE round-trip and
457
+ // only the oversized ones need a follow-up walk.
458
+ function firstReadCommand(type, key) {
459
+ switch (type) {
460
+ case 'string':
461
+ return ['GET', key];
462
+ case 'hash':
463
+ return ['HSCAN', key, '0', 'COUNT', String(ELEMENT_CHUNK)];
464
+ case 'set':
465
+ return ['SSCAN', key, '0', 'COUNT', String(ELEMENT_CHUNK)];
466
+ case 'zset':
467
+ return ['ZSCAN', key, '0', 'COUNT', String(ELEMENT_CHUNK)];
468
+ case 'list':
469
+ return ['LRANGE', key, '0', String(ELEMENT_CHUNK - 1)];
470
+ case 'stream':
471
+ return ['XRANGE', key, '-', '+', 'COUNT', String(ELEMENT_CHUNK)];
472
+ default:
473
+ return null;
474
+ }
475
+ }
476
+ // The largest sequence a stream id can hold: both halves of a stream id are
477
+ // unsigned 64-bit, and Redis rolls a full sequence over into the next
478
+ // millisecond rather than refusing the entry.
479
+ const STREAM_ID_PART_MAX = 18446744073709551615n;
480
+ // Stream ids are `<ms>-<seq>`. XRANGE ranges are inclusive, and the exclusive
481
+ // `(` form only exists from Redis 6.2, so the next page starts at seq+1 of the
482
+ // last id we saw - a form every version understands.
483
+ //
484
+ // The arithmetic is BigInt because both halves are uint64 and a Number cannot
485
+ // hold one past 2^53. `Number('9007199254740993') + 1` is 9007199254740994 by
486
+ // luck, but `Number('18446744073709551615') + 1` is 18446744073709552000 - a
487
+ // value that is not the next id and is not even in the stream, so the walk
488
+ // would silently skip the tail of a large stream. A sequence that is already at
489
+ // the maximum carries into the next millisecond, exactly as Redis does when it
490
+ // assigns one.
491
+ export function nextStreamId(id) {
492
+ const match = /^(\d+)(?:-(\d+))?$/.exec(id);
493
+ if (!match) {
494
+ throw new Error(`Unexpected stream entry id from the source: ${id}`);
495
+ }
496
+ const ms = BigInt(match[1]);
497
+ const seq = match[2] === undefined ? 0n : BigInt(match[2]);
498
+ return seq >= STREAM_ID_PART_MAX ? `${ms + 1n}-0` : `${ms}-${seq + 1n}`;
499
+ }
500
+ function parseStreamEntries(reply) {
501
+ if (!Array.isArray(reply))
502
+ return [];
503
+ const entries = [];
504
+ for (const item of reply) {
505
+ if (!Array.isArray(item) || item.length < 2)
506
+ continue;
507
+ entries.push({
508
+ id: replyToString(item[0]),
509
+ fields: replyToBuffers(item[1]),
510
+ });
511
+ }
512
+ return entries;
513
+ }
514
+ // Finish reading a key whose first chunk came back full. `first` is that chunk.
515
+ async function readRemainder(src, key, type, first) {
516
+ switch (type) {
517
+ case 'string': {
518
+ const value = replyToBuffer(first);
519
+ // A key that vanished between SCAN and GET is a non-event, not an error.
520
+ return value ? { kind: 'string', value } : { kind: 'skip', type: 'none' };
521
+ }
522
+ case 'hash':
523
+ case 'set':
524
+ case 'zset': {
525
+ const { cursor, items } = parseScanReply(first);
526
+ const all = items;
527
+ let next = cursor;
528
+ while (next !== '0') {
529
+ const page = parseScanReply(await src.command([
530
+ type === 'hash' ? 'HSCAN' : type === 'set' ? 'SSCAN' : 'ZSCAN',
531
+ key,
532
+ next,
533
+ 'COUNT',
534
+ String(ELEMENT_CHUNK),
535
+ ]));
536
+ all.push(...page.items);
537
+ next = page.cursor;
538
+ }
539
+ return { kind: 'elements', type, items: all };
540
+ }
541
+ case 'list': {
542
+ const all = replyToBuffers(first);
543
+ // A short page means the end of the list; a full one means there may be
544
+ // more, so keep walking from where the last page stopped.
545
+ while (all.length > 0 && all.length % ELEMENT_CHUNK === 0) {
546
+ const page = replyToBuffers(await src.command([
547
+ 'LRANGE',
548
+ key,
549
+ String(all.length),
550
+ String(all.length + ELEMENT_CHUNK - 1),
551
+ ]));
552
+ if (page.length === 0)
553
+ break;
554
+ all.push(...page);
555
+ }
556
+ return { kind: 'elements', type: 'list', items: all };
557
+ }
558
+ case 'stream': {
559
+ const entries = parseStreamEntries(first);
560
+ while (entries.length > 0 && entries.length % ELEMENT_CHUNK === 0) {
561
+ const page = parseStreamEntries(await src.command([
562
+ 'XRANGE',
563
+ key,
564
+ nextStreamId(entries[entries.length - 1].id),
565
+ '+',
566
+ 'COUNT',
567
+ String(ELEMENT_CHUNK),
568
+ ]));
569
+ if (page.length === 0)
570
+ break;
571
+ entries.push(...page);
572
+ }
573
+ return { kind: 'stream', entries };
574
+ }
575
+ default:
576
+ return { kind: 'skip', type };
577
+ }
578
+ }
579
+ // The commands that recreate one key on the target. A collection is DELeted
580
+ // first so a re-run replaces it instead of appending to it - the same
581
+ // replace-in-place semantics RESTORE ... REPLACE gives the fast path.
582
+ function writeCommands(key, contents, ttlMs) {
583
+ const cmds = [];
584
+ if (contents.kind === 'string') {
585
+ // SET replaces a key of ANY previous type, so it needs no DEL, and PX folds
586
+ // the expiry into the same command.
587
+ cmds.push(ttlMs > 0
588
+ ? ['SET', key, contents.value, 'PX', String(ttlMs)]
589
+ : ['SET', key, contents.value]);
590
+ return cmds;
591
+ }
592
+ if (contents.kind === 'elements') {
593
+ if (contents.items.length === 0)
594
+ return cmds; // an empty collection cannot exist
595
+ cmds.push(['DEL', key]);
596
+ const writer = contents.type === 'hash'
597
+ ? 'HSET'
598
+ : contents.type === 'set'
599
+ ? 'SADD'
600
+ : contents.type === 'zset'
601
+ ? 'ZADD'
602
+ : 'RPUSH';
603
+ // ZSCAN and HSCAN return flat member/score and field/value pairs. ZADD
604
+ // wants score BEFORE member, which is the order ZSCAN already yields
605
+ // reversed, so zsets are re-paired; the others are already in write order.
606
+ const args = contents.type === 'zset'
607
+ ? contents.items.flatMap((_, i, arr) => i % 2 === 0 ? [arr[i + 1], arr[i]] : [])
608
+ : contents.items;
609
+ for (const part of chunk(args, ELEMENT_CHUNK)) {
610
+ cmds.push([writer, key, ...part]);
611
+ }
612
+ if (ttlMs > 0)
613
+ cmds.push(['PEXPIRE', key, String(ttlMs)]);
614
+ return cmds;
615
+ }
616
+ if (contents.kind === 'stream') {
617
+ if (contents.entries.length === 0)
618
+ return cmds;
619
+ cmds.push(['DEL', key]);
620
+ for (const entry of contents.entries) {
621
+ cmds.push(['XADD', key, entry.id, ...entry.fields]);
622
+ }
623
+ if (ttlMs > 0)
624
+ cmds.push(['PEXPIRE', key, String(ttlMs)]);
625
+ }
626
+ return cmds;
627
+ }
628
+ // Read one batch of keys off the source and rewrite them on the target with
629
+ // type-specific commands. Returns how many keys landed and which types had to
630
+ // be skipped.
631
+ async function copyBatchLogically(src, dst, keys) {
632
+ const probe = await src.pipeline(keys.flatMap((k) => [
633
+ ['TYPE', k],
634
+ ['PTTL', k],
635
+ ]));
636
+ const types = keys.map((_, i) => replyToString(probe[i * 2]));
637
+ const ttls = keys.map((_, i) => {
638
+ const pttl = probe[i * 2 + 1];
639
+ return typeof pttl === 'number' && pttl > 0 ? pttl : 0;
640
+ });
641
+ // One round-trip for the first (and usually only) chunk of every key.
642
+ const readable = keys
643
+ .map((key, i) => ({ key, i, cmd: firstReadCommand(types[i], key) }))
644
+ .filter((r) => r.cmd !== null);
645
+ const firsts = readable.length > 0 ? await src.pipeline(readable.map((r) => r.cmd)) : [];
646
+ const skippedTypes = new Set();
647
+ let skipped = 0;
648
+ for (const type of types) {
649
+ // `none` means the key expired or was deleted between SCAN and TYPE.
650
+ if (type !== 'none' && !LOGICAL_COPY_TYPES.has(type)) {
651
+ skippedTypes.add(type);
652
+ skipped++;
653
+ }
654
+ }
655
+ const writes = [];
656
+ let copied = 0;
657
+ for (const [n, entry] of readable.entries()) {
658
+ const contents = await readRemainder(src, entry.key, types[entry.i], firsts[n]);
659
+ if (contents.kind === 'skip')
660
+ continue;
661
+ const cmds = writeCommands(entry.key, contents, ttls[entry.i]);
662
+ if (cmds.length === 0)
663
+ continue;
664
+ writes.push(...cmds);
665
+ copied++;
666
+ }
667
+ for (const part of chunk(writes, ELEMENT_CHUNK)) {
668
+ try {
669
+ await dst.pipeline(part);
670
+ }
671
+ catch (error) {
672
+ // Same restatement as the DUMP/RESTORE path: the logical walk hits the
673
+ // target's ceiling in exactly the same way, and this is the strategy
674
+ // there is no falling back FROM.
675
+ throw describeTargetWriteFailure(error instanceof Error ? error : new Error(String(error)));
676
+ }
677
+ }
678
+ return { copied, skipped, skippedTypes };
679
+ }
680
+ // Move one batch with DUMP/RESTORE. Returns null when the target refused the
681
+ // payload format (or an end does not implement the commands), which is the
682
+ // caller's signal to switch to the logical path for good.
683
+ async function copyBatchWithDumpRestore(src, dst, keys) {
684
+ const probe = await src.pipelineSettled(keys.flatMap((k) => [
685
+ ['DUMP', k],
686
+ ['PTTL', k],
687
+ ]));
688
+ const restoreCmds = [];
689
+ for (let i = 0; i < keys.length; i++) {
690
+ const payload = probe[i * 2];
691
+ if (payload instanceof Error) {
692
+ // The source will not DUMP at all - nothing to retry, switch strategy.
693
+ if (shouldFallBackToLogicalCopy(payload.message))
694
+ return null;
695
+ throw payload;
696
+ }
697
+ const pttl = probe[i * 2 + 1];
698
+ // A key can vanish between SCAN and DUMP; DUMP returns null - skip it.
699
+ if (!Buffer.isBuffer(payload))
700
+ continue;
701
+ const ttlMs = typeof pttl === 'number' && pttl > 0 ? pttl : 0;
702
+ restoreCmds.push(['RESTORE', keys[i], String(ttlMs), payload, 'REPLACE']);
703
+ }
704
+ if (restoreCmds.length === 0)
705
+ return 0;
706
+ const results = await dst.pipelineSettled(restoreCmds);
707
+ const failures = results.filter((r) => r instanceof Error);
708
+ if (failures.length === 0)
709
+ return restoreCmds.length;
710
+ if (failures.every((f) => shouldFallBackToLogicalCopy(f.message)))
711
+ return null;
712
+ // A pipeline can carry several kinds of failure at once. A target-side write
713
+ // refusal (OOM / MISCONF) is the one the operator has to act on, and it is
714
+ // the reason the copy is partial, so it wins over an earlier per-key error
715
+ // that would otherwise be reported just for arriving first.
716
+ const refusal = failures.find((f) => isTargetWriteRefusal(f.message));
717
+ throw describeTargetWriteFailure(refusal ?? failures[0]);
718
+ }
719
+ // Binary-safe keyspace copy: SCAN the source, move each batch into the target,
720
+ // and report progress as it goes. DUMP/RESTORE is tried first because it is
721
+ // exact and cheap; the moment the target refuses that payload format the copy
722
+ // switches - permanently, and re-doing the batch it was in the middle of - to a
723
+ // type-aware read/write walk that does not depend on either end's RDB version.
724
+ // Used by the `restore --from-url` path for redis/valkey.
228
725
  export async function copyRedisKeyspace(source, target, options = {}) {
229
726
  const batchSize = options.batchSize ?? 200;
230
727
  const src = await RespClient.connect(source);
@@ -234,55 +731,42 @@ export async function copyRedisKeyspace(source, target, options = {}) {
234
731
  const total = await src.dbsize();
235
732
  dst = await RespClient.connect(target);
236
733
  await dst.ping();
734
+ let strategy = options.strategy ?? 'dump-restore';
237
735
  let cursor = '0';
238
736
  let scanned = 0;
239
737
  let restored = 0;
738
+ let skipped = 0;
739
+ const skippedTypes = new Set();
240
740
  do {
241
741
  const { cursor: next, keys } = await src.scan(cursor, batchSize);
242
742
  cursor = next;
243
743
  if (keys.length === 0)
244
744
  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))
745
+ if (strategy === 'dump-restore') {
746
+ const moved = await copyBatchWithDumpRestore(src, dst, keys);
747
+ if (moved !== null) {
748
+ scanned += keys.length;
749
+ restored += moved;
750
+ options.onProgress?.({ scanned, restored, total, strategy });
256
751
  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
752
  }
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;
753
+ strategy = 'logical';
282
754
  }
283
- options.onProgress?.({ scanned, restored, total });
755
+ const result = await copyBatchLogically(src, dst, keys);
756
+ scanned += keys.length;
757
+ restored += result.copied;
758
+ skipped += result.skipped;
759
+ for (const t of result.skippedTypes)
760
+ skippedTypes.add(t);
761
+ options.onProgress?.({ scanned, restored, total, strategy });
284
762
  } while (cursor !== '0');
285
- return { keysCopied: restored, total };
763
+ return {
764
+ keysCopied: restored,
765
+ total,
766
+ strategy,
767
+ skipped,
768
+ skippedTypes: [...skippedTypes].sort(),
769
+ };
286
770
  }
287
771
  finally {
288
772
  src.close();