Skip to content

perf(config): 配置保存改为非阻塞写盘并批量提交 - #415

Merged
1w1w11w1 merged 3 commits into
devfrom
perf/config-save-nonblocking
Aug 26, 2026
Merged

perf(config): 配置保存改为非阻塞写盘并批量提交#415
1w1w11w1 merged 3 commits into
devfrom
perf/config-save-nonblocking

Conversation

@1w1w11w1

@1w1w11w1 1w1w11w1 commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

背景

进入 MAA 脚本配置界面加载慢,改任意字段都卡顿。定位后发现最严重的一条不在前端,而在保存路径阻塞事件循环。

根因与改动

1. 写盘同步阻塞整个事件循环(主因)

ConfigBase.save / MultipleConfig.save 声明为 async,但内部直接同步调用 write_fileatomic_write,其中带 os.fsync 并持全局写锁。Windows 上单次几 ms 到几十 ms。后果不只是这一个请求慢,而是所有其它 API 与 WebSocket 广播一起停摆

改为 await asyncio.to_thread(write_file, ...)。全局 _WRITE_LOCKthreading.Lock,挪进线程池后才真正起到串行化保护作用。

2. 逐字段保存 = N 次全量写盘

update_script 等入口逐字段 await set(),每个 set 都触发一次 _commit_changes(),即一次全量 toDict + 写盘。改 3 个字段就写 3 次。而子配置注册了父级 save,一次 set 实际序列化的是整个 ScriptConfig.json(所有脚本 + 所有用户),不是单个脚本。

新增 ConfigBase.update():批量 set(commit=False),全部写入后只提交一次。set() 增加 commit 参数并返回是否真正变更(向后兼容,既有调用点忽略返回值即可)。config.py 中九处同模式调用点一并收敛。

update_game_sign_account 保持原样 —— 它在循环中间读旧值判断凭据是否变化,批量化需要重构,超出本次范围。

3. 前端一次修改两个往返

MAAScriptEdit.handleChangeupdateScript 成功后又 await refreshScript()。值本就由前端发出,这次回读是多余的;且回填时 Object.assign 覆盖整个响应式对象,导致全表单重渲染(输入框跳动的来源)。移除后单次修改从 2 个往返降到 1 个。

4. onMounted 串行等待

loadScript()loadEmulatorOptions() 互不依赖却串行 await,白等一个 RTT。改 Promise.all

测试

  • 新增 test_update_commits_once_for_multiple_fields:三字段批量写入只提交一次。
  • 新增 test_update_skips_commit_when_nothing_changes:无变更不落盘。
  • tests/api/test_game_sign_api.py 的 mock 补上 update,转发到 set 以保持既有断言语义。
  • 全量后端:201 passed。剩余 7 个失败已验证为 dev 上的既有问题(stash 掉本 PR 改动后在干净 dev 上同样复现):append_task_game_sign_summary 的签名与测试期望不一致,以及 test_main.py 的循环导入,均与本 PR 无关。

未包含

get_script 跳过 SubConfigsInfo(前端拿到后即丢弃,却要为其递归解密全部用户配置)会动 API 契约,按讨论留待单独确认。

Sourcery 总结

通过使磁盘持久化操作非阻塞,并将多字段更新合并为单次提交,提高配置编辑的响应速度。

Bug 修复:

  • 防止配置保存过程中的同步磁盘写入阻塞异步事件循环。
  • 成功更新后避免不必要的脚本配置刷新,将每次编辑减少为单个请求。

增强功能:

  • 添加批量配置更新功能,最多只提交一次,并在值未发生变化时跳过磁盘写入。
  • 在视图初始化期间并行加载相互独立的脚本和模拟器选项。

测试:

  • 增加对单次提交批量更新和无操作更新的覆盖,并调整 API 模拟以支持批量配置更新。
Original summary in English

Sourcery 总结

通过使持久化操作非阻塞,并将多字段更新整合为高效的单次提交,提高配置编辑的响应速度。

错误修复:

  • 防止同步配置持久化操作阻塞异步事件循环。
  • 成功编辑后避免不必要的脚本刷新请求。

增强功能:

  • 将配置更改批量处理为单次提交,并在值未发生变化时跳过持久化。
  • 在视图初始化期间并行加载独立的脚本和模拟器数据。

测试:

  • 增加对单次提交批量更新和无操作更新的覆盖,并更新批量配置更改的 API 模拟。
Original summary in English

Sourcery 摘要

通过批量持久化和并行处理独立的前端加载请求,提高配置编辑的响应速度。

错误修复:

  • 防止同步配置持久化操作阻塞异步事件循环。
  • 编辑成功后避免重复发送脚本刷新请求。

增强功能:

  • 将配置更新批量合并为一次持久化操作,并在值未发生变化时跳过磁盘写入。
  • 在视图初始化期间并行加载独立的脚本和模拟器数据。
Original summary in English

Summary by Sourcery

Improve configuration editing responsiveness by batching persistence and parallelizing independent frontend loading requests.

Bug Fixes:

  • Prevent synchronous configuration persistence from blocking the asynchronous event loop.
  • Avoid redundant script refresh requests after successful edits.

Enhancements:

  • Batch configuration updates into a single persistence operation and skip disk writes when values are unchanged.
  • Load independent script and emulator data in parallel during view initialization.

进入 MAA 脚本配置界面加载慢、改字段卡顿的根因是保存路径阻塞事件循环:

- ConfigBase.save / MultipleConfig.save 是 async 函数, 但内部直接同步调用
  write_file, 其 atomic_write 带 os.fsync 并持全局写锁。一次保存会卡住整个
  事件循环, 连带所有其它 API 与 WebSocket 广播一起停摆。改为 asyncio.to_thread。
- update_script 等入口逐字段 await set(), 每个 set 都触发一次全量序列化写盘,
  改 N 个字段就写 N 次。新增 ConfigBase.update() 批量写入后只提交一次,
  九处同模式调用点一并收敛。
- MAAScriptEdit 每次改字段在 updateScript 成功后又 await refreshScript(),
  一次修改两个往返, 且回填时 Object.assign 覆盖整个响应式对象导致全表单重渲染
  (输入框跳动)。值本就由前端发出, 该刷新是多余的, 移除。
- onMounted 串行 await 两个互不依赖的请求, 改 Promise.all 并行。
@sourcery-ai

sourcery-ai Bot commented Aug 25, 2026

Copy link
Copy Markdown

审查者指南

本 PR 将配置保存路径改为线程池中的非阻塞写盘,并通过新增批量 update API 将多字段修改合并为一次全量提交;同时减少前端脚本编辑的往返请求、并行初始化请求,并补充相应测试与 mock 适配。

批量非阻塞配置更新时序图

sequenceDiagram
    participant API as Config API
    participant Config as ConfigBase
    participant ThreadPool as asyncio.to_thread
    participant Disk as Configuration file

    API->>Config: update(data)
    loop each changed field
        Config->>Config: set(group, name, value, commit=false)
    end
    Config->>Config: _commit_changes()
    Config->>ThreadPool: write_file(file, toDict(...))
    ThreadPool->>Disk: atomic write and fsync
    Disk-->>ThreadPool: write completed
    ThreadPool-->>Config: await completion
    Config-->>API: update completed
Loading

并行脚本编辑器初始化时序图

sequenceDiagram
    participant Editor as MAAScriptEdit
    participant ScriptAPI as loadScript()
    participant EmulatorAPI as loadEmulatorOptions

    Editor->>ScriptAPI: loadScript()
    Editor->>EmulatorAPI: loadEmulatorOptions()
    par independent requests
        ScriptAPI-->>Editor: script data
    and
        EmulatorAPI-->>Editor: emulator options
    end
    Editor->>Editor: isInitializing = false
Loading

文件级变更

变更 详情 文件
将配置批量更新收敛为一次变更检测和一次持久化提交。
  • 新增支持 commit 控制和变更结果返回的 set()
  • 新增 update(),批量设置字段且仅在实际变更时提交一次
  • 将脚本、用户、计划、模拟器、队列、时间设置、队列项、工具和全局设置等入口迁移到批量更新
app/models/ConfigBase.py
app/core/config.py
将同步写盘移出异步事件循环,避免 fsync 和写锁阻塞其他请求。
  • 通过 asyncio.to_thread 执行配置文件写入
  • 同时覆盖基础配置和多配置保存路径
app/models/ConfigBase.py
减少脚本编辑页面的冗余网络请求和初始化等待。
  • 移除保存成功后的脚本配置回读及响应式对象整体覆盖
  • 并行加载脚本详情和模拟器选项
frontend/src/views/EditView/Script/MAAScriptEdit.vue
补充批量更新行为的测试并适配 API mock。
  • 验证多字段只提交一次
  • 验证无变更时不提交
  • 为游戏签到 API mock 增加 update 到 set 的转发语义
tests/models/test_config_base.py
tests/api/test_game_sign_api.py

提示与命令

与 Sourcery 交互

  • 触发新的审查: 在 pull request 中评论 @sourcery-ai review
  • 继续讨论: 直接回复 Sourcery 的审查评论。
  • 根据审查评论生成 GitHub issue: 回复审查评论,请 Sourcery 根据该评论创建 issue。你也可以使用 @sourcery-ai issue 回复审查评论,以便从中创建 issue。
  • 生成 pull request 标题: 在 pull request 标题中的任意位置写入 @sourcery-ai,即可随时生成标题。你也可以在 pull request 中评论 @sourcery-ai title,以随时重新生成标题。
  • 生成 pull request 摘要: 在 pull request 正文中的任意位置写入 @sourcery-ai summary,即可在指定位置随时生成 PR 摘要。你也可以在 pull request 中评论 @sourcery-ai summary,以随时重新生成摘要。
  • 生成审查者指南: 在 pull request 中评论 @sourcery-ai guide,即可随时重新生成审查者指南。
  • 解决所有 Sourcery 评论: 在 pull request 中评论 @sourcery-ai resolve,即可解决所有 Sourcery 评论。如果你已经处理完所有评论且不想再看到它们,这项功能会很有用。
  • 忽略所有 Sourcery 审查: 在 pull request 中评论 @sourcery-ai dismiss,即可忽略所有现有的 Sourcery 审查。如果你想从头开始新的审查,这项功能尤其有用——别忘了评论 @sourcery-ai review 来触发新的审查!

自定义使用体验

访问你的控制面板以:

  • 启用或禁用审查功能,例如 Sourcery 生成的 pull request 摘要、审查者指南等。
  • 更改审查语言。
  • 添加、移除或编辑自定义审查说明。
  • 调整其他审查设置。

获取帮助

Original review guide in English

Reviewer's Guide

本 PR 将配置保存路径改为线程池中的非阻塞写盘,并通过新增批量 update API 将多字段修改合并为一次全量提交;同时减少前端脚本编辑的往返请求、并行初始化请求,并补充相应测试与 mock 适配。

Sequence diagram for batched non-blocking configuration updates

sequenceDiagram
    participant API as Config API
    participant Config as ConfigBase
    participant ThreadPool as asyncio.to_thread
    participant Disk as Configuration file

    API->>Config: update(data)
    loop each changed field
        Config->>Config: set(group, name, value, commit=false)
    end
    Config->>Config: _commit_changes()
    Config->>ThreadPool: write_file(file, toDict(...))
    ThreadPool->>Disk: atomic write and fsync
    Disk-->>ThreadPool: write completed
    ThreadPool-->>Config: await completion
    Config-->>API: update completed
Loading

Sequence diagram for parallel script editor initialization

sequenceDiagram
    participant Editor as MAAScriptEdit
    participant ScriptAPI as loadScript()
    participant EmulatorAPI as loadEmulatorOptions

    Editor->>ScriptAPI: loadScript()
    Editor->>EmulatorAPI: loadEmulatorOptions()
    par independent requests
        ScriptAPI-->>Editor: script data
    and
        EmulatorAPI-->>Editor: emulator options
    end
    Editor->>Editor: isInitializing = false
Loading

File-Level Changes

Change Details Files
将配置批量更新收敛为一次变更检测和一次持久化提交。
  • 新增支持 commit 控制和变更结果返回的 set()
  • 新增 update(),批量设置字段且仅在实际变更时提交一次
  • 将脚本、用户、计划、模拟器、队列、时间设置、队列项、工具和全局设置等入口迁移到批量更新
app/models/ConfigBase.py
app/core/config.py
将同步写盘移出异步事件循环,避免 fsync 和写锁阻塞其他请求。
  • 通过 asyncio.to_thread 执行配置文件写入
  • 同时覆盖基础配置和多配置保存路径
app/models/ConfigBase.py
减少脚本编辑页面的冗余网络请求和初始化等待。
  • 移除保存成功后的脚本配置回读及响应式对象整体覆盖
  • 并行加载脚本详情和模拟器选项
frontend/src/views/EditView/Script/MAAScriptEdit.vue
补充批量更新行为的测试并适配 API mock。
  • 验证多字段只提交一次
  • 验证无变更时不提交
  • 为游戏签到 API mock 增加 update 到 set 的转发语义
tests/models/test_config_base.py
tests/api/test_game_sign_api.py

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

嘿——我发现了 2 个问题

面向 AI Agent 的提示
请处理本次代码审查中的评论:

## 各条评论

### 评论 1
<location path="app/models/ConfigBase.py" line_range="1111-1113" />
<code_context>

-        write_file(self.file, await self.toDict(if_decrypt=False))
+        # write_file 内部 fsync 是同步阻塞调用, 放线程池避免卡住事件循环
+        await asyncio.to_thread(
+            write_file, self.file, await self.toDict(if_decrypt=False)
+        )

     async def lock(self):
</code_context>
<issue_to_address>
**issue (bug_risk):** 并发保存可能会在较新的 `toDict()` 快照已经写入之后,又写入较旧的快照。`_WRITE_LOCK` 仅在序列化完成后对 `write_file` 进行串行化,因此无法保护整个“生成快照并写入”的操作,配置文件可能会丢失最近的更新。

**触发条件:** 两个配置更新同时提交,并且它们的异步序列化或线程池调度完成顺序相反时。

**建议修复:** 使用按文件划分的异步提交锁,对快照创建和文件替换进行串行化;或者将完整的序列化与写入操作置于同一个同步边界之后。
</issue_to_address>

### 评论 2
<location path="app/models/ConfigBase.py" line_range="1052-1058" />
<code_context>
             raise RuntimeError(f"脚本 {script_id} 正在运行, 无法更新配置项")

-        script_config = self.ScriptConfig[uid]
-        for group, items in data.items():
-            for name, value in items.items():
-                await script_config.set(group, name, value)
</code_context>
<issue_to_address>
**issue (bug_risk):** `update()` 会在验证和应用后续字段之前修改较早的字段;如果后续项目未知、被锁定或以其他方式抛出异常,该方法会在未调用 `_commit_changes()` 的情况下退出,使较早的修改仅存在于内存中。之后一次无关的保存操作会持久化这些部分修改,尽管本次更新请求已经失败。

**触发条件:** 批处理中至少包含一个有效且发生变化的字段,后面紧跟一个会导致 `set()` 调用抛出异常的字段时。

**建议修复:** 在修改数据之前验证所有条目,或者在批处理失败时回滚已经应用的值。
</issue_to_address>

Sourcery 评估

需要人工审查。 有 2 个问题需要优先处理,并且此次更改改变了配置值的持久化方式:批量更新和线程化文件写入可能会导致磁盘上的配置值错误或丢失,尤其是在保存操作重叠时;此外,前端在保存后也不再从服务器刷新数据。恢复更改可以恢复旧行为,而已经写入的配置值通常可以通过再次运行更新来修正。

阻塞性问题:app/models/ConfigBase.py:1113app/models/ConfigBase.py:1058


Sourcery 对开源项目免费——如果您喜欢我们的审查结果,请考虑分享 ✨
帮助我变得更有用!请在每条评论上点击 👍 或 👎,我会利用反馈来改进审查结果。
Original comment in English

Hey - I've found 2 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="app/models/ConfigBase.py" line_range="1111-1113" />
<code_context>

-        write_file(self.file, await self.toDict(if_decrypt=False))
+        # write_file 内部 fsync 是同步阻塞调用, 放线程池避免卡住事件循环
+        await asyncio.to_thread(
+            write_file, self.file, await self.toDict(if_decrypt=False)
+        )

     async def lock(self):
</code_context>
<issue_to_address>
**issue (bug_risk):** Concurrent saves can write an older `toDict()` snapshot after a newer snapshot has already been written. `_WRITE_LOCK` serializes only `write_file` after serialization, so it does not protect the snapshot-and-write operation as a whole and the configuration file can lose a recent update.

**Triggers:** When two configuration updates commit concurrently and their asynchronous serialization or thread-pool scheduling completes out of order.

**Suggested fix:** Serialize snapshot creation and file replacement with an async per-file commit lock, or move the complete serialization-and-write operation behind the same synchronization boundary.
</issue_to_address>

### Comment 2
<location path="app/models/ConfigBase.py" line_range="1052-1058" />
<code_context>
             raise RuntimeError(f"脚本 {script_id} 正在运行, 无法更新配置项")

-        script_config = self.ScriptConfig[uid]
-        for group, items in data.items():
-            for name, value in items.items():
-                await script_config.set(group, name, value)
</code_context>
<issue_to_address>
**issue (bug_risk):** `update()` mutates earlier fields before validating and applying later fields; if a later item is unknown, locked, or otherwise raises, the method exits without `_commit_changes()`, leaving the earlier mutations present only in memory. A later unrelated save then persists those partial changes even though this update request failed.

**Triggers:** When a batch contains at least one valid changed field followed by a field whose `set()` call raises.

**Suggested fix:** Validate all entries before mutating, or roll back already-applied values when the batch fails.
</issue_to_address>

Sourcery assessment

Needs a human reviewer. 2 findings to address first, and the change alters how configuration values are persisted: batched updates and threaded file writes could leave a wrong or lost configuration value on disk, especially if saves overlap, and the frontend no longer refreshes from the server after saving. Reverting restores the old behavior, while already-written configuration values can generally be corrected by running the update again.

Blocking findings: app/models/ConfigBase.py:1113, app/models/ConfigBase.py:1058


Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread app/models/ConfigBase.py Outdated
Comment on lines +1111 to +1113
await asyncio.to_thread(
write_file, self.file, await self.toDict(if_decrypt=False)
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): 并发保存可能会在较新的 toDict() 快照已经写入之后,又写入较旧的快照。_WRITE_LOCK 仅在序列化完成后对 write_file 进行串行化,因此无法保护整个“生成快照并写入”的操作,配置文件可能会丢失最近的更新。

触发条件: 两个配置更新同时提交,并且它们的异步序列化或线程池调度完成顺序相反时。

建议修复: 使用按文件划分的异步提交锁,对快照创建和文件替换进行串行化;或者将完整的序列化与写入操作置于同一个同步边界之后。

Original comment in English

issue (bug_risk): Concurrent saves can write an older toDict() snapshot after a newer snapshot has already been written. _WRITE_LOCK serializes only write_file after serialization, so it does not protect the snapshot-and-write operation as a whole and the configuration file can lose a recent update.

Triggers: When two configuration updates commit concurrently and their asynchronous serialization or thread-pool scheduling completes out of order.

Suggested fix: Serialize snapshot creation and file replacement with an async per-file commit lock, or move the complete serialization-and-write operation behind the same synchronization boundary.

Comment thread app/models/ConfigBase.py
Comment on lines +1052 to +1058
for group, items in data.items():
for name, value in items.items():
if await self.set(group, name, value, commit=False):
is_changed = True

if is_changed:
await self._commit_changes()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): update() 会在验证和应用后续字段之前修改较早的字段;如果后续项目未知、被锁定或以其他方式抛出异常,该方法会在未调用 _commit_changes() 的情况下退出,使较早的修改仅存在于内存中。之后一次无关的保存操作会持久化这些部分修改,尽管本次更新请求已经失败。

触发条件: 批处理中至少包含一个有效且发生变化的字段,后面紧跟一个会导致 set() 调用抛出异常的字段时。

建议修复: 在修改数据之前验证所有条目,或者在批处理失败时回滚已经应用的值。

Original comment in English

issue (bug_risk): update() mutates earlier fields before validating and applying later fields; if a later item is unknown, locked, or otherwise raises, the method exits without _commit_changes(), leaving the earlier mutations present only in memory. A later unrelated save then persists those partial changes even though this update request failed.

Triggers: When a batch contains at least one valid changed field followed by a field whose set() call raises.

Suggested fix: Validate all entries before mutating, or roll back already-applied values when the batch fails.

实测 to_thread 只挪走 fsync 约3ms,toDict 约30ms 因参数求值顺序
仍阻塞事件循环,收益不足以抵消引入的并发写序风险。前端恢复
保存后回读。批量提交与 set(commit=) 优化保留。
@1w1w11w1
1w1w11w1 merged commit 8a20818 into dev Aug 26, 2026
2 of 3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant