resplite 1.2.4 → 1.2.6
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
|
@@ -217,10 +217,19 @@ const ks = await m.enableKeyspaceNotifications();
|
|
|
217
217
|
// → { ok: true, previous: '', applied: 'KEA' }
|
|
218
218
|
// If CONFIG is renamed and configCommand was not set, ok=false and error explains how to fix it.
|
|
219
219
|
|
|
220
|
+
// Step 0c — Start dirty tracking (in-process, same script)
|
|
221
|
+
await m.startDirtyTracker({
|
|
222
|
+
onProgress: (p) => {
|
|
223
|
+
// one callback per keyspace event tracked during bulk/cutover
|
|
224
|
+
console.log(`[dirty ${p.totalEvents}] event=${p.event} key=${p.key}`);
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
|
|
220
228
|
// Step 1 — Bulk import (checkpointed, resumable). Same script to start or continue.
|
|
221
229
|
// Use keyCountEstimate from preflight to show progress % (estimate; actual count may change).
|
|
222
230
|
const total = info.keyCountEstimate || 1;
|
|
223
231
|
await m.bulk({
|
|
232
|
+
resume: true,
|
|
224
233
|
onProgress: (r) => {
|
|
225
234
|
const pct = total ? ((r.scanned_keys / total) * 100).toFixed(1) : '—';
|
|
226
235
|
console.log(
|
|
@@ -236,6 +245,9 @@ console.log('bulk status:', run.status, '— dirty counts:', dirty);
|
|
|
236
245
|
// Step 2 — Apply dirty keys that changed in Redis during bulk
|
|
237
246
|
await m.applyDirty();
|
|
238
247
|
|
|
248
|
+
// Step 2b — Stop tracker after cutover
|
|
249
|
+
await m.stopDirtyTracker();
|
|
250
|
+
|
|
239
251
|
// Step 3 — Verify a sample of keys match between Redis and the destination
|
|
240
252
|
const result = await m.verify({ samplePct: 0.5, maxSample: 10000 });
|
|
241
253
|
console.log(`verified ${result.sampled} keys — mismatches: ${result.mismatches.length}`);
|
|
@@ -244,13 +256,13 @@ console.log(`verified ${result.sampled} keys — mismatches: ${result.mismatches
|
|
|
244
256
|
await m.close();
|
|
245
257
|
```
|
|
246
258
|
|
|
247
|
-
**Automatic resume (default)**
|
|
259
|
+
**Bult: Automatic resume (default)**
|
|
248
260
|
`resume` defaults to `true`. It doesn't matter whether it's the first run or a resume: the same script works for both starting and continuing. The first run starts from cursor 0; if the process is interrupted (Ctrl+C, crash, etc.), running the script again continues from the last checkpoint. You don't need to pass `resume: false` on the first run or change anything to resume.
|
|
249
261
|
|
|
250
262
|
**Graceful shutdown**
|
|
251
263
|
On SIGINT (Ctrl+C) or SIGTERM, the bulk importer checkpoints progress, sets the run status to `aborted`, closes the SQLite database cleanly (so WAL is checkpointed and the file is not left open), then exits. You can safely interrupt a long-running bulk and resume later.
|
|
252
264
|
|
|
253
|
-
The dirty-key tracker
|
|
265
|
+
The JS API can run the dirty-key tracker in-process via `m.startDirtyTracker()` / `m.stopDirtyTracker()`, so the full flow can run from a single script. You can still use `npx resplite-dirty-tracker start|stop` if you prefer a separate process.
|
|
254
266
|
|
|
255
267
|
#### Renamed CONFIG command
|
|
256
268
|
|
package/package.json
CHANGED
package/src/migration/index.js
CHANGED
|
@@ -26,6 +26,7 @@ import { runApplyDirty } from './apply-dirty.js';
|
|
|
26
26
|
import { runVerify } from './verify.js';
|
|
27
27
|
import { runMigrateSearch } from './migrate-search.js';
|
|
28
28
|
import { getRun, getDirtyCounts } from './registry.js';
|
|
29
|
+
import { startDirtyTracker as startDirtyTrackerProcess } from './tracker.js';
|
|
29
30
|
|
|
30
31
|
/**
|
|
31
32
|
* @typedef {object} MigrationOptions
|
|
@@ -47,6 +48,8 @@ import { getRun, getDirtyCounts } from './registry.js';
|
|
|
47
48
|
* @returns {{
|
|
48
49
|
* preflight(): Promise<object>,
|
|
49
50
|
* enableKeyspaceNotifications(opts?: { value?: string, merge?: boolean }): Promise<{ ok: boolean, previous: string|null, applied: string, error?: string }>,
|
|
51
|
+
* startDirtyTracker(opts?: { pragmaTemplate?: string, onProgress?: function }): Promise<{ running: true }>,
|
|
52
|
+
* stopDirtyTracker(): Promise<{ running: false }>,
|
|
50
53
|
* bulk(opts?: { resume?: boolean, onProgress?: function }): Promise<object>,
|
|
51
54
|
* status(): { run: object, dirty: object } | null,
|
|
52
55
|
* applyDirty(opts?: { batchKeys?: number, maxRps?: number }): Promise<object>,
|
|
@@ -69,6 +72,7 @@ export function createMigration({
|
|
|
69
72
|
if (!to) throw new Error('createMigration: "to" (db path) is required');
|
|
70
73
|
|
|
71
74
|
let _client = null;
|
|
75
|
+
let _tracker = null;
|
|
72
76
|
|
|
73
77
|
async function getClient() {
|
|
74
78
|
if (_client) return _client;
|
|
@@ -113,6 +117,41 @@ export function createMigration({
|
|
|
113
117
|
return setKeyspaceEvents(client, value, { configCommand, merge });
|
|
114
118
|
},
|
|
115
119
|
|
|
120
|
+
/**
|
|
121
|
+
* Start dirty-key tracking in-process for this migration controller.
|
|
122
|
+
* Use this to run the full minimal-downtime flow in one Node script.
|
|
123
|
+
*
|
|
124
|
+
* @param {{
|
|
125
|
+
* pragmaTemplate?: string,
|
|
126
|
+
* onProgress?: (progress: { runId: string, key: string, event: string, totalEvents: number, at: string }) => void | Promise<void>
|
|
127
|
+
* }} [opts]
|
|
128
|
+
*/
|
|
129
|
+
async startDirtyTracker({ pragmaTemplate: pt = pragmaTemplate, onProgress } = {}) {
|
|
130
|
+
if (_tracker) return { running: true };
|
|
131
|
+
const id = requireRunId();
|
|
132
|
+
_tracker = await startDirtyTrackerProcess({
|
|
133
|
+
from,
|
|
134
|
+
to,
|
|
135
|
+
runId: id,
|
|
136
|
+
pragmaTemplate: pt,
|
|
137
|
+
configCommand,
|
|
138
|
+
onProgress,
|
|
139
|
+
});
|
|
140
|
+
return { running: true };
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Stop in-process dirty-key tracking started by `startDirtyTracker`.
|
|
145
|
+
* Safe to call even if tracking is not running.
|
|
146
|
+
*/
|
|
147
|
+
async stopDirtyTracker() {
|
|
148
|
+
if (_tracker) {
|
|
149
|
+
await _tracker.stop();
|
|
150
|
+
_tracker = null;
|
|
151
|
+
}
|
|
152
|
+
return { running: false };
|
|
153
|
+
},
|
|
154
|
+
|
|
116
155
|
/**
|
|
117
156
|
* Step 1 — Bulk import: SCAN all keys from Redis into the destination DB.
|
|
118
157
|
* Resume is on by default: first run starts from 0, later runs continue from checkpoint.
|
|
@@ -208,6 +247,10 @@ export function createMigration({
|
|
|
208
247
|
* Disconnect from Redis. Call when done with all migration operations.
|
|
209
248
|
*/
|
|
210
249
|
async close() {
|
|
250
|
+
if (_tracker) {
|
|
251
|
+
await _tracker.stop().catch(() => {});
|
|
252
|
+
_tracker = null;
|
|
253
|
+
}
|
|
211
254
|
if (_client) {
|
|
212
255
|
await _client.quit().catch(() => {});
|
|
213
256
|
_client = null;
|
package/src/migration/tracker.js
CHANGED
|
@@ -27,6 +27,13 @@ const KEYEVENT_PATTERN = '__keyevent@0__:*';
|
|
|
27
27
|
* @param {string} options.runId - Migration run identifier.
|
|
28
28
|
* @param {string} [options.pragmaTemplate='default']
|
|
29
29
|
* @param {string} [options.configCommand='CONFIG'] - CONFIG command name (in case it was renamed).
|
|
30
|
+
* @param {(progress: {
|
|
31
|
+
* runId: string,
|
|
32
|
+
* key: string,
|
|
33
|
+
* event: string,
|
|
34
|
+
* totalEvents: number,
|
|
35
|
+
* at: string
|
|
36
|
+
* }) => void | Promise<void>} [options.onProgress] - Called for each tracked keyspace event.
|
|
30
37
|
* @returns {Promise<{ stop(): Promise<void> }>}
|
|
31
38
|
* @throws {Error} If keyspace notifications are not enabled on Redis.
|
|
32
39
|
*/
|
|
@@ -36,6 +43,7 @@ export async function startDirtyTracker({
|
|
|
36
43
|
runId,
|
|
37
44
|
pragmaTemplate = 'default',
|
|
38
45
|
configCommand = 'CONFIG',
|
|
46
|
+
onProgress,
|
|
39
47
|
} = {}) {
|
|
40
48
|
if (!to) throw new Error('startDirtyTracker: "to" (db path) is required');
|
|
41
49
|
if (!runId) throw new Error('startDirtyTracker: "runId" is required');
|
|
@@ -66,12 +74,27 @@ export async function startDirtyTracker({
|
|
|
66
74
|
);
|
|
67
75
|
await subClient.connect();
|
|
68
76
|
|
|
77
|
+
let totalEvents = 0;
|
|
69
78
|
await subClient.pSubscribe(KEYEVENT_PATTERN, (message, channel) => {
|
|
70
79
|
const event = typeof channel === 'string'
|
|
71
80
|
? channel.split(':').pop()
|
|
72
81
|
: String(channel ?? '').split(':').pop() || 'unknown';
|
|
73
82
|
try {
|
|
74
83
|
upsertDirtyKey(db, runId, message, event);
|
|
84
|
+
totalEvents += 1;
|
|
85
|
+
if (onProgress) {
|
|
86
|
+
Promise.resolve(
|
|
87
|
+
onProgress({
|
|
88
|
+
runId,
|
|
89
|
+
key: message,
|
|
90
|
+
event,
|
|
91
|
+
totalEvents,
|
|
92
|
+
at: new Date().toISOString(),
|
|
93
|
+
})
|
|
94
|
+
).catch((err) => {
|
|
95
|
+
logError(db, runId, 'dirty_apply', 'Tracker onProgress error: ' + err.message, message);
|
|
96
|
+
});
|
|
97
|
+
}
|
|
75
98
|
} catch (err) {
|
|
76
99
|
logError(db, runId, 'dirty_apply', err.message, message);
|
|
77
100
|
}
|
|
@@ -100,13 +100,17 @@ describe('dirty tracker integration', { timeout: 30_000 }, () => {
|
|
|
100
100
|
const runId = `tracker-test-${Date.now()}`;
|
|
101
101
|
|
|
102
102
|
const m = createMigration({ from: REDIS_URL, to: dbPath, runId });
|
|
103
|
+
const progressEvents = [];
|
|
103
104
|
|
|
104
105
|
const ks = await m.enableKeyspaceNotifications({ value: 'KEA' });
|
|
105
106
|
assert.ok(ks.ok, `Failed to enable keyspace notifications: ${ks.error}`);
|
|
106
107
|
|
|
107
|
-
// ── Start tracker BEFORE bulk
|
|
108
|
-
|
|
109
|
-
|
|
108
|
+
// ── Start tracker BEFORE bulk (same-script API) ─────────────────────
|
|
109
|
+
await m.startDirtyTracker({
|
|
110
|
+
onProgress: (p) => {
|
|
111
|
+
progressEvents.push(p);
|
|
112
|
+
},
|
|
113
|
+
});
|
|
110
114
|
try {
|
|
111
115
|
// ── Bulk import (captures the initial snapshot) ──────────────────
|
|
112
116
|
const run = await m.bulk();
|
|
@@ -121,7 +125,7 @@ describe('dirty tracker integration', { timeout: 30_000 }, () => {
|
|
|
121
125
|
// Give the tracker time to process the keyspace events
|
|
122
126
|
await new Promise((r) => setTimeout(r, 600));
|
|
123
127
|
} finally {
|
|
124
|
-
await
|
|
128
|
+
await m.stopDirtyTracker();
|
|
125
129
|
}
|
|
126
130
|
|
|
127
131
|
// ── Verify dirty keys were captured ─────────────────────────────────
|
|
@@ -130,6 +134,7 @@ describe('dirty tracker integration', { timeout: 30_000 }, () => {
|
|
|
130
134
|
dirty.dirty + dirty.deleted >= 3,
|
|
131
135
|
`Expected ≥3 dirty/deleted keys, got dirty=${dirty.dirty} deleted=${dirty.deleted}`
|
|
132
136
|
);
|
|
137
|
+
assert.ok(progressEvents.length >= 3, `Expected tracker onProgress events, got ${progressEvents.length}`);
|
|
133
138
|
|
|
134
139
|
// ── Apply-dirty: reconcile post-bulk changes ─────────────────────────
|
|
135
140
|
const afterApply = await m.applyDirty();
|