Skip to content

fix: prevent backup export from blocking the event loop - #10424

Open
JosephTian876 wants to merge 4 commits into
AstrBotDevs:masterfrom
JosephTian876:fix/backup-export-blocking-event-loop
Open

JosephTian876 wants to merge 4 commits into
AstrBotDevs:masterfrom
JosephTian876:fix/backup-export-blocking-event-loop

Conversation

@JosephTian876

@JosephTian876 JosephTian876 commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Modifications / 改动点

AstrBotExporter.export_all() ran every ZIP compression and directory walk on the asyncio event loop thread. On the reporting instance (2.4 GB of plugin data) that froze the whole bot for 76 s, and AstrBot's own diagnostic recorded it:

[02:11:06.451] [Core] [INFO] [backup.exporter:92]: 开始导出备份到 ...astrbot_backup_20261006_021106.zip
[02:13:10.498] [Core] [INFO] [backup.exporter:196]: 备份导出完成: ...
[02:13:10.534] [Core] [WARN] [utils.event_loop_diagnostics:93]: Event loop lag detected: 119.578s (threshold 15.000s).

This is not merely a slow export. While the loop is frozen the reverse WebSocket to the OneBot implementation cannot answer; the implementation times out after ~90 s, declares the connection half-open and reconnects. On the reporting instance that reconnect then collided with an unrelated aiocqhttp API-client bookkeeping race, after which every outbound API call raised ApiNotAvailable and the bot stopped responding entirely until it was restarted.

Changes in astrbot/core/backup/exporter.py:

  • Added a dedicated single-worker ThreadPoolExecutor owned by export_all, created at the start and always shut down in finally.
  • Added _run_in_archive_thread() and routed every access to the shared ZipFile (5 × writestr, the directory walk, attachments, knowledge-base media, FAISS index) through it. A single dedicated worker is used rather than the shared default pool (asyncio.to_thread) because zipfile.ZipFile is not safe for concurrent use.
  • The ZipFile is now opened and closed on that worker instead of via with on the event loop. A single-worker executor is a FIFO queue, so a close submitted after an in-flight write cannot overlap it. Previously the with block's __exit__ could call ZipFile.close() on the loop thread while a write was still running, raising Can't close the ZIP file while there is an open writing handle on it and leaving an unusable partial archive.
  • Teardown is BaseException-safe, so an asyncio.CancelledError (which does not inherit from Exception) can no longer skip the partial-archive cleanup.
  • Added a re-entrancy guard so a second concurrent export_all() on the same instance fails immediately with a clear error instead of silently subverting the first call's executor.
  • A failed archive finalisation is now reported as a failure. If writing the central directory (where ENOSPC/EIO/quota errors surface) fails, the partial archive is removed and the error is raised, rather than returning a path to an unopenable file that callers record as a completed backup.

tests/test_backup.py: added regression tests.

No changes to the backup format, ZIP layout, manifest.json, checksums, progress_callback semantics, any public signature, importer.py, or BACKUP_MANIFEST_VERSION. No new dependencies.

Screenshots or Test Results / 运行截图或测试结果

Real 2399 MB dataset, same machine, same AstrBot, only the exporter implementation differing:

event-loop freeze export wall time heartbeats sampled
before 76.72 s 76.72 s 0
after 0.024 s 73.13 s 4688

The "before" row is the point: the heartbeat coroutine collected zero samples during a 76.7 s export, because the loop never got a chance to run. Export wall time is essentially unchanged (76.7 s → 73.1 s), i.e. the work moved off the loop rather than becoming faster.

Output is unchanged. Exporting the same real dataset with the old and new code produces archives matching on: member path list and order (3524 members), per-member CRC / uncompressed size / compress type, all recorded checksums, manifest.json apart from the wall-clock exported_at, and statistics.directories. Spot-checked members are byte-identical. With the clock frozen, the two archives are byte-identical.

A failed finalisation no longer reports success. Patching the real zipfile.ZipFile._write_end_record to raise OSError(ENOSPC):

outcome file left behind
before this fix returns normally, caller records completed corrupt, BadZipFile
after this fix raises OSError with the real errno none; instance reusable

Verification:

  • uv run pytest tests/test_backup.py -q → 103 passed (76 before this change; the extra 15 come from upstream tests merged into master during the rebase)

  • uv run pytest tests/unit -q → 1911 passed, 23 skipped

  • uvx --from ruff==0.15.22 ruff format --check . → 514 files already formatted

  • uvx --from ruff==0.15.22 ruff check . → All checks passed!

  • The new guards fail on the previous implementation (verified by swapping it back: 4 failed), so they genuinely guard the fix rather than merely passing.

  • Independent adversarial verification across three review rounds and three test rounds at hash-pinned revisions: cancellation (36 checks), concurrency (120 checks), failure paths and thread/handle leak checks (20 checks), byte-equivalence and edge inputs (34 checks: CJK/emoji filenames, zero-length files, empty/absent directories, deep nesting, files deleted mid-walk, __pycache__/.pyc exclusion), and the knowledge-base path (23 checks). 12 sequential exports grew neither thread count nor OS handles.

  • This is NOT a breaking change.


Checklist / 检查清单

  • 😊 If there are new features added in the PR, I have discussed it with the authors through issues/emails, etc.
    / 如果 PR 中有新加入的功能,已经通过 Issue / 邮件等方式和作者讨论过。

  • 👀 My changes have been well-tested, and "Verification Steps" and "Screenshots" have been provided above.
    / 我的更改经过了良好的测试,并已在上方提供了“验证步骤”和“运行截图”。

  • 📚 I checked the affected WebUI instructions and screenshots in docs/zh and docs/en against the changed navigation, page structure, and labels, and updated them in this PR (or explained why no documentation update is needed). For renamed, moved, or merged entry points, I included an old entry → new entry mapping in the documentation and changelog.
    / 我已对照变化后的 WebUI 入口、页面结构和术语,核对并在本 PR 中更新 docs/zh 和 docs/en 的相关操作说明与截图(或说明无需更新文档的原因)。入口改名、移动或合并时,已在文档和 changelog 中补充 旧入口 → 新入口 对照。

  • 🤓 I have ensured that no new dependencies are introduced, OR if new dependencies are introduced, they have been added to the appropriate locations in requirements.txt and pyproject.toml.
    / 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到 requirements.txt 和 pyproject.toml 文件相应位置。

  • 😮 My changes do not introduce malicious code.
    / 我的更改没有引入恶意代码。

Summary by Sourcery

Keep backup exports responsive and reliable by moving blocking archive work off the event loop and hardening failure and cleanup handling.

Bug Fixes:

  • Move backup archive generation off the asyncio event loop to prevent large exports from freezing the bot and disrupting connections.
  • Report archive finalisation failures as export errors and remove incomplete backup files instead of returning corrupt archives.
  • Ensure cancelled or interrupted exports clean up resources and partial files safely.

Enhancements:

  • Serialize all archive operations through a dedicated single-worker executor, with safeguards against concurrent exports on the same exporter and stale checksums across reused exports.

Tests:

  • Add comprehensive regression coverage for event-loop responsiveness, thread isolation, cancellation, concurrent exports, cleanup, finalisation failures, archive stability, and exporter reuse.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hey - I've found 3 issues

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

## Individual Comments

### Comment 1
<location path="astrbot/core/backup/exporter.py" line_range="187" />
<code_context>
+                await progress_callback("main_db", 0, 100, "正在导出主数据库...")
+            main_data = await self._export_main_database()
+            main_db_json = json.dumps(
+                main_data, ensure_ascii=False, indent=2, default=str
+            )
+            await self._run_in_archive_thread(
</code_context>
<issue_to_address>
**Export work stalls the event loop**

When database or knowledge-base data is large, or a knowledge base has a large media tree, `export_all()` runs JSON serialization and `_generate_manifest()` synchronously on the event loop; the manifest scan also walks the media tree there. These operations delay heartbeats and other bot tasks despite ZIP writes being offloaded.

Offload JSON serialization and manifest generation, including its filesystem scan, from the event-loop thread.

Also at `astrbot/core/backup/exporter.py:208`, `astrbot/core/backup/exporter.py:232-233`, `astrbot/core/backup/exporter.py:282-283`.
</issue_to_address>

### Comment 2
<location path="astrbot/core/backup/exporter.py" line_range="282" />
<code_context>
-                # 6. 生成 manifest
-                if progress_callback:
-                    await progress_callback("manifest", 0, 100, "正在生成清单...")
-                manifest = self._generate_manifest(main_data, kb_meta_data, dir_stats)
-                manifest_json = json.dumps(manifest, ensure_ascii=False, indent=2)
-                zf.writestr("manifest.json", manifest_json)
</code_context>
<issue_to_address>
**Manifest lists stale checksums**

When the same exporter instance is reused after an optional file or knowledge-base content is omitted, `_generate_manifest()` includes entries left in `self._checksums` from the prior export, so the new manifest lists checksums for members absent from its ZIP.

Reset `_checksums` for each export before collecting checksums.

Also at `astrbot/core/backup/exporter.py:365-366`.
</issue_to_address>

### Comment 3
<location path="astrbot/core/backup/exporter.py" line_range="347" />
<code_context>
+                    )
+                    while not close_task.done():
+                        try:
+                            await asyncio.shield(close_task)
+                        except asyncio.CancelledError:
+                            cancelled_while_cleaning = True
</code_context>
<issue_to_address>
**Failed exports leave partial ZIPs**

When archive opening or closing raises a `BaseException` other than `asyncio.CancelledError`, the `open_task.result()` or shielded `close_task` path propagates the exception past the partial-file removal, leaving a corrupt ZIP in the backup directory.

Ensure partial-file cleanup runs even when archive opening or closing raises a `BaseException`.

Also at `astrbot/core/backup/exporter.py:331`, `astrbot/core/backup/exporter.py:351`.
</issue_to_address>

Sourcery assessment

Needs a human reviewer. 3 findings to address first, and if the executor, cancellation, or finalization handling is wrong, the export could leave a corrupt or incomplete backup archive, or report failure after producing one. Reverting restores the previous export behavior, and the affected archive can be removed and regenerated from the unchanged source data.

Blocking findings: astrbot/core/backup/exporter.py:187, astrbot/core/backup/exporter.py:282, astrbot/core/backup/exporter.py:347


Sourcery is free for open source - if you like our reviews please consider sharing them ✨

Comment thread astrbot/core/backup/exporter.py Resolved
Comment thread astrbot/core/backup/exporter.py Resolved
Comment thread astrbot/core/backup/exporter.py Resolved
@JosephTian876

Copy link
Copy Markdown
Contributor Author

Sourcery's three findings are addressed in f3426110f

All three were real; the event-loop split was incomplete rather than wrong. Each fix has a regression test that fails on the previous revision.

1. Export work still stalled the event loop (exporter.py:187, 208, 232-233, 282-283)

Offloaded to the same single-worker archive executor that already owns the ZipFile:

  • json.dumps for the main DB, KB metadata and per-KB documents
  • _generate_manifest and its json.dumps — this is the one that mattered most, since it walks every KB media directory

Using one worker for both serialization and archive writes is deliberate: it keeps the shared ZipFile serialised while the CPU-bound work still leaves the loop. Test: test_serialization_and_manifest_leave_event_loop_thread records threading.get_ident() inside both calls and asserts neither ran on the loop thread.

2. Manifest listed stale checksums (exporter.py:282, 365-366)

self._checksums is now cleared at the top of export_all, before any state is mutated. Test: test_export_manifest_drops_stale_checksums exports twice on one instance, removes cmd_config.json between runs, and asserts the second manifest neither lists that checksum nor references a member absent from that ZIP.

3. Failed exports left partial ZIPs (exporter.py:331, 347, 351)

The open and close awaits caught only Exception, so a BaseException propagated out of open_task.result() / the shielded close and jumped over the os.remove(zip_path) cleanup. Both paths now capture BaseException into open_error / close_error, and the cleanup condition includes open_error. A failed open with no other error in flight is raised rather than returning a path to a file that was never produced.

Tests: test_base_exception_in_teardown_still_removes_partial_zip (extended with a ZIP-cleanup assertion) and test_base_exception_during_open_still_removes_partial_zip (writes a partial file inside the failing open, asserts the backup directory is empty afterwards).

Verification on f3426110f:

  • tests/test_backup.py — 91 passed
  • ruff format --check + ruff check on both changed files — clean
  • reverted exporter.py to the pre-fix revision: the three new checks fail there and pass here

JosephTian876 and others added 4 commits October 26, 2026 05:41
export_all() ran every ZIP compression and directory walk on the asyncio
event loop thread. On a 2.4 GB dataset that froze the whole bot for 76 s,
and AstrBot's own diagnostic recorded a 119.578 s loop lag, well past its
15 s threshold. While the loop is frozen the reverse WebSocket to the
OneBot implementation cannot answer, so the implementation times out after
~90 s and reconnects.

Archive work now runs on a dedicated single-worker ThreadPoolExecutor owned
by export_all. A single worker is used rather than the shared default pool
because zipfile.ZipFile is not safe for concurrent use, and it doubles as a
FIFO queue: the archive is opened and closed on that worker too, so a close
can never overlap an in-flight write. A failed finalisation is reported as
a failure instead of returning a path to an unusable archive.

Backup format, ZIP layout, manifest.json, checksums, progress_callback
semantics and every public signature are unchanged.
Cover the event-loop offload, archive structural stability, cancellation
teardown, re-entrancy, and the rule that a failed finalisation must not be
reported as a successful export. Each guard fails on the previous
implementation.
Address the review findings on the non-blocking backup export:

- JSON serialization (main DB, KB metadata, KB documents, manifest) and
  manifest generation now run on the archive worker instead of the event
  loop. The manifest walks every KB media directory, so it could freeze
  the loop just as the compression used to.
- Clear self._checksums at the start of every export, so a reused
  exporter cannot carry the previous run's checksums into a new manifest
  that no longer contains those files.
- Capture BaseException from the archive open and close paths into
  open_error/close_error instead of letting it escape the teardown and
  skip the partial-ZIP removal. A failed open with no other error is now
  reported instead of returning a path to a file that was never written.

Each guard has a regression test that fails on the previous revision.
The exporter now also archives `mcp_server.json` from the AstrBot data
directory. The archive structure test asserts an exact member list while the
exporter still read that file from the process data root, so the assertion only
held on a checkout without one.

Point `get_astrbot_data_path` at the temporary data directory used by the
test, matching how the neighbouring `get_backup_directories` patch already
isolates the archive from real user data.
@JosephTian876
JosephTian876 force-pushed the fix/backup-export-blocking-event-loop branch from f342611 to 4958797 Compare October 25, 2026 21:49
@JosephTian876

Copy link
Copy Markdown
Contributor Author

Rebased onto master (832fc6d9)

This PR was CONFLICTING / DIRTY against master. Three upstream commits landed in the same code after the branch point (8831d950):

The conflict surface was astrbot/core/backup/exporter.py (the config-export section of export_all) and the import block of tests/test_backup.py; everything else auto-merged.

How it was resolved

exporter.py — this PR's restructured export_all is kept (dedicated single-worker executor, ZipFile opened and closed on that worker, BaseException-safe teardown, re-entrancy guard, "failed finalisation is a failure"), and master's new behaviour was re-implemented on top of it rather than picked from one side:

  • The mcp_server.json entry keeps upstream's read location and archive path and its _add_checksum call, but zf.writestr now goes through _run_in_archive_thread. That is what keeps the invariant this PR exists to establish — the event-loop thread never touches the shared ZipFile — true for the new member as well, instead of silently reintroducing a synchronous write on the loop.
  • Master's constants.py additions needed no exporter change: _export_main_database() iterates MAIN_DB_MODELS, so cron_jobs, provider_stats, umo_aliases, umo_senders and api_keys are picked up automatically, and dashboard_trusted_devices stays excluded.

tests/test_backup.py — union of both sides, no test dropped: the 12 regression guards from this PR plus test_cron_jobs_roundtrip, test_persistent_models_roundtrip, test_device_trust_is_excluded_and_cleared, test_mcp_config_roundtrip and test_all_main_db_models_have_a_backup_policy from master. One follow-up was needed: test_export_structure_is_stable asserts an exact archive member list, while master's new config/mcp_server.json is read from the process data root, so the assertion only held on a checkout without data/mcp_server.json. The test now points get_astrbot_data_path at the temporary data directory it already uses for get_backup_directories, so it is hermetic instead of environment-dependent. No assertion was relaxed — the member set and the checksum/manifest checks are unchanged.

Commits after the rebase: 37763573 fix, e130525e test, c6bd743c fix (serialization + teardown), 49587979 test (isolation above).

Local verification

Python 3.12.9 venv created with uv, ruff 0.15.22 (the version pinned in .pre-commit-config.yaml):

uv pip install -r requirements.txt
python -m pytest tests/test_backup.py -q     -> 103 passed
python -m pytest tests/unit -q               -> 1911 passed, 23 skipped
python -m ruff format --check .              -> 524 files already formatted
python -m ruff check .                       -> All checks passed!

tests/test_backup.py is now 103 tests rather than the 88 quoted in the description, because the four upstream tests merged in. No test was skipped or xfailed.

Head and CI

New head: 49587979e731a803da8544513a729bf126b62605, rebased onto 832fc6d9 (previous head was f3426110). GitHub now reports mergeable: MERGEABLE instead of CONFLICTING, and mergeStateStatus moved from DIRTY to BLOCKED (i.e. only the pending review remains). The branch diff against master is still exactly two files — astrbot/core/backup/exporter.py and tests/test_backup.py — and nothing was pushed anywhere except this fork branch.

CI on 49587979: 23/23 checks green (gh pr checks 10424 → 23 pass, 0 fail, 0 cancelled), including Run pytest suite on ubuntu/macos/windows, format-check, Analyze (python), build and the 15 smoke-test jobs.

This branch has not been deployed

No deployments
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