修复 AGC 换号后整项目快照重传导致的日志刷屏与客户端超时
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m20s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m57s
Project CI / Backend tests (pull_request) Successful in 4m52s
Project CI / Native shell tests (pull_request) Successful in 5m59s
Project CI / Frontend tests (pull_request) Successful in 1m56s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 8m46s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 9m0s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m24s
Project CI / Repository checks (pull_request) Successful in 1m50s

- 项目快照索引升级为 schema v2:按账号分桶保存基线,换号不再让其它账号的基线失效,也不会被其它账号覆盖
- 同步只读取并写回当前登录账号的基线,其它账号的基线原样保留
- 读取时按 JSON 版本判定并迁移 v1 单账号索引到它自己记录的 userId 桶,格式升级不额外触发一次重传
- read_local_project_snapshot_state 增补 baselineCount / baselinePresent,fileCount / syncRevision / syncedAtMs 改为当前登录账号的基线口径
- 新增与改写用例:账号切换后基线互不覆盖、切回旧账号差异为空、v1 索引迁移进对应账号桶
- 同步更新快照主规范、快照里程碑文档、shared-memory decision-log 与 pitfalls
This commit is contained in:
2026-09-24 11:20:39 +08:00
parent 87e52860a7
commit 3be8e40bc2
7 changed files with 349 additions and 62 deletions
@@ -9,14 +9,13 @@ pub(crate) struct ProjectSnapshotIndexedFile {
pub(crate) checksum: String,
}
/// 上次成功同步的快照。索引只在本机 AppData 中,不进入用户项目目录。
/// 某个账号在某个项目上的上次成功同步基线。
///
/// `user_id` 是远端前缀的一部分:换号后旧索引不再代表同一个远端命名空间,
/// 因此读取时按用户身份判等,不一致就当作冷启动重新全量对比。
/// 基线只对写入它的账号成立:远端对象键里带着 `userId`,换号之后旧基线既不能用来
/// 判断"远端已经有什么",也不能被新账号的同步覆盖掉。
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ProjectSnapshotIndex {
pub(crate) schema_version: u32,
pub(crate) struct ProjectSnapshotBaseline {
pub(crate) project_id: String,
pub(crate) user_id: String,
pub(crate) sync_revision: u64,
@@ -29,12 +28,59 @@ pub(crate) struct ProjectSnapshotIndex {
pub(crate) files: BTreeMap<String, ProjectSnapshotIndexedFile>,
}
pub(crate) fn empty_project_snapshot_index(
project_id: &str,
user_id: &str,
) -> ProjectSnapshotIndex {
/// 索引文件:一个项目一份,按账号分桶保存各账号的基线。
///
/// 曾经整份索引只保存一个账号的基线,换号即当作"没有基线",于是每次切号后打开项目
/// 都把整个工程重传一遍(issue #504:2311 个文件、服务端全是 HEAD 命中的白跑请求,
/// 同时把同进程的 IPC 压到其它接口 15s 超时)。分桶之后各账号各自持有基线,切回旧
/// 账号只需要传真正的差异。
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ProjectSnapshotIndex {
pub(crate) schema_version: u32,
pub(crate) project_id: String,
#[serde(default)]
pub(crate) baselines: BTreeMap<String, ProjectSnapshotBaseline>,
}
/// v1 索引:整份文件只保存一个账号的基线。只读一次,用于迁移。
///
/// `schemaVersion` 不用反序列化:版本已经在选分支时按 JSON 值判定过。
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct LegacyProjectSnapshotIndexV1 {
project_id: String,
user_id: String,
sync_revision: u64,
synced_at_ms: u64,
#[serde(default)]
project_name: Option<String>,
#[serde(default)]
pending_files: Option<u32>,
#[serde(default)]
files: BTreeMap<String, ProjectSnapshotIndexedFile>,
}
impl ProjectSnapshotIndex {
pub(crate) fn baseline_for(&self, user_id: &str) -> Option<&ProjectSnapshotBaseline> {
self.baselines.get(user_id)
}
}
pub(crate) fn empty_project_snapshot_index(project_id: &str) -> ProjectSnapshotIndex {
ProjectSnapshotIndex {
schema_version: PROJECT_SNAPSHOT_INDEX_SCHEMA_VERSION,
project_id: project_id.to_string(),
baselines: BTreeMap::new(),
}
}
/// 某账号尚无基线时的空基线:全量对比会重新上传所有文件,不会漏传。
pub(crate) fn empty_project_snapshot_baseline(
project_id: &str,
user_id: &str,
) -> ProjectSnapshotBaseline {
ProjectSnapshotBaseline {
project_id: project_id.to_string(),
user_id: user_id.to_string(),
sync_revision: 0,
@@ -79,8 +125,8 @@ pub(crate) fn project_snapshot_index_path_at(directory: &Path) -> PathBuf {
directory.join(PROJECT_SNAPSHOT_INDEX_FILE_NAME)
}
/// 读取本地索引。索引不存在、版本不符或内容损坏时返回空索引:这种情况下
/// 全量对比会重新上传所有文件,不会漏传,也不会因为坏索引中断同步。
/// 读取本地索引。索引不存在、版本不符或内容损坏时返回空索引:这种情况下某账号
/// 视为没有基线,会重新上传该账号的全部文件,不会漏传,也不会因为坏索引中断同步。
pub(crate) fn read_project_snapshot_index(
project_id: &str,
) -> Result<ProjectSnapshotIndex, String> {
@@ -93,7 +139,7 @@ pub(crate) fn read_project_snapshot_index_at(
) -> Result<ProjectSnapshotIndex, String> {
let path = project_snapshot_index_path_at(directory);
if !path.exists() {
return Ok(empty_project_snapshot_index(project_id, ""));
return Ok(empty_project_snapshot_index(project_id));
}
let content = match read_game_creator_private_file_to_string(
&path,
@@ -103,29 +149,119 @@ pub(crate) fn read_project_snapshot_index_at(
Ok(content) => content,
Err(error) => {
app_log!("project_snapshot.index.read.failed: {error}");
return Ok(empty_project_snapshot_index(project_id, ""));
return Ok(empty_project_snapshot_index(project_id));
}
};
match serde_json::from_str::<ProjectSnapshotIndex>(&content) {
Ok(index)
if index.schema_version == PROJECT_SNAPSHOT_INDEX_SCHEMA_VERSION
&& index.project_id == project_id =>
{
Ok(index)
}
Ok(_) => Ok(empty_project_snapshot_index(project_id, "")),
let value = match serde_json::from_str::<serde_json::Value>(&content) {
Ok(value) => value,
Err(error) => {
app_log!("project_snapshot.index.parse.failed: {error}");
Ok(empty_project_snapshot_index(project_id, ""))
return Ok(empty_project_snapshot_index(project_id));
}
};
match value
.get("schemaVersion")
.and_then(serde_json::Value::as_u64)
{
Some(version) if version == u64::from(PROJECT_SNAPSHOT_INDEX_SCHEMA_VERSION) => {
match serde_json::from_value::<ProjectSnapshotIndex>(value) {
Ok(index) if index.project_id == project_id => Ok(index),
Ok(_) => Ok(empty_project_snapshot_index(project_id)),
Err(error) => {
app_log!("project_snapshot.index.parse.failed: {error}");
Ok(empty_project_snapshot_index(project_id))
}
}
}
Some(PROJECT_SNAPSHOT_INDEX_LEGACY_SCHEMA_VERSION) => {
match serde_json::from_value::<LegacyProjectSnapshotIndexV1>(value) {
Ok(legacy) => Ok(migrate_legacy_project_snapshot_index(project_id, legacy)),
Err(error) => {
app_log!("project_snapshot.index.parse.failed: {error}");
Ok(empty_project_snapshot_index(project_id))
}
}
}
_ => Ok(empty_project_snapshot_index(project_id)),
}
}
pub(crate) fn write_project_snapshot_index(index: &ProjectSnapshotIndex) -> Result<(), String> {
write_project_snapshot_index_at(&project_snapshot_index_directory(&index.project_id)?, index)
/// 把 v1 的单账号索引原样搬进新的账号分桶:升级后不需要因为格式变化再做一次全量重传。
///
/// `userId` 缺失的 v1 索引无法归属到任何账号,只能丢弃;`user_id` 保持原样不 trim,
/// 因为它是远端前缀的一部分,不做过任何规范化。
fn migrate_legacy_project_snapshot_index(
project_id: &str,
legacy: LegacyProjectSnapshotIndexV1,
) -> ProjectSnapshotIndex {
let mut index = empty_project_snapshot_index(project_id);
if legacy.project_id != project_id || legacy.user_id.is_empty() {
return index;
}
index.baselines.insert(
legacy.user_id.clone(),
ProjectSnapshotBaseline {
project_id: project_id.to_string(),
user_id: legacy.user_id,
sync_revision: legacy.sync_revision,
synced_at_ms: legacy.synced_at_ms,
project_name: legacy.project_name,
pending_files: legacy.pending_files,
files: legacy.files,
},
);
index
}
pub(crate) fn write_project_snapshot_index_at(
/// 读取指定账号的基线;该账号尚无基线时返回空基线,其它账号的基线不受影响。
pub(crate) fn read_project_snapshot_baseline(
project_id: &str,
user_id: &str,
) -> Result<ProjectSnapshotBaseline, String> {
read_project_snapshot_baseline_at(
&project_snapshot_index_directory(project_id)?,
project_id,
user_id,
)
}
pub(crate) fn read_project_snapshot_baseline_at(
directory: &Path,
project_id: &str,
user_id: &str,
) -> Result<ProjectSnapshotBaseline, String> {
let index = read_project_snapshot_index_at(directory, project_id)?;
Ok(index
.baseline_for(user_id)
.cloned()
.unwrap_or_else(|| empty_project_snapshot_baseline(project_id, user_id)))
}
/// 写回某账号的基线,保留同一项目下其它账号的基线。
///
/// 旧索引损坏时按"其它账号没有基线"继续写入:坏文件本身已经不可用,宁可在下一个
/// 账号同步时重算,也不能让这次同步因为索引写失败而整体作废(那会立刻触发重传)。
pub(crate) fn write_project_snapshot_baseline(
baseline: &ProjectSnapshotBaseline,
) -> Result<(), String> {
write_project_snapshot_baseline_at(
&project_snapshot_index_directory(&baseline.project_id)?,
baseline,
)
}
pub(crate) fn write_project_snapshot_baseline_at(
directory: &Path,
baseline: &ProjectSnapshotBaseline,
) -> Result<(), String> {
let mut index = read_project_snapshot_index_at(directory, &baseline.project_id)?;
index
.baselines
.insert(baseline.user_id.clone(), baseline.clone());
write_project_snapshot_index_at(directory, &index)
}
fn write_project_snapshot_index_at(
directory: &Path,
index: &ProjectSnapshotIndex,
) -> Result<(), String> {
@@ -27,7 +27,10 @@ pub(crate) use transport::*;
/// 本机项目快照索引在 AppData 配置目录下的位置。
pub(crate) const PROJECT_SNAPSHOT_DIRECTORY: &str = "project-snapshots";
const PROJECT_SNAPSHOT_INDEX_FILE_NAME: &str = "index.json";
const PROJECT_SNAPSHOT_INDEX_SCHEMA_VERSION: u32 = 1;
/// 索引文件版本。2 起按账号分桶保存基线(见 `index.rs`),v1 只保存单账号基线。
const PROJECT_SNAPSHOT_INDEX_SCHEMA_VERSION: u32 = 2;
/// 只用于迁移读取的历史索引版本。
const PROJECT_SNAPSHOT_INDEX_LEGACY_SCHEMA_VERSION: u64 = 1;
const PROJECT_SNAPSHOT_INDEX_MAX_BYTES: u64 = 32 * 1024 * 1024;
pub(crate) const PROJECT_SNAPSHOT_MAX_FILE_BYTES: u64 =
@@ -293,12 +296,9 @@ async fn sync_project_snapshot_async(
let manifest = read_existing_manifest_for_project(project_root)?;
let project_id = validate_project_snapshot_project_id(manifest.project_id.trim())?;
let previous = read_project_snapshot_index(&project_id)?;
let previous = if previous.user_id == session.user_id {
previous
} else {
empty_project_snapshot_index(&project_id, &session.user_id)
};
// 基线按账号读取:换号不再让别的账号的基线失效,也不会把新账号的空白当成
// 需要整项目重传的理由。
let previous = read_project_snapshot_baseline(&project_id, &session.user_id)?;
let scan = scan_project_snapshot_files(project_root, PROJECT_SNAPSHOT_MAX_FILE_BYTES)?;
// 项目总量上限在本地先判:超限时明确失败,不再逐个文件上传后被服务端整体拒绝。
@@ -362,8 +362,7 @@ async fn sync_project_snapshot_async(
upload_project_snapshot_manifest(&session, &payload)
.await
.map_err(|error| error.message())?;
write_project_snapshot_index(&ProjectSnapshotIndex {
schema_version: PROJECT_SNAPSHOT_INDEX_SCHEMA_VERSION,
write_project_snapshot_baseline(&ProjectSnapshotBaseline {
project_id: project_id.clone(),
user_id: session.user_id.clone(),
sync_revision: next_revision,
@@ -447,7 +446,7 @@ fn project_snapshot_pending_file_count(
}
fn project_snapshot_manifest_needs_sync(
previous: &ProjectSnapshotIndex,
previous: &ProjectSnapshotBaseline,
diff: &ProjectSnapshotDiff,
project_name: &Option<String>,
pending_files: u32,
@@ -516,9 +515,14 @@ pub(crate) struct ProjectSnapshotStateView {
project_id: String,
index_path: String,
index_present: bool,
/// 当前登录账号的基线文件数;没有该账号的基线时为 0。
file_count: usize,
sync_revision: u64,
synced_at_ms: u64,
/// 索引文件里已保存基线的账号数;>1 说明本机在这台机器上用过多个账号。
baseline_count: usize,
/// 当前登录账号在本项目上是否已有基线。没有基线的下一次同步必然全量上传。
baseline_present: bool,
enabled: bool,
}
@@ -547,13 +551,19 @@ pub(crate) fn read_local_project_snapshot_state(
let project_id = validate_project_snapshot_project_id(manifest.project_id.trim())?;
let index_path = project_snapshot_index_path(&project_id)?;
let index = read_project_snapshot_index(&project_id)?;
let user_id = current_platform_session()
.map(|session| session.user_id)
.unwrap_or_default();
let baseline = index.baseline_for(&user_id);
Ok(ProjectSnapshotStateView {
project_id,
index_path: index_path.to_string_lossy().into_owned(),
index_present: index_path.exists(),
file_count: index.files.len(),
sync_revision: index.sync_revision,
synced_at_ms: index.synced_at_ms,
file_count: baseline.map_or(0, |baseline| baseline.files.len()),
sync_revision: baseline.map_or(0, |baseline| baseline.sync_revision),
synced_at_ms: baseline.map_or(0, |baseline| baseline.synced_at_ms),
baseline_count: index.baselines.len(),
baseline_present: baseline.is_some(),
enabled: project_snapshot_sync_enabled(),
})
}
@@ -57,15 +57,19 @@ fn project_snapshot_index_round_trips_and_recovers_from_corruption() {
let root = fixture_root();
let directory =
project_snapshot_index_directory_at(root.path(), "project-1").expect("index directory");
assert!(read_project_snapshot_index_at(&directory, "project-1")
.expect("missing index reads as empty")
.files
.is_empty());
assert!(
read_project_snapshot_baseline_at(&directory, "project-1", "user-1")
.expect("missing index reads as empty")
.files
.is_empty()
);
let mut index = empty_project_snapshot_index("project-1", "user-1");
index.sync_revision = 3;
index.synced_at_ms = 1_700_000_000_000;
index.files.insert(
let mut baseline = empty_project_snapshot_baseline("project-1", "user-1");
baseline.sync_revision = 3;
baseline.synced_at_ms = 1_700_000_000_000;
baseline.project_name = Some("完整工程".to_string());
baseline.pending_files = Some(0);
baseline.files.insert(
"game/index.html".to_string(),
ProjectSnapshotIndexedFile {
size_bytes: 13,
@@ -73,23 +77,107 @@ fn project_snapshot_index_round_trips_and_recovers_from_corruption() {
checksum: "fnv1a64:0000000000000001".to_string(),
},
);
write_project_snapshot_index_at(&directory, &index).expect("write index");
write_project_snapshot_baseline_at(&directory, &baseline).expect("write baseline");
assert_eq!(
read_project_snapshot_index_at(&directory, "project-1").expect("read index"),
index
read_project_snapshot_baseline_at(&directory, "project-1", "user-1").expect("read index"),
baseline
);
let path = project_snapshot_index_path_at(&directory);
fs::write(&path, b"{ not json").expect("corrupt index");
assert!(
read_project_snapshot_index_at(&directory, "project-1")
read_project_snapshot_baseline_at(&directory, "project-1", "user-1")
.expect("corrupt index reads as empty")
.files
.is_empty(),
"损坏索引必须退化为空索引,让下一次同步全量重算"
"损坏索引必须退化为空基线,让下一次同步全量重算"
);
}
#[test]
fn project_snapshot_index_keeps_each_account_baseline_after_account_switch() {
let root = fixture_root();
let directory =
project_snapshot_index_directory_at(root.path(), "project-1").expect("index directory");
let mut first = empty_project_snapshot_baseline("project-1", "user-1");
first.sync_revision = 4;
first.files.insert(
"game/index.html".to_string(),
ProjectSnapshotIndexedFile {
size_bytes: 13,
modified_ms: 34,
checksum: "fnv1a64:0000000000000001".to_string(),
},
);
write_project_snapshot_baseline_at(&directory, &first).expect("write first account baseline");
let mut second = empty_project_snapshot_baseline("project-1", "user-2");
second.sync_revision = 1;
second.files.insert(
"game/other.html".to_string(),
ProjectSnapshotIndexedFile {
size_bytes: 21,
modified_ms: 98,
checksum: "fnv1a64:0000000000000002".to_string(),
},
);
write_project_snapshot_baseline_at(&directory, &second).expect("write second account baseline");
assert_eq!(
read_project_snapshot_baseline_at(&directory, "project-1", "user-1")
.expect("first account"),
first,
"另一个账号同步过后,本账号基线必须原样保留"
);
assert_eq!(
read_project_snapshot_baseline_at(&directory, "project-1", "user-2")
.expect("second account"),
second
);
let index = read_project_snapshot_index_at(&directory, "project-1").expect("read index");
assert_eq!(index.baselines.len(), 2);
}
#[test]
fn project_snapshot_account_switch_back_does_not_reupload_the_project() {
let root = fixture_root();
write_fixture_file(root.path(), "game/index.html", b"<html></html>");
let scan = scan_fixture(root.path());
let initial =
compute_project_snapshot_diff(&scan, &BTreeMap::new(), PROJECT_SNAPSHOT_MAX_SYNC_BYTES)
.expect("initial diff");
assert_eq!(initial.uploads.len(), 1);
let directory =
project_snapshot_index_directory_at(root.path(), "project-1").expect("index directory");
let mut first = empty_project_snapshot_baseline("project-1", "user-1");
first.files = initial.current.clone();
write_project_snapshot_baseline_at(&directory, &first).expect("write first account baseline");
// 换到尚未同步过的账号:没有基线,必然全量上传,这是预期行为。
let mut second = read_project_snapshot_baseline_at(&directory, "project-1", "user-2")
.expect("unknown account reads as empty baseline");
let switched =
compute_project_snapshot_diff(&scan, &second.files, PROJECT_SNAPSHOT_MAX_SYNC_BYTES)
.expect("diff under the other account");
assert_eq!(switched.uploads.len(), 1);
second.files = switched.current;
write_project_snapshot_baseline_at(&directory, &second).expect("write second account baseline");
// 切回原账号:基线仍在,不能再传一遍整个工程(issue #504 的回归点)。
let back = read_project_snapshot_baseline_at(&directory, "project-1", "user-1")
.expect("first account baseline survives");
let reverted =
compute_project_snapshot_diff(&scan, &back.files, PROJECT_SNAPSHOT_MAX_SYNC_BYTES)
.expect("diff after switching back");
assert!(
reverted.uploads.is_empty(),
"切回旧账号不能重新整项目重传:{reverted:#?}"
);
assert!(!reverted.has_changes());
}
#[test]
fn project_snapshot_scan_skips_excluded_paths_and_oversized_files() {
let root = fixture_root();
@@ -711,7 +799,7 @@ fn project_snapshot_manifest_metadata_changes_sync_without_content_changes() {
)
.unwrap();
assert_eq!(initial.uploads[0].size_bytes, 0);
let mut previous = empty_project_snapshot_index("project-1", "user-1");
let mut previous = empty_project_snapshot_baseline("project-1", "user-1");
previous.files = initial.current;
let mut unchanged = compute_project_snapshot_diff(
&scan_fixture(root.path()),
@@ -786,14 +874,45 @@ fn project_snapshot_pending_files_include_every_unsynced_path_once() {
}
#[test]
fn project_snapshot_legacy_index_keeps_completeness_unknown() {
let index: ProjectSnapshotIndex = serde_json::from_value(serde_json::json!({
"schemaVersion": 1, "projectId": "project-1", "userId": "user-1",
"syncRevision": 1, "syncedAtMs": 1, "files": {}
}))
.unwrap();
assert_eq!(index.project_name, None);
assert_eq!(index.pending_files, None);
fn project_snapshot_legacy_index_migrates_into_its_account_bucket() {
let root = fixture_root();
let directory =
project_snapshot_index_directory_at(root.path(), "project-1").expect("index directory");
fs::create_dir_all(&directory).expect("create index directory");
fs::write(
project_snapshot_index_path_at(&directory),
serde_json::json!({
"schemaVersion": 1,
"projectId": "project-1",
"userId": "user-1",
"syncRevision": 1,
"syncedAtMs": 1,
"files": {
"game/index.html": {
"sizeBytes": 13,
"modifiedMs": 34,
"checksum": "fnv1a64:0000000000000001"
}
}
})
.to_string(),
)
.expect("write legacy index");
let baseline = read_project_snapshot_baseline_at(&directory, "project-1", "user-1")
.expect("legacy index migrates");
assert_eq!(baseline.sync_revision, 1);
assert_eq!(baseline.files.len(), 1);
// v1 没有完整性声明:迁移后仍然是"完整性未知",不能凭空补成已完成。
assert_eq!(baseline.project_name, None);
assert_eq!(baseline.pending_files, None);
assert!(
read_project_snapshot_baseline_at(&directory, "project-1", "user-2")
.expect("other account reads as empty")
.files
.is_empty(),
"迁移只能归属到 v1 记录的账号,不能送给其它账号"
);
}
/// 真实链路冒烟:客户端差异引擎 → 本地 api-server → 真实 OSS。