Skip to content

feat(native): object-level change version (object-local, minimal bookkeeping) - #51

Merged
esrrhs merged 6 commits into
masterfrom
feat/native-object-version
Nov 9, 2026
Merged

esrrhs merged 6 commits into
masterfrom
feat/native-object-version

Conversation

@esrrhs

@esrrhs esrrhs commented Nov 6, 2026 •

Copy link
Copy Markdown
Owner

背景

上层(增量存档、C++ 镜像增量同步、UI 缓存失效)需要低成本判断"某 NativeObject 自上次记录以来有没有变过"。

本 PR 为每个 NativeObject 增加单调递增的变更版本号。语义是"对象自身"粒度:只反映这个对象自己的写入,不包含它的子树。

语义与理由

每个对象只维护自己的单调计数;任何写入本对象都会让它自增,不做任何向父对象/祖先的传播,也不维护父子边索引。

  • 增量快照本来就要遍历对象树才知道有哪些对象、各自版本。遍历只是读指针,成本在微秒级;真正贵的是字段序列化。因此"父对象 O(1) 跳过整棵子树"没有实际收益。
  • 版本号只是"可能变过"的信号,不是精确引用账本:父对象在 SetObject/Del/SetNil/Clear 等处一律 bump 自己即可;多 bump 一次无害。
  • 需要"整棵子树"粒度的调用方,自己遍历对象图、记录读到的每个对象版本,在上层聚合。
操作 是否 bump 该对象自身
自身字段写(SetInt/Float/Bool/String/Object/SetNil/Del/Clear/SetFromCVar) 是
方法注册/注销、SetId/SetGroupId、SetFinalizer 是
直接子对象的挂载/卸载(父对象自身结构变化) 是(仅该父对象)
子对象内部的写入 否(只 bump 子对象自己)
销毁仍被引用的子对象 否(见下方约定)
任何读操作 否

其他性质:全局饱和自增时间戳,单调不回绕;只与同一对象上次记录的值比较;不写入存档载荷、不改变既有数据格式;Destroy() 后版本号保留最后一次值。

对象图与销毁约定

核实结论(用代码与用例证明):

  1. 同一子对象可被同一父对象的多个字段引用:SetObject("a", c) 与 SetObject("b", c) 同时指向 c;解绑其中一处后另一处仍有效(用例 same_child_in_multiple_fields)。
  2. 同一子对象可被不同父对象共享:p1->SetObject("c", leaf) 与 p2->SetObject("c", leaf)(用例 nested_writes_do_not_bump_ancestors)。
  3. 销毁仍被父对象引用的子对象(既有行为,本 PR 前后都如此):父对象字段保持悬空指针,没有自动置空、也没有拒绝销毁;区别仅是本 PR 前的实现会顺手 bump 父对象版本,现在不再通知。既有用例从未覆盖悬空状态。

因此本 PR 明确约定并在公开头注明:

  • 销毁对象前,必须先把它从所有父对象的字段中移除(Del/SetNil/Clear 或覆盖字段)。移除动作本身会 bump 父对象,版本信号不丢。
  • DestroyGroup / Clear 整组销毁不受此限:组内对象同时销毁,不会在存活对象里留下悬空引用。
  • 用例 destroy_requires_detach_first 钉住该约定。

字段收敛(本 PR 最后一轮)

  • 保留:uint64_t version_(写就自增,读不变)、TouchVersion() 统一写入钩子。
  • 删除:children(子对象引用计数 map)、parents(父对象集合)、LinkChild/UnlinkChild/DetachAll 及全部调用点。删除后每个对象不再有额外的 unique_ptr 字段,每条父子关系也不再分配 unordered_map/unordered_set。
  • debug_hash / debug_hash_epoch 改为编译期开关 FAKELUA_NATIVE_OBJECT_DEBUG_HASH(CMake option,默认 OFF):默认构建不含这两个字段、不维护 epoch;SetVersionDebugEnabled 为空操作,SelfCheck 恒返回 true。调试构建(-DFAKELUA_NATIVE_OBJECT_DEBUG_HASH=ON)才有内容哈希自检。SelfCheck 不再做子树递归(已无子边索引),recursive 参数为兼容保留并忽略。

内存变化(Release,实测)

指标 收敛前 收敛后
sizeof(NativeObject) 16 B 16 B(impl_ 8 + version_ 8,未变)
Impl 每对象固定字段 含 children/parents/debug 共 32 B 0(默认构建)
15,000 个扁平对象(3 字段) 14.50 MB(966 B/对象) 14.02 MB(934 B/对象,−3.3%)
15,000 父 + 15,000 子(每父 1 子) 28.33 MB(1888 B/父) 24.39 MB(1626 B/父,−13.9%)

嵌套场景收益主要来自每条父子关系省掉的 map/set 堆分配。

基准(Release,5 次中位数)

用例 收敛前(上一提交) 收敛后 origin/master 基线
SetInt 12.2 ns 12.4 ns 11.8 ns
GetInt 12.1 ns 12.6 ns 12.1 ns
SetString 17.7 ns 18.1 ns 17.1 ns
CreateDestroy 167 ns 164 ns 159 ns
SetObject 14.4 ns 13.8 ns 13.0 ns
DeepLeafWrite(8 层) 11.5 ns 11.8 ns 11.2 ns

读路径零回退;写路径比基线多约 0.6–0.8 ns(版本号自增 + 统一钩子),深树写与浅层一致。

对外 API(include/fakelua.h)

uint64_t GetVersion() const;               // 本对象当前版本(O(1))
bool IsChangedSince(uint64_t v) const;     // 是否晚于记录点(O(1))
void MarkModified();                       // 外部状态变更时手动标记(容器实现已内置)
static void SetVersionDebugEnabled(bool);  // 编译期开关打开时才有效
static bool SelfCheck(const NativeObject*, bool recursive = true); // 同上;默认构建恒 true

调用方配合:保存"对象指针 + 上次看到的版本号",obj->IsChangedSince(saved) 为 true 即重新采集/同步,更新后存回 GetVersion()。

写入点清单(逐一覆盖)

src/native/object/native_object.cpp

  • SetInt / SetFloat / SetBool / SetString / SetObject / SetNil / Del / Clear
  • SetFromCVar、NativeSpecSet(快照未建立 / 增量 / 置 nil 三个分支)
  • SetId / SetGroupId / SetFinalizer
  • RegisterMethod / UnregisterMethod
  • MarkModified(容器/外部状态)
  • TouchVersion(唯一钩子;只提升本对象自身)

src/native/container/native_container.cpp(内容存在对象之外)

  • ClearOf、Deque/Map/Set/Vector/SmallVector/List 的 Push/Pop/Set/Erase(共 22 处 MarkModified())

测试(9 个 test_native_version)

all_write_paths_bump、read_paths_do_not_bump、nested_writes_do_not_bump_ancestors(含多父共享)、child_create_and_clear_bump_parent、destroy_requires_detach_first、same_child_in_multiple_fields、monotonic_versions、self_check_is_cycle_safe(A↔B 环图不崩不挂起)、lua_paths_and_container(Lua 侧读写与容器)。

已知风险 / 未覆盖路径

  • 销毁约定:销毁前未从父字段移除,父对象字段会悬空(既有问题,本 PR 明确为调用方错误)。不再自动 bump 父对象。
  • 无子树粒度:父对象不会因子对象写入而变脏;需要子树语义必须自行聚合。
  • Kind::Table 内部变更不 bump 所属对象;外部资源状态(io/socket/DB)需 MarkModified();绕过对象 API 的直接内存写无法感知。
  • 调试自检默认关闭:默认构建 SelfCheck 恒 true;白盒访问器已删除,"人为制造漏标 → 检出"的负向用例不存在,实际检出能力缺少直接测试,仅靠写入点枚举 + 统一 TouchVersion() 钩子降低风险。
  • uint64 饱和边界无法通过公开 API 触达,仅保留实现说明。

仓库状态说明

远端 master 曾被 force-update(本地旧 63f823f 与远端 a6fe459 tree 相同,仅历史重写),无法 fast-forward。按约定未对 master 做 reset/rebase/force-push,而是从 origin/master 新建 feat/native-object-version 分支提交;本地 master 保持原样,变更前快照留存于 /Users/mingming/git-backup-2026-11-06/。

…checks

* feat(native): monotonic per-object change stamp with eager upward propagation

Every NativeObject now owns a monotonically increasing version_ taken from a
global saturated counter. Any write (Set*, Del/Clear, SetNil, SetId/GroupId,
finalizer/method registration, MarkModified, container mutation, child link)
bumps it and propagates the same stamp to all ancestors, so a parent's version
is always >= any descendant's. Reads never touch it.

Design choice: eager propagation of a global timestamp + per-object max, instead
of lazy recursive comparison (which is not O(1)) or a parent-side sum of child
versions (not monotonic under child removal/sharing). Cost: query is O(1), write
is O(number of ancestors); ~8B version, 8B propagation epoch per object plus
lazily allocated parent/child edge sets.

Track child edges (children ref counts + parents set) so detaching, re-parenting
and object destruction can bump former ancestors and avoid dangling pointers.
Destroy() detaches first, then frees Impl; version_ lives outside Impl and keeps
increasing after Destroy.

Public API (include/fakelua.h):
  uint64_t GetVersion() const;              // current subtree stamp
  bool IsChangedSince(uint64_t v) const;    // O(1) dirty test
  void MarkModified();                      // external state linked to an object
  static void SetVersionDebugEnabled(bool); // FNV-1a content hash self-check
  static bool SelfCheck(const NativeObject*, bool recursive = true);

Callers remember the last seen version per object and compare later; no payload
or serialization change, versions are never persisted.

* fix(native): guard propagation walk against roots without parent sets

Only push parents that actually own a parent edge table; a root reached during
the upward walk previously dereferenced a null unique_ptr in the lazy edge
bookkeeping.

* test(native): cover writes, reads, nesting, saturation, cycles and self-check

Add 8 GTest cases plus Lua fixtures covering every write entry point, read-only
access paths (direct, snapshot, pairs, containers), nested propagation and
sibling isolation, child create/destroy/clear, monotonicity and uint64
saturation, debug hash detection of a deliberately unmarked raw write, cycle
guarding, and Lua/container paths.

* perf(native): add NativeObject micro-benchmarks for cross-revision comparison

SetInt/GetInt/SetString/CreateDestroy/SetObject/DeepLeafWrite use only APIs that
exist both before and after this change so numbers stay comparable.
…pagation

* refactor(native): remove upward version propagation

TouchVersion now only raises the object's own stamp (O(1), independent of
tree depth); LinkChild/UnlinkChild no longer push stamps to ancestors and the
propagation epoch bookkeeping is gone. Direct child attach/detach still bumps
the owning object because that is its own structural change, and child
deletion bumps only the direct parent (DetachAll).

Semantics is now object-local: a write inside a child never surfaces on its
parent or ancestors. Callers that need subtree granularity traverse the graph
themselves and aggregate the per-object versions they read (traversal is
pointer-only and cheap; field serialization is the expensive part).

* test(native): assert child writes do not bump ancestors and SelfCheck is cycle-safe

Replace the propagation test with nested_writes_do_not_bump_ancestors, add
destroy_bumps_direct_parent_only, and add self_check_is_cycle_safe (A<->B)
proving the visited-set walk terminates on cyclic graphs and still detects a
write that bypassed the object API. Object graphs may contain cycles: LinkChild
does not reject them, so SelfCheck must stay cycle-safe.

* docs(native): document object-local scope and cycle safety on the public API
@esrrhs esrrhs changed the title feat(native): object-level change version for O(1) subtree dirty checks feat(native): object-level change version (object-local, no subtree) Nov 6, 2026
* refactor(native): keep only a forward + friend declaration for test access

NativeObjectVersionTestAccess was fully declared in include/fakelua.h, the
installed public header. Move its declarations to the internal
src/native/object/native_object_test_access.h (included only by the unit tests
and native_object.cpp) and leave a single forward declaration plus the friend
line in the public header, so installed headers expose no test-only surface.

Public API is unchanged: GetVersion/IsChangedSince/MarkModified and the debug
SetVersionDebugEnabled/SelfCheck stay in fakelua.h because embedders need them
for incremental sync and for diagnosing missed markings in their own builds;
they cost nothing while debug is off.

* test(native): include the internal test-access header
@codecov-commenter

codecov-commenter commented Nov 6, 2026 •

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 96.07843% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 89.77%. Comparing base (6f54d8d) to head (6fb97d6).

Files with missing lines Patch % Lines
src/native/object/native_object.cpp 93.22% 4 Missing ⚠️
❗ Your organization needs to install the Codecov GitHub app to enable full functionality.
Additional details and impacted files
@@            Coverage Diff             @@
##           master      #51      +/-   ##
==========================================
+ Coverage   89.75%   89.77%   +0.01%     
==========================================
  Files         125      125              
  Lines       27231    27317      +86     
==========================================
+ Hits        24441    24523      +82     
- Misses       2790     2794       +4     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

…surface

* test(native): remove the white-box accessor header and rewrite tests

Delete src/native/object/native_object_test_access.h together with the
forward and friend declarations that made it reachable from the installed
header include/fakelua.h. The public header now carries no test-only traces.

Tests now use public API only:
- drop the SetFromCVar bump case (same path is reached by Lua-side writes);
- drop direct GetAsCVar reading (covered by the Lua read-only case);
- drop uint64 saturation (unreachable via public API, would need 2^64 writes);
- drop the "bypass the API, then detect with SelfCheck" negative case, since
  without a backdoor a missed marking cannot be simulated;
- keep write paths, read-only paths, nesting isolation, direct child
  create/destroy, destroy-marks-only-direct-parent and cycle-safe SelfCheck.
Follow-up to the previous commit: remove the (now unread) header file itself
so the tree contains no test-only sources.
…ompile time

* refactor(native): keep only version_ plus the per-write hook

A change version is a "may have changed" signal, not a reference ledger.
Delete the children refcount map and the parents set: SetObject/Del/SetNil/
Clear/SetFromCVar now only bump the owning object, and Destroy no longer
notifies any parent. This removes two per-object unique_ptr fields and one
heap allocation per link.

Contract (documented in the public header): detach an object (Del/SetNil/
Clear or overwrite) from every parent field before Destroy. Group destruction
is unaffected since all objects in the group die together.

* perf(native): compile-time gate for the content-hash self-check

New CMake option FAKELUA_NATIVE_OBJECT_DEBUG_HASH (default OFF) enables
debug_hash/debug_hash_epoch and their epoch maintenance. Default builds carry
no debug fields: SetVersionDebugEnabled is a no-op and SelfCheck returns true.
SelfCheck no longer walks children because no child links are tracked.

* test(native): pin the detach-first contract and shared-child behaviour

Replace destroy-bump expectations, add same_child_in_multiple_fields and
destroy_requires_detach_first, and keep the cycle-safe SelfCheck smoke test.
@esrrhs esrrhs changed the title feat(native): object-level change version (object-local, no subtree) feat(native): object-level change version (object-local, minimal bookkeeping) Nov 6, 2026
@esrrhs
esrrhs merged commit 237c48a into master Nov 9, 2026
9 checks passed
@esrrhs
esrrhs deleted the feat/native-object-version branch November 9, 2026 02:19
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.

2 participants