Feature/mongodb extension - #2698
Conversation
…simplify CAS - Remove duplicate ascending index on createdAt in MongoRemoteSnapshotClient; the TTL index already provides the same sorting capability - Add Objects.requireNonNull guards to MongoBaseStore constructor for database and collectionName parameters - Simplify MongoBaseStore.putIfVersion() return: findOneAndUpdate with version filter already guarantees the result matches expectedVersion + 1
…n MongoAgentStateStore - save() now increments version on each call (Updates.inc) - saveIfVersion(UNVERSIONED) reads version directly from MongoDB document instead of deserializing through State.class interface - saveIfVersion(0) catches MongoCommandException in addition to MongoWriteException for DuplicateKey errors from findOneAndUpdate - TTL index changed from expireAfterSeconds=0 to 30 days to prevent silent data loss by MongoDB TTL monitor - ensureIndexes gracefully handles IndexOptionsConflict (error 85) when upgrading from old TTL index parameters test(extensions-mongodb): add contract tests for BaseStore, AgentStateStore and index lifecycle - MongoBaseStoreContractTest (6 tests): CRUD, CAS, idempotent delete, search - MongoAgentStateStoreContractTest (6 tests): versioning, CAS concurrency - MongoIndexLifecycleContractTest (7 tests): index parameters, TTL values, upgrade from old TTL=0 without IndexOptionsConflict
… safety Assumptions.abort() in @BeforeAll does not prevent @afterall from running. When MongoDB is unreachable, @afterall calls client.getDatabase(dbName).drop() which blocks for 60s then throws MongoTimeoutException — causing CI failure. Fix: add `connected` flag, set only on successful ping. @afterall guards all MongoDB operations behind `if (connected)`. Also move dbName assignment before the try-catch to avoid null in disconnect.
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
AgentScopeJavaBot
left a comment
There was a problem hiding this comment.
🤖 AI Review
This PR adds a new agentscope-extensions-mongodb module providing MongoDB-backed distributed storage for AgentScope. It implements 6 source files covering all DistributedStore components: MongoAgentStateStore (session state with single-document model and CAS optimistic locking), MongoBaseStore (workspace KV store), MongoSandboxExecutionGuard (distributed lock via TTL documents), MongoRemoteSnapshotClient (sandbox snapshot storage), and MongoDistributedStore (facade). The code is well-structured, follows existing extension patterns (Redis/MySQL/PostgreSQL), and includes comprehensive test coverage (112 tests across 9 test files). The overall quality is high — the CAS logic is correct, the single-document model is well-designed, and edge cases (duplicate key errors, TTL index conflicts) are properly handled.
| public long saveIfVersion( | ||
| String userId, String sessionId, String key, State value, long expectedVersion) { | ||
| validateKey(key); | ||
| if (expectedVersion == UNVERSIONED) { |
There was a problem hiding this comment.
[recommended] Non-atomic read-after-write in saveIfVersion(UNVERSIONED) path
When expectedVersion == UNVERSIONED, save() atomically increments the version via $inc, then a separate find() reads it back. Between these two operations, another concurrent writer could modify the version, causing the returned value to be stale.
While the caller explicitly opted out of versioning semantics (UNVERSIONED), returning a misleading version could confuse downstream logic that uses it for subsequent CAS calls.
Consider combining the write and version read into a single findOneAndUpdate with returnDocument(AFTER).
| } | ||
|
|
||
| @Override | ||
| public void delete(String userId, String sessionId, String key) { |
There was a problem hiding this comment.
[recommended] delete(userId, sessionId, key) does not update FIELD_UPDATED_AT
When a specific key is deleted from a session document via $unset, the updatedAt field is not refreshed. Since the TTL index (expireAfter(30 days)) relies on updatedAt, a session where all keys are individually deleted (rather than the whole document being deleted) will have its TTL countdown based on the last save() call, not the delete operation.
Fix: include Updates.set(FIELD_UPDATED_AT, new Date()) in the update.
| Indexes.ascending(FIELD_USER_ID), Indexes.ascending(FIELD_SESSION_ID))); | ||
|
|
||
| String ttlIndexName = FIELD_UPDATED_AT + "_1"; | ||
| long ttlSeconds = 30L * 24 * 3600; |
There was a problem hiding this comment.
[recommended] TTL index expiration hardcoded to 30 days
The TTL index on updatedAt is set to 30L * 24 * 3600 seconds (30 days). This is a reasonable default but should be configurable via the Builder to accommodate different session lifecycle requirements (e.g., short-lived test sessions vs. long-lived production sessions).
Similarly, MongoRemoteSnapshotClient hardcodes a 7-day TTL. Consider adding builder parameters.
| new FindOneAndUpdateOptions().returnDocument(ReturnDocument.AFTER)); | ||
|
|
||
| if (reclaimed != null) { | ||
| log.debug( |
There was a problem hiding this comment.
[recommended] No lock renewal mechanism — TOCTOU window after TTL expiry
The lock acquisition uses insertOne + ownership verification find. If the TTL expires between these two operations, another process could acquire the lock while the first process believes it still holds it. This is the same fundamental limitation as the Redis SET NX PX approach and is documented as a "safety valve".
However, unlike the Redis implementation (which uses a Lua CAS script for safe release), the MongoDB release() uses deleteOne with owner filter — which is correct but doesn't protect against the TOCTOU window after successful acquisition.
Consider:
- Adding a heartbeat/renewal mechanism for long-running operations
- Documenting the maximum safe operation duration relative to TTL more prominently in the class Javadoc
- Logging a warning in
release()if the delete count is 0 (indicating the lock was already reclaimed by another process)
|
|
||
| private static final Logger log = LoggerFactory.getLogger(MongoBaseStore.class); | ||
|
|
||
| private static final String FIELD_ID = "_id"; |
There was a problem hiding this comment.
[nitpick] Null byte (\0) separator in itemDocId
The compound _id uses \0 as a namespace/key separator. While MongoDB handles null bytes in strings correctly, this can cause issues when exporting data to tools/formats that don't handle null bytes (CSV, some JSON parsers) or debugging via mongosh where null bytes may not display.
The Redis extension uses : as separator. Consider using a visible separator like : or / with proper escaping, or document this design choice explicitly.
| MongoSandboxExecutionGuard.builder(mongoClient) | ||
| .databaseName(databaseName) | ||
| .build(); | ||
| cachedExecutionGuard = result; |
There was a problem hiding this comment.
[nitpick] close() does not propagate to cached components
When close() is called and ownsClient == true, the MongoClient is closed. However, cached MongoAgentStateStore instances (created via agentStateStore()) also implement AutoCloseable but are not closed.
Since they share the same MongoClient (supplied via builder.mongoClient()), their close() won't attempt to close the client (ownsClient=false in their context). So this is functionally safe, but for consistency and future-proofing, consider closing cached components.
| private static final String DEFAULT_DATABASE_NAME = "agentscope"; | ||
| private static final String DEFAULT_COLLECTION_NAME = "agentscope_sessions"; | ||
| private static final String ANON_USER = "__anon__"; | ||
| private static final String LIST_SUFFIX = ":list"; |
There was a problem hiding this comment.
[nitpick] validateKey regex restricts keys to [a-zA-Z0-9_.\-]+ — not documented in AgentStateStore contract
The key validation pattern is a reasonable security measure to prevent BSON field name injection. However, this constraint is not documented in the AgentStateStore interface contract, which could lead to surprises for callers using keys with other characters (e.g., colons, slashes).
Consider documenting this in the class Javadoc and/or proposing a standardized key format constraint at the interface level.
AgentScopeJavaBot
left a comment
There was a problem hiding this comment.
🤖 AI Review
This PR adds a new agentscope-extensions-mongodb module providing MongoDB-backed distributed storage for AgentScope. It implements 6 source files covering all DistributedStore components: MongoAgentStateStore (session state with single-document model and CAS optimistic locking), MongoBaseStore (workspace KV store), MongoSandboxExecutionGuard (distributed lock via TTL documents), MongoRemoteSnapshotClient (sandbox snapshot storage), and MongoDistributedStore (facade). The code is well-structured, follows existing extension patterns (Redis/MySQL/PostgreSQL), and includes comprehensive test coverage (112 tests across 9 test files). The overall quality is high — the CAS logic is correct, the single-document model is well-designed, and edge cases (duplicate key errors, TTL index conflicts) are properly handled.
| public long saveIfVersion( | ||
| String userId, String sessionId, String key, State value, long expectedVersion) { | ||
| validateKey(key); | ||
| if (expectedVersion == UNVERSIONED) { |
There was a problem hiding this comment.
[recommended] Non-atomic read-after-write in saveIfVersion(UNVERSIONED) path
When expectedVersion == UNVERSIONED, save() atomically increments the version via $inc, then a separate find() reads it back. Between these two operations, another concurrent writer could modify the version, causing the returned value to be stale.
While the caller explicitly opted out of versioning semantics (UNVERSIONED), returning a misleading version could confuse downstream logic that uses it for subsequent CAS calls.
Consider combining the write and version read into a single findOneAndUpdate with returnDocument(AFTER).
| } | ||
|
|
||
| @Override | ||
| public void delete(String userId, String sessionId, String key) { |
There was a problem hiding this comment.
[recommended] delete(userId, sessionId, key) does not update FIELD_UPDATED_AT
When a specific key is deleted from a session document via $unset, the updatedAt field is not refreshed. Since the TTL index (expireAfter(30 days)) relies on updatedAt, a session where all keys are individually deleted (rather than the whole document being deleted) will have its TTL countdown based on the last save() call, not the delete operation.
Fix: include Updates.set(FIELD_UPDATED_AT, new Date()) in the update.
| Indexes.ascending(FIELD_USER_ID), Indexes.ascending(FIELD_SESSION_ID))); | ||
|
|
||
| String ttlIndexName = FIELD_UPDATED_AT + "_1"; | ||
| long ttlSeconds = 30L * 24 * 3600; |
There was a problem hiding this comment.
[recommended] TTL index expiration hardcoded to 30 days
The TTL index on updatedAt is set to 30L * 24 * 3600 seconds (30 days). This is a reasonable default but should be configurable via the Builder to accommodate different session lifecycle requirements (e.g., short-lived test sessions vs. long-lived production sessions).
Similarly, MongoRemoteSnapshotClient hardcodes a 7-day TTL. Consider adding builder parameters.
| new FindOneAndUpdateOptions().returnDocument(ReturnDocument.AFTER)); | ||
|
|
||
| if (reclaimed != null) { | ||
| log.debug( |
There was a problem hiding this comment.
[recommended] No lock renewal mechanism — TOCTOU window after TTL expiry
The lock acquisition uses insertOne + ownership verification find. If the TTL expires between these two operations, another process could acquire the lock while the first process believes it still holds it. This is the same fundamental limitation as the Redis SET NX PX approach and is documented as a "safety valve".
However, unlike the Redis implementation (which uses a Lua CAS script for safe release), the MongoDB release() uses deleteOne with owner filter — which is correct but doesn't protect against the TOCTOU window after successful acquisition.
Consider:
- Adding a heartbeat/renewal mechanism for long-running operations
- Documenting the maximum safe operation duration relative to TTL more prominently in the class Javadoc
- Logging a warning in
release()if the delete count is 0 (indicating the lock was already reclaimed by another process)
|
|
||
| private static final Logger log = LoggerFactory.getLogger(MongoBaseStore.class); | ||
|
|
||
| private static final String FIELD_ID = "_id"; |
There was a problem hiding this comment.
[nitpick] Null byte (\0) separator in itemDocId
The compound _id uses \0 as a namespace/key separator. While MongoDB handles null bytes in strings correctly, this can cause issues when exporting data to tools/formats that don't handle null bytes (CSV, some JSON parsers) or debugging via mongosh where null bytes may not display.
The Redis extension uses : as separator. Consider using a visible separator like : or / with proper escaping, or document this design choice explicitly.
| MongoSandboxExecutionGuard.builder(mongoClient) | ||
| .databaseName(databaseName) | ||
| .build(); | ||
| cachedExecutionGuard = result; |
There was a problem hiding this comment.
[nitpick] close() does not propagate to cached components
When close() is called and ownsClient == true, the MongoClient is closed. However, cached MongoAgentStateStore instances (created via agentStateStore()) also implement AutoCloseable but are not closed.
Since they share the same MongoClient (supplied via builder.mongoClient()), their close() won't attempt to close the client (ownsClient=false in their context). So this is functionally safe, but for consistency and future-proofing, consider closing cached components.
| private static final String DEFAULT_DATABASE_NAME = "agentscope"; | ||
| private static final String DEFAULT_COLLECTION_NAME = "agentscope_sessions"; | ||
| private static final String ANON_USER = "__anon__"; | ||
| private static final String LIST_SUFFIX = ":list"; |
There was a problem hiding this comment.
[nitpick] validateKey regex restricts keys to [a-zA-Z0-9_.\-]+ — not documented in AgentStateStore contract
The key validation pattern is a reasonable security measure to prevent BSON field name injection. However, this constraint is not documented in the AgentStateStore interface contract, which could lead to surprises for callers using keys with other characters (e.g., colons, slashes).
Consider documenting this in the class Javadoc and/or proposing a standardized key format constraint at the interface level.
AgentScope-Java Version
2.0.3-SNAPSHOT
Description
Background
This PR adds the
agentscope-extensions-mongodbmodule, providing a MongoDB-backed distributed storage backend for AgentScope Java.Why MongoDB
Agent 的执行信息(对话历史、思维链、工具调用记录等)通常具有以下特点:
MongoDB 的文档模型天然适合这类场景——单个 Session 对应一个 BSON Document,字段长度不受限制,Schema 灵活可变。作者在实际项目 HiveMind 中已验证了这一方案的可行性,使用 MongoDB 存储 Agent 的 Session 信息与对话历史,运行稳定。
Changes
New files — 6 source + 9 test files:
pom.xmlagentscope-core,agentscope-harness,mongodb-driver-syncMongoDistributedStore.javaDistributedStoreentry point, aggregates all sub-componentsstate/MongoAgentStateStore.javaAgentStateStoreimplementation; single-document model with CAS optimistic lockingstore/MongoBaseStore.javaBaseStoreworkspace KV implementation with namespace compound indexsandbox/MongoSandboxExecutionGuard.javafindOneAndUpdateacquisitionsnapshot/MongoSnapshotSpec.javasnapshot/MongoRemoteSnapshotClient.javaModified files — 3 POM registrations:
agentscope-extensions/pom.xml<module>agentscope-extensions-mongodb</module>agentscope-dependencies-bom/pom.xmlmongodb-driver.versionproperty +dependencyManagemententryagentscope-distribution/agentscope-bom/pom.xmlagentscope-distribution/agentscope-all/pom.xmlDesign Decisions
mongodb-driver-sync)findOneAndUpdate+_version_{key}fieldListHashUtilsampling hash detects changes;pushEachavoids full rewrites^[a-zA-Z_][a-zA-Z0-9_]*$prevents.and$in MongoDB field namesIndexOptionsConflict(error 85), drop old index, recreate with new paramsBug Fixes (discovered during testing)
开发过程中发现并修复了 5 个真实 Bug,均由合约测试覆盖:
save()不递增 versionUpdates.inc(versionField, 1L)缺失,导致乐观锁 CAS 全部失效saveIfVersion(0)异常类型错误findOneAndUpdate抛MongoCommandException而非MongoWriteException,导致并发冲突时直接崩溃saveIfVersion(UNVERSIONED)反序列化失败State是接口无法被 Jackson 实例化,改为只读取 version 字段expireAfterSeconds=0导致 session 数据 60 秒内被 MongoDB TTL 守护进程清除IndexOptionsConflict (error 85)导致应用无法启动How to Test
Testing Summary
Assumptions.abort()自动跳过MongoBaseStoreContractTest— 6 tests, 覆盖 KV 存储读写语义MongoAgentStateStoreContractTest— 6 tests, 覆盖版本控制与并发安全MongoIndexLifecycleContractTest— 7 tests, 覆盖索引参数与升级迁移Checklist
mvn spotless:applymvn test— 112 tests, 0 failures)Related Issue
Closes #2636
References
builder.mongoClient(),builder.databaseName(), etc.)详细测试报告
MongoDB Extension 测试报告与设计解读
1. 测试全景
本模块的测试分为三个层次,各层职责明确、互不重叠:
合计:112 个自动化测试,全部通过。
2. 为什么需要合约测试
2.1 项目已有的合约测试模式
AgentScope 项目对每种存储接口都定义了一套行为合约(Contract),合约测试的核心思想是:
项目中有两份权威合约:
BaseStoreContractTestagentscope-harnessInMemoryStoreAgentStateStoreVersioningContractTestagentscope-coreInMemoryAgentStateStore这些合约测试使用 Java 的 模板方法模式——基类定义测试逻辑,子类通过 override
newStore()注入不同后端。核心模块注释明确要求:2.2 为什么 MongoDB 扩展需要独立编写(而非继承)
我们的 MongoDB 合约测试没有继承基类,而是独立编写。原因是:
BaseStoreContractTest是 package-private class(没有public修饰符),位于io.agentscope.harness.agent.filesystem.remote.store包中,跨包无法继承InMemoryStore.search()使用前缀匹配(search(["a"])返回["a","b"]下的条目),但MongoBaseStore.search()使用精确命名空间匹配。直接继承会导致搜索测试失败独立编写保证了:测试逻辑与核心合约完全对齐,同时适配 MongoDB 的行为差异。
3. 合约测试设计详解
3.1 MongoBaseStoreContractTest — 6 个测试
位置:
src/test/java/.../store/MongoBaseStoreContractTest.java这 6 个测试覆盖了
BaseStore接口的全部核心语义:putGetRoundTrip_versionStartsAtOneput_incrementsVersion$inc原子操作,验证版本自增正确putIfVersion_successAndConflictputIfVersionZero_createIfAbsentMemoryConsolidator中 watermark 写入使用的模式delete_isIdempotentsearch_exactNamespaceMatch第 6 个测试(search)是专门为 MongoDB 编写的,它验证了一个重要行为差异:
这个差异不影响实际使用,因为 AgentScope 的协调命名空间(
["memory", "consolidation"])没有子命名空间,但必须在测试中明确记录。3.2 MongoAgentStateStoreContractTest — 6 个测试
位置:
src/test/java/.../state/MongoAgentStateStoreContractTest.java这 6 个测试覆盖了
AgentStateStore的版本控制语义——这是 AgentScope 防止并发写入冲突的核心机制:supportsVersioninggetVersioned_absent_returnsVersionZerosaveIfVersion_createIfAbsentsaveIfVersion_unconditionalOverwriteplainSave_bumpsVersionUpdates.inc(versionField, 1L)在 save() 中生效concurrentWriters_onlyOneSucceeds第 6 个测试(并发写入)是最关键的,它用
CountDownLatch精确控制两个线程同时竞争:3.3 MongoIndexLifecycleContractTest — 7 个测试
位置:
src/test/java/.../MongoIndexLifecycleContractTest.java为什么需要单独的索引测试:
MongoDB 的索引参数(TTL 值、sparse、unique)不会体现在读写接口的返回值中,因此前两类合约测试完全无法感知。但索引参数错误会导致:
这类 Bug 是运维层面的致命问题,本地 E2E 测试证明了它们确实会发生,必须有专门的自动化测试覆盖。
agentStateStore_compoundIndex(user_id, session_id)复合索引存在agentStateStore_ttlIndex_30days_updated_atTTL 索引参数是 2592000 秒(30 天)agentStateStore_ttlUpgrade_fromZerobaseStore_namespaceIndexnamespace单字段索引存在baseStore_compoundIndex(namespace, key)复合索引存在sandboxGuard_ttlIndex_immediateexpiresAtTTL=0(锁立即过期)snapshotClient_ttlIndex_7dayscreatedAtTTL=7 天第 3 个测试(升级测试)是最关键的,它分三个阶段模拟真实升级场景:
4. 测试基础设施设计
4.1 MongoDB 连接与跳过机制
每个合约测试类都使用
Assumptions.abort()实现 CI 安全:为什么用
Assumptions.abort()而不是@Disabled:@DisabledAssumptions.abort()JUnit 5 的
Assumptions机制让同一个测试在不同环境下自动适配,无需维护两套测试配置。4.2 数据库隔离策略
每个测试类使用带时间戳的独立数据库名:
为什么不用固定数据库名:
test_db):多个测试并行或测试失败后残留数据会污染后续运行@AfterAll中db.drop()清理,彻底消除数据残留4.3 测试生命周期
@BeforeEach每次创建新 store 而不是复用,是为了确保每个测试从干净状态开始,避免测试间相互影响。5. 测试过程中发现并修复的 Bug
在编写和执行合约测试的过程中,发现了 5 个真实 Bug,这些 Bug 在纯单元测试(Mockito mock)中无法暴露,只有对真实 MongoDB 执行时才会触发:
Bug 1:
save()不递增 version(P0)现象:
plainSave_bumpsVersion测试失败——期望 version=1,实际 version=0根因:
save()方法的 MongoDB update 操作只更新了数据字段和_updated_at,没有Updates.inc(versionField, 1L)影响: 不修复会导致 plain save 后的 version 始终为 0,下游的乐观锁 CAS 全部失效。
Bug 2:
saveIfVersion(0)的 DuplicateKey 异常类型错误(P0)现象:
saveIfVersion_createIfAbsent测试抛出未捕获的MongoCommandException根因:
findOneAndUpdate在 upsert=true 时遇到 DuplicateKey 冲突,抛出的是MongoCommandException(error code 11000),而不是代码中只捕获的MongoWriteException影响: 不修复会导致并发场景下
saveIfVersion(0)直接抛异常崩溃,而不是返回 UNVERSIONED 表示冲突。Bug 3:
saveIfVersion(UNVERSIONED)反序列化失败(P1)现象:
saveIfVersion_unconditionalOverwrite测试抛出JsonException: Failed to deserialize JSON to State根因:
UNVERSIONED分支调用getVersioned(... State.class)来读取写入后的 version,但State是接口,Jackson 无法实例化影响: 不修复会导致所有使用
UNVERSIONED模式的写入都抛异常。Bug 4: TTL 索引
expireAfterSeconds=0导致数据立即过期(P0 — 严重)现象: 应用运行期间 session 数据正常,重启后
agentscope_sessions文档内容为空根因:
ensureIndexes()中 TTL 索引的expireAfterSeconds设为 0,意味着_updated_at字段一过期文档就立即删除。MongoDB TTL 监控线程每 60 秒扫描一次,会删除所有_updated_at已过期的文档影响: 这是用户在实际使用中发现的 Bug——所有 session 数据在 MongoDB TTL 守护进程运行后(最多 60 秒)就会被清除,导致重启后数据丢失。
Bug 5: 索引参数变更时启动失败(P1)
现象: 修复 Bug 4 后,应用启动报
IndexOptionsConflict (error 85)根因: 数据库中已存在旧的 TTL 索引(
expireAfterSeconds=0),代码尝试用新参数(expireAfterSeconds=2592000)创建同名索引,MongoDB 拒绝创建影响: 不修复会导致从旧版本升级时应用无法启动。
Bug 修复总结
plainSave_bumpsVersionsaveIfVersion_createIfAbsentsaveIfVersion_unconditionalOverwriteagentStateStore_ttlIndex_30daysagentStateStore_ttlUpgrade_fromZero结论: 5 个 Bug 全部能被合约测试覆盖,纯单元测试(Mockito mock)无法发现任何一个。其中 Bug 1-3 由读写语义合约测试直接发现,Bug 4-5 在用户实际使用中首次暴露后,通过新增索引生命周期合约测试补充了自动化回归保护。
6. 测试执行方式
6.1 运行单元测试(CI 自动执行)
输出示例:
6.2 运行合约测试(需要本地 MongoDB)
合约测试包含在上面的命令中。如果本地没有 MongoDB,合约测试会自动跳过(输出中显示
Skipped):如果本地有 MongoDB(
localhost:27017),19 个合约测试会正常执行。6.3 构建 + 格式检查 + 测试 一体化
7. 实际测试执行结果
7.1 执行环境
7.2 完整测试结果
7.3 合约测试详细结果
BaseStore 合约(6/6 pass):
AgentStateStore 合约(6/6 pass):
saveIfVersion_createIfAbsent耗时较长(291ms)是因为它涉及findOneAndUpdate的 upsert 操作,比普通读写多一次 MongoDB 内部的条件检查。7.3.3 Index Lifecycle 合约(7/7 pass):
baseStore_namespaceIndex耗时较长(367ms)是因为首次创建MongoBaseStore实例时需要建立连接和创建索引。8. CI 策略与 PR 验收
8.1 CI 中的行为
由于项目未使用 Testcontainers,GitHub CI 无法连接 MongoDB:
合约测试的
Assumptions.abort()机制确保了 CI 不会因为 MongoDB 不可用而报错。8.2 PR 验收建议
9. 总结
本次测试工作完成了:
BaseStore和AgentStateStore的读写语义(12 个)+ 全组件索引生命周期(7 个)合约测试的价值在于:它用真实 MongoDB 驱动代码,暴露了 Mockito mock 无法发现的问题。特别是
findOneAndUpdate的异常类型差异(MongoCommandExceptionvsMongoWriteException)和$inc版本递增行为,只有在真实数据库上才能验证。