Skip to content

Commit 4ab5254

Browse files
committed
perf: verify cached ranges for large document pagination
1 parent 7f9649f commit 4ab5254

14 files changed

Lines changed: 442 additions & 27 deletions

File tree

‎changelogs/1.1.0.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
- 正文分页不设文档总大小准入门槛;模型选择范围和是否续读。全文与分页保留原始 BOM/CRLF,MD/MDX MIME 一致,全文哈希与本页哈希分别表达。
88
- 全文单篇预算 `read.maxBytes` 保持兼容,新增独立 `read.maxPageBytes` 控制分页/流块;段落边界采用线性扫描并保留 CRLF 原始偏移。
99
- KnowledgeReader 新增可选 isContentCurrent 强来源核验合同;正文续页可复用有界私有副本,HTTP 参考用条件 HEAD/强 ETag,减少重复完整扫描。普通文件、弱验证器及缓存未命中继续完整核验,内容变化拒绝旧游标。缓存每实例最多 4 MiB、16 条,文档不设大小准入限制。
10+
- KnowledgeReader 新增可选 readRange;较大正文的续页通过首次完整扫描留下的有界分块哈希独立验证范围,HTTP 参考读取仓库外磁盘快照,避免超过内存正文缓存容量后反复全文扫描。来源、范围或证明不可用时保持完整核验回退。
1011
- 外部检索即使不调用 access,也核验全部来源与命中范围,包括零命中。数据库 Catalog 与全部八类关系检查完整性,漏项和假空流明确失败。
1112
- Runtime 保留白名单修订/上下文错误及安全动作,移除 Adapter 私有 message/details;期限或未知异常仍为 unavailable。
1213
- HTTP 与词法检索源码参考增加宿主代理 Agent、强 ETag 完整快照复用和受预算约束的流式 Top-K。缓存预算不限制文档大小。

‎examples/seed-runtime/README.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010

1111
来源经代理访问时,宿主通过 `agentForUrl` 注入兼容 Node 20/22/24 的 Agent,例如根开发依赖中的 `proxy-agent`。Agent 由宿主管理和关闭,Reader 不接管它的生命周期;代理凭据不进入报告。
1212

13-
可选 `snapshot` 由宿主指定仓库外绝对目录,并配置 `maxBytes`、`maxEntries`、`ttlMs`。只有完整读取且带强 ETag 的正文才写入快照;后续条件请求返回 304 时仍核对本地完整正文的字节数和 SHA-256。弱/无验证器或正文超过缓存预算时继续完整流式读取;快照预算控制缓存保留,不限制可读取文档大小。正文续页还支持可选 `isContentCurrent`:条件 HEAD 的 304 核验已绑定的强 ETag,Core 复用已完整哈希的私有字节副本,每实例最多 4 MiB、16 条,避免重复磁盘扫描和哈希。不支持 HEAD、弱验证器、过期或超缓存容量时重新完整核验。Reader 的 `close()` 只清理它自己创建的快照子目录。
13+
可选 `snapshot` 由宿主指定仓库外绝对目录,并配置 `maxBytes`、`maxEntries`、`ttlMs`。只有完整读取且带强 ETag 的正文才写入快照;后续条件 GET 返回 304 时仍核对本地完整正文的字节数和 SHA-256。弱/无验证器或正文超过磁盘快照预算时继续完整流式读取;快照预算控制缓存保留,不限制可读取文档大小。
14+
15+
正文续页支持 `isContentCurrent`:条件 HEAD 的 304 核验已绑定的强 ETag。Core 复用已完整哈希的私有副本,正文与证明共用每实例最多 4 MiB、16 条。启用磁盘快照时还提供 `readRange`,较大正文只读取所需的证明块,由 Core 用首次扫描留下的 SHA-256 独立核对原始字节;不要求来源支持 HTTP Range。缺失快照回退完整读取,损坏快照明确失败;不支持 HEAD、弱验证器、过期或超磁盘预算时重新完整核验。Reader 的 `close()` 等待在途范围读取和租用结束,只清理自己创建的快照子目录。
1416

1517
`text-retrieval.ts` 是确定性的词项索引。`TextKnowledgeRetriever` 通过 `scan` 消费大正文,查询时保留有界 Top-K;完整索引仅在 `maxCachedBytes`、`maxCachedEntries` 及选择数预算内缓存,超过预算时继续流式检索。`configure({ pageBytes })` 控制扫描块大小,`maxDocumentBytes` 是兼容旧名称,两者都不表示全文准入上限;只提供 `read` 的旧 access 使用有界全文回退。Core 正文分页接口只保留所选范围,并计算全文哈希;模型决定是否继续读取。
1618

@@ -20,6 +22,8 @@
2022

2123
构建 Core 和测试源码后,可运行 `node scripts/validation/knowledge-profile.mjs` 测量受控 HTTP 来源。基准分别覆盖可缓存、短行密集和容量溢出场景,每个场景使用独立检索器及固定选择,记录实际 `cachedSelections`、`cachedEntries`、`cachedBytes`,分别汇总冷查询、索引缓存命中、未命中和零命中。索引命中仍会核验实际来源,不表示免除 HTTP 读取;无样本的耗时字段为 null,缓存字节计账也不等于堆内存峰值。
2224

25+
`node --expose-gc scripts/validation/knowledge-page-profile.mjs` 验证 5 MiB 合成来源的 160 页、6 个并发范围查询及无强证明的完整扫描回退。各场景使用独立子进程,分别记录完整扫描字节、范围读取字节、HTTP 请求,以及分页和并发阶段的采样内存峰值。可用 `--baseline-core /absolute/dist/index.js` 对照已验证的旧 Core。采样峰值包含来源服务和测试消费者,不等于缓存保留量;耗时用于观察趋势,不作为共享环境的硬性通过阈值。
26+
2327
检索按已选 targets 重查内容身份,零命中也需要有效证据。静态定义、正文或分块配置变更后,由接入方显式重建或调用 invalidate,Core 不自动刷新。这条路径使用零次模型调用,证明本机 HTTP 和检索链路,不证明语义搜索质量或 Agent 任务成功率。临时 Provider 位于仓库同级 `capability-graph-artifacts`,结束后关闭 Reader、graph、HTTP 服务并清理自建目录。正式回归位于根 `test/http-retrieval.test.ts` 和 `test/text-retrieval.test.ts`。
2428

2529
## 运行

‎examples/seed-runtime/knowledge-reader.ts‎

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ import path from "node:path";
66
import type { KnowledgeReader, KnowledgeDocumentRef, KnowledgeReadContext } from "@devcodex/capability-graph";
77

88
type ReadResult = Awaited<ReturnType<KnowledgeReader["read"]>>;
9-
interface Snapshot { file: string; etag: string; sha256: string; contentType: string; bytes: number; expires: number; leases: number; retired: boolean }
9+
type FileIdentity = { dev: bigint; ino: bigint; size: bigint; mtimeNs: bigint; ctimeNs: bigint };
10+
interface Snapshot { file: string; identity: FileIdentity; etag: string; sha256: string; contentType: string; bytes: number; expires: number; leases: number; retired: boolean }
1011
interface SnapshotOptions { directory: string; maxBytes?: number; maxEntries?: number; ttlMs?: number }
1112
interface Options { allowedOrigins: readonly string[]; timeoutMs?: number; maxConcurrent?: number;
1213
/** Host-owned Agent (e.g. proxy-agent on Node 20/22/24); Reader never closes it. */
@@ -24,6 +25,7 @@ export class HttpKnowledgeReader implements KnowledgeReader {
2425
private readonly snapshotLimits?: { maxBytes: number; maxEntries: number; ttlMs: number };
2526
private directory?: Promise<string>;
2627
private closed = false;
28+
readonly readRange?: NonNullable<KnowledgeReader["readRange"]>;
2729
constructor(private readonly options: Options) {
2830
this.timeoutMs = options.timeoutMs ?? 2000; this.maxConcurrent = options.maxConcurrent ?? 4;
2931
if (![this.timeoutMs, this.maxConcurrent].every((value) => Number.isSafeInteger(value) && value > 0) || !options.allowedOrigins.length) throw new Error("Explicit origins and positive HTTP limits are required");
@@ -36,6 +38,7 @@ export class HttpKnowledgeReader implements KnowledgeReader {
3638
if (!path.isAbsolute(options.snapshot.directory)) throw new Error("Snapshot directory must be an absolute host-owned path outside the source checkout");
3739
this.snapshotLimits = { maxBytes: options.snapshot.maxBytes ?? 16_777_216, maxEntries: options.snapshot.maxEntries ?? 8, ttlMs: options.snapshot.ttlMs ?? 300_000 };
3840
if (!Object.values(this.snapshotLimits).every((value) => Number.isSafeInteger(value) && value > 0)) throw new Error("Invalid snapshot budgets");
41+
this.readRange = this.readSnapshotRange.bind(this);
3942
}
4043
}
4144
get activeRequests(): number { return this.requests.size; }
@@ -85,6 +88,50 @@ export class HttpKnowledgeReader implements KnowledgeReader {
8588
finish(); this.streams.delete(done);
8689
}
8790
}
91+
/** Read only a leased, fully downloaded representation after Core's strong revalidation.
92+
* Core independently verifies aligned block hashes; file identity detects local replacement
93+
* or edits early, and is never used as proof that the remote source is current. */
94+
private async readSnapshotRange(ref: KnowledgeDocumentRef, _context: KnowledgeReadContext,
95+
options: Parameters<NonNullable<KnowledgeReader["readRange"]>>[2]): ReturnType<NonNullable<KnowledgeReader["readRange"]>> {
96+
if (this.closed || ref.locator.type !== "http" || this.streams.size >= this.maxConcurrent) throw new Error("Reader unavailable");
97+
if (![options.startOffset, options.endOffset].every(Number.isSafeInteger) || options.startOffset < 0 || options.endOffset < options.startOffset) throw new Error("Invalid range");
98+
options.signal?.throwIfAborted();
99+
const source = ref.locator.url; const snapshot = this.snapshots.get(source);
100+
if (!snapshot || snapshot.expires <= Date.now() || options.contentId !== `k:${snapshot.sha256.slice(0, 16)}`) return undefined;
101+
if (options.endOffset > snapshot.bytes) throw new Error("Invalid range");
102+
let finish!: () => void; const done = new Promise<void>((resolve) => { finish = resolve; }); this.streams.add(done);
103+
snapshot.leases++; let handle: FileHandle | undefined;
104+
const unchanged = (actual: FileIdentity) => Object.entries(snapshot.identity).every(([key, value]) => actual[key as keyof FileIdentity] === value);
105+
try {
106+
try { handle = await open(snapshot.file, "r"); }
107+
catch (error) {
108+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
109+
if (this.snapshots.get(source) === snapshot) await this.retire(source);
110+
return undefined;
111+
}
112+
if (!unchanged(await handle.stat({ bigint: true }))) throw new Error("Snapshot changed");
113+
const bytes = Buffer.alloc(options.endOffset - options.startOffset); let offset = 0;
114+
while (offset < bytes.length) {
115+
options.signal?.throwIfAborted(); if (this.closed) throw new Error("Reader unavailable");
116+
const { bytesRead } = await handle.read(bytes, offset, Math.min(32768, bytes.length - offset), options.startOffset + offset);
117+
if (!bytesRead) throw new Error("Snapshot incomplete"); offset += bytesRead;
118+
}
119+
options.signal?.throwIfAborted(); if (this.closed || !unchanged(await handle.stat({ bigint: true }))) throw new Error("Snapshot changed");
120+
if (this.snapshots.get(source) === snapshot) { this.snapshots.delete(source); this.snapshots.set(source, snapshot); }
121+
return { bytes, contentId: options.contentId, source, contentType: snapshot.contentType, totalBytes: snapshot.bytes };
122+
} catch (error) {
123+
if (this.snapshots.get(source) === snapshot) await this.retire(source);
124+
throw error;
125+
} finally {
126+
try {
127+
try { await handle?.close(); }
128+
finally {
129+
snapshot.leases--;
130+
if (snapshot.retired && !snapshot.leases) await rm(snapshot.file, { force: true });
131+
}
132+
} finally { finish(); this.streams.delete(done); }
133+
}
134+
}
88135
/** Strong ETag validates a previously complete disk snapshot; weak/no validator falls back to full GET.
89136
* Normal streams still verify complete bytes; Core may reuse its own proved copy for body pages. No 206 is accepted. */
90137
async *stream(ref: KnowledgeDocumentRef, _context: KnowledgeReadContext, options: { chunkBytes: number; signal?: AbortSignal }) {
@@ -161,9 +208,14 @@ export class HttpKnowledgeReader implements KnowledgeReader {
161208
if (!emitted) yield { bytes: new Uint8Array(), source, contentType };
162209
if (writer && temporary && !this.closed) {
163210
await writer.close(); writer = undefined;
211+
const inspection = await open(temporary, "r"); let identity: FileIdentity;
212+
try {
213+
const stat = await inspection.stat({ bigint: true });
214+
identity = { dev: stat.dev, ino: stat.ino, size: stat.size, mtimeNs: stat.mtimeNs, ctimeNs: stat.ctimeNs };
215+
} finally { await inspection.close(); }
164216
while (this.snapshots.size && (this.snapshots.size >= this.snapshotLimits!.maxEntries || this.cachedSnapshotBytes + total > this.snapshotLimits!.maxBytes)) await this.retire(this.snapshots.keys().next().value!);
165217
await this.retire(source);
166-
this.snapshots.set(source, { file: temporary, etag: etag!, sha256: sourceHash.digest("hex"), bytes: total, contentType, expires: Date.now() + this.snapshotLimits!.ttlMs, leases: 0, retired: false }); published = true;
218+
this.snapshots.set(source, { file: temporary, identity, etag: etag!, sha256: sourceHash.digest("hex"), bytes: total, contentType, expires: Date.now() + this.snapshotLimits!.ttlMs, leases: 0, retired: false }); published = true;
167219
}
168220
} finally {
169221
clearTimeout(timer); options.signal?.removeEventListener("abort", abort);

‎examples/vextjs/README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,6 @@ node scripts/validation/vextjs-consumer.mjs /external/built-fixed-vextjs-source
100100

101101
Core 的 11 个已知检索任务必须召回全部预期能力,未知需求不得误推荐;逐条知识证据必须非零且片段匹配原字节。原生 MCP 的 frontend 基线为 0.5,否定/同义词挑战基线为 0,明确保留上游能力限制而不声称原生全召回。正式文档任务用独立查询/期望文本验收,零命中会失败;不是根据文件名产生查询。
102102

103-
HTTP Reader 可注入 Node 20/22/24 兼容的宿主 ProxyAgent,代理凭据不进入报告。强 ETag 完整快照复用,弱/无验证器或超缓存预算回退全文流式扫描。快照在宿主指定的仓库外目录,Reader close 清理自有子目录。参考词法索引超过字节/条目预算时流式 Top-K,不拒绝大文档。以上为源码参考,主包不导出专用 Vext/HTTP 后端。
103+
HTTP Reader 可注入 Node 20/22/24 兼容的宿主 ProxyAgent,代理凭据不进入报告。强 ETag 完整快照复用;较大正文的续页可从磁盘只读所需块,由 Core 用首次扫描的分块哈希独立验证。弱/无验证器、证明不可用或超磁盘快照预算回退全文流式扫描。快照在宿主指定的仓库外目录,Reader close 等待在途读取并清理自有子目录。参考词法索引超过字节/条目预算时流式 Top-K,不拒绝大文档。以上为源码参考,主包不导出专用 Vext/HTTP 后端。
104104

105-
URL 使用相同分页接口和原始 UTF-8 字节偏移;无需更换 Core 分段算法。当前 Reader 不依赖 Range,每次读取都校验完整正文身份,正文改变拒绝旧游标。弱/无验证器或缓存无法保留全文时,每页可能重新传输整篇正文;缓存预算控制保留成本,不是文档准入门槛。
105+
URL 使用相同分页接口和原始 UTF-8 字节偏移;无需更换 Core 分段算法。当前 Reader 不依赖 HTTP Range,首次完整核验正文,续页核验强来源证明和实际范围字节,正文改变拒绝旧游标。弱/无验证器、证明不可用或磁盘快照无法保留全文时,每页可能重新传输整篇正文;缓存预算控制保留成本,不是文档准入门槛。

0 commit comments

Comments
 (0)