resplite 1.3.8 → 1.4.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 +11 -2
- package/docs/generated/migration-apply-dirty-concurrency-progress.md +7 -0
- package/package.json +1 -1
- package/src/migration/apply-dirty.js +180 -46
- package/src/migration/import-one.js +60 -11
- package/src/migration/index.js +12 -4
- package/test/unit/migration-apply-dirty.test.js +101 -0
- package/test/unit/migration-import-one.test.js +94 -0
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) : '—';
|
|
@@ -167,7 +166,17 @@ await rl.question('Stop app traffic to Redis, then press Enter to apply the fina
|
|
|
167
166
|
rl.close();
|
|
168
167
|
|
|
169
168
|
// Step 3 — Apply dirty keys that changed in Redis during bulk
|
|
170
|
-
await m.applyDirty({
|
|
169
|
+
await m.applyDirty({
|
|
170
|
+
concurrency: 32,
|
|
171
|
+
batchKeys: 5000,
|
|
172
|
+
onProgress: (r) => {
|
|
173
|
+
console.log(
|
|
174
|
+
`dirty processed=${r.dirty_keys_processed} pending=${r.dirty_pending} ` +
|
|
175
|
+
`applied=${r.dirty_keys_applied} deleted=${r.dirty_keys_deleted} ` +
|
|
176
|
+
`rate=${r.dirty_keys_per_second.toFixed(1)} keys/s eta=${r.dirty_eta_seconds ?? '—'}s`
|
|
177
|
+
);
|
|
178
|
+
},
|
|
179
|
+
});
|
|
171
180
|
|
|
172
181
|
// Step 3b — Stop tracker after cutover
|
|
173
182
|
await m.stopDirtyTracker();
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: 90dhbsvf0f
|
|
3
|
+
type: implementation
|
|
4
|
+
title: Apply dirty migration concurrency and progress
|
|
5
|
+
created: '2026-03-12 14:55:12'
|
|
6
|
+
---
|
|
7
|
+
Improved src/migration/apply-dirty.js to support concurrent dirty-key apply via options.concurrency (chunked Promise.all worker model) while preserving max_rps throttling. Added richer onProgress payload fields: dirty_keys_processed, dirty_pending, dirty_keys_per_second, dirty_eta_seconds, and related counters. Exposed new options through createMigration().applyDirty: concurrency and progressIntervalMs. Updated README migration cutover snippet with high-throughput applyDirty example and progress logging. Added unit test test/unit/migration-apply-dirty.test.js validating concurrency and progress payload. Verified with node --test test/unit/migration-apply-dirty.test.js and npm run test:unit (all passing).
|
package/package.json
CHANGED
|
@@ -16,6 +16,30 @@ function sleep(ms) {
|
|
|
16
16
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
function buildDirtyProgressPayload(run, startedAtMs, totalProcessed, totalFetched, pendingDirty, pendingDeleted) {
|
|
20
|
+
if (!run) return null;
|
|
21
|
+
const elapsedSec = Math.max(0.001, (Date.now() - startedAtMs) / 1000);
|
|
22
|
+
const keysPerSec = totalProcessed / elapsedSec;
|
|
23
|
+
const pendingTotal = pendingDirty + pendingDeleted;
|
|
24
|
+
const etaSeconds = keysPerSec > 0 ? Math.ceil(pendingTotal / keysPerSec) : null;
|
|
25
|
+
const applied = Number(run.dirty_keys_applied || 0);
|
|
26
|
+
const deleted = Number(run.dirty_keys_deleted || 0);
|
|
27
|
+
const reconciled = applied + deleted;
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
...run,
|
|
31
|
+
dirty_elapsed_seconds: elapsedSec,
|
|
32
|
+
dirty_keys_per_second: keysPerSec,
|
|
33
|
+
dirty_keys_processed: totalProcessed,
|
|
34
|
+
dirty_keys_fetched: totalFetched,
|
|
35
|
+
dirty_reconciled_total: reconciled,
|
|
36
|
+
dirty_pending: pendingTotal,
|
|
37
|
+
dirty_pending_dirty: pendingDirty,
|
|
38
|
+
dirty_pending_deleted: pendingDeleted,
|
|
39
|
+
dirty_eta_seconds: etaSeconds,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
19
43
|
/**
|
|
20
44
|
* Apply dirty keys: for each key in registry with state=dirty, reimport from Redis or delete in destination.
|
|
21
45
|
* @param {import('redis').RedisClientType} redisClient
|
|
@@ -25,10 +49,19 @@ function sleep(ms) {
|
|
|
25
49
|
* @param {string} [options.pragmaTemplate='default']
|
|
26
50
|
* @param {number} [options.batch_keys=200]
|
|
27
51
|
* @param {number} [options.max_rps=0]
|
|
52
|
+
* @param {number} [options.concurrency=1]
|
|
53
|
+
* @param {number} [options.progress_interval_ms=2000]
|
|
28
54
|
* @param {(run: object) => void | Promise<void>} [options.onProgress] - Called after each batch with the current run row.
|
|
29
55
|
*/
|
|
30
56
|
export async function runApplyDirty(redisClient, dbPath, runId, options = {}) {
|
|
31
|
-
const {
|
|
57
|
+
const {
|
|
58
|
+
pragmaTemplate = 'default',
|
|
59
|
+
batch_keys = 200,
|
|
60
|
+
max_rps = 0,
|
|
61
|
+
concurrency = 1,
|
|
62
|
+
progress_interval_ms = 2000,
|
|
63
|
+
onProgress,
|
|
64
|
+
} = options;
|
|
32
65
|
|
|
33
66
|
const db = openDb(dbPath, { pragmaTemplate });
|
|
34
67
|
const run = getRun(db, runId);
|
|
@@ -43,7 +76,41 @@ export async function runApplyDirty(redisClient, dbPath, runId, options = {}) {
|
|
|
43
76
|
const storages = { keys, strings, hashes, sets, lists, zsets };
|
|
44
77
|
|
|
45
78
|
const minIntervalMs = max_rps > 0 ? 1000 / max_rps : 0;
|
|
46
|
-
|
|
79
|
+
const workerCount = Number.isFinite(concurrency) ? Math.max(1, Math.floor(concurrency)) : 1;
|
|
80
|
+
let nextAllowedAt = 0;
|
|
81
|
+
const startedAtMs = Date.now();
|
|
82
|
+
let totalProcessed = 0;
|
|
83
|
+
let totalFetched = 0;
|
|
84
|
+
let lastProgressAt = 0;
|
|
85
|
+
|
|
86
|
+
async function awaitRateLimit() {
|
|
87
|
+
if (minIntervalMs <= 0) return;
|
|
88
|
+
const now = Date.now();
|
|
89
|
+
const scheduled = Math.max(now, nextAllowedAt);
|
|
90
|
+
nextAllowedAt = scheduled + minIntervalMs;
|
|
91
|
+
if (scheduled > now) {
|
|
92
|
+
await sleep(scheduled - now);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function emitProgress(force = false, pendingDirty = 0, pendingDeleted = 0) {
|
|
97
|
+
if (!onProgress) return;
|
|
98
|
+
const now = Date.now();
|
|
99
|
+
if (!force && now - lastProgressAt < progress_interval_ms) return;
|
|
100
|
+
const current = getRun(db, runId);
|
|
101
|
+
lastProgressAt = now;
|
|
102
|
+
if (current) {
|
|
103
|
+
const payload = buildDirtyProgressPayload(
|
|
104
|
+
current,
|
|
105
|
+
startedAtMs,
|
|
106
|
+
totalProcessed,
|
|
107
|
+
totalFetched,
|
|
108
|
+
pendingDirty,
|
|
109
|
+
pendingDeleted
|
|
110
|
+
);
|
|
111
|
+
Promise.resolve(onProgress(payload)).catch(() => {});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
47
114
|
|
|
48
115
|
for (;;) {
|
|
49
116
|
let r = getRun(db, runId);
|
|
@@ -57,79 +124,146 @@ export async function runApplyDirty(redisClient, dbPath, runId, options = {}) {
|
|
|
57
124
|
const deletedBatch = getDirtyBatch(db, runId, 'deleted', batch_keys);
|
|
58
125
|
if (dirtyBatch.length === 0 && deletedBatch.length === 0) break;
|
|
59
126
|
|
|
60
|
-
|
|
127
|
+
totalFetched += dirtyBatch.length + deletedBatch.length;
|
|
128
|
+
let aborted = false;
|
|
61
129
|
|
|
62
130
|
// ── Re-import (or remove) keys that changed while bulk was running ──
|
|
63
|
-
for (
|
|
131
|
+
for (let i = 0; i < dirtyBatch.length; i += workerCount) {
|
|
64
132
|
r = getRun(db, runId);
|
|
65
|
-
if (r && r.status === RUN_STATUS.ABORTED)
|
|
133
|
+
if (r && r.status === RUN_STATUS.ABORTED) {
|
|
134
|
+
aborted = true;
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
66
137
|
while (r && r.status === RUN_STATUS.PAUSED) {
|
|
67
138
|
await sleep(2000);
|
|
68
139
|
r = getRun(db, runId);
|
|
69
140
|
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
if (elapsed < minIntervalMs) await sleep(minIntervalMs - elapsed);
|
|
74
|
-
lastKeyTime = Date.now();
|
|
141
|
+
if (r && r.status === RUN_STATUS.ABORTED) {
|
|
142
|
+
aborted = true;
|
|
143
|
+
break;
|
|
75
144
|
}
|
|
76
145
|
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
146
|
+
const chunk = dirtyBatch.slice(i, i + workerCount);
|
|
147
|
+
const results = await Promise.all(
|
|
148
|
+
chunk.map(async ({ key: keyBuf }) => {
|
|
149
|
+
const keyName = keyBuf.toString('utf8');
|
|
150
|
+
try {
|
|
151
|
+
await awaitRateLimit();
|
|
152
|
+
const type = (await redisClient.type(keyName)).toLowerCase();
|
|
153
|
+
if (type === 'none' || !type) {
|
|
154
|
+
return { keyBuf, keyName, state: 'deleted' };
|
|
155
|
+
}
|
|
156
|
+
const outcome = await importKeyFromRedis(redisClient, keyName, storages, {});
|
|
157
|
+
return { keyBuf, keyName, state: 'imported', outcome };
|
|
158
|
+
} catch (err) {
|
|
159
|
+
return { keyBuf, keyName, state: 'exception', error: err };
|
|
160
|
+
}
|
|
161
|
+
})
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
for (const result of results) {
|
|
165
|
+
try {
|
|
166
|
+
if (result.state === 'deleted') {
|
|
167
|
+
keys.delete(result.keyBuf);
|
|
168
|
+
markDirtyState(db, runId, result.keyBuf, 'deleted');
|
|
169
|
+
} else if (result.state === 'imported') {
|
|
170
|
+
if (result.outcome.ok) {
|
|
171
|
+
markDirtyState(db, runId, result.keyBuf, 'applied');
|
|
172
|
+
} else if (result.outcome.skipped) {
|
|
173
|
+
markDirtyState(db, runId, result.keyBuf, 'skipped');
|
|
174
|
+
} else {
|
|
175
|
+
logError(
|
|
176
|
+
db,
|
|
177
|
+
runId,
|
|
178
|
+
'dirty_apply',
|
|
179
|
+
result.outcome.error ? 'Import failed' : 'Skipped',
|
|
180
|
+
result.keyName
|
|
181
|
+
);
|
|
182
|
+
markDirtyState(db, runId, result.keyBuf, 'error');
|
|
183
|
+
}
|
|
89
184
|
} else {
|
|
90
|
-
logError(db, runId, 'dirty_apply',
|
|
91
|
-
markDirtyState(db, runId, keyBuf, 'error');
|
|
185
|
+
logError(db, runId, 'dirty_apply', result.error.message, result.keyBuf);
|
|
186
|
+
markDirtyState(db, runId, result.keyBuf, 'error');
|
|
92
187
|
}
|
|
188
|
+
} catch (err) {
|
|
189
|
+
logError(db, runId, 'dirty_apply', err.message, result.keyBuf);
|
|
190
|
+
markDirtyState(db, runId, result.keyBuf, 'error');
|
|
191
|
+
} finally {
|
|
192
|
+
totalProcessed++;
|
|
93
193
|
}
|
|
94
|
-
} catch (err) {
|
|
95
|
-
logError(db, runId, 'dirty_apply', err.message, keyBuf);
|
|
96
|
-
markDirtyState(db, runId, keyBuf, 'error');
|
|
97
194
|
}
|
|
195
|
+
|
|
196
|
+
emitProgress(false, Math.max(0, dirtyBatch.length - (i + chunk.length)), deletedBatch.length);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (aborted) {
|
|
200
|
+
emitProgress(true, dirtyBatch.length, deletedBatch.length);
|
|
201
|
+
break;
|
|
98
202
|
}
|
|
99
203
|
|
|
100
204
|
// ── Apply deletions recorded by the tracker (del / expired events) ──
|
|
101
205
|
// The tracker already determined these keys are gone; delete from destination.
|
|
102
206
|
// Marked as 'deleted' in the run counter; state changed away from 'deleted'
|
|
103
207
|
// so the next getDirtyBatch call won't return them again (avoiding infinite loop).
|
|
104
|
-
for (
|
|
208
|
+
for (let i = 0; i < deletedBatch.length; i += workerCount) {
|
|
105
209
|
r = getRun(db, runId);
|
|
106
|
-
if (r && r.status === RUN_STATUS.ABORTED)
|
|
210
|
+
if (r && r.status === RUN_STATUS.ABORTED) {
|
|
211
|
+
aborted = true;
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
107
214
|
while (r && r.status === RUN_STATUS.PAUSED) {
|
|
108
215
|
await sleep(2000);
|
|
109
216
|
r = getRun(db, runId);
|
|
110
217
|
}
|
|
218
|
+
if (r && r.status === RUN_STATUS.ABORTED) {
|
|
219
|
+
aborted = true;
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
111
222
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
223
|
+
const chunk = deletedBatch.slice(i, i + workerCount);
|
|
224
|
+
for (const { key: keyBuf } of chunk) {
|
|
225
|
+
try {
|
|
226
|
+
keys.delete(keyBuf);
|
|
227
|
+
// Increment dirty_keys_deleted counter and transition state out of 'deleted'
|
|
228
|
+
// so this key is not re-processed in the next batch iteration.
|
|
229
|
+
const now = Date.now();
|
|
230
|
+
db.prepare(
|
|
231
|
+
`UPDATE migration_dirty_keys SET state = 'applied', last_seen_at = ? WHERE run_id = ? AND key = ?`
|
|
232
|
+
).run(now, runId, keyBuf);
|
|
233
|
+
db.prepare(
|
|
234
|
+
`UPDATE migration_runs SET dirty_keys_deleted = dirty_keys_deleted + 1, updated_at = ? WHERE run_id = ?`
|
|
235
|
+
).run(now, runId);
|
|
236
|
+
} catch (err) {
|
|
237
|
+
logError(db, runId, 'dirty_apply', err.message, keyBuf);
|
|
238
|
+
markDirtyState(db, runId, keyBuf, 'error');
|
|
239
|
+
} finally {
|
|
240
|
+
totalProcessed++;
|
|
241
|
+
}
|
|
126
242
|
}
|
|
243
|
+
emitProgress(false, 0, Math.max(0, deletedBatch.length - (i + chunk.length)));
|
|
244
|
+
if (aborted) break;
|
|
127
245
|
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
246
|
+
|
|
247
|
+
if (aborted) {
|
|
248
|
+
emitProgress(true, dirtyBatch.length, deletedBatch.length);
|
|
249
|
+
break;
|
|
131
250
|
}
|
|
251
|
+
|
|
252
|
+
const pendingDirty = db.prepare(
|
|
253
|
+
`SELECT COUNT(*) as n FROM migration_dirty_keys WHERE run_id = ? AND state = 'dirty'`
|
|
254
|
+
).get(runId).n;
|
|
255
|
+
const pendingDeleted = db.prepare(
|
|
256
|
+
`SELECT COUNT(*) as n FROM migration_dirty_keys WHERE run_id = ? AND state = 'deleted'`
|
|
257
|
+
).get(runId).n;
|
|
258
|
+
emitProgress(true, pendingDirty, pendingDeleted);
|
|
132
259
|
}
|
|
133
260
|
|
|
261
|
+
const finalPendingDirty = db.prepare(
|
|
262
|
+
`SELECT COUNT(*) as n FROM migration_dirty_keys WHERE run_id = ? AND state = 'dirty'`
|
|
263
|
+
).get(runId).n;
|
|
264
|
+
const finalPendingDeleted = db.prepare(
|
|
265
|
+
`SELECT COUNT(*) as n FROM migration_dirty_keys WHERE run_id = ? AND state = 'deleted'`
|
|
266
|
+
).get(runId).n;
|
|
267
|
+
emitProgress(true, finalPendingDirty, finalPendingDeleted);
|
|
134
268
|
return getRun(db, runId);
|
|
135
269
|
}
|
|
@@ -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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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 };
|
package/src/migration/index.js
CHANGED
|
@@ -36,7 +36,7 @@ import { startDirtyTracker as startDirtyTrackerProcess } from './tracker.js';
|
|
|
36
36
|
* @property {string} [pragmaTemplate='default'] - PRAGMA preset.
|
|
37
37
|
* @property {number} [scanCount=1000]
|
|
38
38
|
* @property {number} [maxRps=0] - Max requests/s (0 = unlimited).
|
|
39
|
-
* @property {number} [concurrency=1] - Concurrent imports during bulk migration.
|
|
39
|
+
* @property {number} [concurrency=1] - Concurrent imports during bulk/apply-dirty migration.
|
|
40
40
|
* @property {number} [estimatedTotalKeys=0] - Optional total-keys estimate for ETA/progress in onProgress.
|
|
41
41
|
* @property {number} [batchKeys=200]
|
|
42
42
|
* @property {number} [batchBytes=67108864] - 64 MB default.
|
|
@@ -54,7 +54,7 @@ import { startDirtyTracker as startDirtyTrackerProcess } from './tracker.js';
|
|
|
54
54
|
* stopDirtyTracker(): Promise<{ running: false }>,
|
|
55
55
|
* bulk(opts?: { resume?: boolean, onProgress?: function }): Promise<object>,
|
|
56
56
|
* status(): { run: object, dirty: object } | null,
|
|
57
|
-
* applyDirty(opts?: { batchKeys?: number, maxRps?: number, onProgress?: function }): Promise<object>,
|
|
57
|
+
* applyDirty(opts?: { batchKeys?: number, maxRps?: number, concurrency?: number, progressIntervalMs?: number, onProgress?: function }): Promise<object>,
|
|
58
58
|
* verify(opts?: { samplePct?: number, maxSample?: number }): Promise<object>,
|
|
59
59
|
* migrateSearch(opts?: { onlyIndices?: string[], scanCount?: number, maxRps?: number, batchDocs?: number, maxSuggestions?: number, skipExisting?: boolean, withSuggestions?: boolean, onProgress?: function }): Promise<object>,
|
|
60
60
|
* close(): Promise<void>,
|
|
@@ -205,15 +205,23 @@ export function createMigration({
|
|
|
205
205
|
/**
|
|
206
206
|
* Step 3 — Apply dirty: reconcile keys that changed in Redis during bulk import.
|
|
207
207
|
*
|
|
208
|
-
* @param {{ batchKeys?: number, maxRps?: number, onProgress?: (run: object) => void }} [opts]
|
|
208
|
+
* @param {{ batchKeys?: number, maxRps?: number, concurrency?: number, progressIntervalMs?: number, onProgress?: (run: object) => void }} [opts]
|
|
209
209
|
*/
|
|
210
|
-
async applyDirty({
|
|
210
|
+
async applyDirty({
|
|
211
|
+
batchKeys: bk = batchKeys,
|
|
212
|
+
maxRps: rps = maxRps,
|
|
213
|
+
concurrency: c = concurrency,
|
|
214
|
+
progressIntervalMs: pim = 2000,
|
|
215
|
+
onProgress,
|
|
216
|
+
} = {}) {
|
|
211
217
|
const id = requireRunId();
|
|
212
218
|
const client = await getClient();
|
|
213
219
|
return runApplyDirty(client, to, id, {
|
|
214
220
|
pragmaTemplate,
|
|
215
221
|
batch_keys: bk,
|
|
216
222
|
max_rps: rps,
|
|
223
|
+
concurrency: c,
|
|
224
|
+
progress_interval_ms: pim,
|
|
217
225
|
onProgress,
|
|
218
226
|
});
|
|
219
227
|
},
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for dirty apply concurrency/progress behavior.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it } from 'node:test';
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import { openDb } from '../../src/storage/sqlite/db.js';
|
|
8
|
+
import { runApplyDirty } from '../../src/migration/apply-dirty.js';
|
|
9
|
+
import { createRun, upsertDirtyKey, getDirtyCounts } from '../../src/migration/registry.js';
|
|
10
|
+
import { tmpDbPath } from '../helpers/tmp.js';
|
|
11
|
+
|
|
12
|
+
function sleep(ms) {
|
|
13
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
class FakeRedisStringClient {
|
|
17
|
+
constructor(initialValues, delayMs = 8) {
|
|
18
|
+
this.values = new Map(Object.entries(initialValues));
|
|
19
|
+
this.delayMs = delayMs;
|
|
20
|
+
this.inFlight = 0;
|
|
21
|
+
this.maxInFlight = 0;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async type(key) {
|
|
25
|
+
this.inFlight++;
|
|
26
|
+
this.maxInFlight = Math.max(this.maxInFlight, this.inFlight);
|
|
27
|
+
try {
|
|
28
|
+
await sleep(this.delayMs);
|
|
29
|
+
return this.values.has(key) ? 'string' : 'none';
|
|
30
|
+
} finally {
|
|
31
|
+
this.inFlight--;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async pTTL() {
|
|
36
|
+
this.inFlight++;
|
|
37
|
+
this.maxInFlight = Math.max(this.maxInFlight, this.inFlight);
|
|
38
|
+
try {
|
|
39
|
+
await sleep(this.delayMs);
|
|
40
|
+
return -1;
|
|
41
|
+
} finally {
|
|
42
|
+
this.inFlight--;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async get(key) {
|
|
47
|
+
this.inFlight++;
|
|
48
|
+
this.maxInFlight = Math.max(this.maxInFlight, this.inFlight);
|
|
49
|
+
try {
|
|
50
|
+
await sleep(this.delayMs);
|
|
51
|
+
return this.values.get(key) ?? null;
|
|
52
|
+
} finally {
|
|
53
|
+
this.inFlight--;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe('migration apply-dirty', () => {
|
|
59
|
+
it('processes dirty keys with configured concurrency and emits progress payloads', async () => {
|
|
60
|
+
const dbPath = tmpDbPath();
|
|
61
|
+
const runId = `apply-dirty-concurrency-${Date.now()}`;
|
|
62
|
+
const totalKeys = 30;
|
|
63
|
+
|
|
64
|
+
const db = openDb(dbPath, { pragmaTemplate: 'minimal' });
|
|
65
|
+
createRun(db, runId, 'redis://x:6379');
|
|
66
|
+
const initialValues = {};
|
|
67
|
+
for (let i = 0; i < totalKeys; i++) {
|
|
68
|
+
const key = `k:${i}`;
|
|
69
|
+
initialValues[key] = `v:${i}`;
|
|
70
|
+
upsertDirtyKey(db, runId, key, 'set');
|
|
71
|
+
}
|
|
72
|
+
db.close();
|
|
73
|
+
|
|
74
|
+
const fakeRedis = new FakeRedisStringClient(initialValues);
|
|
75
|
+
const progress = [];
|
|
76
|
+
|
|
77
|
+
const run = await runApplyDirty(fakeRedis, dbPath, runId, {
|
|
78
|
+
pragmaTemplate: 'minimal',
|
|
79
|
+
batch_keys: totalKeys,
|
|
80
|
+
concurrency: 8,
|
|
81
|
+
progress_interval_ms: 0,
|
|
82
|
+
onProgress: (r) => progress.push(r),
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
assert.equal(run.dirty_keys_applied, totalKeys);
|
|
86
|
+
assert.equal(run.dirty_keys_deleted, 0);
|
|
87
|
+
assert.ok(fakeRedis.maxInFlight > 1, `Expected concurrent calls, maxInFlight=${fakeRedis.maxInFlight}`);
|
|
88
|
+
assert.ok(progress.length >= 1, 'Expected at least one onProgress callback');
|
|
89
|
+
const last = progress[progress.length - 1];
|
|
90
|
+
assert.equal(last.dirty_pending, 0);
|
|
91
|
+
assert.equal(last.dirty_reconciled_total, totalKeys);
|
|
92
|
+
assert.ok(Number.isFinite(last.dirty_keys_per_second));
|
|
93
|
+
|
|
94
|
+
const verifyDb = openDb(dbPath, { pragmaTemplate: 'minimal' });
|
|
95
|
+
const counts = getDirtyCounts(verifyDb, runId);
|
|
96
|
+
verifyDb.close();
|
|
97
|
+
assert.equal(counts.dirty, 0);
|
|
98
|
+
assert.equal(counts.deleted, 0);
|
|
99
|
+
assert.equal(counts.applied, totalKeys);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -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
|
+
});
|