Files
Unlucky-Launcher/crates/core/src/setting/save.rs
T
AskaEth bc41dfcf7d
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Unlucky Launcher v0.0.1-alpha
Rust 原生单文件 Minecraft 启动器:CLI 完整可用(install/launch/accounts/java/diagnose/agent),
MCP server 接入外部 Agent 工具,GUI 开发中。
2026-10-31 21:54:10 +08:00

218 lines
6.2 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 保存通道的**去抖攒批**部分(纯逻辑,可注入时钟,确定性单测)。
//!
//! 出处:`DevDocs/analyse/07-settings-packaging-update.md` §1.6(HMCL `FileSaver`):
//! 入队 → 消费线程先 `sleep(200ms)` 等更多改动 → 全量 drain 去重 → 逐条 `saveSync`(tmp + move)。
//! 效果:一次窗口拖拽只产生一次写盘,而不是 60 次。
//!
//! 分层:本文件只管「什么时候写、写什么」;真正落盘由 [`JobSink`] 决定
//! (生产用 [`AtomicSink`],测试用记录型 sink)。
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use super::error::SettingResult;
/// 默认去抖窗口(analyse/07 §1.6 的 200ms)。
pub const DEFAULT_DEBOUNCE: Duration = Duration::from_millis(200);
/// 一条待写任务。
#[derive(Debug, Clone, PartialEq, Eq)]
struct Pending {
payload: Vec<u8>,
queued_at: Instant,
}
/// 按 key 去重的去抖队列:同一 key 的新内容覆盖旧内容,但**保留首次入队时间**
/// (窗口从第一次改动开始算,避免连续改动把写盘无限推迟)。
#[derive(Debug, Clone)]
pub struct DebounceQueue<K: Ord> {
debounce: Duration,
pending: BTreeMap<K, Pending>,
}
impl<K: Ord + Clone> DebounceQueue<K> {
/// 新建一个去抖队列。
#[must_use]
pub fn new(debounce: Duration) -> Self {
Self {
debounce,
pending: BTreeMap::new(),
}
}
/// 去抖窗口。
#[must_use]
pub const fn debounce(&self) -> Duration {
self.debounce
}
/// 入队(同 key 覆盖内容)。返回 `true` 表示是新增条目。
pub fn enqueue(&mut self, key: K, payload: impl Into<Vec<u8>>, now: Instant) -> bool {
match self.pending.get_mut(&key) {
Some(existing) => {
existing.payload = payload.into();
false
}
None => {
self.pending.insert(
key,
Pending {
payload: payload.into(),
queued_at: now,
},
);
true
}
}
}
/// 待写条目数。
#[must_use]
pub fn len(&self) -> usize {
self.pending.len()
}
/// 是否没有待写任务。
#[must_use]
pub fn is_empty(&self) -> bool {
self.pending.is_empty()
}
/// 某 key 是否在队列里。
#[must_use]
pub fn contains(&self, key: &K) -> bool {
self.pending.contains_key(key)
}
/// 最早该写盘的时刻。
#[must_use]
pub fn next_due(&self) -> Option<Instant> {
self.pending
.values()
.map(|pending| pending.queued_at + self.debounce)
.min()
}
/// 取出所有已过窗口的任务(按键排序,保证写盘顺序稳定)。
pub fn drain_due(&mut self, now: Instant) -> Vec<(K, Vec<u8>)> {
let due: Vec<K> = self
.pending
.iter()
.filter(|(_, pending)| pending.queued_at + self.debounce <= now)
.map(|(key, _)| key.clone())
.collect();
self.take(due)
}
/// 取出全部任务(关停兜底)。
pub fn drain_all(&mut self) -> Vec<(K, Vec<u8>)> {
let all: Vec<K> = self.pending.keys().cloned().collect();
self.take(all)
}
fn take(&mut self, keys: Vec<K>) -> Vec<(K, Vec<u8>)> {
keys.into_iter()
.filter_map(|key| {
self.pending
.remove(&key)
.map(|pending| (key, pending.payload))
})
.collect()
}
}
impl<K: Ord + Clone> Default for DebounceQueue<K> {
fn default() -> Self {
Self::new(DEFAULT_DEBOUNCE)
}
}
/// 真正落盘的执行者(测试可注入记录型实现)。
pub trait JobSink {
/// 写一个文件。
///
/// # Errors
/// 落盘失败时返回 [`super::error::SettingError`]。
fn write(&self, path: &Path, payload: &[u8]) -> SettingResult<()>;
}
/// 生产用 sink:tmp → fsync → rename。
#[derive(Debug, Clone, Copy, Default)]
pub struct AtomicSink;
impl JobSink for AtomicSink {
fn write(&self, path: &Path, payload: &[u8]) -> SettingResult<()> {
crate::platform::atomic::write_atomic(path, payload)?;
Ok(())
}
}
/// 去抖队列 + sink 的组合体(app 层持有它,或交给 [`super::save_worker::SaveWorker`] 托管)。
#[derive(Debug, Clone)]
pub struct CoalescingWriter<S: JobSink> {
queue: DebounceQueue<PathBuf>,
sink: S,
}
impl<S: JobSink> CoalescingWriter<S> {
/// 组合去抖队列与 sink。
#[must_use]
pub fn new(sink: S, debounce: Duration) -> Self {
Self {
queue: DebounceQueue::new(debounce),
sink,
}
}
/// 入队一次写盘。
pub fn queue(&mut self, path: impl Into<PathBuf>, payload: impl Into<Vec<u8>>, now: Instant) {
self.queue.enqueue(path.into(), payload, now);
}
/// 待写条目数。
#[must_use]
pub fn pending_len(&self) -> usize {
self.queue.len()
}
/// 是否没有待写任务。
#[must_use]
pub fn is_empty(&self) -> bool {
self.queue.is_empty()
}
/// 最早该写盘的时刻。
#[must_use]
pub fn next_due(&self) -> Option<Instant> {
self.queue.next_due()
}
/// 写掉已过窗口的任务,返回写了几个文件。
///
/// # Errors
/// sink 落盘失败时返回 [`super::error::SettingError`]。
pub fn flush_due(&mut self, now: Instant) -> SettingResult<usize> {
let jobs = self.queue.drain_due(now);
self.run(jobs)
}
/// 写掉全部任务(关停兜底),返回写了几个文件。
///
/// # Errors
/// sink 落盘失败时返回 [`super::error::SettingError`]。
pub fn flush_all(&mut self) -> SettingResult<usize> {
let jobs = self.queue.drain_all();
self.run(jobs)
}
fn run(&mut self, jobs: Vec<(PathBuf, Vec<u8>)>) -> SettingResult<usize> {
let mut written = 0usize;
for (path, payload) in jobs {
self.sink.write(&path, &payload)?;
written += 1;
}
Ok(written)
}
}