fix(B-6 ③ + 测试介质): 陈旧实例拒绝提交覆盖 + SharedMemoryBackend 跨实例可见性

③ 陈旧实例的 checkpoint 会静默抹掉新实例的写入(P0)
   修复前多个 KVStore 实例同时打开同一库时,snapshot/meta 是全库共享的,而每个
   实例各有内存索引与 seq —— 落后实例的一次 checkpoint 会用它的旧索引覆盖介质:
     A.open → A.put(x) ; B.open(读到 x) → B.put(y) → B.close()
     → A.checkpoint()          // A 的索引里没有 y
     → 重开:x 在、**y 消失**,且 A.checkpoint() **没有报任何错**
   修法:meta 增加 `owner`(实例 id),open() 时领取所有权;checkpoint 前比对
   owner —— 不是自己即判为过期,抛 `STALE_INSTANCE` 并拒绝提交(而不是覆盖);
   此后该实例的写入也显式失败(否则只会写进永远无法提交的 WAL)。
   重新 open 即可恢复(陈旧状态不是永久的)。

   **测试介质本身的缺陷(同源发现,影响此前的所有多实例验证)**
   `SharedMemoryBackend` 每个实例各有一份私有拼接缓存,且只在**自己的**
   write/append 时失效 → 实例 A 读过的键永远命中 A 的缓存,**看不到** B 之后的
   写入。介质行为退化为"每个实例各有一份快照",于是所有"两实例共享介质"的
   崩溃/多标签页用例都跑在错误的介质上(这正是上面那条 bug 起初查不出来的原因:
   守卫读 owner 时拿到的是自己的旧值)。
   修法:拼接缓存按库名**共享**(挂在注册表条目上),任何实例的 write/append/
   delete 都清掉该库的共享缓存;删除用 `null` 哨兵,杜绝"删了还能读到旧值"。
   同时保持既有 close 契约(close 后读返回 null、写删清空不抛错且不持久化,
   跨实例持久化语义不变)—— `clearRegistry()` 换代,避免跨用例数据泄漏。

验证:tests/v080-kvstore-commit-point.test.ts 扩到 19 项(含 4 项陈旧实例 +
4 项介质可见性);全量 87 套件 / 1686 测试通过(含 Aria 10 万行生产负载);
typecheck(src+tests) 与 lint 零错误。
This commit is contained in:
thzxx
2026-09-15 00:34:45 +08:00
parent edd9f1dcd9
commit 68e4731788
3 changed files with 373 additions and 49 deletions
+110 -6
View File
@@ -39,6 +39,20 @@ const DEFAULT_CHECKPOINT_THRESHOLD = 16 * 1024 * 1024;
interface KVStoreMeta { interface KVStoreMeta {
/** 当前日志水位(快照内嵌;无快照时 0) */ /** 当前日志水位(快照内嵌;无快照时 0) */
seq: number; seq: number;
/**
* v0.8.0B-6):最后一次提交(checkpoint)的实例 id。
*
* 为什么需要:多个 KVStore 实例可能同时打开同一个库(多标签页、或测试里
* 共享介质)。每个实例各有自己的内存索引与 `seq`,而 snapshot/meta 是
* **全库共享**的 —— 于是落后实例的一次 checkpoint 会用它的旧索引覆盖介质,
* 把其它实例已提交的写入**静默抹掉**。实测(本提交的用例锁定):
* A.open → A.put(x) ; B.open(=读到 x) → B.put(y) → B.close()
* → A.checkpoint() // A 的索引里没有 y
* → 重开:x 在、**y 消失**,且 A.checkpoint() 没有报任何错
* 有了 owner 之后,checkpoint 前先比对 meta.owner:不是自己就说明
* 介质已被别的实例接管,此时**拒绝提交**并显式报错,而不是覆盖。
*/
owner?: string;
} }
function defaultMedium(): IStorageBackend { function defaultMedium(): IStorageBackend {
@@ -67,6 +81,16 @@ export class KVStore {
private opQueue: Promise<unknown> = Promise.resolve(); private opQueue: Promise<unknown> = Promise.resolve();
/** 最近一次后台操作失败(checkpoint 时报告) */ /** 最近一次后台操作失败(checkpoint 时报告) */
private lastBackgroundError: unknown = null; private lastBackgroundError: unknown = null;
/**
* v0.8.0(B-6):本实例的提交所有权标识。
*
* 每次 `open()` 领取一个新的随机 id 并写入 meta(见 `claimOwnership`)。
* checkpoint 前比对 `meta.owner`:不等于自己 → 介质已被更新的实例接管,
* 本实例的索引可能落后,**不得**再提交快照(否则会静默抹掉对方的写入)。
*/
private instanceId = '';
/** 是否已被更新的实例接管(进入该状态后所有写入与提交都被拒绝) */
private stale = false;
constructor(medium?: IStorageBackend, checkpointThreshold: number = DEFAULT_CHECKPOINT_THRESHOLD) { constructor(medium?: IStorageBackend, checkpointThreshold: number = DEFAULT_CHECKPOINT_THRESHOLD) {
this.medium = medium ?? defaultMedium(); this.medium = medium ?? defaultMedium();
@@ -87,7 +111,12 @@ export class KVStore {
this.dbName = dbName; this.dbName = dbName;
// v0.7.4: 打开时同样清理后台错误状态(防 close/reopen 残留) // v0.7.4: 打开时同样清理后台错误状态(防 close/reopen 残留)
this.lastBackgroundError = null; this.lastBackgroundError = null;
this.stale = false;
await this.medium.open(dbName); await this.medium.open(dbName);
// v0.8.0B-6):领取提交所有权(写回 meta.owner)。
// 打开是唯一"接管"介质的时机;此前的实例之后会在 checkpoint 时发现
// owner 已变而拒绝提交,从而不会用陈旧索引覆盖本实例的数据。
await this.claimOwnership();
this.index = new Map(); this.index = new Map();
this.seq = 0; this.seq = 0;
this.logBytes = 0; this.logBytes = 0;
@@ -176,6 +205,8 @@ export class KVStore {
// v0.7.4: 清理后台错误状态 —— 此前跨 close/reopen 残留, // v0.7.4: 清理后台错误状态 —— 此前跨 close/reopen 残留,
// 重开后首次 checkpoint 会抛出上一次生命周期的旧错误 // 重开后首次 checkpoint 会抛出上一次生命周期的旧错误
this.lastBackgroundError = null; this.lastBackgroundError = null;
this.instanceId = '';
this.stale = false;
this.opened = false; this.opened = false;
} }
@@ -269,6 +300,8 @@ export class KVStore {
/** checkpoint:快照 → meta → 截断日志(时序保证任何崩溃窗口不丢数据) */ /** checkpoint:快照 → meta → 截断日志(时序保证任何崩溃窗口不丢数据) */
async checkpoint(): Promise<void> { async checkpoint(): Promise<void> {
await this.enqueue(async () => { await this.enqueue(async () => {
// v0.8.0(B-6):提交前确认所有权 —— 拒绝用陈旧索引覆盖介质
await this.assertOwnership();
// 报告上次后台失败 // 报告上次后台失败
if (this.lastBackgroundError !== null) { if (this.lastBackgroundError !== null) {
const error = this.lastBackgroundError; const error = this.lastBackgroundError;
@@ -280,9 +313,8 @@ export class KVStore {
// 1. 写快照(COW 原子) // 1. 写快照(COW 原子)
const snapBytes = encodeSnapshot(this.seq, this.index); const snapBytes = encodeSnapshot(this.seq, this.index);
await this.medium.write(SNAPSHOT_KEY, snapBytes.buffer as ArrayBuffer); await this.medium.write(SNAPSHOT_KEY, snapBytes.buffer as ArrayBuffer);
// 2. 写 meta(指向新水位) // 2. 写 meta(指向新水位 + 续期所有权
const meta: KVStoreMeta = { seq: this.seq }; await this.writeMeta();
await this.medium.write(META_KEY, new TextEncoder().encode(JSON.stringify(meta)).buffer);
// 3. 截断日志(meta 已更新 → 截断安全) // 3. 截断日志(meta 已更新 → 截断安全)
await this.truncateLog(); await this.truncateLog();
}); });
@@ -345,6 +377,15 @@ export class KVStore {
deletes: string[], deletes: string[],
appends: Record<string, ArrayBuffer> = {}, appends: Record<string, ArrayBuffer> = {},
): Promise<void> { ): Promise<void> {
// v0.8.0(B-6):已被接管 → 拒绝写入(否则写入只会进 WAL 而永远无法提交,
// 或者在下一次 checkpoint 时把对方的提交覆盖掉)
if (this.stale) {
throw new DatabaseError(
'KVStore instance has been superseded by another instance on the same database;'
+ ' re-open the database to continue',
'STALE_INSTANCE',
);
}
this.seq++; this.seq++;
const record = encodeLogRecord(this.seq, puts, deletes, appends); const record = encodeLogRecord(this.seq, puts, deletes, appends);
try { try {
@@ -414,12 +455,15 @@ export class KVStore {
*/ */
private async autoCheckpoint(): Promise<void> { private async autoCheckpoint(): Promise<void> {
try { try {
await this.assertOwnership();
await this.medium.write(SNAPSHOT_KEY, encodeSnapshot(this.seq, this.index).buffer as ArrayBuffer); await this.medium.write(SNAPSHOT_KEY, encodeSnapshot(this.seq, this.index).buffer as ArrayBuffer);
const meta: KVStoreMeta = { seq: this.seq }; await this.writeMeta();
await this.medium.write(META_KEY, new TextEncoder().encode(JSON.stringify(meta)).buffer);
await this.truncateLog(); await this.truncateLog();
} catch (error) { } catch (error) {
this.lastBackgroundError = error; // 陈旧实例的自动 checkpoint 失败**不**计入 lastBackgroundError
// 那会把它伪装成"介质故障",而真实原因是本实例已被接管,用户需要的是
// 立即的、明确的 STALE_INSTANCE 错误(由 assertOwnership 在显式路径给出)。
if (!this.stale) this.lastBackgroundError = error;
} }
} }
@@ -473,6 +517,66 @@ export class KVStore {
} }
} }
// =======================================================================
// 提交所有权(v0.8.0 / B-6
// =======================================================================
/** 生成实例 id(无需密码学强度,只要同期实例间不碰撞) */
private newInstanceId(): string {
const rand = Math.random().toString(36).slice(2);
return `kv-${Date.now().toString(36)}-${rand}`;
}
/** 读取 meta(缺失或损坏时返回 null) */
private async readMeta(): Promise<KVStoreMeta | null> {
try {
const raw = await this.medium.read(META_KEY);
if (!raw || raw.byteLength === 0) return null;
return JSON.parse(new TextDecoder().decode(raw)) as KVStoreMeta;
} catch {
return null;
}
}
/** 写入 meta(水位 + 所有权续期) */
private async writeMeta(): Promise<void> {
const meta: KVStoreMeta = { seq: this.seq, owner: this.instanceId };
await this.medium.write(META_KEY, new TextEncoder().encode(JSON.stringify(meta)).buffer);
}
/** open() 时领取所有权:写入本实例 id,使此前打开的实例在提交时发现自己已过期 */
private async claimOwnership(): Promise<void> {
this.instanceId = this.newInstanceId();
await this.writeMeta();
}
/**
* 提交前校验所有权。
*
* 只有当介质上的 owner 存在、且**不是**本实例时才算过期:
* - owner 缺失(旧版本库 / meta 被清理)→ 视为无主,允许提交;
* - owner === 自己 → 正常。
* 一旦判定过期,本实例进入 `stale` 状态:后续写入与提交都显式报错 ——
* 显式失败远好于静默丢数据(这是本 wound 的全部意义)。
*/
private async assertOwnership(): Promise<void> {
const meta = await this.readMeta();
if (meta?.owner && this.instanceId && meta.owner !== this.instanceId) {
this.stale = true;
throw new DatabaseError(
'KVStore instance has been superseded by another instance on the same database'
+ ' (refusing to commit a stale snapshot, which would discard the newer instance\'s writes);'
+ ' re-open the database to continue',
'STALE_INSTANCE',
);
}
}
/** 本实例是否已被更新实例接管(诊断用) */
isStale(): boolean {
return this.stale;
}
/** 确定日志中有效字节长度(从 0 开始连续解析到第一条损坏/残缺记录) */ /** 确定日志中有效字节长度(从 0 开始连续解析到第一条损坏/残缺记录) */
private findValidLogLength(log: Uint8Array): number { private findValidLogLength(log: Uint8Array): number {
let offset = 0; let offset = 0;
+121 -43
View File
@@ -7,54 +7,127 @@
* (模拟"磁盘持久化"语义——重新 open 同名库可读到上次写入的数据)。 * (模拟"磁盘持久化"语义——重新 open 同名库可读到上次写入的数据)。
* *
* 仅用于测试与 Node 环境;浏览器使用 OPFS 介质(KVStore 默认自动选择)。 * 仅用于测试与 Node 环境;浏览器使用 OPFS 介质(KVStore 默认自动选择)。
*
* ============================================================================
* v0.8.0B-6):**介质本身必须有"多个实例共享同一份字节"的语义**
* ============================================================================
* 修复前每个实例各有一个私有 `materialized` 拼接缓存,而缓存只在**自己的**
* write/append 时失效。于是实例 A 读过的键会一直被 A 的缓存命中,**看不到**
* 实例 B 之后的写入 —— 介质行为退化为"每个实例各有一份快照"。
*
* 这个缺陷直接损害了本项目的验证能力:所有"两个实例共享介质"的崩溃/多标签页
* 用例都在一个错误的介质上运行。实测:
* A.open(db) → A 读 meta(缓存 A 的 owner
* B.open(db) → B 写 meta(新 owner
* A.checkpoint() → A 读 meta 仍得到**自己**的 owner
* → "陈旧实例拒绝提交"的所有权守卫看起来失效,实际是介质没把 B 的写入
* 反映给 A。
*
* 现在缓存**按库名共享**(挂在注册表条目上),并且失效是"对这一份存储"生效的:
* - 任何实例的 write/append/delete 都会清掉该库的共享缓存;
* - 存储为空时写回 `null` 哨兵,使"删除后仍读到旧值"不可能发生。
*/ */
import type { IStorageBackend } from '../aria/store/backend'; import type { IStorageBackend } from '../aria/store/backend';
/** 全局注册表:dbName → key → chunk 列表(跨实例共享,模拟持久化 */ /** 一份"磁盘"chunk 列表 + 拼接缓存(按库名共享,跨实例可见 */
const registry = new Map<string, Map<string, ArrayBuffer[]>>(); interface DbStore {
/** key → chunk 列表(append O(1)read 时一次性拼接) */
chunks: Map<string, ArrayBuffer[]>;
/** 惰性拼接缓存:key → 拼接结果(`null` 表示"该键不存在" */
materialized: Map<string, ArrayBuffer | null>;
}
/** 全局注册表:dbName(+ 库 id)→ 共享存储(跨实例共享,模拟持久化) */
const registry = new Map<string, DbStore>();
/**
* 库 id 计数器:用于 `clearRegistry()` 后让"同名但属于新会话"的库拿到
* **新的** DbStore,而不是复用上一会话残留的对象引用。
*/
let registryEpoch = 0;
/** 当前会话里 dbName → 本次会话的 DbStore(换代后失效) */
let liveStores = new Map<string, { epoch: number; store: DbStore }>();
function storeFor(name: string): DbStore {
const live = liveStores.get(name);
if (live && live.epoch === registryEpoch) return live.store;
const existing = registry.get(name);
if (existing) {
// 同一会话内复用(多个 backend 实例共享同一份字节)
liveStores.set(name, { epoch: registryEpoch, store: existing });
return existing;
}
const created: DbStore = { chunks: new Map(), materialized: new Map() };
registry.set(name, created);
liveStores.set(name, { epoch: registryEpoch, store: created });
return created;
}
export class SharedMemoryBackend implements IStorageBackend { export class SharedMemoryBackend implements IStorageBackend {
private dbName = ''; private dbName = '';
/** 存储:key → chunk 列表(append O(1)read 时一次性拼接缓存) */ private store: DbStore | null = null;
private chunks: Map<string, ArrayBuffer[]> = new Map();
/** 惰性拼接缓存(read 后缓存,write/append 失效) */
private materialized: Map<string, ArrayBuffer> = new Map();
/** 清空全局注册表(测试隔离用) */ /**
* 清空全局注册表(测试隔离用)。
*
* 同时让"当前会话"失效:后续 open 会为同名库创建全新的存储 ——
* 否则 `clearRegistry()` 之后新建的实例可能仍指向上一用例的 DbStore
*(注册表被清空但对象引用还活在旧实例里),出现跨用例数据泄漏。
*/
static clearRegistry(): void { static clearRegistry(): void {
registry.clear(); registry.clear();
registryEpoch += 1;
liveStores = new Map();
} }
async open(name: string): Promise<void> { async open(name: string): Promise<void> {
this.dbName = name; this.dbName = name;
if (!registry.has(name)) { this.store = storeFor(name);
registry.set(name, new Map());
}
// 从注册表恢复 chunks(持久化语义)
this.chunks = registry.get(name)! as unknown as Map<string, ArrayBuffer[]>;
this.materialized = new Map();
} }
/** close 不清除数据(持久化语义:重开同名库数据仍在) */ /** close 不清除数据(持久化语义:重开同名库数据仍在) */
async close(): Promise<void> { async close(): Promise<void> {
this.chunks = new Map(); this.store = null;
this.materialized = new Map();
} }
isOpen(): boolean { isOpen(): boolean {
return this.dbName !== ''; return this.dbName !== '' && this.store !== null;
}
/**
* 取当前共享存储。
*
* v0.8.0`close()` 之后**不再**惰性重新绑定到共享存储 —— 那会让
* "close 后读不到数据"变成"close 后又读到了"(实测该行为被
* `tests/production-abnormal.test.ts` 的 close 契约用例逮到)。
*
* 正确语义(与既有契约一致,见该用例注释):
* - close 后读 → `null`(本实例已不持有数据);
* - close 后写/删/清空 → **不抛错**,但作用在游离内存上、**不持久化**
* - 跨实例持久化语义不受影响:另一个实例 open 同名库仍能读到 close 前的数据。
*/
private db(): DbStore {
if (!this.store) {
// 游离存储:仅供 close 后的调用"安全落地",不进入注册表
this.store = { chunks: new Map(), materialized: new Map() };
}
return this.store;
} }
async read(key: string): Promise<ArrayBuffer | null> { async read(key: string): Promise<ArrayBuffer | null> {
if (this.materialized.has(key)) return this.materialized.get(key)!; const db = this.db();
const list = this.chunks.get(key); if (db.materialized.has(key)) return db.materialized.get(key) ?? null;
if (!list || list.length === 0) return null; const list = db.chunks.get(key);
if (!list || list.length === 0) {
db.materialized.set(key, null);
return null;
}
if (list.length === 1) { if (list.length === 1) {
this.materialized.set(key, list[0]); db.materialized.set(key, list[0]);
return list[0]; return list[0];
} }
const total = list.reduce((s2, c) => s2 + c.byteLength, 0); const total = list.reduce((sum, c) => sum + c.byteLength, 0);
const combined = new Uint8Array(total); const combined = new Uint8Array(total);
let off = 0; let off = 0;
for (const c of list) { for (const c of list) {
@@ -62,56 +135,61 @@ export class SharedMemoryBackend implements IStorageBackend {
off += c.byteLength; off += c.byteLength;
} }
const buf = combined.buffer as ArrayBuffer; const buf = combined.buffer as ArrayBuffer;
this.materialized.set(key, buf); db.materialized.set(key, buf);
return buf; return buf;
} }
async write(key: string, data: ArrayBuffer): Promise<void> { async write(key: string, data: ArrayBuffer): Promise<void> {
this.chunks.set(key, [data]); const db = this.db();
this.materialized.set(key, data); db.chunks.set(key, [data]);
db.materialized.set(key, data);
} }
async append(key: string, data: ArrayBuffer): Promise<void> { async append(key: string, data: ArrayBuffer): Promise<void> {
// O(1) 追加:只记录 chunkread 时惰性拼接 // O(1) 追加:只记录 chunkread 时惰性拼接
const list = this.chunks.get(key); // 缓存按库共享,因此这里清掉的缓存对所有实例都生效 —— 这正是
if (list) { // "另一个实例的写入必须对自己可见"这一语义的实现点。
list.push(data); const db = this.db();
} else { const list = db.chunks.get(key);
this.chunks.set(key, [data]); if (list) list.push(data);
} else db.chunks.set(key, [data]);
this.materialized.delete(key); db.materialized.delete(key);
} }
async writeMany(entries: Record<string, ArrayBuffer>): Promise<void> { async writeMany(entries: Record<string, ArrayBuffer>): Promise<void> {
// 同步批量写入 = 原子(JS 单线程,无中间 await 点) // 同步批量写入 = 原子(JS 单线程,无中间 await 点)
const db = this.db();
for (const [key, data] of Object.entries(entries)) { for (const [key, data] of Object.entries(entries)) {
this.chunks.set(key, [data]); db.chunks.set(key, [data]);
this.materialized.set(key, data); db.materialized.set(key, data);
} }
} }
async delete(key: string): Promise<void> { async delete(key: string): Promise<void> {
this.chunks.delete(key); const db = this.db();
this.materialized.delete(key); db.chunks.delete(key);
db.materialized.set(key, null);
} }
async deleteMany(keys: string[]): Promise<void> { async deleteMany(keys: string[]): Promise<void> {
const db = this.db();
for (const key of keys) { for (const key of keys) {
this.chunks.delete(key); db.chunks.delete(key);
this.materialized.delete(key); db.materialized.set(key, null);
} }
} }
async listKeys(): Promise<string[]> { async listKeys(): Promise<string[]> {
return Array.from(this.chunks.keys()); return Array.from(this.db().chunks.keys());
} }
async exists(key: string): Promise<boolean> { async exists(key: string): Promise<boolean> {
return this.chunks.has(key); return this.db().chunks.has(key);
} }
async clear(): Promise<void> { async clear(): Promise<void> {
this.chunks.clear(); const db = this.db();
this.materialized.clear(); db.chunks.clear();
db.materialized.clear();
} }
} }
+142
View File
@@ -21,6 +21,7 @@
import { KVStore } from '../src/engine/kvstore/index'; import { KVStore } from '../src/engine/kvstore/index';
import { MemoryBackend } from '../src/engine/aria/store/backend'; import { MemoryBackend } from '../src/engine/aria/store/backend';
import { FaultyBackend } from './helpers/faulty-backend'; import { FaultyBackend } from './helpers/faulty-backend';
import { SharedMemoryBackend } from '../src/engine/kvstore/shared_memory_medium';
import { decode } from './helpers/assertions'; import { decode } from './helpers/assertions';
const enc = (s: string): ArrayBuffer => new TextEncoder().encode(s).buffer as ArrayBuffer; const enc = (s: string): ArrayBuffer => new TextEncoder().encode(s).buffer as ArrayBuffer;
@@ -239,3 +240,144 @@ describe('[v0.8.0] B-6repair() 与 open() 的恢复口径一致', () => {
expect(decode(await reopened.get('keep'))).toBe('kept'); expect(decode(await reopened.get('keep'))).toBe('kept');
}); });
}); });
// ---------------------------------------------------------------------------
// B-6 ③:陈旧实例不得覆盖更新实例的提交
// ---------------------------------------------------------------------------
describe('[v0.8.0] B-6 ③:陈旧实例拒绝提交(多实例共享同一库)', () => {
it('落后实例的 checkpoint 被拒绝,不会抹掉新实例的写入', async () => {
SharedMemoryBackend.clearRegistry();
// A 打开并写入
const a = new KVStore(new SharedMemoryBackend(), 0);
await a.open('b6-stale');
await a.put('x', enc('from-A'));
// B 打开同一库(读到 x)并写入 y,然后关闭
const b = new KVStore(new SharedMemoryBackend(), 0);
await b.open('b6-stale');
await b.put('y', enc('from-B'));
b.close();
// 修复前:A.checkpoint() 用 A 的陈旧索引(没有 y)覆盖介质 → y 静默消失,
// 且**不报任何错**(实测:after A checkpoint -> x: from-A y: null)。
await expect(a.checkpoint()).rejects.toMatchObject({ code: 'STALE_INSTANCE' });
expect(a.isStale()).toBe(true);
// y 必须还在
const c = new KVStore(new SharedMemoryBackend(), 0);
await c.open('b6-stale');
expect(decode(await c.get('x'))).toBe('from-A');
expect(decode(await c.get('y'))).toBe('from-B');
});
it('陈旧实例的后续写入被拒绝(不再产生无法提交的数据)', async () => {
SharedMemoryBackend.clearRegistry();
const a = new KVStore(new SharedMemoryBackend(), 0);
await a.open('b6-stale-write');
await a.put('x', enc('1'));
const b = new KVStore(new SharedMemoryBackend(), 0);
await b.open('b6-stale-write');
b.close();
await expect(a.checkpoint()).rejects.toMatchObject({ code: 'STALE_INSTANCE' });
// 已进入陈旧状态:写入必须显式失败,而不是"写进 WAL 但永远无法提交"
await expect(a.put('z', enc('2'))).rejects.toMatchObject({ code: 'STALE_INSTANCE' });
});
it('重新 open 可恢复(陈旧状态不是永久的)', async () => {
SharedMemoryBackend.clearRegistry();
const a = new KVStore(new SharedMemoryBackend(), 0);
await a.open('b6-stale-recover');
await a.put('x', enc('1'));
const b = new KVStore(new SharedMemoryBackend(), 0);
await b.open('b6-stale-recover');
await expect(a.checkpoint()).rejects.toMatchObject({ code: 'STALE_INSTANCE' });
await a.close();
// 重新 open → 重新领取所有权 → 正常读写与提交
await a.open('b6-stale-recover');
expect(a.isStale()).toBe(false);
await a.put('x2', enc('2'));
await expect(a.checkpoint()).resolves.toBeUndefined();
const c = new KVStore(new SharedMemoryBackend(), 0);
await c.open('b6-stale-recover');
expect(decode(await c.get('x2'))).toBe('2');
});
it('单实例(无竞争)不受影响:所有权检查不误报', async () => {
SharedMemoryBackend.clearRegistry();
const store = new KVStore(new SharedMemoryBackend(), 0);
await store.open('b6-single');
await store.put('k', enc('v'));
await expect(store.checkpoint()).resolves.toBeUndefined();
await expect(store.checkpoint()).resolves.toBeUndefined(); // 幂等
expect(decode(await store.get('k'))).toBe('v');
});
});
// ---------------------------------------------------------------------------
// B-6 介质一致性(shared_memory_medium 的跨实例可见性)
// ---------------------------------------------------------------------------
describe('[v0.8.0] B-6 介质:跨实例写入必须相互可见', () => {
it('实例 B 的写入对已打开的实例 A 立即可见', async () => {
SharedMemoryBackend.clearRegistry();
const a = new KVStore(new SharedMemoryBackend(), 0);
await a.open('b6-medium-vis');
await a.put('k1', enc('a1'));
// A 先读一次(触发缓存填充)
expect(decode(await a.get('k1'))).toBe('a1');
// 直接经介质读(KVStore.get 只读内存索引,这里验证的是介质层)
const bMedium = new SharedMemoryBackend();
await bMedium.open('b6-medium-vis');
await bMedium.write('probe', enc('from-B'));
expect(new TextDecoder().decode((await bMedium.read('probe'))!)).toBe('from-B');
// A 再读同一个键:必须看到 B 写的内容
expect(new TextDecoder().decode((await bMedium.read('probe'))!)).toBe('from-B');
});
it('删除后不得再读到旧值(缓存必须按删除失效)', async () => {
SharedMemoryBackend.clearRegistry();
const m1 = new SharedMemoryBackend();
await m1.open('b6-medium-del');
await m1.write('k', enc('v'));
expect(decode(await m1.read('k'))).toBe('v');
const m2 = new SharedMemoryBackend();
await m2.open('b6-medium-del');
await m2.delete('k');
// m1 的缓存不得掩盖删除
expect(await m1.read('k')).toBeNull();
expect(await m2.read('k')).toBeNull();
});
it('append 后 read 必须包含新数据(跨实例)', async () => {
SharedMemoryBackend.clearRegistry();
const m1 = new SharedMemoryBackend();
await m1.open('b6-medium-append');
await m1.append('k', enc('A'));
expect(decode(await m1.read('k'))).toBe('A');
const m2 = new SharedMemoryBackend();
await m2.open('b6-medium-append');
await m2.append('k', enc('B'));
expect(decode(await m1.read('k'))).toBe('AB');
});
it('clearRegistry 后同名库是全新存储(测试隔离有效)', async () => {
SharedMemoryBackend.clearRegistry();
const m1 = new SharedMemoryBackend();
await m1.open('b6-medium-epoch');
await m1.write('k', enc('old'));
SharedMemoryBackend.clearRegistry();
const m2 = new SharedMemoryBackend();
await m2.open('b6-medium-epoch');
expect(await m2.read('k')).toBeNull();
});
});