resplite 1.0.0 → 1.0.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.
@@ -9,6 +9,7 @@ import { createHashesStorage } from '../storage/sqlite/hashes.js';
9
9
  import { createSetsStorage } from '../storage/sqlite/sets.js';
10
10
  import { createListsStorage } from '../storage/sqlite/lists.js';
11
11
  import { createZsetsStorage } from '../storage/sqlite/zsets.js';
12
+ import { createBlockingManager } from '../blocking/manager.js';
12
13
  import { expectString, expectHash, expectSet, expectList, expectZset, typeName } from './validate.js';
13
14
  import { asKey, asValue } from '../util/buffers.js';
14
15
 
@@ -45,7 +46,7 @@ export function createEngine(opts = {}) {
45
46
  return meta;
46
47
  }
47
48
 
48
- return {
49
+ const engine = {
49
50
  get(key) {
50
51
  const k = asKey(key);
51
52
  const meta = getKeyMeta(key);
@@ -262,14 +263,18 @@ export function createEngine(opts = {}) {
262
263
  const k = asKey(key);
263
264
  getKeyMeta(key);
264
265
  const buf = values.map((v) => (Buffer.isBuffer(v) ? v : asValue(v)));
265
- return lists.lpush(k, buf);
266
+ const n = lists.lpush(k, buf);
267
+ if (this._blockingManager) this._blockingManager.wakeup(k);
268
+ return n;
266
269
  },
267
270
 
268
271
  rpush(key, ...values) {
269
272
  const k = asKey(key);
270
273
  getKeyMeta(key);
271
274
  const buf = values.map((v) => (Buffer.isBuffer(v) ? v : asValue(v)));
272
- return lists.rpush(k, buf);
275
+ const n = lists.rpush(k, buf);
276
+ if (this._blockingManager) this._blockingManager.wakeup(k);
277
+ return n;
273
278
  },
274
279
 
275
280
  llen(key) {
@@ -398,7 +403,10 @@ export function createEngine(opts = {}) {
398
403
  _lists: lists,
399
404
  _zsets: zsets,
400
405
  _clock: clock,
406
+ _blockingManager: null,
401
407
  };
408
+ engine._blockingManager = createBlockingManager(engine, { clock });
409
+ return engine;
402
410
  }
403
411
 
404
412
  export { KEY_TYPES };
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Delta apply: reimport or delete keys from dirty registry (SPEC_F §F.8).
3
+ */
4
+
5
+ import { openDb } from '../storage/sqlite/db.js';
6
+ import { createKeysStorage } from '../storage/sqlite/keys.js';
7
+ import { createStringsStorage } from '../storage/sqlite/strings.js';
8
+ import { createHashesStorage } from '../storage/sqlite/hashes.js';
9
+ import { createSetsStorage } from '../storage/sqlite/sets.js';
10
+ import { createListsStorage } from '../storage/sqlite/lists.js';
11
+ import { createZsetsStorage } from '../storage/sqlite/zsets.js';
12
+ import { getRun, getDirtyBatch, markDirtyState, logError, RUN_STATUS } from './registry.js';
13
+ import { importKeyFromRedis } from './import-one.js';
14
+
15
+ function sleep(ms) {
16
+ return new Promise((resolve) => setTimeout(resolve, ms));
17
+ }
18
+
19
+ /**
20
+ * Apply dirty keys: for each key in registry with state=dirty, reimport from Redis or delete in destination.
21
+ * @param {import('redis').RedisClientType} redisClient
22
+ * @param {string} dbPath
23
+ * @param {string} runId
24
+ * @param {object} options
25
+ * @param {string} [options.pragmaTemplate='default']
26
+ * @param {number} [options.batch_keys=200]
27
+ * @param {number} [options.max_rps=0]
28
+ */
29
+ export async function runApplyDirty(redisClient, dbPath, runId, options = {}) {
30
+ const { pragmaTemplate = 'default', batch_keys = 200, max_rps = 0 } = options;
31
+
32
+ const db = openDb(dbPath, { pragmaTemplate });
33
+ const run = getRun(db, runId);
34
+ if (!run) throw new Error(`Run ${runId} not found`);
35
+
36
+ const keys = createKeysStorage(db);
37
+ const strings = createStringsStorage(db, keys);
38
+ const hashes = createHashesStorage(db, keys);
39
+ const sets = createSetsStorage(db, keys);
40
+ const lists = createListsStorage(db, keys);
41
+ const zsets = createZsetsStorage(db, keys);
42
+ const storages = { keys, strings, hashes, sets, lists, zsets };
43
+
44
+ const minIntervalMs = max_rps > 0 ? 1000 / max_rps : 0;
45
+ let lastKeyTime = 0;
46
+
47
+ for (;;) {
48
+ let r = getRun(db, runId);
49
+ if (r && r.status === RUN_STATUS.ABORTED) break;
50
+ while (r && r.status === RUN_STATUS.PAUSED) {
51
+ await sleep(2000);
52
+ r = getRun(db, runId);
53
+ }
54
+
55
+ const batch = getDirtyBatch(db, runId, 'dirty', batch_keys);
56
+ if (batch.length === 0) break;
57
+
58
+ for (const { key: keyBuf } of batch) {
59
+ r = getRun(db, runId);
60
+ if (r && r.status === RUN_STATUS.ABORTED) break;
61
+ while (r && r.status === RUN_STATUS.PAUSED) {
62
+ await sleep(2000);
63
+ r = getRun(db, runId);
64
+ }
65
+
66
+ if (minIntervalMs > 0) {
67
+ const elapsed = Date.now() - lastKeyTime;
68
+ if (elapsed < minIntervalMs) await sleep(minIntervalMs - elapsed);
69
+ lastKeyTime = Date.now();
70
+ }
71
+
72
+ const keyName = keyBuf.toString('utf8');
73
+ try {
74
+ const type = (await redisClient.type(keyName)).toLowerCase();
75
+ if (type === 'none' || !type) {
76
+ keys.delete(keyBuf);
77
+ markDirtyState(db, runId, keyBuf, 'deleted');
78
+ } else {
79
+ const outcome = await importKeyFromRedis(redisClient, keyName, storages, {});
80
+ if (outcome.ok) {
81
+ markDirtyState(db, runId, keyBuf, 'applied');
82
+ } else if (outcome.skipped) {
83
+ markDirtyState(db, runId, keyBuf, 'skipped');
84
+ } else {
85
+ logError(db, runId, 'dirty_apply', outcome.error ? 'Import failed' : 'Skipped', keyName);
86
+ markDirtyState(db, runId, keyBuf, 'error');
87
+ }
88
+ }
89
+ } catch (err) {
90
+ logError(db, runId, 'dirty_apply', err.message, keyBuf);
91
+ markDirtyState(db, runId, keyBuf, 'error');
92
+ }
93
+ }
94
+ }
95
+
96
+ return getRun(db, runId);
97
+ }
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Bulk import from Redis with checkpointing, resume, and throttling (SPEC_F §F.7).
3
+ */
4
+
5
+ import { openDb } from '../storage/sqlite/db.js';
6
+ import { createKeysStorage } from '../storage/sqlite/keys.js';
7
+ import { createStringsStorage } from '../storage/sqlite/strings.js';
8
+ import { createHashesStorage } from '../storage/sqlite/hashes.js';
9
+ import { createSetsStorage } from '../storage/sqlite/sets.js';
10
+ import { createListsStorage } from '../storage/sqlite/lists.js';
11
+ import { createZsetsStorage } from '../storage/sqlite/zsets.js';
12
+ import {
13
+ createRun,
14
+ getRun,
15
+ updateBulkProgress,
16
+ setRunStatus,
17
+ logError,
18
+ RUN_STATUS,
19
+ } from './registry.js';
20
+ import { importKeyFromRedis } from './import-one.js';
21
+
22
+ function parseScanResult(result) {
23
+ if (Array.isArray(result)) {
24
+ return { cursor: parseInt(result[0], 10), keys: result[1] || [] };
25
+ }
26
+ if (result && typeof result === 'object') {
27
+ const cursor = typeof result.cursor === 'number' ? result.cursor : parseInt(String(result.cursor), 10);
28
+ const keys = result.keys || [];
29
+ return { cursor, keys };
30
+ }
31
+ return { cursor: 0, keys: [] };
32
+ }
33
+
34
+ function sleep(ms) {
35
+ return new Promise((resolve) => setTimeout(resolve, ms));
36
+ }
37
+
38
+ /**
39
+ * Run bulk import: SCAN keys from Redis, import into RespLite DB with checkpointing.
40
+ * @param {import('redis').RedisClientType} redisClient
41
+ * @param {string} dbPath
42
+ * @param {string} runId
43
+ * @param {object} options
44
+ * @param {string} options.sourceUri
45
+ * @param {string} [options.pragmaTemplate='default']
46
+ * @param {number} [options.scan_count=1000]
47
+ * @param {number} [options.max_rps=0] - 0 = no limit
48
+ * @param {number} [options.batch_keys=200]
49
+ * @param {number} [options.batch_bytes=64*1024*1024] - 64MB
50
+ * @param {number} [options.checkpoint_interval_sec=30]
51
+ * @param {boolean} [options.resume=false]
52
+ * @param {function(run): void} [options.onProgress] - called after checkpoint with run row
53
+ */
54
+ export async function runBulkImport(redisClient, dbPath, runId, options = {}) {
55
+ const {
56
+ sourceUri,
57
+ pragmaTemplate = 'default',
58
+ scan_count = 1000,
59
+ max_rps = 0,
60
+ batch_keys = 200,
61
+ batch_bytes = 64 * 1024 * 1024,
62
+ checkpoint_interval_sec = 30,
63
+ resume = false,
64
+ onProgress,
65
+ } = options;
66
+
67
+ const db = openDb(dbPath, { pragmaTemplate });
68
+ const keys = createKeysStorage(db);
69
+ const strings = createStringsStorage(db, keys);
70
+ const hashes = createHashesStorage(db, keys);
71
+ const sets = createSetsStorage(db, keys);
72
+ const lists = createListsStorage(db, keys);
73
+ const zsets = createZsetsStorage(db, keys);
74
+ const storages = { keys, strings, hashes, sets, lists, zsets };
75
+
76
+ createRun(db, runId, sourceUri, { scan_count_hint: scan_count });
77
+ let run = getRun(db, runId);
78
+ if (!run) throw new Error(`Run ${runId} not found`);
79
+
80
+ let cursor = resume && run.scan_cursor !== undefined ? parseInt(String(run.scan_cursor), 10) : 0;
81
+ let scanned_keys = resume ? (run.scanned_keys || 0) : 0;
82
+ let migrated_keys = resume ? (run.migrated_keys || 0) : 0;
83
+ let skipped_keys = resume ? (run.skipped_keys || 0) : 0;
84
+ let error_keys = resume ? (run.error_keys || 0) : 0;
85
+ let migrated_bytes = resume ? (run.migrated_bytes || 0) : 0;
86
+
87
+ if (!resume) {
88
+ updateBulkProgress(db, runId, { scan_cursor: String(cursor), scanned_keys, migrated_keys, skipped_keys, error_keys, migrated_bytes });
89
+ }
90
+
91
+ let lastCheckpointTime = Date.now();
92
+ let batchScanned = 0;
93
+ let batchBytes = 0;
94
+ const minIntervalMs = max_rps > 0 ? 1000 / max_rps : 0;
95
+ let lastKeyTime = 0;
96
+
97
+ try {
98
+ do {
99
+ run = getRun(db, runId);
100
+ if (run && run.status === RUN_STATUS.ABORTED) {
101
+ break;
102
+ }
103
+ while (run && run.status === RUN_STATUS.PAUSED) {
104
+ await sleep(2000);
105
+ run = getRun(db, runId);
106
+ }
107
+
108
+ const result = await redisClient.scan(cursor, { COUNT: scan_count });
109
+ const parsed = parseScanResult(result);
110
+ cursor = parsed.cursor;
111
+ const keyList = parsed.keys || [];
112
+
113
+ for (const keyName of keyList) {
114
+ run = getRun(db, runId);
115
+ if (run && run.status === RUN_STATUS.ABORTED) break;
116
+ while (run && run.status === RUN_STATUS.PAUSED) {
117
+ await sleep(2000);
118
+ run = getRun(db, runId);
119
+ }
120
+
121
+ scanned_keys++;
122
+ if (minIntervalMs > 0) {
123
+ const elapsed = Date.now() - lastKeyTime;
124
+ if (elapsed < minIntervalMs) await sleep(minIntervalMs - elapsed);
125
+ lastKeyTime = Date.now();
126
+ }
127
+
128
+ const now = Date.now();
129
+ const outcome = await importKeyFromRedis(redisClient, keyName, storages, { now });
130
+ if (outcome.ok) {
131
+ migrated_keys++;
132
+ migrated_bytes += outcome.bytes || 0;
133
+ batchScanned++;
134
+ batchBytes += outcome.bytes || 0;
135
+ } else if (outcome.skipped) {
136
+ skipped_keys++;
137
+ } else {
138
+ error_keys++;
139
+ logError(db, runId, 'bulk', outcome.error ? 'Import failed' : 'Skipped', keyName);
140
+ }
141
+
142
+ const now2 = Date.now();
143
+ const shouldCheckpoint =
144
+ batchScanned >= batch_keys ||
145
+ batchBytes >= batch_bytes ||
146
+ now2 - lastCheckpointTime >= checkpoint_interval_sec * 1000;
147
+ if (shouldCheckpoint) {
148
+ updateBulkProgress(db, runId, {
149
+ scan_cursor: String(cursor),
150
+ scanned_keys,
151
+ migrated_keys,
152
+ skipped_keys,
153
+ error_keys,
154
+ migrated_bytes,
155
+ });
156
+ lastCheckpointTime = now2;
157
+ batchScanned = 0;
158
+ batchBytes = 0;
159
+ run = getRun(db, runId);
160
+ if (onProgress && run) onProgress(run);
161
+ }
162
+ }
163
+ } while (cursor !== 0);
164
+
165
+ updateBulkProgress(db, runId, {
166
+ scan_cursor: '0',
167
+ scanned_keys,
168
+ migrated_keys,
169
+ skipped_keys,
170
+ error_keys,
171
+ migrated_bytes,
172
+ });
173
+ setRunStatus(db, runId, RUN_STATUS.COMPLETED);
174
+ return getRun(db, runId);
175
+ } catch (err) {
176
+ setRunStatus(db, runId, RUN_STATUS.FAILED);
177
+ updateBulkProgress(db, runId, { last_error: err.message });
178
+ logError(db, runId, 'bulk', err.message, null);
179
+ throw err;
180
+ }
181
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Import a single key from Redis into RespLite storages (shared by bulk and delta apply).
3
+ * SPEC_F §F.7.1, F.8.2.
4
+ */
5
+
6
+ import { asKey, asValue } from '../util/buffers.js';
7
+
8
+ const SUPPORTED_TYPES = new Set(['string', 'hash', 'set', 'list', 'zset']);
9
+
10
+ function toBuffer(value) {
11
+ if (value == null) return null;
12
+ if (Buffer.isBuffer(value)) return value;
13
+ if (typeof value === 'string') return Buffer.from(value, 'utf8');
14
+ return Buffer.from(String(value), 'utf8');
15
+ }
16
+
17
+ /**
18
+ * Fetch one key from Redis and write to storages. Idempotent (upsert).
19
+ * @param {import('redis').RedisClientType} redisClient
20
+ * @param {string} keyName
21
+ * @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
23
+ * @returns {Promise<{ ok: boolean; skipped?: boolean; error?: boolean; bytes?: number }>}
24
+ */
25
+ export async function importKeyFromRedis(redisClient, keyName, storages, options = {}) {
26
+ const now = options.now ?? Date.now();
27
+ const { keys, strings, hashes, sets, lists, zsets } = storages;
28
+
29
+ try {
30
+ const type = (await redisClient.type(keyName)).toLowerCase();
31
+ if (!SUPPORTED_TYPES.has(type)) {
32
+ return { ok: false, skipped: true };
33
+ }
34
+
35
+ let pttl = await redisClient.pTTL(keyName);
36
+ if (pttl === -2) pttl = -1;
37
+ const expiresAt = pttl > 0 ? now + pttl : null;
38
+ const keyBuf = asKey(keyName);
39
+
40
+ let bytes = keyBuf.length;
41
+
42
+ if (type === 'string') {
43
+ const value = await redisClient.get(keyName);
44
+ if (value === undefined || value === null) return { ok: false, skipped: true };
45
+ const valBuf = asValue(value);
46
+ bytes += valBuf.length;
47
+ strings.set(keyBuf, valBuf, { expiresAt, updatedAt: now });
48
+ return { ok: true, bytes };
49
+ }
50
+
51
+ if (type === 'hash') {
52
+ const obj = await redisClient.hGetAll(keyName);
53
+ if (!obj || typeof obj !== 'object') return { ok: false, skipped: true };
54
+ const pairs = [];
55
+ for (const [f, v] of Object.entries(obj)) {
56
+ const fb = toBuffer(f);
57
+ const vb = toBuffer(v);
58
+ pairs.push(fb, vb);
59
+ bytes += fb.length + vb.length;
60
+ }
61
+ if (pairs.length === 0) return { ok: false, skipped: true };
62
+ hashes.setMultiple(keyBuf, pairs, { updatedAt: now });
63
+ keys.setExpires(keyBuf, expiresAt, now);
64
+ return { ok: true, bytes };
65
+ }
66
+
67
+ if (type === 'set') {
68
+ const members = await redisClient.sMembers(keyName);
69
+ if (!members || !members.length) return { ok: false, skipped: true };
70
+ const memberBuffers = members.map((m) => toBuffer(m));
71
+ for (const b of memberBuffers) bytes += b.length;
72
+ sets.add(keyBuf, memberBuffers, { updatedAt: now });
73
+ keys.setExpires(keyBuf, expiresAt, now);
74
+ return { ok: true, bytes };
75
+ }
76
+
77
+ if (type === 'list') {
78
+ const elements = await redisClient.lRange(keyName, 0, -1);
79
+ if (!elements || !elements.length) return { ok: false, skipped: true };
80
+ const valueBuffers = elements.map((e) => toBuffer(e));
81
+ for (const b of valueBuffers) bytes += b.length;
82
+ lists.rpush(keyBuf, valueBuffers, { updatedAt: now });
83
+ keys.setExpires(keyBuf, expiresAt, now);
84
+ return { ok: true, bytes };
85
+ }
86
+
87
+ 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 };
98
+ }
99
+
100
+ return { ok: false, skipped: true };
101
+ } catch (err) {
102
+ return { ok: false, error: true };
103
+ }
104
+ }
105
+
106
+ export { SUPPORTED_TYPES };
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Preflight check: estimate key count, type distribution, notify-keyspace-events (SPEC_F §F.9 Step 0).
3
+ */
4
+
5
+ /**
6
+ * @param {import('redis').RedisClientType} redisClient
7
+ * @returns {Promise<{ keyCountEstimate: number; typeDistribution: Record<string, number>; notifyKeyspaceEvents: string | null; recommended: { scan_count: number; max_concurrency: number; max_rps: number } }>}
8
+ */
9
+ export async function runPreflight(redisClient) {
10
+ let keyCountEstimate = 0;
11
+ try {
12
+ keyCountEstimate = await redisClient.dbSize();
13
+ } catch (_) {
14
+ keyCountEstimate = 0;
15
+ }
16
+
17
+ const typeDistribution = { string: 0, hash: 0, set: 0, list: 0, zset: 0, other: 0 };
18
+ const sampleSize = Math.min(1000, Math.max(100, Math.floor(keyCountEstimate / 10)));
19
+ let cursor = 0;
20
+ let sampled = 0;
21
+
22
+ if (keyCountEstimate > 0) {
23
+ do {
24
+ const result = await redisClient.scan(cursor, { COUNT: 200 });
25
+ const keys = Array.isArray(result) ? result[1] : (result?.keys || []);
26
+ cursor = Array.isArray(result) ? parseInt(result[0], 10) : (result?.cursor ?? 0);
27
+
28
+ for (const key of keys) {
29
+ if (sampled >= sampleSize) break;
30
+ try {
31
+ const type = (await redisClient.type(key)).toLowerCase();
32
+ if (typeDistribution[type] !== undefined) typeDistribution[type]++;
33
+ else typeDistribution.other++;
34
+ sampled++;
35
+ } catch (_) {}
36
+ }
37
+ if (sampled >= sampleSize) break;
38
+ } while (cursor !== 0);
39
+ }
40
+
41
+ let notifyKeyspaceEvents = null;
42
+ try {
43
+ const config = await redisClient.configGet('notify-keyspace-events');
44
+ notifyKeyspaceEvents = config && typeof config === 'object' ? config['notify-keyspace-events'] : null;
45
+ if (typeof notifyKeyspaceEvents !== 'string') notifyKeyspaceEvents = null;
46
+ } catch (_) {
47
+ notifyKeyspaceEvents = null;
48
+ }
49
+
50
+ const recommended = {
51
+ scan_count: 1000,
52
+ max_concurrency: 32,
53
+ max_rps: keyCountEstimate > 100000 ? 2000 : 1000,
54
+ };
55
+
56
+ return {
57
+ keyCountEstimate,
58
+ typeDistribution,
59
+ notifyKeyspaceEvents,
60
+ recommended,
61
+ };
62
+ }