fix: prevent SharedPreferences deadlocks - #9649
Conversation
There was a problem hiding this comment.
Hey - I've found 4 issues
Prompt for AI Agents
Please address the comments from this code review:
## Individual Comments
### Comment 1
<location path="astrbot/core/utils/shared_preferences.py" line_range="125-134" />
<code_context>
+ async def _drain_write_queue(self) -> None:
</code_context>
<issue_to_address>
**issue (bug_risk):** Queued writes can get stuck if they are enqueued while the writer task is still running but the queue has just become empty.
Because `_drain_write_queue` uses `queue.get_nowait()` in a loop and `_schedule_write` only starts a writer when `self._writer_task is None or self._writer_task.done()`, there’s a race where the queue becomes empty, the task is still marked running, and a new item is enqueued before `_writer_task` completes. In that case no new writer task is scheduled and the new item is never processed. To avoid stuck writes, consider making `_drain_write_queue` a long‑lived consumer using `await queue.get()` in a loop, exiting only on an explicit shutdown/sentinel, or alternately removing `_writer_task` and spawning a fresh task per flush.
</issue_to_address>
### Comment 2
<location path="astrbot/core/provider/manager.py" line_range="218-227" />
<code_context>
- def get_using_provider(
- self, provider_type: ProviderType, umo=None
+ def _resolve_using_provider(
+ self,
+ provider_type: ProviderType,
</code_context>
<issue_to_address>
**nitpick:** The helper method’s docstring advertises a ValueError that is never raised, which may mislead callers.
The `_resolve_using_provider` docstring claims it may raise `ValueError` for unsupported `provider_type`, but the implementation only returns `None` for disabled/unavailable providers. This contract mismatch can cause callers to handle exceptions that never occur instead of the `None` case.
Please either add explicit validation that raises on unsupported `ProviderType` values, or update the docstring to reflect the current behavior (no exceptions, `None` as the failure mode), especially since both sync and async `get_using_provider` variants now rely on this helper.
</issue_to_address>
### Comment 3
<location path="tests/unit/test_shared_preferences.py" line_range="28-31" />
<code_context>
+async def test_sync_put_updates_cache_and_persists_without_blocking(preferences):
+ store, database = preferences
+
+ started = time.monotonic()
+ store.put("theme", "dark", scope="global", scope_id="global")
+
+ assert time.monotonic() - started < 0.1
+ assert store.get("theme", scope="global", scope_id="global") == "dark"
+
</code_context>
<issue_to_address>
**suggestion (testing):** The strict timing assertion in `test_sync_put_updates_cache_and_persists_without_blocking` may be flaky across environments.
This test encodes a 100ms latency guarantee for `store.put`, which is likely to be flaky under CI load or on slower systems. Since the goal is to verify non-blocking behavior rather than exact timing, consider either mocking the persistence path so the test only asserts that `put` returns without awaiting, or relaxing the timing constraint (e.g., relative to a known blocking operation) to avoid spurious failures unrelated to the deadlock fix.
Suggested implementation:
```python
import time
import pytest
import pytest_asyncio
```
```python
@pytest.mark.asyncio
async def test_sync_put_updates_cache_and_persists_without_blocking(preferences):
store, database = preferences
# This test verifies that `put` returns promptly and updates the in-memory cache
# without asserting a very tight latency bound that could be flaky under CI load.
started = time.monotonic()
store.put("theme", "dark", scope="global", scope_id="global")
elapsed = time.monotonic() - started
# Allow a more relaxed upper bound to avoid spurious failures while still
# catching pathological blocking behavior.
assert elapsed < 0.5
assert store.get("theme", scope="global", scope_id="global") == "dark"
```
Depending on how `SharedPreferences` and the underlying persistence layer are implemented, you may want to further align this test with the non-blocking guarantee by:
1. Introducing a mock or spy for the persistence method (e.g., the database write call) and asserting that it is invoked asynchronously after `put` returns, rather than relying on wall-clock timing.
2. If you adopt that mocking approach, the `elapsed` assertion could be removed entirely, and the test would instead assert that `put` does not await the persistence coroutine (for example, by making the mocked persistence artificially slow and confirming that `put` returns before it completes).
These adjustments will require access to the actual `SharedPreferences` implementation and the persistence API to determine the best hook point for mocking.
</issue_to_address>
### Comment 4
<location path="astrbot/core/utils/shared_preferences.py" line_range="46" />
<code_context>
+ self._cache_initialized = False
+ self._initializing = False
+ self._initialize_lock = asyncio.Lock()
+ self._loop: asyncio.AbstractEventLoop | None = None
+ self._write_queue: asyncio.Queue[_WriteOperation] | None = None
+ self._writer_task: asyncio.Task[None] | None = None
</code_context>
<issue_to_address>
**issue (complexity):** Consider simplifying SharedPreferences to a single event loop with one writer task and queue, removing multi-loop rebinding logic, pending write handling, and extra state flags.
The main complexity comes from supporting rebinding across multiple event loops and coordinating `_pending_writes` vs the live `_write_queue`. If you don’t actually need multi-loop ownership, you can simplify to a single-loop, single-queue model and drop most of the state flags without losing any behaviour.
### 1. Bind to a single event loop and remove rebinding / `_pending_writes`
You can bind once in `initialize()` and enforce a stricter contract:
```python
class SharedPreferences:
def __init__(self, db_helper: BaseDatabase, json_storage_path=None) -> None:
...
self._cache: dict[tuple[str, str, str], Any] = {}
self._cache_lock = threading.RLock()
self._cache_initialized = False
self._loop: asyncio.AbstractEventLoop | None = None
self._write_queue: asyncio.Queue[_WriteOperation] | None = None
self._writer_task: asyncio.Task[None] | None = None
```
```python
async def initialize(self) -> None:
loop = asyncio.get_running_loop()
async with self._initialize_lock:
if self._loop is not None and self._loop is not loop:
raise RuntimeError("SharedPreferences must be used on a single event loop")
if self._cache_initialized:
return
self._loop = loop
self._write_queue = asyncio.Queue()
self._writer_task = loop.create_task(
self._writer(), name="shared_preferences_writer"
)
preferences = await self.db_helper.get_preferences()
with self._cache_lock:
self._cache = {
(item.scope, item.scope_id, item.key): deepcopy(item.value["val"])
for item in preferences
}
self._cache_initialized = True
```
Key changes:
- No `_initializing`, `_pending_writes`, or queue migration from an old loop.
- If someone calls from a different loop, you fail fast with a clear error instead of trying to rebind.
### 2. Single writer task that owns both cache and DB mutations
Instead of `_apply_cache_operation` + `_schedule_write` + `_drain_write_queue`, you can make the writer the single source of truth:
```python
def _submit_write(self, operation: _WriteOperation) -> None:
# Caller must have ensured initialize() was called
if self._write_queue is None:
raise RuntimeError("SharedPreferences not initialized")
self._write_queue.put_nowait(operation)
```
```python
async def _writer(self) -> None:
assert self._write_queue is not None
while True:
action, scope, scope_id, key, value, completion = await self._write_queue.get()
try:
# Update cache first
with self._cache_lock:
if action == "put" and key is not None:
self._cache[(scope, scope_id, key)] = deepcopy(value)
elif action == "remove" and key is not None:
self._cache.pop((scope, scope_id, key), None)
elif action == "clear":
keys = [
k for k in self._cache
if k[0] == scope and k[1] == scope_id
]
for k in keys:
self._cache.pop(k, None)
# Then persist
if action == "put" and key is not None:
await self.db_helper.insert_preference_or_update(
scope, scope_id, key, {"val": value}
)
elif action == "remove" and key is not None:
await self.db_helper.remove_preference(scope, scope_id, key)
elif action == "clear":
await self.db_helper.clear_preferences(scope, scope_id)
else:
raise ValueError(f"Unknown preference write operation: {action}")
if completion is not None and not completion.done():
completion.set_result(None)
except Exception as exc:
logger.error(
"Failed to persist shared preference operation %s for %s/%s: %s",
action, scope, scope_id, exc, exc_info=True,
)
if completion is not None and not completion.done():
completion.set_exception(exc)
finally:
self._write_queue.task_done()
```
Effects:
- All mutations (cache + DB) are linearized through one queue and one task.
- `_apply_cache_operation` and `_schedule_write` can be removed entirely.
- You no longer need `_initializing` or to replay `_pending_writes` into both cache and queue.
### 3. Simplify `flush()` around the single writer/queue
With the writer always running after initialization, `flush()` becomes simpler:
```python
async def flush(self) -> None:
if self._write_queue is None or self._loop is None:
return
loop = asyncio.get_running_loop()
if loop is not self._loop:
raise RuntimeError("flush() must be called on the owning event loop")
await self._write_queue.join()
```
No need to:
- Check `loop.is_running()` vs a stopped loop.
- Call `initialize()` from here or handle rebinding.
- Await `_writer_task` each time; the writer runs for the lifetime of the store.
### 4. Deprecated sync APIs: keep behaviour simple and consistent
If the deprecated sync APIs still need to hit the DB (and be consistent with async ones), you can reuse the async pipeline via the main loop instead of direct cache writes:
```python
@deprecated(...)
def put(self, key, value, scope: str | None = None, scope_id: str | None = None) -> None:
if self._loop is None:
raise RuntimeError("SharedPreferences must be initialized before sync calls")
op_scope = scope or "unknown"
op_scope_id = scope_id or "unknown"
fut = asyncio.run_coroutine_threadsafe(
self.put_async(op_scope, op_scope_id, key, value),
self._loop,
)
fut.result()
```
Same pattern for `get`, `remove`, `clear`. That avoids:
- Divergent semantics between sync and async paths.
- Additional cache-only state that depends on whether `initialize()`/`flush()` ran.
---
All of the above keeps your new features (async access, in-memory cache, batching writes) but removes the multi-loop and pending-write state machine, which is where most of the incidental complexity comes from.
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
| async def _drain_write_queue(self) -> None: | ||
| """Persist queued preference mutations in FIFO order.""" | ||
| queue = self._write_queue | ||
| if queue is None: | ||
| return | ||
| while True: | ||
| try: | ||
| operation = queue.get_nowait() | ||
| except asyncio.QueueEmpty: | ||
| return |
There was a problem hiding this comment.
issue (bug_risk): Queued writes can get stuck if they are enqueued while the writer task is still running but the queue has just become empty.
Because _drain_write_queue uses queue.get_nowait() in a loop and _schedule_write only starts a writer when self._writer_task is None or self._writer_task.done(), there’s a race where the queue becomes empty, the task is still marked running, and a new item is enqueued before _writer_task completes. In that case no new writer task is scheduled and the new item is never processed. To avoid stuck writes, consider making _drain_write_queue a long‑lived consumer using await queue.get() in a loop, exiting only on an explicit shutdown/sentinel, or alternately removing _writer_task and spawning a fresh task per flush.
| def _resolve_using_provider( | ||
| self, | ||
| provider_type: ProviderType, | ||
| umo: str | None, | ||
| provider_id: str | None, | ||
| ) -> Providers | None: | ||
| """获取正在使用的提供商实例。 | ||
| """Resolve a provider preference with configuration fallbacks. | ||
|
|
||
| Args: | ||
| provider_type (ProviderType): 提供商类型。 | ||
| umo (str, optional): 用户会话 ID,用于提供商会话隔离。 | ||
| provider_type: Provider type to resolve. |
There was a problem hiding this comment.
nitpick: The helper method’s docstring advertises a ValueError that is never raised, which may mislead callers.
The _resolve_using_provider docstring claims it may raise ValueError for unsupported provider_type, but the implementation only returns None for disabled/unavailable providers. This contract mismatch can cause callers to handle exceptions that never occur instead of the None case.
Please either add explicit validation that raises on unsupported ProviderType values, or update the docstring to reflect the current behavior (no exceptions, None as the failure mode), especially since both sync and async get_using_provider variants now rely on this helper.
| started = time.monotonic() | ||
| store.put("theme", "dark", scope="global", scope_id="global") | ||
|
|
||
| assert time.monotonic() - started < 0.1 |
There was a problem hiding this comment.
suggestion (testing): The strict timing assertion in test_sync_put_updates_cache_and_persists_without_blocking may be flaky across environments.
This test encodes a 100ms latency guarantee for store.put, which is likely to be flaky under CI load or on slower systems. Since the goal is to verify non-blocking behavior rather than exact timing, consider either mocking the persistence path so the test only asserts that put returns without awaiting, or relaxing the timing constraint (e.g., relative to a known blocking operation) to avoid spurious failures unrelated to the deadlock fix.
Suggested implementation:
import time
import pytest
import pytest_asyncio@pytest.mark.asyncio
async def test_sync_put_updates_cache_and_persists_without_blocking(preferences):
store, database = preferences
# This test verifies that `put` returns promptly and updates the in-memory cache
# without asserting a very tight latency bound that could be flaky under CI load.
started = time.monotonic()
store.put("theme", "dark", scope="global", scope_id="global")
elapsed = time.monotonic() - started
# Allow a more relaxed upper bound to avoid spurious failures while still
# catching pathological blocking behavior.
assert elapsed < 0.5
assert store.get("theme", scope="global", scope_id="global") == "dark"Depending on how SharedPreferences and the underlying persistence layer are implemented, you may want to further align this test with the non-blocking guarantee by:
- Introducing a mock or spy for the persistence method (e.g., the database write call) and asserting that it is invoked asynchronously after
putreturns, rather than relying on wall-clock timing. - If you adopt that mocking approach, the
elapsedassertion could be removed entirely, and the test would instead assert thatputdoes not await the persistence coroutine (for example, by making the mocked persistence artificially slow and confirming thatputreturns before it completes).
These adjustments will require access to the actualSharedPreferencesimplementation and the persistence API to determine the best hook point for mocking.
| self._cache_initialized = False | ||
| self._initializing = False | ||
| self._initialize_lock = asyncio.Lock() | ||
| self._loop: asyncio.AbstractEventLoop | None = None |
There was a problem hiding this comment.
issue (complexity): Consider simplifying SharedPreferences to a single event loop with one writer task and queue, removing multi-loop rebinding logic, pending write handling, and extra state flags.
The main complexity comes from supporting rebinding across multiple event loops and coordinating _pending_writes vs the live _write_queue. If you don’t actually need multi-loop ownership, you can simplify to a single-loop, single-queue model and drop most of the state flags without losing any behaviour.
1. Bind to a single event loop and remove rebinding / _pending_writes
You can bind once in initialize() and enforce a stricter contract:
class SharedPreferences:
def __init__(self, db_helper: BaseDatabase, json_storage_path=None) -> None:
...
self._cache: dict[tuple[str, str, str], Any] = {}
self._cache_lock = threading.RLock()
self._cache_initialized = False
self._loop: asyncio.AbstractEventLoop | None = None
self._write_queue: asyncio.Queue[_WriteOperation] | None = None
self._writer_task: asyncio.Task[None] | None = None async def initialize(self) -> None:
loop = asyncio.get_running_loop()
async with self._initialize_lock:
if self._loop is not None and self._loop is not loop:
raise RuntimeError("SharedPreferences must be used on a single event loop")
if self._cache_initialized:
return
self._loop = loop
self._write_queue = asyncio.Queue()
self._writer_task = loop.create_task(
self._writer(), name="shared_preferences_writer"
)
preferences = await self.db_helper.get_preferences()
with self._cache_lock:
self._cache = {
(item.scope, item.scope_id, item.key): deepcopy(item.value["val"])
for item in preferences
}
self._cache_initialized = TrueKey changes:
- No
_initializing,_pending_writes, or queue migration from an old loop. - If someone calls from a different loop, you fail fast with a clear error instead of trying to rebind.
2. Single writer task that owns both cache and DB mutations
Instead of _apply_cache_operation + _schedule_write + _drain_write_queue, you can make the writer the single source of truth:
def _submit_write(self, operation: _WriteOperation) -> None:
# Caller must have ensured initialize() was called
if self._write_queue is None:
raise RuntimeError("SharedPreferences not initialized")
self._write_queue.put_nowait(operation) async def _writer(self) -> None:
assert self._write_queue is not None
while True:
action, scope, scope_id, key, value, completion = await self._write_queue.get()
try:
# Update cache first
with self._cache_lock:
if action == "put" and key is not None:
self._cache[(scope, scope_id, key)] = deepcopy(value)
elif action == "remove" and key is not None:
self._cache.pop((scope, scope_id, key), None)
elif action == "clear":
keys = [
k for k in self._cache
if k[0] == scope and k[1] == scope_id
]
for k in keys:
self._cache.pop(k, None)
# Then persist
if action == "put" and key is not None:
await self.db_helper.insert_preference_or_update(
scope, scope_id, key, {"val": value}
)
elif action == "remove" and key is not None:
await self.db_helper.remove_preference(scope, scope_id, key)
elif action == "clear":
await self.db_helper.clear_preferences(scope, scope_id)
else:
raise ValueError(f"Unknown preference write operation: {action}")
if completion is not None and not completion.done():
completion.set_result(None)
except Exception as exc:
logger.error(
"Failed to persist shared preference operation %s for %s/%s: %s",
action, scope, scope_id, exc, exc_info=True,
)
if completion is not None and not completion.done():
completion.set_exception(exc)
finally:
self._write_queue.task_done()Effects:
- All mutations (cache + DB) are linearized through one queue and one task.
_apply_cache_operationand_schedule_writecan be removed entirely.- You no longer need
_initializingor to replay_pending_writesinto both cache and queue.
3. Simplify flush() around the single writer/queue
With the writer always running after initialization, flush() becomes simpler:
async def flush(self) -> None:
if self._write_queue is None or self._loop is None:
return
loop = asyncio.get_running_loop()
if loop is not self._loop:
raise RuntimeError("flush() must be called on the owning event loop")
await self._write_queue.join()No need to:
- Check
loop.is_running()vs a stopped loop. - Call
initialize()from here or handle rebinding. - Await
_writer_taskeach time; the writer runs for the lifetime of the store.
4. Deprecated sync APIs: keep behaviour simple and consistent
If the deprecated sync APIs still need to hit the DB (and be consistent with async ones), you can reuse the async pipeline via the main loop instead of direct cache writes:
@deprecated(...)
def put(self, key, value, scope: str | None = None, scope_id: str | None = None) -> None:
if self._loop is None:
raise RuntimeError("SharedPreferences must be initialized before sync calls")
op_scope = scope or "unknown"
op_scope_id = scope_id or "unknown"
fut = asyncio.run_coroutine_threadsafe(
self.put_async(op_scope, op_scope_id, key, value),
self._loop,
)
fut.result()Same pattern for get, remove, clear. That avoids:
- Divergent semantics between sync and async paths.
- Additional cache-only state that depends on whether
initialize()/flush()ran.
All of the above keeps your new features (async access, in-memory cache, batching writes) but removes the multi-loop and pending-write state machine, which is where most of the incidental complexity comes from.
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
astrbot-docs | 0222df3 | Commit Preview URL Branch Preview URL |
Aug 12 2026, 04:47 PM |
* chore: update readme * feat: support PowerShell 7 for Windows local shell (#9622) * feat(config): add Windows PowerShell version option * feat(computer): honor Windows PowerShell version in local runtime * fix(computer): correct stale shell comment for configurable PowerShell * refactor(computer): inline windows shell resolution per AGENTS.md * test(computer): assert windows_shell in exec_managed call * test: make shell tests cross-platform Co-authored-by: Donoym <prober13c14@gmail.com> * refactor(computer): auto-detect Windows shell instead of config * fix(computer): drop stale cmd.exe references after shell auto-detect * style: apply ruff format and import order * Update astrbot/core/astr_main_agent.py Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> --------- Co-authored-by: Donoym <prober13c14@gmail.com> Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com> Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> * feat: add download-count sorting to plugin marketplace (#9570) * feat: 插件市场支持按下载量排序 为插件市场现有的前端本地排序补充下载量选项,复用已规范化的 download_count,不改变默认推荐顺序、后端接口和 OpenAPI。 新增稳定且不修改输入的下载量排序工具:支持升降序、未知值双向置底、非法值兜底及相同值原始顺序保留;接入市场排序控件并补齐中、英、俄三语文案。 新增 9 项专项单元测试,覆盖正常排序、零值、缺失与非法值、负数、数字字符串、小数、稳定性和输入不可变。验证通过 45 项 Dashboard Node 测试、额外性质检查、三语 JSON 校验、Vue 类型检查和生产构建。 * Delete dashboard/tests/marketPluginSort.test.mjs * refactor: 内联插件市场下载量排序 删除独立的市场下载量排序工具,将下载量比较逻辑直接放在现有市场排序分支。 缺失下载量使用默认值 0,保持前端本地排序与现有排序行为一致。 验证:vue-tsc --noEmit;pnpm run build;node --test dashboard/tests/*.test.mjs;git diff --check。 --------- Co-authored-by: C₂₂H₂₅NO₆ <Sisyphbaous-DT-Project@users.noreply.github.com> Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com> * fix: improve dark theme text contrast (#9643) * fix: propagate QQ media upload failures (#9644) * feat(qqofficial): add chunked file uploads (#9646) Add a dedicated chunked uploader for large local QQ Official media. Keep C2C and group endpoints explicit, support server-provided part index bases, and retry transient upload operations. Co-authored-by: Fuyan Yuan <33221728+TheRainstorm@users.noreply.github.com> * fix(kb): support UUID fallback in get_kb_by_name and warn on missing KBs (#9636) Fixes #9529. When kb_names contains UUIDs instead of names, get_kb_by_name() would return None and check_all_kb() would silently skip retrieval with only a DEBUG-level log. Modifications: - get_kb_by_name() now falls back to UUID lookup when name match fails - check_all_kb() distinguishes None (not found) from empty KBs and logs a WARNING with the count of missing KBs - Add regression tests covering UUID lookup, name preference, and logging * fix: prevent SharedPreferences deadlocks (#9649) * fix: avoid blocking shared preference access * docs: clarify shared preference cache roles * docs: explain shared preference cache purpose * docs: link cache rationale to pull request * chore: bump version to 4.27.3 (#9650) * chore: bump version to 4.27.3 * Update changelog for version 4.27.3 Updated changelog for version 4.27.3 with new features, maintenance updates, and bug fixes. * Refactor: stat service deprecated stats (#9658) * refactor(dashboard): migrate get_stat off deprecated stats methods Rewrite StatService.get_stat to query PlatformStat directly via db_helper.get_db(), following the existing get_provider_token_stats pattern in the same file, instead of the deprecated get_base_stats/get_grouped_base_stats/get_total_message_count. - Windowed rows are fetched once with an explicit ORDER BY timestamp (the old get_base_stats relied on insertion order) - Per-platform sums and hourly time-series buckets are aggregated in Python; total message count uses func.coalesce(func.sum(...), 0) - Response shape is unchanged: platform entries keep the {name, count, timestamp} keys, now built as plain dicts so no deprecated po.Platform/Stats classes are instantiated - Verified semantically identical against the old methods with an A/B comparison over a seeded database (time series, per-platform sums, total count, and empty-window case all match) * test(dashboard): cover StatService.get_stat aggregation semantics The existing test_get_stat route test only asserts the HTTP status and the presence of the platform key, so an aggregation regression would pass unnoticed. Add focused unit tests that seed PlatformStat rows and assert the windowed per-platform sums, the global message total, the hourly time-series bucket shape, the response key set, and the empty-window behavior. * feat(provider): add SSYCloud chat completion provider (#9659) * fix: include JSON cards in group context (#9655) * fix: include JSON cards in group context * fix: allow JSON cards to trigger active replies * docs: add ShengSuanYun provider guide * fix: detect audio format from file content (#9612) * feat: log effective Windows local runtime shell (#9648) * feat: log effective Windows local runtime shell * chore: remove Windows shell logging tests --------- Co-authored-by: Soulter <905617992@qq.com> * refactor(misskey): migrate off deprecated visibility resolver alias (#9674) --------- Co-authored-by: Soulter <905617992@qq.com> Co-authored-by: Wei Chengqian <wcqqq1214@gmail.com> Co-authored-by: Donoym <prober13c14@gmail.com> Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com> Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> Co-authored-by: C₂₂H₂₅NO₆ <96930391+Sisyphbaous-DT-Project@users.noreply.github.com> Co-authored-by: C₂₂H₂₅NO₆ <Sisyphbaous-DT-Project@users.noreply.github.com> Co-authored-by: Fuyan Yuan <33221728+TheRainstorm@users.noreply.github.com> Co-authored-by: lxfight <1686540385@qq.com> Co-authored-by: Ruochen Pan <badbatch0x01@gmail.com> Co-authored-by: xxshu26 <15197757832@163.com> Co-authored-by: PeiPei233 <49641967+PeiPei233@users.noreply.github.com> Co-authored-by: xiaoxuan010 <2592053474@qq.com> Co-authored-by: C10H14N2O5 <100066858+C10H14N2O5@users.noreply.github.com>
Summary
Root cause
The deprecated synchronous SharedPreferences methods submitted database coroutines to a secondary event loop and blocked on
.result(). The secondary loop used the same SQLAlchemy async connection pool as the main loop. When the pool was exhausted, the main loop could block waiting for the synchronous result while also being the only loop able to release the required connection, creating a circular wait.The new implementation serves synchronous reads from memory and makes synchronous writes update the cache immediately before scheduling ordered persistence. Async writes await persistence completion and surface database failures.
Fixes #9633.
Validation
uv run ruff format .uv run ruff check .tests/suite: 2128 passed, 2 deselectedcaplogcapture failures where the expected warning is emitted to stdoutSummary by Sourcery
Replace the blocking SharedPreferences sync bridge with an event-loop-bound cached store and ordered write queue, and introduce async provider selection and tool toggle APIs used across core, pipeline, plugins, and dashboard.
Bug Fixes:
Enhancements:
Documentation:
Tests: