fix(P0): SSTable rangeScan 尾块漏读 — 索引块记录块尾 key 但二分按块首语义定位,endKey 落在块尾时下一块被排除导致二级索引查询丢数据(5万行丢106~771条);endBlockIdx 多扫一块 + 4 个 sstable 边界回归 + 重建二级索引大数据量回归(含崩溃恢复)。附带:checkpoint 同步 flush 二级索引 LSM(P1)、kv 后端页面化 + SharedMemoryBackend chunk 化、生产负载验证测试
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* v0.6.1-fix(P0) — 二级索引大数据量完整性回归测试
|
||||
*
|
||||
* 覆盖两类根因:
|
||||
* 1. LSM.put 的 `!this.flushing` 条件竞态(修复:每次 freeze 一律挂链)
|
||||
* 2. SSTable rangeScan 尾块漏读(索引键为块尾 key,locateBlockLE 排除
|
||||
* 尾 key 越界的下一块 → 范围扫描丢尾部数据;修复:endBlockIdx 多扫一块)
|
||||
*/
|
||||
import { AriaEngine } from '../../src/engine/aria/index';
|
||||
import { createSchema } from '../../src/table/schema';
|
||||
import { installOPFSMock } from '../helpers/opfs-mock';
|
||||
|
||||
let counter = 0;
|
||||
function uniqueDB(): string {
|
||||
return `idxrace-${Date.now()}-${++counter}-${Math.random().toString(36).slice(2, 6)}`;
|
||||
}
|
||||
|
||||
beforeEach(() => { installOPFSMock(new Map()); });
|
||||
|
||||
describe('AriaEngine — 二级索引完整性(P0 回归)', () => {
|
||||
it('5 万行写入:索引查询与主表一致(修复前丢 106~771 条)', async () => {
|
||||
const engine = new AriaEngine({
|
||||
storageBackend: 'opfs',
|
||||
memtableSizeThreshold: 256 * 1024,
|
||||
checkpointInterval: 2000,
|
||||
walSyncMode: 'full',
|
||||
});
|
||||
await engine.open(uniqueDB(), 1);
|
||||
await engine.createTable(createSchema('big', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
val: { type: 'number' },
|
||||
tag: { type: 'string', index: true },
|
||||
name: { type: 'string' },
|
||||
}));
|
||||
|
||||
const TOTAL = 50000;
|
||||
for (let batch = 0; batch < TOTAL / 1000; batch++) {
|
||||
const rows = [] as Record<string, unknown>[];
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
const idx = batch * 1000 + i;
|
||||
rows.push({ id: `k${idx}`, val: idx, tag: `t${idx % 10}`, name: `User${idx}` });
|
||||
}
|
||||
await engine.insert('big', rows);
|
||||
}
|
||||
|
||||
expect(await engine.count('big')).toBe(TOTAL);
|
||||
|
||||
// 每个 tag 的索引查询都完整(修复前丢 106-771 条)
|
||||
for (let t = 0; t < 10; t++) {
|
||||
const viaIdx = await engine.find('big', { table: 'big', where: { tag: `t${t}` } });
|
||||
expect(viaIdx.length).toBe(5000);
|
||||
}
|
||||
|
||||
// 崩溃恢复后索引仍完整
|
||||
await (engine as any).backend.close();
|
||||
(engine as any).opened = false;
|
||||
const engine2 = new AriaEngine({
|
||||
storageBackend: 'opfs',
|
||||
memtableSizeThreshold: 256 * 1024,
|
||||
checkpointInterval: 2000,
|
||||
walSyncMode: 'full',
|
||||
});
|
||||
await engine2.open((engine as any).dbName, 1);
|
||||
for (let t = 0; t < 10; t++) {
|
||||
const viaIdx = await engine2.find('big', { table: 'big', where: { tag: `t${t}` } });
|
||||
expect(viaIdx.length).toBe(5000);
|
||||
}
|
||||
await engine2.close();
|
||||
}, 180000);
|
||||
|
||||
it('小批量高频写入(每批 50 行)触发极端 freeze 竞态', async () => {
|
||||
const engine = new AriaEngine({
|
||||
storageBackend: 'opfs',
|
||||
memtableSizeThreshold: 32 * 1024,
|
||||
checkpointInterval: 5000,
|
||||
walSyncMode: 'full',
|
||||
});
|
||||
await engine.open(uniqueDB(), 1);
|
||||
await engine.createTable(createSchema('items', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
tag: { type: 'string', index: true },
|
||||
}));
|
||||
|
||||
const TOTAL = 4000;
|
||||
for (let i = 0; i < TOTAL; i++) {
|
||||
await engine.insert('items', [{ id: `k${i}`, tag: i % 3 === 0 ? 'a' : 'b' }]);
|
||||
}
|
||||
expect(await engine.count('items')).toBe(TOTAL);
|
||||
expect(await engine.find('items', { table: 'items', where: { tag: 'a' } })).toHaveLength(Math.ceil(TOTAL / 3));
|
||||
expect(await engine.find('items', { table: 'items', where: { tag: 'b' } })).toHaveLength(TOTAL - Math.ceil(TOTAL / 3));
|
||||
await engine.close();
|
||||
}, 120000);
|
||||
|
||||
it('多索引列同时写入:每列索引都完整', async () => {
|
||||
const engine = new AriaEngine({
|
||||
storageBackend: 'opfs',
|
||||
memtableSizeThreshold: 64 * 1024,
|
||||
checkpointInterval: 5000,
|
||||
walSyncMode: 'full',
|
||||
});
|
||||
await engine.open(uniqueDB(), 1);
|
||||
await engine.createTable(createSchema('multi', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
cat: { type: 'string', index: true },
|
||||
grp: { type: 'number', index: true },
|
||||
}));
|
||||
|
||||
const TOTAL = 6000;
|
||||
for (let batch = 0; batch < TOTAL / 100; batch++) {
|
||||
const rows = [] as Record<string, unknown>[];
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const idx = batch * 100 + i;
|
||||
rows.push({ id: `k${idx}`, cat: idx % 2 === 0 ? 'even' : 'odd', grp: idx % 5 });
|
||||
}
|
||||
await engine.insert('multi', rows);
|
||||
}
|
||||
expect(await engine.find('multi', { table: 'multi', where: { cat: 'even' } })).toHaveLength(TOTAL / 2);
|
||||
expect(await engine.find('multi', { table: 'multi', where: { cat: 'odd' } })).toHaveLength(TOTAL / 2);
|
||||
expect(await engine.find('multi', { table: 'multi', where: { grp: 0 } })).toHaveLength(TOTAL / 5);
|
||||
expect(await engine.find('multi', { table: 'multi', where: { grp: 4 } })).toHaveLength(TOTAL / 5);
|
||||
await engine.close();
|
||||
}, 120000);
|
||||
});
|
||||
@@ -171,7 +171,7 @@ describe('AriaEngine + kv 后端(storageBackend: kv)', () => {
|
||||
await e2.close();
|
||||
});
|
||||
|
||||
it('页面化存储:SSTable 页面经 KVStore 读写', async () => {
|
||||
it('页面化存储:SSTable 拆 4KB 页面经 KVStore 读写(v0.6.1 页面化消除整 value 读放大)', async () => {
|
||||
const dbName = uniqueDB();
|
||||
const e1 = createEngine();
|
||||
await e1.open(dbName, 1);
|
||||
@@ -185,10 +185,10 @@ describe('AriaEngine + kv 后端(storageBackend: kv)', () => {
|
||||
}
|
||||
await e1.insert('docs', rows);
|
||||
await (e1 as any).lsm.flush();
|
||||
// kv 后端:SSTable 整 value 存储(KVStore 日志型天然适合大块,无需拆 4KB 页面)
|
||||
// kv 后端页面化:SSTable 存为 pg_ 页面 key(4KB 粒度,缓存按页命中)
|
||||
const kv = (e1 as any).backend.getKV();
|
||||
const keys = await kv.listKeys();
|
||||
expect(keys.some((k: string) => k.startsWith('sst_'))).toBe(true);
|
||||
expect(keys.some((k: string) => k.startsWith('pg_'))).toBe(true);
|
||||
await e1.close();
|
||||
|
||||
const e2 = createEngine();
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* AriaEngine — 生产负载验证(正确性优先,规模可完成)
|
||||
*
|
||||
* 覆盖 README 宣称的核心能力在生产负载下的正确性:
|
||||
* 1. 5 万行写入(多级 Compaction)→ 完整查询 → 崩溃恢复
|
||||
* 2. 高频更新/删除(Compaction 回收墓碑)→ 重启后一致
|
||||
* 3. 大 value(100KB×50)→ 编码/压缩/恢复
|
||||
* 4. 混合操作 + 崩溃 → 已确认写入零丢失
|
||||
* 5. 大量删除(90%)+ Compaction → 重启无残留
|
||||
* 6. kv 后端 5 万行(页面化路径)
|
||||
*
|
||||
* 注:10 万级 kv 后端性能专项见 CHANGELOG v0.6.1 待办(KVStore 日志增长优化)。
|
||||
*/
|
||||
import { AriaEngine } from '../../src/engine/aria/index';
|
||||
import { createSchema } from '../../src/table/schema';
|
||||
import { SharedMemoryBackend } from '../../src/engine/kvstore/shared_memory_medium';
|
||||
import { installOPFSMock } from '../helpers/opfs-mock';
|
||||
|
||||
let counter = 0;
|
||||
function uniqueDB(): string {
|
||||
return `pload-${Date.now()}-${++counter}-${Math.random().toString(36).slice(2, 6)}`;
|
||||
}
|
||||
|
||||
const SCHEMA = () => createSchema('big', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
val: { type: 'number' },
|
||||
tag: { type: 'string', index: true },
|
||||
name: { type: 'string' },
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
SharedMemoryBackend.clearRegistry();
|
||||
installOPFSMock(new Map());
|
||||
});
|
||||
|
||||
describe('AriaEngine — 生产负载验证', () => {
|
||||
it('5 万行写入(多级 Compaction + 页面化)→ 完整查询 → 崩溃恢复(OPFS 后端)', async () => {
|
||||
const dbName = uniqueDB();
|
||||
const engine = new AriaEngine({
|
||||
storageBackend: 'opfs',
|
||||
memtableSizeThreshold: 256 * 1024,
|
||||
checkpointInterval: 2000,
|
||||
walSyncMode: 'full',
|
||||
});
|
||||
await engine.open(dbName, 1);
|
||||
await engine.createTable(SCHEMA());
|
||||
|
||||
const TOTAL = 50000;
|
||||
for (let batch = 0; batch < TOTAL / 1000; batch++) {
|
||||
const rows = [] as Record<string, unknown>[];
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
const idx = batch * 1000 + i;
|
||||
rows.push({ id: `k${idx}`, val: idx, tag: `t${idx % 10}`, name: `User${idx}` });
|
||||
}
|
||||
await engine.insert('big', rows);
|
||||
}
|
||||
expect(await engine.count('big')).toBe(TOTAL);
|
||||
|
||||
// 多级 compaction(VACUUM 语义)
|
||||
await (engine as any).lsm.flush();
|
||||
for (let level = 0; level < 4; level++) {
|
||||
await (engine as any).lsm.compactLevel(level);
|
||||
}
|
||||
const stats = (engine as any).lsm.getStats();
|
||||
expect((stats.levelCounts as number[]).reduce((a: number, b: number) => a + b, 0)).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// 完整查询
|
||||
expect((await engine.find('big', { table: 'big' }))).toHaveLength(TOTAL);
|
||||
|
||||
// 崩溃恢复
|
||||
await (engine as any).backend.close();
|
||||
(engine as any).opened = false;
|
||||
const engine2 = new AriaEngine({
|
||||
storageBackend: 'opfs',
|
||||
memtableSizeThreshold: 256 * 1024,
|
||||
checkpointInterval: 2000,
|
||||
walSyncMode: 'full',
|
||||
});
|
||||
await engine2.open(dbName, 1);
|
||||
expect(await engine2.count('big')).toBe(TOTAL);
|
||||
for (const id of ['k0', 'k25000', 'k49999']) {
|
||||
expect(await engine2.find('big', { table: 'big', where: { id } })).toHaveLength(1);
|
||||
}
|
||||
// 索引(重启重建)
|
||||
expect(await engine2.find('big', { table: 'big', where: { tag: 't5' } })).toHaveLength(5000);
|
||||
await engine2.close();
|
||||
}, 180000);
|
||||
|
||||
it('高频更新/删除(Compaction 回收墓碑)→ 重启后一致', async () => {
|
||||
const dbName = uniqueDB();
|
||||
const engine = new AriaEngine({
|
||||
storageBackend: 'opfs',
|
||||
memtableSizeThreshold: 128 * 1024,
|
||||
checkpointInterval: 1000,
|
||||
walSyncMode: 'full',
|
||||
});
|
||||
await engine.open(dbName, 1);
|
||||
await engine.createTable(SCHEMA());
|
||||
|
||||
const rows = [] as Record<string, unknown>[];
|
||||
for (let i = 0; i < 10000; i++) rows.push({ id: `k${i}`, val: i, tag: `t${i % 5}` });
|
||||
await engine.insert('big', rows);
|
||||
|
||||
let seed = 7;
|
||||
const rand = () => { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff; };
|
||||
for (let i = 0; i < 2000; i++) {
|
||||
const id = `k${Math.floor(rand() * 10000)}`;
|
||||
if (rand() < 0.5) {
|
||||
await engine.update('big', { table: 'big', where: { id } }, { val: Math.floor(rand() * 1e9) });
|
||||
} else {
|
||||
await engine.delete('big', { table: 'big', where: { id } });
|
||||
}
|
||||
}
|
||||
await (engine as any).lsm.flush();
|
||||
await (engine as any).lsm.compactLevel(0);
|
||||
|
||||
const count = await engine.count('big');
|
||||
expect(count).toBeGreaterThan(0);
|
||||
await engine.close();
|
||||
|
||||
const engine2 = new AriaEngine({
|
||||
storageBackend: 'opfs',
|
||||
memtableSizeThreshold: 128 * 1024,
|
||||
checkpointInterval: 1000,
|
||||
walSyncMode: 'full',
|
||||
});
|
||||
await engine2.open(dbName, 1);
|
||||
expect(await engine2.count('big')).toBe(count);
|
||||
await engine2.close();
|
||||
}, 120000);
|
||||
|
||||
it('大 value(100KB × 50)压缩写入/恢复完整', async () => {
|
||||
const dbName = uniqueDB();
|
||||
const engine = new AriaEngine({
|
||||
storageBackend: 'opfs',
|
||||
compression: true,
|
||||
memtableSizeThreshold: 512 * 1024,
|
||||
checkpointInterval: 5000,
|
||||
walSyncMode: 'full',
|
||||
});
|
||||
await engine.open(dbName, 1);
|
||||
await engine.createTable(createSchema('docs', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
body: { type: 'string' },
|
||||
}));
|
||||
const chunk = '这是大段生产数据内容。'.repeat(5000); // ~100KB
|
||||
for (let i = 0; i < 50; i++) {
|
||||
await engine.insert('docs', [{ id: `d${i}`, body: chunk }]);
|
||||
}
|
||||
await engine.close();
|
||||
|
||||
const engine2 = new AriaEngine({
|
||||
storageBackend: 'opfs',
|
||||
compression: true,
|
||||
memtableSizeThreshold: 512 * 1024,
|
||||
checkpointInterval: 5000,
|
||||
walSyncMode: 'full',
|
||||
});
|
||||
await engine2.open(dbName, 1);
|
||||
expect(await engine2.count('docs')).toBe(50);
|
||||
const one = await engine2.find('docs', { table: 'docs', where: { id: 'd25' } });
|
||||
expect((one[0].body as string).length).toBe(chunk.length);
|
||||
await engine2.close();
|
||||
}, 120000);
|
||||
|
||||
it('混合操作 + 崩溃:已确认写入零丢失(20000 操作)', async () => {
|
||||
const dbName = uniqueDB();
|
||||
const engine = new AriaEngine({
|
||||
storageBackend: 'opfs',
|
||||
memtableSizeThreshold: 128 * 1024,
|
||||
checkpointInterval: 1000,
|
||||
walSyncMode: 'full',
|
||||
});
|
||||
await engine.open(dbName, 1);
|
||||
await engine.createTable(SCHEMA());
|
||||
|
||||
let seed = 123;
|
||||
const rand = () => { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff; };
|
||||
const confirmed = new Map<string, { val: number; tag: string }>();
|
||||
for (let i = 0; i < 20000; i++) {
|
||||
const r = rand();
|
||||
const id = `k${Math.floor(rand() * 10000)}`;
|
||||
if (r < 0.5) {
|
||||
const row = { id, val: Math.floor(rand() * 1e9), tag: `t${Math.floor(rand() * 5)}` };
|
||||
try {
|
||||
await engine.insert('big', [row]);
|
||||
confirmed.set(id, row);
|
||||
} catch (e) {
|
||||
if ((e as { code?: string }).code !== 'DUPLICATE_KEY') throw e;
|
||||
}
|
||||
} else if (r < 0.8) {
|
||||
const val = Math.floor(rand() * 1e9);
|
||||
await engine.update('big', { table: 'big', where: { id } }, { val });
|
||||
if (confirmed.has(id)) confirmed.set(id, { ...confirmed.get(id)!, val });
|
||||
} else {
|
||||
await engine.delete('big', { table: 'big', where: { id } });
|
||||
confirmed.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
await (engine as any).backend.close();
|
||||
(engine as any).opened = false;
|
||||
|
||||
const engine2 = new AriaEngine({
|
||||
storageBackend: 'opfs',
|
||||
memtableSizeThreshold: 128 * 1024,
|
||||
checkpointInterval: 1000,
|
||||
walSyncMode: 'full',
|
||||
});
|
||||
await engine2.open(dbName, 1);
|
||||
expect(await engine2.count('big')).toBe(confirmed.size);
|
||||
let sampled = 0;
|
||||
for (const [id, expected] of confirmed) {
|
||||
if (sampled++ > 1000) break;
|
||||
const rows = await engine2.find('big', { table: 'big', where: { id } });
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].val).toBe(expected.val);
|
||||
}
|
||||
await engine2.close();
|
||||
}, 120000);
|
||||
|
||||
it('大量删除(90% 行)+ Compaction → 重启无残留(墓碑清理)', async () => {
|
||||
const dbName = uniqueDB();
|
||||
const engine = new AriaEngine({
|
||||
storageBackend: 'opfs',
|
||||
memtableSizeThreshold: 128 * 1024,
|
||||
checkpointInterval: 1000,
|
||||
walSyncMode: 'full',
|
||||
});
|
||||
await engine.open(dbName, 1);
|
||||
await engine.createTable(SCHEMA());
|
||||
const rows = [] as Record<string, unknown>[];
|
||||
for (let i = 0; i < 10000; i++) rows.push({ id: `k${i}`, val: i, tag: `t${i % 5}` });
|
||||
await engine.insert('big', rows);
|
||||
|
||||
const toDelete = [] as string[];
|
||||
for (let i = 1000; i < 10000; i++) toDelete.push(`k${i}`);
|
||||
await engine.delete('big', { table: 'big', where: { id: { $in: toDelete } } });
|
||||
expect(await engine.count('big')).toBe(1000);
|
||||
|
||||
await (engine as any).lsm.flush();
|
||||
for (let l = 0; l < 4; l++) await (engine as any).lsm.compactLevel(l);
|
||||
await engine.close();
|
||||
|
||||
const engine2 = new AriaEngine({
|
||||
storageBackend: 'opfs',
|
||||
memtableSizeThreshold: 128 * 1024,
|
||||
checkpointInterval: 1000,
|
||||
walSyncMode: 'full',
|
||||
});
|
||||
await engine2.open(dbName, 1);
|
||||
expect(await engine2.count('big')).toBe(1000);
|
||||
const all = await engine2.find('big', { table: 'big' });
|
||||
expect(all.every((r) => Number(String(r.id).slice(1)) < 1000)).toBe(true);
|
||||
await engine2.close();
|
||||
}, 120000);
|
||||
|
||||
it('kv 后端 5 万行(页面化路径):写入 → 崩溃 → 恢复完整', async () => {
|
||||
const dbName = uniqueDB();
|
||||
const engine = new AriaEngine({
|
||||
storageBackend: 'kv',
|
||||
memtableSizeThreshold: 512 * 1024,
|
||||
checkpointInterval: 5000,
|
||||
walSyncMode: 'full',
|
||||
});
|
||||
await engine.open(dbName, 1);
|
||||
await engine.createTable(SCHEMA());
|
||||
|
||||
const TOTAL = 50000;
|
||||
for (let batch = 0; batch < TOTAL / 1000; batch++) {
|
||||
const rows = [] as Record<string, unknown>[];
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
const idx = batch * 1000 + i;
|
||||
rows.push({ id: `k${idx}`, val: idx, tag: `t${idx % 10}`, name: `User${idx}` });
|
||||
}
|
||||
await engine.insert('big', rows);
|
||||
}
|
||||
expect(await engine.count('big')).toBe(TOTAL);
|
||||
await (engine as any).backend.close();
|
||||
(engine as any).opened = false;
|
||||
|
||||
const engine2 = new AriaEngine({
|
||||
storageBackend: 'kv',
|
||||
memtableSizeThreshold: 512 * 1024,
|
||||
checkpointInterval: 5000,
|
||||
walSyncMode: 'full',
|
||||
});
|
||||
await engine2.open(dbName, 1);
|
||||
expect(await engine2.count('big')).toBe(TOTAL);
|
||||
expect(await engine2.find('big', { table: 'big', where: { tag: 't3' } })).toHaveLength(5000);
|
||||
await engine2.close();
|
||||
}, 180000);
|
||||
});
|
||||
@@ -83,6 +83,81 @@ describe('AriaEngine — SSTable Builder + Reader', () => {
|
||||
expect(items).toHaveLength(count);
|
||||
});
|
||||
|
||||
// v0.6.1-fix(P0): 索引键是"块内最后一个 key",locateBlockLE 排除尾 key 越界的
|
||||
// 下一块会导致范围扫描漏读尾部数据(二级索引 5 万行查询丢 106~219 条)。
|
||||
it('v0.6.1-fix — 范围扫描跨前缀边界不漏尾部数据', () => {
|
||||
// 小块强制多块;t5 数据分散在多块中,最后一个 t5 块之后紧跟 t6 块(尾 key 越界)
|
||||
const builder = new SSTableBuilder(120);
|
||||
for (let i = 0; i < 30; i++) {
|
||||
builder.add(`t5:k${String(i).padStart(5, '0')}`, { pk: `k${i}` });
|
||||
}
|
||||
for (let i = 0; i < 30; i++) {
|
||||
builder.add(`t6:k${String(i).padStart(5, '0')}`, { pk: `k${i}` });
|
||||
}
|
||||
for (let i = 0; i < 30; i++) {
|
||||
builder.add(`t7:k${String(i).padStart(5, '0')}`, { pk: `k${i}` });
|
||||
}
|
||||
const { sstableData } = builder.build();
|
||||
|
||||
const reader = new SSTableReader(sstableData, makeMeta(sstableData));
|
||||
const t5: [string, Record<string, unknown>][] = [];
|
||||
reader.rangeScan('t5', 't5\uffff', (k, v) => t5.push([k, v]));
|
||||
expect(t5).toHaveLength(30);
|
||||
expect(t5[0][0]).toBe('t5:k00000');
|
||||
expect(t5[t5.length - 1][0]).toBe('t5:k00029');
|
||||
});
|
||||
|
||||
it('v0.6.1-fix — 范围扫描 endKey 恰在块尾(不越界不丢、不多扫错数据)', () => {
|
||||
const builder = new SSTableBuilder(150);
|
||||
for (let i = 0; i < 40; i++) {
|
||||
builder.add(`p-${String(i).padStart(3, '0')}`, { v: i });
|
||||
}
|
||||
for (let i = 0; i < 20; i++) {
|
||||
builder.add(`q-${String(i).padStart(3, '0')}`, { v: i });
|
||||
}
|
||||
const { sstableData } = builder.build();
|
||||
|
||||
const reader = new SSTableReader(sstableData, makeMeta(sstableData));
|
||||
// endKey 恰等于某条存在的 key:结果必须包含它且不含 p 之外的 key
|
||||
const results: [string, Record<string, unknown>][] = [];
|
||||
reader.rangeScan('p-000', 'p-039', (k, v) => results.push([k, v]));
|
||||
expect(results).toHaveLength(40);
|
||||
expect(results[39][0]).toBe('p-039');
|
||||
});
|
||||
|
||||
it('v0.6.1-fix — 范围扫描 endKey 超出全部数据(返回全量)', () => {
|
||||
const builder = new SSTableBuilder(100);
|
||||
for (let i = 0; i < 25; i++) {
|
||||
builder.add(`z-${String(i).padStart(3, '0')}`, { v: i });
|
||||
}
|
||||
const { sstableData } = builder.build();
|
||||
|
||||
const reader = new SSTableReader(sstableData, makeMeta(sstableData));
|
||||
const results: [string, Record<string, unknown>][] = [];
|
||||
reader.rangeScan('z-000', '\uffff\uffff', (k, v) => results.push([k, v]));
|
||||
expect(results).toHaveLength(25);
|
||||
});
|
||||
|
||||
it('v0.6.1-fix — 多前缀数据 rangeScan 与 scanAll 结果一致', () => {
|
||||
// 模拟索引混合写入:t0..t9 交叉 + 尾部跨块(flush 路径先排序再构建)
|
||||
const items: [string, Record<string, unknown>][] = [];
|
||||
for (let i = 0; i < 120; i++) {
|
||||
const tag = `t${i % 10}`;
|
||||
items.push([`${tag}:k${String(i).padStart(5, '0')}`, { pk: `k${i}` }]);
|
||||
}
|
||||
items.sort((a, b) => (a[0] < b[0] ? -1 : 1));
|
||||
const builder = new SSTableBuilder(200);
|
||||
for (const [k, v] of items) builder.add(k, v);
|
||||
const { sstableData } = builder.build();
|
||||
|
||||
const reader = new SSTableReader(sstableData, makeMeta(sstableData));
|
||||
for (let t = 0; t < 10; t++) {
|
||||
const keys: string[] = [];
|
||||
reader.rangeScan(`t${t}`, `t${t}\uffff`, (k) => keys.push(k));
|
||||
expect(keys).toHaveLength(12);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
it('边界 — 空 SSTable 不抛异常', () => {
|
||||
const builder = new SSTableBuilder(4096);
|
||||
|
||||
Reference in New Issue
Block a user