11.2 增量更新与回滚

差异只说明「差在哪」,还要变成「怎么下、出错怎么退」。本节把 11.1 的 added+changed 按 id 贪心装批,单批不超 65536 字节、超预算资产独占一批:7 个资产装成 4 批,最大批 131072 字节;回滚是反向差异,用纯函数断言 apply(base, forward)==target 且 apply(target, rollback)==base。

11.2 增量更新与回滚

11.1 算出了「差在哪」:added 2、changed 5、removed 1,要下 230400 字节。但差异本身不是计划——真正的更新计划还要回答三件事:怎么分批下、每批多大、出错怎么退。本节把差异变成一份可执行的更新计划,并给回滚一个可判定的语义。传输层怎么重试、怎么断点续传仍是基础设施的事,本节只做计划层。

11.2.1 更新计划 = 把要下的资产装进有限容量的批

更新计划的核心约束只有一个:单批下载字节数不超过 updatePolicy.batchBudgetBytes(样本里 65536)。为什么要有上限?一批要么整批成功、要么整批重来——批太大,一次网络抖动就得重下几百 KB;批太小,请求数暴涨。上限是工程折中,但它必须是整数,这样两侧装批结果才能逐位一致。

11.2.2 贪心装批:确定性优先于最优

装批用的是最朴素的首次适应贪心:把要下的 id 排序后依次放入当前批,放不下就开新批。它不保证批数最少(那是最优装箱,NP 难),但保证同一输入同一结果——这是跨语言对拍的前提。单个资产自身就超过上限时(样本里 atlas_ui 是 131072 > 65536),它独占一批并标记 oversize。

下面这段是 S 层脚本,复用 11.1 的差异函数,产出更新计划与回滚计划:

#!/usr/bin/env python3
# file: sim/ta11_update.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ta11_update.py
"""11.2 增量更新计划与回滚的 S 层计算器(卷二第 11 章)。

复用 sim/ta11_diff.py 的 diff_maps(保证与 11.1 同一口径)。做两件事:

* **更新计划**:added + changed 的资产按 id 排序后贪心装批,每批不超过
  updatePolicy.batchBudgetBytes;单资产超上限时独占一批(oversize)。
  naiveBatchCount = (downloadBytes + budget - 1) // budget 是批数下界。
* **回滚计划**:target → base 的反向差异,下载 changed + removed,删除 added。
  断言 apply(base, forward) == target 且 apply(target, rollback) == base——回滚即可逆。

落 out/ta11_update.json。
"""
import json
import os
import sys

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from ta11_diff import diff_maps, load_manifests  # noqa: E402

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
OUT = os.path.join(ROOT, "out")


def pack_batches(ids, sizes, budget):
    """按 id 顺序贪心装批,返回 [{index, ids, bytes, oversize}]。"""
    batches, cur, cur_bytes = [], [], 0
    for aid in ids:
        sz = sizes[aid]
        if cur and cur_bytes + sz > budget:
            batches.append({"index": len(batches), "ids": cur, "bytes": cur_bytes,
                            "oversize": len(cur) == 1 and cur_bytes > budget})
            cur, cur_bytes = [], 0
        cur.append(aid)
        cur_bytes += sz
    if cur:
        batches.append({"index": len(batches), "ids": cur, "bytes": cur_bytes,
                        "oversize": len(cur) == 1 and cur_bytes > budget})
    return batches


def apply_manifest(entries_map, rows):
    """把差异作用到一份清单上,返回 id → hash(只关心内容)。"""
    out = {aid: e["hash"] for aid, e in entries_map.items()}
    for r in rows:
        if r["status"] == "removed":
            out.pop(r["id"], None)
        else:
            out[r["id"]] = r["toHash"]
    return out


def main() -> int:
    cfg, base_map, target_map = load_manifests()
    budget = cfg["updatePolicy"]["batchBudgetBytes"]
    fwd = diff_maps(base_map, target_map)
    back = diff_maps(target_map, base_map)

    dl_ids = sorted(r["id"] for r in fwd if r["status"] in ("added", "changed"))
    sizes = {aid: target_map[aid]["sizeBytes"] for aid in dl_ids}
    download_bytes = sum(sizes.values())
    batches = pack_batches(dl_ids, sizes, budget)

    rb_ids = sorted(r["id"] for r in back if r["status"] in ("added", "changed"))
    rollback_bytes = sum(base_map[aid]["sizeBytes"] for aid in rb_ids)
    delete_ids = sorted(r["id"] for r in back if r["status"] == "removed")

    base_hashes = {a: e["hash"] for a, e in base_map.items()}
    target_hashes = {a: e["hash"] for a, e in target_map.items()}
    forward_ok = apply_manifest(base_map, fwd) == target_hashes
    rollback_ok = apply_manifest(target_map, back) == base_hashes

    report = {
        "project": cfg["project"], "batchBudgetBytes": budget, "downloadCount": len(dl_ids),
        "downloadBytes": download_bytes, "naiveBatchCount": (download_bytes + budget - 1) // budget,
        "batchCount": len(batches), "oversizeCount": sum(1 for b in batches if b["oversize"]),
        "maxBatchBytes": max(b["bytes"] for b in batches), "batches": batches,
        "rollbackCount": len(rb_ids), "rollbackBytes": rollback_bytes,
        "rollbackDeleteIds": delete_ids, "reversible": forward_ok and rollback_ok,
    }
    os.makedirs(OUT, exist_ok=True)
    with open(os.path.join(OUT, "ta11_update.json"), "w", encoding="utf-8") as fh:
        json.dump(report, fh, ensure_ascii=False, indent=2, sort_keys=True)
        fh.write("\n")
    print(f"downloadCount={len(dl_ids)} downloadBytes={download_bytes} budget={budget}")
    print(f"batchCount={len(batches)} naiveBatchCount={report['naiveBatchCount']} "
          f"oversizeCount={report['oversizeCount']} maxBatchBytes={report['maxBatchBytes']}")
    for b in batches:
        print(f"  batch{b['index']} bytes={b['bytes']:>6} oversize={str(b['oversize']).lower():5s} "
              f"ids={','.join(b['ids'])}")
    print(f"rollbackCount={len(rb_ids)} rollbackBytes={rollback_bytes} "
          f"rollbackDeleteIds={','.join(delete_ids)}")
    print(f"reversible={str(report['reversible']).lower()} forwardOk={str(forward_ok).lower()} "
          f"rollbackOk={str(rollback_ok).lower()}")
    print("wrote out/ta11_update.json")
    return 0


if __name__ == "__main__":
    sys.exit(main())

实跑 /opt/homebrew/bin/python3 sim/ta11_update.py:

downloadCount=7 downloadBytes=230400 budget=65536
batchCount=4 naiveBatchCount=4 oversizeCount=1 maxBatchBytes=131072
  batch0 bytes=131072 oversize=true  ids=atlas_ui
  batch1 bytes= 16384 oversize=false ids=furn_lamp_01,icon_moon,icon_star
  batch2 bytes= 65536 oversize=false ids=tex_lamp_brass
  batch3 bytes= 17408 oversize=false ids=ui_hud,vfx_steam_puff
rollbackCount=6 rollbackBytes=285696 rollbackDeleteIds=icon_moon,vfx_steam_puff
reversible=true forwardOk=true rollbackOk=true
wrote out/ta11_update.json

7 个资产装成 4 批。naiveBatchCount = (230400 + 65536 - 1) // 65536 = 4 是「每批都尽量装满」的下界,贪心实际也是 4——正好打平;atlas_ui 单独占 batch0,因为它 131072 字节已经超过 65536 的批上限,标 oversize。

11.2.3 P 层:同一套装批的 TS 内核

下面这段是 P 层 TS 内核 core/src/ta11/update.ts,与上面的 Python 逐条对应:

// file: core/src/ta11/update.ts
// tier: P
// verify: node --test 'core/test/ta11_*.test.ts'
// 11.2 增量更新计划与回滚的 P 层内核。与 sim/ta11_update.py 同一口径:按 id 贪心装批,
// 单资产超预算时独占一批;回滚 = 反向差异,用 applyManifest 断言可逆。只允许可擦除 TS 语法。
import { type DiffRow, type Entry } from "./diff.ts";

export type Batch = { index: number; ids: string[]; bytes: number; oversize: boolean };

export function packBatches(ids: string[], sizes: Map<string, number>, budget: number): Batch[] {
  const batches: Batch[] = [];
  let cur: string[] = [];
  let curBytes = 0;
  for (const id of ids) {
    const sz = sizes.get(id) as number;
    if (cur.length > 0 && curBytes + sz > budget) {
      batches.push({ index: batches.length, ids: cur, bytes: curBytes,
                     oversize: cur.length === 1 && curBytes > budget });
      cur = [];
      curBytes = 0;
    }
    cur.push(id);
    curBytes += sz;
  }
  if (cur.length > 0) {
    batches.push({ index: batches.length, ids: cur, bytes: curBytes,
                   oversize: cur.length === 1 && curBytes > budget });
  }
  return batches;
}

/** 把差异作用到一份清单上,返回 id → hash(只关心内容)。 */
export function applyManifest(entries: Map<string, Entry>, rows: DiffRow[]): Map<string, string> {
  const out = new Map<string, string>();
  for (const [id, e] of entries) out.set(id, e.hash);
  for (const r of rows) {
    if (r.status === "removed") out.delete(r.id);
    else out.set(r.id, r.toHash);
  }
  return out;
}

export function hashMap(entries: Map<string, Entry>): Map<string, string> {
  const out = new Map<string, string>();
  for (const [id, e] of entries) out.set(id, e.hash);
  return out;
}

11.2.4 回滚语义 = 可逆

回滚不是「再下一份完整的 base」,而是 base → target 差异的逆。把 target 当起点、base 当终点重算一次差异,就得到回滚计划:changed 要下回旧内容,对 base 而言的 added(tex_counter_wood)要下回,对 base 而言的 removed(icon_moon、vfx_steam_puff)要删。本节用纯函数 applyManifest 把这件事验出来——回滚的定义就是正向运算的逆:

// file: core/test/ta11_update.test.ts
// tier: P
// verify: node --test 'core/test/ta11_*.test.ts'
// 11.2 跨语言对照:读 out/ta11_update.json,断言 TS 侧装批结果与 Python 逐位一致;
// 并断言可逆性 apply(base, forward) == target 且 apply(target, rollback) == base。
// 负例:无下载时批数为 0;单个超预算资产独占一批且标记 oversize。
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { diffMaps, mapById, type Entry } from "../src/ta11/diff.ts";
import { applyManifest, hashMap, packBatches } from "../src/ta11/update.ts";

const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const cfg = JSON.parse(readFileSync(resolve(ROOT, "config/ta11_manifest.json"), "utf8"));
const py = JSON.parse(readFileSync(resolve(ROOT, "out/ta11_update.json"), "utf8"));
const base = mapById(cfg.base.entries as Entry[]);
const target = mapById(cfg.target.entries as Entry[]);
const budget = cfg.updatePolicy.batchBudgetBytes as number;

test("跨语言:TS 装批 == Python 装批(逐位一致)", () => {
  const fwd = diffMaps(base, target);
  const ids = fwd.filter((r) => r.status === "added" || r.status === "changed").map((r) => r.id).sort();
  const sizes = new Map<string, number>(ids.map((id) => [id, (target.get(id) as Entry).sizeBytes]));
  const batches = packBatches(ids, sizes, budget);
  assert.deepEqual(batches, py.batches);
  assert.equal(batches.length, py.batchCount);
  assert.equal(batches.length, 4);
  assert.equal(py.naiveBatchCount, 4);
  assert.equal(py.oversizeCount, 1);
  assert.equal(py.maxBatchBytes, 131072);
});

test("可逆性:正向应用得 target,回滚应用得 base", () => {
  const fwd = diffMaps(base, target);
  const back = diffMaps(target, base);
  assert.deepEqual(applyManifest(base, fwd), hashMap(target));
  assert.deepEqual(applyManifest(target, back), hashMap(base));
  assert.equal(py.reversible, true);
});

test("负例:无下载 → 批数为 0;超预算资产独占一批", () => {
  assert.deepEqual(packBatches([], new Map(), budget), []);
  const sizes = new Map<string, number>([["big", 131072], ["small", 2048]]);
  const b = packBatches(["big", "small"], sizes, budget);
  assert.equal(b.length, 2);
  assert.equal(b[0].oversize, true);
  assert.equal(b[1].oversize, false);
});

实跑 node --test 'core/test/ta11_*.test.ts'(本节 3 条;本章三节合计 9 条):

✔ 跨语言:TS 装批 == Python 装批(逐位一致)
✔ 可逆性:正向应用得 target,回滚应用得 base
✔ 负例:无下载 → 批数为 0;超预算资产独占一批
ℹ pass 9
ℹ fail 0

回滚要下 6 个资产、285696 字节,并删除 2 个新增资产。reversible=true 意味着正向与回滚互为逆运算:先更新再回滚,清单逐条还原。

11.2.5 回滚为什么必须可判定

回滚最容易被写成「再下一份完整包」——那样回滚的字节数等于整个包,一次误更新就要用户重下几百 MB。把它定义成「反向差异」之后,回滚的代价降为 rollbackBytes,而且它和正向更新一样是可判定的:给一份 base 和一份 target,回滚计划唯一确定,apply 之后逐条相等。

注意 rollbackBytes = 285696 > downloadBytes = 230400:回滚比正向贵,因为 removed 的 tex_counter_wood(65536 字节)要下回来,而正向更新只需删除它、不下载。「删除」在正向是省字节的,在回滚是费字节的——不对称来自「新增/删除」在正反两个方向上互换。把这条不对称算清楚,才能给回滚预留带宽预算,而不是等出事了才发现回滚下不动。

阅读导航:上一节:11.1 清单与差异计算 · 下一节:11.3 版本一致性与缓存失效 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「gameart」更多文章

  1. 附录 A:风格参数表、量化规则与数据集索引
  2. 9.3 等距资产的制作约束
  3. 9.2 等距 tile 与遮挡规则