From 68e47317887b03103b4156b72fb8b66f36ef2994 Mon Sep 17 00:00:00 2001 From: thzxx Date: Tue, 15 Sep 2026 00:34:45 +0800 Subject: [PATCH] =?UTF-8?q?fix(B-6=20=E2=91=A2=20+=20=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E4=BB=8B=E8=B4=A8):=20=E9=99=88=E6=97=A7=E5=AE=9E=E4=BE=8B?= =?UTF-8?q?=E6=8B=92=E7=BB=9D=E6=8F=90=E4=BA=A4=E8=A6=86=E7=9B=96=20+=20Sh?= =?UTF-8?q?aredMemoryBackend=20=E8=B7=A8=E5=AE=9E=E4=BE=8B=E5=8F=AF?= =?UTF-8?q?=E8=A7=81=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ③ 陈旧实例的 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 零错误。 --- src/engine/kvstore/index.ts | 116 ++++++++++++++- src/engine/kvstore/shared_memory_medium.ts | 164 +++++++++++++++------ tests/v080-kvstore-commit-point.test.ts | 142 ++++++++++++++++++ 3 files changed, 373 insertions(+), 49 deletions(-) diff --git a/src/engine/kvstore/index.ts b/src/engine/kvstore/index.ts index 0962078..0ae36db 100644 --- a/src/engine/kvstore/index.ts +++ b/src/engine/kvstore/index.ts @@ -39,6 +39,20 @@ const DEFAULT_CHECKPOINT_THRESHOLD = 16 * 1024 * 1024; interface KVStoreMeta { /** 当前日志水位(快照内嵌;无快照时 0) */ seq: number; + /** + * v0.8.0(B-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 { @@ -67,6 +81,16 @@ export class KVStore { private opQueue: Promise = Promise.resolve(); /** 最近一次后台操作失败(checkpoint 时报告) */ 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) { this.medium = medium ?? defaultMedium(); @@ -87,7 +111,12 @@ export class KVStore { this.dbName = dbName; // v0.7.4: 打开时同样清理后台错误状态(防 close/reopen 残留) this.lastBackgroundError = null; + this.stale = false; await this.medium.open(dbName); + // v0.8.0(B-6):领取提交所有权(写回 meta.owner)。 + // 打开是唯一"接管"介质的时机;此前的实例之后会在 checkpoint 时发现 + // owner 已变而拒绝提交,从而不会用陈旧索引覆盖本实例的数据。 + await this.claimOwnership(); this.index = new Map(); this.seq = 0; this.logBytes = 0; @@ -176,6 +205,8 @@ export class KVStore { // v0.7.4: 清理后台错误状态 —— 此前跨 close/reopen 残留, // 重开后首次 checkpoint 会抛出上一次生命周期的旧错误 this.lastBackgroundError = null; + this.instanceId = ''; + this.stale = false; this.opened = false; } @@ -269,6 +300,8 @@ export class KVStore { /** checkpoint:快照 → meta → 截断日志(时序保证任何崩溃窗口不丢数据) */ async checkpoint(): Promise { await this.enqueue(async () => { + // v0.8.0(B-6):提交前确认所有权 —— 拒绝用陈旧索引覆盖介质 + await this.assertOwnership(); // 报告上次后台失败 if (this.lastBackgroundError !== null) { const error = this.lastBackgroundError; @@ -280,9 +313,8 @@ export class KVStore { // 1. 写快照(COW 原子) const snapBytes = encodeSnapshot(this.seq, this.index); await this.medium.write(SNAPSHOT_KEY, snapBytes.buffer as ArrayBuffer); - // 2. 写 meta(指向新水位) - const meta: KVStoreMeta = { seq: this.seq }; - await this.medium.write(META_KEY, new TextEncoder().encode(JSON.stringify(meta)).buffer); + // 2. 写 meta(指向新水位 + 续期所有权) + await this.writeMeta(); // 3. 截断日志(meta 已更新 → 截断安全) await this.truncateLog(); }); @@ -345,6 +377,15 @@ export class KVStore { deletes: string[], appends: Record = {}, ): Promise { + // 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++; const record = encodeLogRecord(this.seq, puts, deletes, appends); try { @@ -414,12 +455,15 @@ export class KVStore { */ private async autoCheckpoint(): Promise { try { + await this.assertOwnership(); await this.medium.write(SNAPSHOT_KEY, encodeSnapshot(this.seq, this.index).buffer as ArrayBuffer); - const meta: KVStoreMeta = { seq: this.seq }; - await this.medium.write(META_KEY, new TextEncoder().encode(JSON.stringify(meta)).buffer); + await this.writeMeta(); await this.truncateLog(); } 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 { + 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 { + 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 { + this.instanceId = this.newInstanceId(); + await this.writeMeta(); + } + + /** + * 提交前校验所有权。 + * + * 只有当介质上的 owner 存在、且**不是**本实例时才算过期: + * - owner 缺失(旧版本库 / meta 被清理)→ 视为无主,允许提交; + * - owner === 自己 → 正常。 + * 一旦判定过期,本实例进入 `stale` 状态:后续写入与提交都显式报错 —— + * 显式失败远好于静默丢数据(这是本 wound 的全部意义)。 + */ + private async assertOwnership(): Promise { + 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 开始连续解析到第一条损坏/残缺记录) */ private findValidLogLength(log: Uint8Array): number { let offset = 0; diff --git a/src/engine/kvstore/shared_memory_medium.ts b/src/engine/kvstore/shared_memory_medium.ts index 8712219..2272a33 100644 --- a/src/engine/kvstore/shared_memory_medium.ts +++ b/src/engine/kvstore/shared_memory_medium.ts @@ -7,54 +7,127 @@ * (模拟"磁盘持久化"语义——重新 open 同名库可读到上次写入的数据)。 * * 仅用于测试与 Node 环境;浏览器使用 OPFS 介质(KVStore 默认自动选择)。 + * + * ============================================================================ + * v0.8.0(B-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'; -/** 全局注册表:dbName → key → chunk 列表(跨实例共享,模拟持久化) */ -const registry = new Map>(); +/** 一份"磁盘":chunk 列表 + 拼接缓存(按库名共享,跨实例可见) */ +interface DbStore { + /** key → chunk 列表(append O(1),read 时一次性拼接) */ + chunks: Map; + /** 惰性拼接缓存:key → 拼接结果(`null` 表示"该键不存在") */ + materialized: Map; +} + +/** 全局注册表:dbName(+ 库 id)→ 共享存储(跨实例共享,模拟持久化) */ +const registry = new Map(); + +/** + * 库 id 计数器:用于 `clearRegistry()` 后让"同名但属于新会话"的库拿到 + * **新的** DbStore,而不是复用上一会话残留的对象引用。 + */ +let registryEpoch = 0; +/** 当前会话里 dbName → 本次会话的 DbStore(换代后失效) */ +let liveStores = new Map(); + +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 { private dbName = ''; - /** 存储:key → chunk 列表(append O(1),read 时一次性拼接缓存) */ - private chunks: Map = new Map(); - /** 惰性拼接缓存(read 后缓存,write/append 失效) */ - private materialized: Map = new Map(); + private store: DbStore | null = null; - /** 清空全局注册表(测试隔离用) */ + /** + * 清空全局注册表(测试隔离用)。 + * + * 同时让"当前会话"失效:后续 open 会为同名库创建全新的存储 —— + * 否则 `clearRegistry()` 之后新建的实例可能仍指向上一用例的 DbStore + *(注册表被清空但对象引用还活在旧实例里),出现跨用例数据泄漏。 + */ static clearRegistry(): void { registry.clear(); + registryEpoch += 1; + liveStores = new Map(); } async open(name: string): Promise { this.dbName = name; - if (!registry.has(name)) { - registry.set(name, new Map()); - } - // 从注册表恢复 chunks(持久化语义) - this.chunks = registry.get(name)! as unknown as Map; - this.materialized = new Map(); + this.store = storeFor(name); } /** close 不清除数据(持久化语义:重开同名库数据仍在) */ async close(): Promise { - this.chunks = new Map(); - this.materialized = new Map(); + this.store = null; } 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 { - if (this.materialized.has(key)) return this.materialized.get(key)!; - const list = this.chunks.get(key); - if (!list || list.length === 0) return null; + const db = this.db(); + if (db.materialized.has(key)) return db.materialized.get(key) ?? null; + const list = db.chunks.get(key); + if (!list || list.length === 0) { + db.materialized.set(key, null); + return null; + } if (list.length === 1) { - this.materialized.set(key, list[0]); + db.materialized.set(key, 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); let off = 0; for (const c of list) { @@ -62,56 +135,61 @@ export class SharedMemoryBackend implements IStorageBackend { off += c.byteLength; } const buf = combined.buffer as ArrayBuffer; - this.materialized.set(key, buf); + db.materialized.set(key, buf); return buf; } async write(key: string, data: ArrayBuffer): Promise { - this.chunks.set(key, [data]); - this.materialized.set(key, data); + const db = this.db(); + db.chunks.set(key, [data]); + db.materialized.set(key, data); } async append(key: string, data: ArrayBuffer): Promise { - // O(1) 追加:只记录 chunk,read 时惰性拼接 - const list = this.chunks.get(key); - if (list) { - list.push(data); - } else { - this.chunks.set(key, [data]); - } - this.materialized.delete(key); + // O(1) 追加:只记录 chunk,read 时惰性拼接。 + // 缓存按库共享,因此这里清掉的缓存对所有实例都生效 —— 这正是 + // "另一个实例的写入必须对自己可见"这一语义的实现点。 + const db = this.db(); + const list = db.chunks.get(key); + if (list) list.push(data); + else db.chunks.set(key, [data]); + db.materialized.delete(key); } async writeMany(entries: Record): Promise { // 同步批量写入 = 原子(JS 单线程,无中间 await 点) + const db = this.db(); for (const [key, data] of Object.entries(entries)) { - this.chunks.set(key, [data]); - this.materialized.set(key, data); + db.chunks.set(key, [data]); + db.materialized.set(key, data); } } async delete(key: string): Promise { - this.chunks.delete(key); - this.materialized.delete(key); + const db = this.db(); + db.chunks.delete(key); + db.materialized.set(key, null); } async deleteMany(keys: string[]): Promise { + const db = this.db(); for (const key of keys) { - this.chunks.delete(key); - this.materialized.delete(key); + db.chunks.delete(key); + db.materialized.set(key, null); } } async listKeys(): Promise { - return Array.from(this.chunks.keys()); + return Array.from(this.db().chunks.keys()); } async exists(key: string): Promise { - return this.chunks.has(key); + return this.db().chunks.has(key); } async clear(): Promise { - this.chunks.clear(); - this.materialized.clear(); + const db = this.db(); + db.chunks.clear(); + db.materialized.clear(); } } diff --git a/tests/v080-kvstore-commit-point.test.ts b/tests/v080-kvstore-commit-point.test.ts index 5bd26fa..5d18053 100644 --- a/tests/v080-kvstore-commit-point.test.ts +++ b/tests/v080-kvstore-commit-point.test.ts @@ -21,6 +21,7 @@ import { KVStore } from '../src/engine/kvstore/index'; import { MemoryBackend } from '../src/engine/aria/store/backend'; import { FaultyBackend } from './helpers/faulty-backend'; +import { SharedMemoryBackend } from '../src/engine/kvstore/shared_memory_medium'; import { decode } from './helpers/assertions'; const enc = (s: string): ArrayBuffer => new TextEncoder().encode(s).buffer as ArrayBuffer; @@ -239,3 +240,144 @@ describe('[v0.8.0] B-6:repair() 与 open() 的恢复口径一致', () => { 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(); + }); +});