resplite 1.3.8 → 1.4.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.
package/README.md CHANGED
@@ -141,7 +141,6 @@ await m.startDirtyTracker({
141
141
  // Step 1 — Bulk import (checkpointed, resumable). Same script to start or continue.
142
142
  // Use keyCountEstimate from preflight to compute ETA/progress during bulk.
143
143
  await m.bulk({
144
- resume: true,
145
144
  estimatedTotalKeys: info.keyCountEstimate,
146
145
  onProgress: (r) => {
147
146
  const pct = r.progress_pct != null ? r.progress_pct.toFixed(1) : '—';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "resplite",
3
- "version": "1.3.8",
3
+ "version": "1.4.0",
4
4
  "description": "A RESP2 server with practical Redis compatibility, backed by SQLite",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -14,16 +14,35 @@ function toBuffer(value) {
14
14
  return Buffer.from(String(value), 'utf8');
15
15
  }
16
16
 
17
+ function parseZscanResult(raw) {
18
+ if (!Array.isArray(raw) || raw.length < 2) {
19
+ return { cursor: 0, entries: [] };
20
+ }
21
+ const cursor = parseInt(String(raw[0] ?? '0'), 10) || 0;
22
+ const flat = Array.isArray(raw[1]) ? raw[1] : [];
23
+ const entries = [];
24
+ for (let i = 0; i < flat.length; i += 2) {
25
+ const member = flat[i];
26
+ const score = flat[i + 1];
27
+ if (member == null || score == null) continue;
28
+ entries.push({ value: member, score: Number(score) });
29
+ }
30
+ return { cursor, entries };
31
+ }
32
+
17
33
  /**
18
34
  * Fetch one key from Redis and write to storages. Idempotent (upsert).
19
35
  * @param {import('redis').RedisClientType} redisClient
20
36
  * @param {string} keyName
21
37
  * @param {{ keys: import('../storage/sqlite/keys.js').ReturnType<import('../storage/sqlite/keys.js').createKeysStorage>; strings: ReturnType<import('../storage/sqlite/strings.js').createStringsStorage>; hashes: ReturnType<import('../storage/sqlite/hashes.js').createHashesStorage>; sets: ReturnType<import('../storage/sqlite/sets.js').createSetsStorage>; lists: ReturnType<import('../storage/sqlite/lists.js').createListsStorage>; zsets: ReturnType<import('../storage/sqlite/zsets.js').createZsetsStorage> }} storages
22
- * @param {{ now?: number }} options
38
+ * @param {{ now?: number, zsetScanCount?: number }} options
23
39
  * @returns {Promise<{ ok: boolean; skipped?: boolean; error?: boolean; bytes?: number }>}
24
40
  */
25
41
  export async function importKeyFromRedis(redisClient, keyName, storages, options = {}) {
26
42
  const now = options.now ?? Date.now();
43
+ const zsetScanCount = Number.isFinite(options.zsetScanCount)
44
+ ? Math.max(10, Math.floor(options.zsetScanCount))
45
+ : 1000;
27
46
  const { keys, strings, hashes, sets, lists, zsets } = storages;
28
47
 
29
48
  try {
@@ -85,16 +104,46 @@ export async function importKeyFromRedis(redisClient, keyName, storages, options
85
104
  }
86
105
 
87
106
  if (type === 'zset') {
88
- const withScores = await redisClient.zRangeWithScores(keyName, 0, -1);
89
- if (!withScores || !withScores.length) return { ok: false, skipped: true };
90
- const pairs = withScores.map((item) => ({
91
- member: toBuffer(item.value),
92
- score: Number(item.score),
93
- }));
94
- for (const p of pairs) bytes += p.member.length + 8;
95
- zsets.add(keyBuf, pairs, { updatedAt: now });
96
- keys.setExpires(keyBuf, expiresAt, now);
97
- return { ok: true, bytes };
107
+ try {
108
+ // Use cursor-based reads to avoid loading very large sorted sets in one call.
109
+ let cursor = 0;
110
+ let wroteAny = false;
111
+ do {
112
+ const raw = await redisClient.sendCommand([
113
+ 'ZSCAN',
114
+ keyName,
115
+ String(cursor),
116
+ 'COUNT',
117
+ String(zsetScanCount),
118
+ ]);
119
+ const parsed = parseZscanResult(raw);
120
+ cursor = parsed.cursor;
121
+ if (parsed.entries.length === 0) continue;
122
+ const pairs = parsed.entries.map((item) => ({
123
+ member: toBuffer(item.value),
124
+ score: Number(item.score),
125
+ }));
126
+ for (const p of pairs) bytes += p.member.length + 8;
127
+ zsets.add(keyBuf, pairs, { updatedAt: now });
128
+ wroteAny = true;
129
+ } while (cursor !== 0);
130
+
131
+ if (!wroteAny) return { ok: false, skipped: true };
132
+ keys.setExpires(keyBuf, expiresAt, now);
133
+ return { ok: true, bytes };
134
+ } catch {
135
+ // Fallback for clients/backends without command passthrough support.
136
+ const withScores = await redisClient.zRangeWithScores(keyName, 0, -1);
137
+ if (!withScores || !withScores.length) return { ok: false, skipped: true };
138
+ const pairs = withScores.map((item) => ({
139
+ member: toBuffer(item.value),
140
+ score: Number(item.score),
141
+ }));
142
+ for (const p of pairs) bytes += p.member.length + 8;
143
+ zsets.add(keyBuf, pairs, { updatedAt: now });
144
+ keys.setExpires(keyBuf, expiresAt, now);
145
+ return { ok: true, bytes };
146
+ }
98
147
  }
99
148
 
100
149
  return { ok: false, skipped: true };
@@ -0,0 +1,94 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { importKeyFromRedis } from '../../src/migration/import-one.js';
4
+
5
+ function makeStorages() {
6
+ const calls = {
7
+ zsetAdds: [],
8
+ setExpires: [],
9
+ };
10
+ return {
11
+ calls,
12
+ storages: {
13
+ keys: {
14
+ setExpires(key, expiresAt, updatedAt) {
15
+ calls.setExpires.push({ key, expiresAt, updatedAt });
16
+ },
17
+ },
18
+ strings: {},
19
+ hashes: {},
20
+ sets: {},
21
+ lists: {},
22
+ zsets: {
23
+ add(key, pairs) {
24
+ calls.zsetAdds.push({ key, pairs });
25
+ },
26
+ },
27
+ },
28
+ };
29
+ }
30
+
31
+ describe('importKeyFromRedis zset handling', () => {
32
+ it('imports large zsets with ZSCAN chunks', async () => {
33
+ const { storages, calls } = makeStorages();
34
+ let scanCalls = 0;
35
+ const redis = {
36
+ async type() {
37
+ return 'zset';
38
+ },
39
+ async pTTL() {
40
+ return -1;
41
+ },
42
+ async sendCommand(argv) {
43
+ assert.equal(argv[0], 'ZSCAN');
44
+ scanCalls += 1;
45
+ if (scanCalls === 1) return ['7', ['a', '1', 'b', '2']];
46
+ if (scanCalls === 2) return ['0', ['c', '3']];
47
+ return ['0', []];
48
+ },
49
+ async zRangeWithScores() {
50
+ throw new Error('fallback should not be used when ZSCAN works');
51
+ },
52
+ };
53
+
54
+ const result = await importKeyFromRedis(redis, 'big:zset', storages, { now: 1000, zsetScanCount: 2 });
55
+
56
+ assert.equal(result.ok, true);
57
+ assert.equal(result.skipped, undefined);
58
+ assert.equal(scanCalls, 2);
59
+ assert.equal(calls.zsetAdds.length, 2);
60
+ assert.equal(calls.zsetAdds[0].pairs.length, 2);
61
+ assert.equal(calls.zsetAdds[1].pairs.length, 1);
62
+ assert.equal(calls.setExpires.length, 1);
63
+ // key bytes + member bytes + score metadata estimate (8 bytes/member)
64
+ assert.equal(result.bytes, Buffer.byteLength('big:zset') + (1 + 1 + 1) + (3 * 8));
65
+ });
66
+
67
+ it('falls back to zRangeWithScores when ZSCAN passthrough is unavailable', async () => {
68
+ const { storages, calls } = makeStorages();
69
+ let fallbackUsed = false;
70
+ const redis = {
71
+ async type() {
72
+ return 'zset';
73
+ },
74
+ async pTTL() {
75
+ return -1;
76
+ },
77
+ async sendCommand() {
78
+ throw new Error('sendCommand not supported');
79
+ },
80
+ async zRangeWithScores() {
81
+ fallbackUsed = true;
82
+ return [{ value: 'member-1', score: 42 }];
83
+ },
84
+ };
85
+
86
+ const result = await importKeyFromRedis(redis, 'legacy:zset', storages, { now: 1000 });
87
+
88
+ assert.equal(result.ok, true);
89
+ assert.equal(fallbackUsed, true);
90
+ assert.equal(calls.zsetAdds.length, 1);
91
+ assert.equal(calls.zsetAdds[0].pairs.length, 1);
92
+ assert.equal(calls.setExpires.length, 1);
93
+ });
94
+ });