diff --git a/.helper/ai-archive/evidence/current/current-validation-status.md b/.helper/ai-archive/evidence/current/current-validation-status.md new file mode 100644 index 00000000000..1180a6a675e --- /dev/null +++ b/.helper/ai-archive/evidence/current/current-validation-status.md @@ -0,0 +1,240 @@ +# State Archive Harness 当前现实使用状态 + +- 状态日期:2026-09-10 +- 权威范围:当前验证用途与最近已记录现场 +- 重要:这是文档快照,不是live monitor;任何远端操作前必须重做只读preflight。 + +## 总览 + +| 节点 | 当前角色 | 最近现实使用 | 当前证据边界 | +|---|---|---|---| +| `tron-apse1-amd-002` | 最终性能验证 | Verified `53702079ee`隔离canary正常轮转PASS但WAL恢复FAIL,已回滚到`ddb0d6ef33` | rollback run active且越过candidate高度;crash/recovery与production Gate保持OPEN | +| `tron-apse1-arm-001` | 主要开发辅助 | fd69340ca3 P66同Snapshot/并行产物,原runtime升级并两次clean reopen,联网继续 | 83,836,637旧基线与83,836,754新写入重开authority一致;100样本0.729 block/s非A/B,crash/production Gate仍OPEN | +| `tron-apse1-arm-002` | 次要辅助/备用 | PathState保持disabled;100k Archive窗口继续同步,并提供52,246块/23,229,129交易增长统计 | auxiliary capacity evidence;不提供PathState/common-checkpoint或最终性能验收 | +| `tron-apse1-amd-001` | Geth核心外部架构对照观察组 | Geth v1.17.4 path archive Phase 1执行到约25.50M;本机与集中监控可用 | external control evidence;当前历史RPC不可用,不替代AMD java-tron Gate | + +## Geth核心外部架构对照组 + +- 2026-09-09只读现场:Geth commit `36a7dc72`,unit/PID为`geth-world-state-archive.service/182838`,参数 + `--state.scheme path --history.state 0 --history.trienode -1 --syncmode full --cache 6144`,Pebble加ancient state + freezer;Geth/Lighthouse unit均active; +- 同墙钟近似窗口:Geth 457块/167,971交易/99秒,即4.616 block/s、1,696.68 tx/s;AMD最新Hot/Common为 + 40块/10,562交易/98秒,即0.408 block/s、107.78 tx/s。链与持久语义不同,仅用于架构数量级对照; +- 三个历史balance探针均返回`historical state ... is not available`,当前数字不是完整可查询archive吞吐; +- Geth 6060 metrics返回200,Alloy运行;Thanos中`instance="tron-apse1-amd-001"`的`geth-archive`和 + `node-exporter-full`均`up=1`;详细见 + [`Geth对照观察结果`](../results/geth-path-archive-control-observation-20260909.md)。 +- 03:59 UTC曾把Geth从20G短暂调为29G/30G,随后按人工修正回调为`MemoryHigh=infinity / MemoryMax=22G`; + Lighthouse保持8 GiB,两service硬上限合计30 GiB。回调后同一PID、0新增restart/OOM/kill且短窗继续推进208块; + Geth距自身硬上限仅约0.38 GiB,主机无swap,仍须观察host available、memory PSI及kernel/cgroup OOM。 + +## AMD-002:最终性能节点的当前基线 + +### 2026-09-06 memory-rebase修复run(历史基线,已被后续部署取代) + +- 历史候选:GitHub Verified `37723917cbddd98df067a7c9576300ebd9f14725`;AMD/JDK8 JAR SHA-256 + `a50dec3f42443e8b7d29d330e9a6d84a4f39a7f3998f87bd915b66bf032a0e96`; +- unit:`amd002-archive-block2-sync-mf10-37723917cb.service`;monitor: + `amd002-archive-block2-monitor-mf10-37723917cb.service`;当时active,后由`ddb0d6ef33`部署正常接替; +- 继续使用`/data/blade/node_mainnet/runtime-a2fc535a5a/output-directory`和既有maxFlushCount=10配置;没有 + exact-27 scan、ingest、rebuild、repair或format migration; +- 启动日志明确`Common checkpoint authority established, skip legacy checkpoint recovery`,随后三组件以 + format-v1共同head 85,136,463附着; +- 精确窗口`(85,136,463, 85,136,963]`为500块、212,456 tx、691.105秒,wall为0.7235 block/s; + checkpoint、ProcessBlock、PathState和Archive分别为472.25、347.66、282.09和276.56 ms/block; +- JVM仍为Xms8G/Xmx18G/direct1G,cgroup仍为29G/30G。后续采样old-gen 52.41%、Full GC 0; + `memory.high`已出现但max/OOM/kill/restart/error为0。cgroup约14.70 GiB anon和13.73 GiB file, + 不能把high直接写成PathState retained-heap复发; +- evidence:`/data/blade/node_mainnet/archive-block2-37723917cb/evidence/sync-20260906T160557Z/`。 +- 30分钟夜间watch `amd002-archive-block2-nightwatch-37723917cb.service`当时active。它持续记录window throughput、 + JVM GC和OS/cgroup证据;连续90分钟不推进时先取证后正常restart且6小时冷却,disk<=80 GiB或 + memory.max/oom_kill时保护性停止。不会自动调参、删库、修复、迁移、重建或替换artifact。 +- 后段同量级交易密度精确500块窗口`(85,151,099, 85,151,599]`为0.6086 block/s;PathState仍稳定在 + 277.56 ms/block,checkpoint增至582.52 ms/block。30分钟watch最低窗为0.2932 block/s,RSS约21.4 GiB, + high超过58万但Full GC、max/OOM/kill/restart仍为0;唯一peer ERROR没有引起runtime失败。 +- 首个完整transaction-aware v2窗口为1,154块/439,177 tx/1800秒,即0.6411 block/s、243.99 tx/s; + PushBlock 1519.75、checkpoint 530.82 ms/block。窗口内10 peers、0 error/restart/OOM/kill/FGC; + old-gen端点86.86%但随后Young GC回落到78.86%。该run当时继续观察,下一candidate只增加checkpoint phase timer。 +- 00:51 UTC前连续值守无自动动作/incident,RSS约22.5M KiB稳定超过3小时、Full GC持续为0;最近四窗 + 0.448--0.486 block/s。v2从01:01 UTC起增加transaction、tx/s、PushBlock和checkpoint字段,首行仅建立日志游标。 +- 01:31--03:31 UTC五个完整v2窗口:block/s 0.641--1.008、tx/s 205.42--243.99、tx/block由380.57降至 + 212.75;checkpoint由530.82降至372.25 ms/block。RSS约22.6M KiB稳定,old-gen可回落且Full GC仍为0; + memory.high持续增长但max/OOM/kill为0,未触发自动动作。 +- 04:51--05:00 UTC rollout只读preflight再次确认FullNode、monitor、night-watch均active、0 restart;三authority + 在在线时点精确一致于durable head 85,162,803,checksum有效且连续5次无共同WAL。最后完整watch为1,246块、 + 405,964 tx、0.691838 block/s、checkpoint 519.12 ms/block,0 error/OOM/kill/FGC; +- 当前是lite FullNode,四个固定高度wallet端点均关闭,JSON-RPC也disabled。因此固定高度API Gate仍OPEN;为保持配置 + 不变,section 3.2仅提出“停机前H/hash + 新日志在H的blockID”continuity替代oracle,需与AMD rollout一并人工确认; +- 新candidate root/unit、pre-rollout snapshot和quarantine runtime identity均已列明并确认未占用。当前没有build、copy、 + stop/start或部署;详见[AMD只读preflight](../results/amd002-instrumentation-rollout-preflight-20260906.md)及 + [rollout plan section 3.2](../validation-operations/common-checkpoint-instrumentation-rollout-plan.md#32-待人工确认的精确amd-side-by-side-payload)。 + +### 2026-09-06至2026-09-07当前instrumentation run + +- 人工随后明确确认section 3.2及continuity替代oracle;`ddb0d6ef33` AMD/JDK8 clean JAR SHA-256为 + `de1fc9601c684a27b3827bd2b17d3a7664a5d61dd70e9440dfc638e8d8d19879`,embedded commit正确、dirty=false; +- 旧run正常停止并在共同D=85,164,093冻结三authority;266,924 files的runtime已建立强制reflink回退基线。新run从D + 附着,并在停机前H=85,164,102输出相同blockID,continuity PASS; +- instrumentation精确500块为0.7782 block/s、280.03 tx/s;首个完整30分钟窗为1,334块、489,385 tx、0.7407 + block/s。PathState rebase prepare约216.6 ms/block,是最大稳定叶子阶段;Archive end/close约97.0 ms/block但 + P95达6.49秒/batch,是尾部第一调查对象; +- 06:22 UTC FullNode及两个observer均active、0 restart,三authority在线一致于85,166,403,0 WAL/error/OOM/kill/FGC; + rollout与首窗Gate关闭,长期/strict A/B/production Gate仍OPEN。详见 + [AMD instrumentation结果](../results/amd002-common-checkpoint-instrumentation-rollout-20260906.md)。 +- 第二个完整30分钟窗为1,236块、491,445 tx、0.6863 block/s;checkpoint为569.98 ms/block,PathState rebase + prepare为229.14 ms/block,Archive end为106.49 ms/block且P95 6.55秒/batch。与首窗tx/block增加8.38%时,两个 + phase形态均复现;源码确认Archive end关闭target级writer并在最后引用释放时调用LevelDB `DB.close()`; +- 2026-09-07 02:59 UTC只读复核:最近实际部署仍为`ddb0d6ef33`,FullNode/monitor/night-watch均active、0 restart。 + 从05:29:37到次日02:30:52 UTC约21小时推进46,752块/75,675秒,即0.6178 block/s、1.619 s/block、约主网产块速度 + 1.85倍;最近12/6/3小时分别为0.6232/0.5438/0.5787 block/s,最新30分钟为0.7285 block/s。42个窗口范围 + 0.4528--1.0827 block/s,个别窗口超过1 block/s但长期目标未通过; +- 同一时点RSS约20.1 GiB、cgroup current约29.0 GiB,`memory.high=1,907,783`,但max/OOM/kill、Full GC和restart均为0。 + high只证明reclaim/throttling压力,不单独证明泄漏或解释全部同步差距; +- 2026-09-07 03:07 UTC同一runtime在线存储快照:`state-archive/`相对部署前reflink基线增加5,207,171,072 allocated B, + 对应约48,229--48,599 blocks和107,145.64--107,967.64 B/block;85M block外推Archive为8.283--8.347 TiB, + 加一次当前PathState代理值为8.578--8.641 TiB。该值是online allocated方向证据,不是clean-stop整节点容量Gate; + 详见[AMD存储外推](../results/amd002-online-storage-extrapolation-20260907.md); +- 06:30 UTC三个unit仍active、0 restart,0 error/WAL/OOM/kill/FGC,`/data`可用约270.3 GiB。已形成唯一待选候选 + [`CCI-O01`](../../design/ai/commit-recovery/common-checkpoint-serving-index-handle-reuse-candidate.md):只改变writer + 物理handle生命周期并补齐runtime关闭所有权;尚未实现或证明收益。 + +以下`e92aae0c89`材料保留为修复前历史基线,不代表当前运行identity。 + +- 机型:AWS `c6a.4xlarge`,AMD EPYC 7R13,16 vCPU,约30 GiB RAM; +- 数据盘:1 TiB EBS gp3/XFS,历史确认7,500 IOPS / 250 MiB/s; +- 历史候选:`feature/archive_block2@e92aae0c8922726f4b255764a7dd1981fe8a9ebb`,GitHub Verified; +- AMD/JDK8 JAR SHA-256:`440ef66647dbeb74c4c33c914339f266bc5a185248af3f93f60c80acac2ae939`; +- 一致输入是已完成exact-27、super和format-v1 PathState的冻结LevelDB head `85,130,544`: + `/data/blade/node_mainnet/archive-block2-bef5e53c-ready-85130544-20260904T2048Z/output-directory`; +- 当前独立runtime为`/data/blade/node_mainnet/runtime-a2fc535a5a/output-directory`,unit为 + `amd002-archive-block2-sync-mf10-e92aae0c89.service`;`formatVersion=1`、P2P已开启; +- 兼容接管只把已验证legacy `CURRENT`转换为共同baseline,没有运行exact-27 ingest/rebuild;固定高度正常关闭再启动 + 后再次在85,130,544附着,启动约1秒打开全部PathState store,无scan/redo/error; +- 2026-09-05 09:49 UTC抽取二进制marker验证:Chainbase `CHAINBASE_CURRENT`与PathState `CURRENT` + 除各自magic外identity完全相同;Archive `READABLE`的format identity、payload digest、last epoch/block/hash和 + state root逐字段一致,三文件checksum均正确;该证据才是设计中的三authority共同checkpoint; +- 2026-09-05 09:53 UTC同步head至少85,130,824,8个active peer,unit无重启、无OOM;cgroup在线由 + `25G/26G`调为`MemoryHigh=29G/MemoryMax=30G`,JVM仍为`Xmx=18G`。调整后`memory.high`事件停止增长, + 但逐块checkpoint仍约1.5--2.6秒,不能把同步慢单独归因于cgroup; +- 人工决定后已把`storage.snapshot.maxFlushCount`由1改为10,新配置SHA-256为 + `1ee65e2fb5a084dead602734c6c335201fcc77c767b58773078ef2069ec3e6c8`。旧run正常关闭到`close end`; + 非整批停止前durable marker为85,130,951、API head为85,130,979,shutdown把8块尾批共同发布到 + 85,130,959,三authority保持一致;重启准确从该head附着并恢复同步; +- 10-block batch首窗flush为4.1--6.7秒/批,约0.41--0.67秒/块;09:56:48--09:58:06推进 + 74块,约0.95 block/s,高于主网约0.333 block/s并具备净追赶能力。重启后flush短窗约1.38--3.40秒/批; +- 2026-09-05 10:28 UTC只读复核monitor:10:02:02--10:28:06连续1,564秒推进1,298块,墙钟约 + 0.830 block/s;8 connections、0 error、0 restart、0 OOM。RSS由8,742,536 KiB升至14,133,892 KiB, + cgroup peak 28,923,559,936 B;同窗`/data`可用字节减少31,243,923,456 B,主要受reflink CoW、SST生命周期 + 和compaction影响,不能当作逻辑Archive净增长,但必须作为现实停止条件继续观察; +- `e92aae0c89`保持相同runtime/config/JVM/cgroup完成精确500块对照:521.691秒、176,691交易、 + 0.9584 block/s;50个checkpoint对应50次serving-index open,checkpoint为229.06 ms/block。相对旧窗 + blocks/s提升28.9%、checkpoint摊销下降47.2%,但新窗tx/block高21.9%,证据是online attributed而非严格A/B; +- 对照后正常关闭到`close end`,共同durable head为85,133,781;三authority文件hash在重启前后不变,重启从 + 该head附着并跳过legacy recovery。没有运行ingest/rebuild,也没有修复或删除数据库; +- 持续证据位于`/data/blade/node_mainnet/archive-block2-e92aae0c89/evidence/maxflush10-20260905/`, + monitor unit为`amd002-archive-block2-monitor-mf10-e92aae0c89.service`; +- 2026-09-05 13:01 UTC长窗复核:有效monitor样本在6,727秒推进2,636块,仅0.3919 block/s; + `memory.high`首次非零后的样本段约0.1822 block/s,最近80块按PushBlock cost约0.0391 block/s。 + high累计206,806但max/OOM/OOM-kill/restart为0;checkpoint通常约3--4秒/10块,约25秒停顿主要落在 + prepare/trie/nodePlan。15秒device样本未饱和。该证据标记为退化相关性,尚未证明内存限流是唯一根因; +- 2026-09-05 13:10 UTC JVM只读诊断把直接机制收敛为Full GC thrashing:18 GiB old gen使用99.99%, + `Allocation Failure`触发约25秒Full GC,累计109次/2,638.593秒;VM Thread约占一核而host约93.5% idle, + CPU无cgroup throttle且I/O pressure低。PSS约26 GiB、几乎全为anonymous。对象保留源仍未知,未运行 + histogram/dump,也未停启或调参; +- 13:13--13:15 UTC续观测:unit仍active且0 restart,但两次API均3秒超时;连续三块各耗时约51.5--52.1秒, + 表明节点仍间歇推进而非死锁。cgroup约31.14 GB,`memory.high`在20秒增加139次至211,835,max/OOM仍为0; + `jstat`也无法attach。唯一ERROR为peer非法同步范围,不是数据库或三authority错误;本轮未执行侵入诊断或停启; +- 人工授权后于13:20 UTC冻结pre-stop evidence并正常停止,未发送kill。systemd约44秒后返回,shutdown执行一次 + 2,002 ms共同checkpoint并记录`close end`;主unit/monitor均inactive且无目标FullNode进程。Chainbase、PathState、 + Archive都在85,136,463,format/payload/block hash/state root一致、marker checksum有效、共同WAL为0;没有重启、 + 删除、修复或重建。最后完成块85,136,482之上的19块属于正常丢弃的可逆内存窗口; +- 2026-09-05 13:20 UTC源码审计确认PathState最新head的28个Trie snapshot各自通过`parent`保留全部历史 + snapshot和旧node graph;外层history限长无效,normal common checkpoint没有PathState内存rebase callback。 + 该机制与运行增长一致但无histogram字节归属。建议正常停止当前run并不重启,等待人工授权; +- 停止的`d0a7802dc2` format-v2 partial在Account 116,000,000行、PathState约18G且无`CURRENT`,继续保留但不再是候选; +- `bef5e53c`曾完成27/27和legacy顶层`CURRENT`,但后续同步在block `85,133,105`发生VM receipt + `SUCCESS -> OUT_OF_TIME`不一致;正常重启又暴露Chainbase/PathState恢复head不一致。该旧unit已停止, + 运行库和故障副本原位保留,没有回退、修复或删除; +- 日志`checkpoint v2 recover success`仅表示legacy Chainbase checkpoint v2,不得记为三authority共同checkpoint成功。 + +详细时间线和不断变化的unit/head/磁盘数据见 +[AMD-002准备与同步记录](../../../../.dev_ops/depoly_node/records/2026-09-04-amd-002-archive-sync-prep.md)。 + +## ARM-001:主要开发辅助 + +### 当前同步持久化对照校准(2026-09-10) + +- ARM-001 文档曾记录 r15 的同步 durable 配置;2026-09-11 对齐后 live 使用 + `config-p66-live.conf`:JDK17、Chainbase/PathState/Hot/serving 均 RocksDB,`pathStateRoot.mode=shadow`、 + `volatileSnapshotBenchmark=false`、`asyncPrepareBenchmark=false`,unit MemoryHigh/Max=29/30 GiB。 +- AMD-002 2026-09-11 已切换 live PID `2726724`:显式 JDK17 + RocksDB9.7.4,Chainbase LevelDB、同步 + PathState/Archive 语义,MemoryHigh/Max=29/30 GiB;JDK17 x86 通过显式候选属性放行。 +- 因此此前“ARM 异步/AMD 同步”的速度归因已撤销。后续比较必须绑定 DB engine/JNI、JDK、JVM/cgroup、config hash、 + 起始 head/hash、tx/block 与窗口;不能把 ARM 的历史 async 窗口与 AMD 的 synchronous 窗口直接比较。 + +- `ddb0d6ef33` ARM/JDK17 clean JAR SHA-256为 + `7921af4c80d7e46b303c33c4c8b25f407057ad90607ffc9c3cfa82c6a3cd4203`,embedded commit正确、 + `git.dirty=false`; +- head-38 MR-06 base的两个独立reflink副本完成13次apply关联和12,594-byte共同WAL SIGKILL;首次恢复唯一 + `mode=recover`到head 39,第二次为zero-action,恢复后四authority hash逐项不变; +- apply阶段中Archive materialize均值23.28 ms且12/13次为列出的最大子阶段;Chainbase materialize均值21.50 ms, + redo/runtime total均值74.24/78.77 ms。由于fresh私链无真实交易且r5并发运行,只用于决定AMD观测字段; +- 最终candidate进程、open fd和WAL均为0;r5仍为PID 747996、active、0 restart。证据根为 + `/data/blade/common-checkpoint-instrumentation-ddb0d6ef33`; +- `37723917cb` MR-06根:`/data/blade/common-checkpoint-memory-rebase-37723917cb`;ARM JDK17 JAR SHA-256 + `89420091236810c13ec030b06b78fdd9b41a148079de78ea181845f006078788`; +- 为避免隐式格式迁移,旧format-v2隔离副本只记录fail-closed,不被打开或重建;验证改用fresh format-v1私链; +- 命中11,141-byte共同WAL后精确SIGKILL;首次恢复退役WAL,第二次恢复仍在head 38附着,四个authority marker + checksum逐项稳定;r5的PID、restart count和数据路径未变化; +- 四个新增memory-rebase测试4/4以及framework main/test Checkstyle通过。完整ARM集合的两个失败分别来自 + aarch64 LevelDB JNI不可用和RocksDB 9.7 OPTIONS文本差异,不能将其写成完整ARM suite全绿; +- fresh私链接近实时出块导致effective flush近似逐块,不能当作maxFlushCount=10性能样本。header diagnostic + MISMATCH仍是独立OPEN Gate。 + +以下为更早的ARM开发证据。 + +- 本轮候选:base `bef5e53c`之上的36个未提交文件;规范化内容manifest + `211c6c89fa0d0b35e0e66eba41b8a80d43b0fa139efdfb9a568565219a435b22`; +- ARM JAR SHA-256 `ee676ad34406ce4f630f53d76ff073f19fa952303f6481e4f0eaf2d9b17c3acd`,配置SHA-256 + `9a99a4d5750bdc36ca85add5388b929cf838b20940194f4bbd890657450b754a`; +- 私链block-final实测PathState `durableWrites=0, journal=0`;SIGKILL保留11,227-byte共同WAL,恢复后WAL退役并在 + head 24附着,第二次启动head与三authority marker hash不变; +- 证据根:`/data/blade/common-checkpoint-dev-20260905-ec90022d`;原始失败副本和两个后续故障副本全部保留; +- 2026-09-05 03:32 UTC可用661,668,003,840 bytes,所有本轮FullNode unit已停止; +- 保留唯一full-valid base和r5历史证据,不用next-format代码原地打开r5目录; +- 每次运行前重查process、mount/device/filesystem、runtime/CURRENT、base identity、evidence和cgroup; +- 私链持续出现`Path-state header diagnostic: result=MISMATCH`,target-specific marker随checkpoint增长;二者和 + fresh mainnet容量均未关闭。本机结论仅为`development evidence`,不能替代AMD-002最终性能验收。 + +详细现场见[ARM-001当前部署](../../../../.dev_ops/current_depoly/tron-apse1-arm-001.md)。 + +## ARM-002:次要辅助/备用 + +- 当前保持`storage.pathStateRoot.enabled=false`,不复制、创建或启动PathState runtime; +- 当前允许作为Archive增长预估样本,与ARM-001 current stateRoot固定footprint分列后组合;不得称为同机A/B; +- 可用于Historical query固定语料、replay oracle、查询脚本和不需要PathState的辅助回归; +- 只在ARM-001不适合承载某个辅助任务时作为备用,不自动接管最终性能验证; +- 证据一律标记`auxiliary`。 + +详细现场见[ARM-002当前部署](../../../../.dev_ops/current_depoly/tron-apse1-arm-002.md)。 + +当前容量结果见[ARM-002 Archive增长与ARM-001 stateRoot容量预估](../results/storage-growth-arm002-state-root-arm001-20260905.md)。 +当前最高优先级问题的背景、指标和关闭条件见 +[`HT-001同步速度与可持续性`](../../problem/focus/state-root-sync-speed.md)。 + +## 当前下一验证链 + +1. 已完成:AMD-002以冻结`a2fc535a5a`、JAR、config和format-v1 ready输入完成无重建兼容接管; +2. 已完成:固定高度正常关闭/reopen并验证三authority共同checkpoint与zero-scan; +3. 已完成:部署`e92aae0c89`消除同一redo内重复open,取得固定500块对照并通过正常停机/恢复Gate; +4. 已完成:ARM-001对`37723917cb`执行真实WAL crash、恢复、第二次恢复和短窗资源MR-06; +5. 已完成:AMD-002以`37723917cb`冻结首个500块statistics;PathState没有明显回退,checkpoint为第一大项; +6. 进行中:保持run不变取得30分钟以上长窗,持续观察FGC、old-gen、cgroup anon/file、吞吐和磁盘; +7. 已完成:`ddb0d6ef33`在ARM-001通过13次phase关联、真实WAL crash和second recovery;Archive materialize为当前 + development样本均值最大子阶段; +8. 已完成:AMD `ddb0d6ef33` clean build、normal stop、reflink基线、continuity、3-checkpoint smoke及observer接管; +9. 已完成:首个精确500块和完整30分钟phase窗口;PathState rebase prepare为均值主项,Archive close为尾部主项; +10. 已完成:第二个完整窗口复现两项phase形态,源码关闭路径已定位,并形成单变量`CCI-O01`及A/B、风险和磁盘控制表; +11. 下一步等待人工选择是否准备`CCI-O01`本地实现与测试;未选择前保持live run及全部远端现场不变; +12. 同步完成后按[`AI-MANAGEMENT.md`](../../execution/AI-MANAGEMENT.md)暂停修改并执行项目控制恢复; +13. header diagnostic、target-marker retention、reorg/fault/power-loss与production acceptance仍是独立开放Gate。 diff --git a/.helper/ai-archive/execution/records/ITER-20260910-397.md b/.helper/ai-archive/execution/records/ITER-20260910-397.md new file mode 100644 index 00000000000..8e2b3ef61e1 --- /dev/null +++ b/.helper/ai-archive/execution/records/ITER-20260910-397.md @@ -0,0 +1,18 @@ +# ITER-20260910-397 + +## Slice + +HLT-002 AMD-002 JDK17/RocksDB 9.7.4 candidate gate and baseline restoration. + +## Observed + +- AMD-002 installed OpenJDK `17.0.20` at `/usr/lib/jvm/java-17-openjdk-amd64/bin/java`; installation changed the system `java` alternative, so the live control was relaunched with the explicit JDK8 binary `/usr/lib/jvm/java-8-openjdk-amd64/jre/bin/java`. +- Initial JDK17 smoke failed closed before database open with `Java 1.8 is required for amd64 architecture. Detected version 17`. This was an application architecture gate, not a RocksDB JNI failure. +- Commit `9ea27f2797` adds the opt-in system property `tron.java17.x86.candidate`; default x86 behavior remains Java8-only. The commit is signed and pushed to `origin/feature/archive_block2`. +- Rebuilt JDK17 + RocksDB 9.7.4 candidate on AMD-002; build succeeded and produced SHA-256 `91a0560fc9a4581d9f5643e0233c60772be00cfb8945154760942f83af7ef76e`. +- With `-Dtron.java17.x86.candidate=true` and P2P disabled, candidate startup succeeded. Existing 27 PathState stores, Chainbase LevelDB stores, Common checkpoint, and State Archive stores opened; no JNI/linkage exception observed. Candidate unit was stopped after the startup gate, so no performance claim is made. +- AMD-002 control was restored using explicit JDK8, unit `amd002-state-archive-current-8ac7a6afb1-live.service`; follow-up verification showed active PID and ports 8090/18888/9527 listening. + +## Conclusion + +JDK17 application startup compatibility is now proven for the existing AMD snapshot when explicitly opted in, while the production/default x86 gate remains unchanged. HLT-002 still lacks an attributable P2P performance window and long-term compaction evidence; keep the 9.7.4/JDK17 runtime as a candidate only. diff --git a/.helper/ai-archive/execution/records/ITER-20260910-398.md b/.helper/ai-archive/execution/records/ITER-20260910-398.md new file mode 100644 index 00000000000..50c96c20007 --- /dev/null +++ b/.helper/ai-archive/execution/records/ITER-20260910-398.md @@ -0,0 +1,17 @@ +# ITER-20260910-398 + +## Slice + +HLT-002 AMD-002 JDK17 + RocksDB 9.7.4 P2P short window. + +## Observed + +- Candidate unit `amd002-hlt002-jdk17-rocks97-9ea27f2797` started with `/usr/lib/jvm/java-17-openjdk-amd64/bin/java`, `-Dtron.java17.x86.candidate=true`, existing snapshot runtime, and P2P enabled. +- Window start via `/wallet/getnowblock`: block `85150075`. +- After approximately 120 seconds, head was `85150103`: +28 blocks, approximately `0.23 block/s`. +- Candidate logs showed peer block reception and no `ERROR`, `Exception`, or `Caused by` entries in the sampled tail. +- Candidate was stopped; AMD-002 control was restored with explicit JDK8 binary `/usr/lib/jvm/java-8-openjdk-amd64/jre/bin/java`. Unit active with `NRestarts=0`; ports 8090/18888/9527 listening. + +## Conclusion + +The JDK17/RocksDB9.7.4 candidate passes a short P2P liveness window on the existing snapshot. Because the candidate started from an older snapshot than the live control, the +28-block rate is mechanism evidence only, not a strict A/B performance promotion. A synchronized control/candidate pair and longer window remain required before native-options tuning or 30m/6h gates. diff --git a/.helper/ai-archive/execution/records/ITER-20260910-399.md b/.helper/ai-archive/execution/records/ITER-20260910-399.md new file mode 100644 index 00000000000..78f23269ad9 --- /dev/null +++ b/.helper/ai-archive/execution/records/ITER-20260910-399.md @@ -0,0 +1,16 @@ +# ITER-20260910-399 + +## Slice + +HLT-002 AMD-002 same-snapshot sequential A/B short window. + +## Observed + +- While control was stopped, two reflink runtimes were created from the same `runtime-current-8ac7a6afb1` snapshot; existing runtimes and failed candidates were retained. +- JDK8 control (`amd002-hlt002-ab-control-9ea27f2797`): measured 120 seconds from block `85150420` to `85150443`, +23 blocks, approximately `0.192 block/s`. +- JDK17 + RocksDB 9.7.4 candidate (`amd002-hlt002-ab-jdk17-9ea27f2797`): measured 120 seconds from block `85150409` to `85150435`, +26 blocks, approximately `0.217 block/s`. +- Candidate produced no sampled `ERROR`, `Exception`, or `Caused by` lines. Both units were stopped cleanly. AMD-002 original live was restored with explicit JDK8 and remains the active control. + +## Conclusion + +The synchronized snapshot experiment provides directional evidence of approximately 13% higher short-window throughput for the JDK17/RocksDB9.7.4 candidate. It is not a promotion result: runs were sequential, network conditions were not randomized, and each window is only 120 seconds. Repeat interleaved windows and collect checkpoint/compaction/resource metrics before native-options tuning or long-term gates. diff --git a/.helper/ai-archive/execution/records/ITER-20260910-400.md b/.helper/ai-archive/execution/records/ITER-20260910-400.md new file mode 100644 index 00000000000..57dcff266a8 --- /dev/null +++ b/.helper/ai-archive/execution/records/ITER-20260910-400.md @@ -0,0 +1,17 @@ +# ITER-20260910-400 + +## Slice + +HLT-002 second same-snapshot A/B preparation and candidate startup failure. + +## Observed + +- AMD-002 live was stopped and two fresh reflink runtimes were created from `runtime-current-8ac7a6afb1`; existing runtimes were retained. +- The JDK17 candidate unit reached `ActiveState=active` at the systemd level but did not expose API port 8090. Startup log reported `BeanCreationException` caused by `RuntimeException: Asset num is wrong!` during manager initialization. +- No performance window was counted. The candidate was stopped and AMD-002 control was restored with explicit JDK8; PID and ports 8090/18888/9527 are healthy. +- Post-failure inspection showed the failed clone's `database/block` had `CURRENT=MANIFEST-000002` and only about 16 KB, while the source live database had `CURRENT=MANIFEST-001533` and about 4.7 GB. The copy was incomplete, explaining `latestBlockHeaderNumber=0` and `Asset num is wrong!`. +- AssetUpdateHelper logged `Total block:1`, confirming the cloned block store exposed latest height zero rather than the source live height. + +## Conclusion + +Do not treat this as a JDK17/RocksDB incompatibility: the earlier clean snapshot candidate passed startup and P2P liveness. Before another A/B attempt, complete the copy under a long-running job and gate startup on regular-file totals, database CURRENT/MANIFEST presence and size, decoded archive asset identity, and PathState/Common checkpoint alignment; preserve this failed runtime as evidence. diff --git a/.helper/ai-archive/execution/records/ITER-20260910-401.md b/.helper/ai-archive/execution/records/ITER-20260910-401.md new file mode 100644 index 00000000000..9f316860747 --- /dev/null +++ b/.helper/ai-archive/execution/records/ITER-20260910-401.md @@ -0,0 +1,16 @@ +# ITER-20260910-401 + +## Slice + +HLT-002 verified long-running reflink copy and startup-gate check. + +## Observed + +- A dedicated systemd copy unit completed successfully after the live node was stopped. Source `runtime-current-8ac7a6afb1` and destination `runtime-hlt002-safe-jdk17` were compared before any candidate use. +- `database/block`: 769 regular files and 5,050,533,467 apparent bytes on both sides; `CURRENT` and `MANIFEST-001533` SHA-256 matched. +- `path-state-root`: 3,272 regular files on both sides; `CURRENT` SHA-256 matched. `state-archive/hot`: 149 regular files and 2,476,596,233 apparent bytes on both sides; `CURRENT` SHA-256 matched. +- A subsequent candidate smoke was accidentally started while the control had already been restored, so Prometheus port binding conflicted. Its API output came from the live control and is discarded; the candidate was stopped immediately. AMD-002 control remains active with JDK8 and ports 8090/18888/9527. + +## Conclusion + +The copy-integrity gate now distinguishes the prior 16-KB incomplete clone from a complete snapshot. The next candidate run must stop control for the entire smoke/performance window and verify ownership of 8090/18888/9527 before counting any API or throughput result. diff --git a/.helper/ai-archive/execution/records/ITER-20260910-402.md b/.helper/ai-archive/execution/records/ITER-20260910-402.md new file mode 100644 index 00000000000..951d7d193d2 --- /dev/null +++ b/.helper/ai-archive/execution/records/ITER-20260910-402.md @@ -0,0 +1,15 @@ +# ITER-20260910-402 + +## Slice + +HLT-002 complete-copy candidate startup gate. + +## Observed + +- A fresh snapshot was copied by the long-running `amd002-hlt002-safe-copy2` unit while control was stopped. `database/block` matched source at 771 files and 5,062,113,945 bytes; `CURRENT` SHA-256 matched. +- With control kept stopped for the entire smoke, JDK17 + RocksDB9.7.4 candidate unit `amd002-hlt002-safe2-jdk17` started successfully using the explicit opt-in property. API port 8090 and PBFT port 9527 were owned by the candidate; `/wallet/getnowblock` returned head `85150943`. Latest logs showed all API services started and JVM `17.0.20`. +- Candidate was stopped and AMD-002 control was restored with explicit JDK8. Existing incomplete/failed runtimes remain retained. + +## Conclusion + +The complete-copy and exclusive-port gates now produce a valid JDK17 startup result from the latest snapshot. This closes the prior snapshot/port false negatives; a P2P performance window can now be run from this validated destination, with control kept stopped until candidate teardown. diff --git a/.helper/ai-archive/execution/records/ITER-20260910-403.md b/.helper/ai-archive/execution/records/ITER-20260910-403.md new file mode 100644 index 00000000000..81a7d8d7475 --- /dev/null +++ b/.helper/ai-archive/execution/records/ITER-20260910-403.md @@ -0,0 +1,16 @@ +# ITER-20260910-403 + +## Slice + +HLT-002 verified-snapshot JDK17/RocksDB9.7.4 P2P window. + +## Observed + +- A fresh candidate runtime was copied by long-running unit `amd002-hlt002-p2p-copy`; block DB validation matched source at 772 files and 5,069,146,804 bytes, with identical `CURRENT`. +- Control remained stopped while candidate unit `amd002-hlt002-p2p-jdk17` owned ports 8090/18888/9527. +- 120-second P2P window: head `85151013→85151045`, +32 blocks, approximately `0.267 block/s`. At sampling, process CPU was about 471% and RSS about 9.8 GB. +- Candidate was stopped and AMD-002 control restored with explicit JDK8. Historical error lines in the reused candidate log were from prior failed experiments; no new failure occurred in this window. + +## Conclusion + +The verified-copy and exclusive-port gates now support a valid JDK17 P2P sample. The observed rate is directionally above the earlier JDK8 same-snapshot sample (`0.192 block/s`), but remains a short, non-interleaved measurement; repeat control/candidate windows with synchronized resource and checkpoint telemetry before promotion. diff --git a/.helper/ai-archive/execution/records/ITER-20260910-404.md b/.helper/ai-archive/execution/records/ITER-20260910-404.md new file mode 100644 index 00000000000..a6537df0fa7 --- /dev/null +++ b/.helper/ai-archive/execution/records/ITER-20260910-404.md @@ -0,0 +1,16 @@ +# ITER-20260910-404 + +## Slice + +HLT-002 AMD-002 host exclusivity and slow-sync attribution. + +## Observed + +- AMD-002 reports 16 vCPUs on an AMD EPYC 7R13 host. At observation time load average was `4.81/5.09/4.86`; the FullNode was the only material user process at about 467% CPU. Alloy and system agents were each near 1% or below. +- The live unit has no CPU quota (`CPUQuotaPerSecUSec=infinity`), MemoryHigh 29 GiB, MemoryMax 30 GiB, swap disabled, and MemoryCurrent about 17.0 GB. `vmstat` showed 85–94% idle CPU and no swap activity. +- I/O PSI was non-zero (`some/full avg10 about 5.3%`), while the node had 525 listed peers. This points away from host CPU contention and toward storage wait plus per-block database/write/commit work; peer count alone does not establish useful sync bandwidth. +- The environment is not proven bare-metal exclusive: it is a 16-vCPU host/VM with background system services. No competing heavy workload was observed during the sample. + +## Conclusion + +The observed ~0.2–0.3 block/s is not explained by another process monopolizing AMD-002 CPU or by the cgroup CPU limit. Next HLT-002 windows must collect interval disk latency/utilization, write amplification, checkpoint/compaction timing, and per-block stage timings; only then tune RocksDB options or claim a storage fix. diff --git a/.helper/ai-archive/execution/records/ITER-20260910-405.md b/.helper/ai-archive/execution/records/ITER-20260910-405.md new file mode 100644 index 00000000000..26800094ed4 --- /dev/null +++ b/.helper/ai-archive/execution/records/ITER-20260910-405.md @@ -0,0 +1,16 @@ +# ITER-20260910-405 + +## Slice + +HLT-002 ARM-001 versus AMD-002 live configuration attribution. + +## Observed + +- The actual ARM-001 host is `10.255.10.101` (the previously checked `10.255.10.84` currently runs a Geth archive process). ARM-001 FullNode PID `1665805` runs JDK8 with `/data/blade/state-archive-4f143bd6b8/config-p66-live.conf`. +- ARM-001 live config reports `db.engine=ROCKSDB`, State Archive enabled, and `pathStateRoot.mode=shadow`; its service has no cgroup MemoryHigh/MemoryMax and no CPU quota. Host load was `7.06` on 16 vCPUs, FullNode about 454% CPU, IO PSI avg10 about 2.55%. +- AMD-002 live runs JDK8 with Chainbase LevelDB, synchronous PathState/Archive semantics, MemoryHigh 29 GiB and MemoryMax 30 GiB, no CPU quota. Host load was about `4.56`, FullNode about 410% CPU, IO PSI avg10 about 5.38%, cgroup MemoryCurrent about 30.1 GB. +- ARM-001 did not expose the expected 8090/18888 API ports in this observation; only 9527 was listening. Therefore no current same-window block-rate comparison was performed. + +## Conclusion + +AMD's lower observed rate is plausibly explained by the heavier synchronous persistence path, Chainbase LevelDB, and tighter memory/cgroup/IO conditions rather than lack of host CPU. ARM historical rates are not an apples-to-apples control until ARM is actually running the documented synchronous configuration with matching JDK, engines, cgroup, snapshot head, and transaction density. diff --git a/.helper/ai-archive/execution/records/ITER-20260911-406.md b/.helper/ai-archive/execution/records/ITER-20260911-406.md new file mode 100644 index 00000000000..5c3a2e7d4c8 --- /dev/null +++ b/.helper/ai-archive/execution/records/ITER-20260911-406.md @@ -0,0 +1,16 @@ +# ITER-20260911-406 + +## Slice + +HLT-002 ARM/AMD runtime alignment requested by operator. + +## Observed + +- ARM-001 (`10.255.10.101`) live JVM is OpenJDK `17.0.20` and its current unit already has `MemoryHigh=MemoryMax=31138512896` (30 GiB), with swap disabled. The effective config has `pathStateRoot.mode=shadow`, `volatileSnapshotBenchmark=false`, and `asyncPrepareBenchmark=false`. +- `StorageConfig.PathStateRootConfig.postProcess()` rejects every mode other than `shadow`; writing literal `mode=sync` would fail closed at startup. The effective synchronous behavior is represented by the two benchmark flags remaining false. +- AMD-002 was switched from explicit JDK8 to the signed JDK17+RocksDB9.7.4 candidate JAR, with `-Dtron.java17.x86.candidate=true`, MemoryHigh 29 GiB, MemoryMax 30 GiB, and swap disabled. Unit `amd002-state-archive-current-9ea27f2797-live` is active with zero restarts, ports 8090/18888/9527 listening. +- After startup, AMD head advanced `85151840→85151866` in about 60 seconds; this is a live liveness sample, not a long-term performance claim. + +## Conclusion + +The requested JDK17 AMD live switch is complete. ARM already matches JDK17 and 30-GiB cgroup limits, but cannot be changed to a literal `sync` mode without a separate format/config contract change; current flags already disable volatile and async benchmark paths. Fair performance comparison remains blocked on aligning ARM's actual runtime mode/engine semantics with AMD and collecting matched windows. diff --git a/.helper/ai-archive/execution/records/ITER-20260911-407.md b/.helper/ai-archive/execution/records/ITER-20260911-407.md new file mode 100644 index 00000000000..bbfcc51a562 --- /dev/null +++ b/.helper/ai-archive/execution/records/ITER-20260911-407.md @@ -0,0 +1,16 @@ +# ITER-20260911-407 + +## Slice + +HLT-002 ARM/AMD aligned live model deployment. + +## Observed + +- ARM-001 was restarted with explicit `/usr/lib/jvm/java-17-openjdk-arm64/bin/java`, Xmx18G/direct1G, MemoryHigh=29 GiB, MemoryMax=30 GiB, MemorySwapMax=0. Its effective `pathStateRoot.mode=shadow` remains required by `StorageConfig`; both volatile and async benchmark flags are false, which is the supported synchronous path. +- The first restart attempt left the transient unit in a stopped state; after reset-failed and a clean re-create, `state-archive-8ac7-live.service` became active with PID `1811527`, zero restarts, and the 30-GiB cgroup settings. +- AMD-002 was switched live to explicit JDK17 + RocksDB9.7.4 candidate JAR with `tron.java17.x86.candidate=true`, MemoryHigh=29 GiB, MemoryMax=30 GiB, swap disabled. Unit `amd002-state-archive-current-9ea27f2797-live` is active with PID `2726724`, zero restarts, ports 8090/18888/9527 listening, and head advanced `85151840→85151866` in approximately 60 seconds. +- ARM's literal `mode=sync` was not written because the current code rejects it at config post-processing; changing that contract is a separate implementation task. + +## Conclusion + +Both live nodes now use JDK17 and the same 29-GiB high/30-GiB max cgroup envelope. Their storage engines and supported PathState mode still differ (ARM Chainbase RocksDB; AMD Chainbase LevelDB), so future speed comparisons must retain that distinction. diff --git a/.helper/ai-archive/execution/records/ITER-20260911-408.md b/.helper/ai-archive/execution/records/ITER-20260911-408.md new file mode 100644 index 00000000000..0bbc38b1adc --- /dev/null +++ b/.helper/ai-archive/execution/records/ITER-20260911-408.md @@ -0,0 +1,16 @@ +# ITER-20260911-408 + +## Slice + +HLT-002 aligned live baseline rate observation. + +## Observed + +- ARM-001 and AMD-002 are both live on JDK17 with Xmx18G/direct1G, MemoryHigh 29 GiB, MemoryMax 30 GiB, swap disabled, Common checkpoint enabled, PathState/Archive RocksDB, `mode=shadow`, and volatile/async benchmark flags false. +- The remaining intentional engine difference is Chainbase: ARM RocksDB versus AMD LevelDB. +- Concurrent metrics sampling used `tron:block_process_latency_seconds_count{sync="true"}`. ARM increased `530→565` over about 73 seconds (`~0.48 block/s`); AMD increased `777→804` over about 76 seconds (`~0.36 block/s`). Header gauges at the later sample were ARM `83,851,674` and AMD `85,152,623`. +- This was online natural traffic, not locked tx/block, peers, network, or interleaved A/B. No restart or error gate was observed during sampling. + +## Conclusion + +The aligned baseline currently shows ARM processing faster than AMD by roughly 35% in this short observation, consistent with Chainbase engine as a candidate factor. It is not causal proof; use matched snapshot/traffic windows and collect per-block stage, I/O, and checkpoint/compaction metrics before attributing the gap to LevelDB versus RocksDB. diff --git a/.helper/ai-archive/execution/records/ITER-20260911-409.md b/.helper/ai-archive/execution/records/ITER-20260911-409.md new file mode 100644 index 00000000000..7e494f4abdb --- /dev/null +++ b/.helper/ai-archive/execution/records/ITER-20260911-409.md @@ -0,0 +1,16 @@ +# ITER-20260911-409 + +## Slice + +HLT-002 aligned live 1k-block observation setup. + +## Observed + +- ARM-001 current header gauge at setup: `83,851,674`; 1k target: `83,852,674`. +- AMD-002 current header gauge at setup: `85,152,623`; 1k target: `85,153,623`. +- Both nodes remain JDK17 with Xmx18G/direct1G, MemoryHigh 29 GiB, MemoryMax 30 GiB, swap disabled, Common checkpoint enabled, PathState/Archive RocksDB, `mode=shadow`, and volatile/async benchmark flags false. Chainbase remains the sole intentional engine difference (ARM RocksDB, AMD LevelDB). +- The observation contract is to retain complete 1k ranges and collect header/block-process counters, PushBlock and checkpoint stage timings, tx count/tx per block, CPU, I/O PSI, RSS/cgroup memory, GC and error/restart counters. No cross-height rate merging is allowed. + +## Conclusion + +The 1k-block quantitative bottleneck observation is armed from explicit per-node anchors. Final attribution is deferred until each target is reached and the complete windows are exported; expected candidates are Chainbase engine write/compaction cost, checkpoint amortization, and I/O wait. diff --git a/.helper/ai-archive/execution/records/ITER-20260911-410.md b/.helper/ai-archive/execution/records/ITER-20260911-410.md new file mode 100644 index 00000000000..a622d192a2d --- /dev/null +++ b/.helper/ai-archive/execution/records/ITER-20260911-410.md @@ -0,0 +1,16 @@ +# ITER-20260911-410 + +## Slice + +HLT-002 1k-block night-watch deployment. + +## Observed + +- Watcher script `hlt002-1k-watch.sh` was committed as `1ab34783c9` and cgroup-path fix as `eb0a2baeed`, then installed on ARM-001 and AMD-002. +- ARM watcher unit `arm001-hlt002-1k-watch` is active; first corrected sample head `83,851,985`, process count `882`, cgroup current `16,640,135,168` bytes, CPU `464%`, RSS `12,186,192 KiB`. +- AMD watcher unit `amd002-hlt002-1k-watch` is active; first corrected sample head `85,152,911`, process count `1,096`, cgroup current `30,057,975,808` bytes, CPU `367%`, RSS `21,535,316 KiB`, IO PSI avg60 about `19.16%`. +- Targets remain ARM `83,852,959` and AMD `85,153,885`; watchers stop themselves at target and preserve TSV evidence. + +## Conclusion + +Night watch is active on both live nodes. The first corrected sample already shows materially higher AMD memory pressure and IO PSI; final bottleneck attribution waits for complete 1k ranges and stage counters. diff --git a/.helper/ai-archive/execution/records/ITER-20260911-411.md b/.helper/ai-archive/execution/records/ITER-20260911-411.md new file mode 100644 index 00000000000..e6aeb23a947 --- /dev/null +++ b/.helper/ai-archive/execution/records/ITER-20260911-411.md @@ -0,0 +1,21 @@ +# ITER-20260911-411 + +## Slice + +HLT-002 completed aligned 1k-block observation and bottleneck attribution. + +## Observed + +- ARM-001 completed `83,851,963→83,852,970`: +1007 blocks over 2034 s, `0.495084 block/s`; process/push counters both +1007. Average CPU 472.2%, RSS 16.71 GiB, cgroup current 21.64 GiB (max observed 24.16 GiB), IO PSI avg60 sample mean 6.55%. +- AMD-002 completed `85,152,892→85,153,903`: +1011 blocks over 2816 s, `0.359020 block/s`; process/push counters both +1012. Average CPU 354.8%, RSS 20.67 GiB, cgroup current 28.05 GiB (max observed 29.00 GiB), IO PSI avg60 sample mean 15.16%. +- Relative wall-clock result: AMD was about 27.5% slower (`0.359` vs `0.495 block/s`). Both nodes stayed active with zero restarts; watchers stopped automatically at target. +- ARM's `tron.log` had rotated/stopped before this window and systemd journal did not retain matching PushBlock lines, so complete ARM PushBlock/checkpoint stage P50/P95 cannot be reconstructed from this run. Current histogram totals are cumulative and not valid as a 1k delta without a preserved baseline. + +## Quantitative attribution + +- The strongest measured differentiator is storage pressure: AMD mean IO PSI was about 2.3x ARM and mean cgroup current was about 6.4 GiB higher, while AMD CPU utilization was lower. This is consistent with LevelDB Chainbase write/compaction and the 30-GiB memory envelope causing more reclaim/IO stalls. +- Chainbase engine remains the only intentional software engine difference, but this run is not a causal engine benchmark: tx/block, peer/network timing, and per-stage checkpoint logs were not frozen. Follow-up should preserve PushBlock/checkpoint logs or scrape stage metrics at the start/end of the 1k window. + +## Conclusion + +The complete 1k observation confirms a material AMD throughput gap and quantitatively points to memory/IO pressure rather than CPU starvation. It does not yet prove LevelDB as the sole cause; a matched fixed-input or retained-log 1k A/B is still required for final engine attribution. diff --git a/.helper/ai-archive/execution/scripts/hlt002-1k-watch.sh b/.helper/ai-archive/execution/scripts/hlt002-1k-watch.sh new file mode 100755 index 00000000000..5b352c78e93 --- /dev/null +++ b/.helper/ai-archive/execution/scripts/hlt002-1k-watch.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +set -u + +if [[ $# -ne 3 ]]; then + echo "usage: $0 TARGET_HEAD UNIT OUTPUT_TSV" >&2 + exit 2 +fi + +target=$1 +unit=$2 +output=$3 +unit_cgroup=${unit%.service}.service +mkdir -p "$(dirname "$output")" +if [[ ! -s "$output" ]]; then + printf 'epoch\thead\tprocess_count\tpush_count\tpid\trestarts\tmemory_current\tmemory_high\tmemory_max\tpsi_io_some_avg60\tpsi_io_full_avg60\tcpu_pct\trss_kib\n' > "$output" +fi + +while :; do + epoch=$(date +%s) + metrics=$(curl -fsS --max-time 10 http://127.0.0.1:9527/metrics 2>/dev/null || true) + head=$(printf '%s\n' "$metrics" | awk '/^tron:header_height / {printf "%.0f", $2; exit}') + process_count=$(printf '%s\n' "$metrics" | awk '/^tron:block_process_latency_seconds_count\{sync="true"/ {print $2; exit}') + push_count=$(printf '%s\n' "$metrics" | awk '/^tron:block_push_latency_seconds_count / {print $2; exit}') + pid=$(systemctl show "$unit" -p MainPID --value 2>/dev/null || printf 0) + restarts=$(systemctl show "$unit" -p NRestarts --value 2>/dev/null || printf 0) + cg=/sys/fs/cgroup/system.slice/${unit_cgroup} + current=$(cat "$cg/memory.current" 2>/dev/null || printf 0) + high=$(cat "$cg/memory.high" 2>/dev/null || printf 0) + max=$(cat "$cg/memory.max" 2>/dev/null || printf 0) + psi_some=$(awk '/^some / {for (i=1;i<=NF;i++) if ($i ~ /^avg60=/) {sub("avg60=", "", $i); print $i}}' /proc/pressure/io) + psi_full=$(awk '/^full / {for (i=1;i<=NF;i++) if ($i ~ /^avg60=/) {sub("avg60=", "", $i); print $i}}' /proc/pressure/io) + cpu=0; rss=0 + if [[ "$pid" =~ ^[1-9][0-9]*$ ]]; then + read -r cpu rss < <(ps -p "$pid" -o %cpu=,rss= 2>/dev/null || echo 0 0) + fi + printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ + "$epoch" "$head" "$process_count" "$push_count" "$pid" "$restarts" \ + "$current" "$high" "$max" "$psi_some" "$psi_full" "$cpu" "$rss" >> "$output" + if [[ "$head" =~ ^[0-9]+$ ]] && (( head >= target )); then + break + fi + sleep 60 +done diff --git a/.helper/ai-archive/execution/tasks/TASK-023.md b/.helper/ai-archive/execution/tasks/TASK-023.md new file mode 100644 index 00000000000..a842dc19eb9 --- /dev/null +++ b/.helper/ai-archive/execution/tasks/TASK-023.md @@ -0,0 +1,53 @@ +--- +id: TASK-023 +type: task +title: HLT-002 AMD JDK17 RocksDB long-sync performance +status: in_progress +priority: high +parent: HLT-002 +owner: AI-archive +created: 2026-09-10 +updated: 2026-09-10 +active_worktree: . +active_branch: feature/archive_block2 +--- + +# HLT-002 AMD/JDK17/RocksDB长期同步性能 + +## 目标 + +在 AMD-002 上建立与 ARM-001 可比的 JDK17 + RocksDB 高版本长期同步基线,逐项验证 JVM、JNI、native options、cgroup/cache 与磁盘基础设施对 PushBlock、PathState、Common checkpoint、State Archive 和 compaction 的影响。 + +## 当前基线 + +- AMD-002 live control:JDK8、Chainbase LevelDB、PathState/Hot/serving RocksDB 5.15.10,Xmx18G/direct1G,MemoryHigh=29G、MemoryMax=30G、swap=0。 +- `abd9b1bbfb`:RocksDB JNI 9.7.4 在当前完整 runtime reflink 上启动兼容通过;P2P 窗口 60s=8 blocks、180s=20 blocks(约0.11–0.13 block/s),随后回滚;候选 runtime/evidence 保留。 +- AMD-002 已安装 OpenJDK 17.0.20,路径 `/usr/lib/jvm/java-17-openjdk-amd64/bin/java`;系统默认 `java` 已切换为 JDK17,因此 control 必须使用显式 JDK8 路径 `/usr/lib/jvm/java-8-openjdk-amd64/jre/bin/java`。 +- `9ea27f2797` 增加 `-Dtron.java17.x86.candidate=true` 的显式 x86 JDK17 候选门禁;JDK17+RocksDB9.7.4 候选在现有快照上 P2P-disabled 启动成功,27 个 PathState store、Common checkpoint 与 State Archive 数据库均打开。 +- 同一候选 P2P 窗口约 120 秒从区块 85150075 到 85150103(+28,约 0.23 block/s),未见 ERROR/Exception;窗口结束后已恢复 JDK8 control。该样本起点为候选旧快照,不能与实时 control 直接作严格 A/B 晋级结论。 +- 同快照顺序 A/B:JDK8 control 120 秒 `85150420→85150443`(+23,约 0.192 block/s);JDK17+RocksDB9.7.4 120 秒 `85150409→85150435`(+26,约 0.217 block/s)。候选约高 13%,但受顺序网络窗口与起点微差影响,仅作方向性证据。 +- 第二组候选从最新 live 停机快照启动时触发 `Asset num is wrong!`,未进入 API/性能窗口;失败 runtime 的 `database/block` 仅约 16 KB、`CURRENT=MANIFEST-000002`,而 live 为约 4.7 GB、`MANIFEST-001533`,确认是 reflink 复制未完成/残缺导致的空 block DB。已停止并恢复 control;后续必须先做 regular-file/manifest/marker 完整性校验。 +- 进一步日志确认失败 clone 的 `latestBlockHeaderNumber=0`(`Total block:1`),与残缺 block DB 一致;现有失败目录可作为复制完整性负例。 +- 使用独立长时 copy unit 重新复制后,block DB 校验为 769 文件/5,050,533,467 字节,PathState 3272 文件,Archive hot 149 文件,关键 CURRENT/MANIFEST hash 与源一致。后续 smoke 因 control 已恢复、Prometheus 端口冲突而不计入性能/启动结论。 +- 第二份完整快照在 control 保持停止期间启动 JDK17 smoke 成功,HTTP/PBFT API 全部启动并返回快照 head;候选随后停止并恢复 JDK8 control。 +- 在完整性校验通过且 control 全程停止的前提下,JDK17+RocksDB9.7.4 P2P 窗口从 `85151013` 到 `85151045`(120 秒 +32,约 `0.267 block/s`),采样 CPU 约 471%、RSS 约 9.8 GB;窗口后已恢复 control。该值仍需与同快照 JDK8 交错复测。 +- 对比校准:真正 ARM-001 为 `10.255.10.101`,当前 live 实际是 JDK8、Chainbase RocksDB、`pathStateRoot.mode=shadow`,且 unit 无 MemoryHigh/Max;AMD-002 是 JDK8、Chainbase LevelDB、同步 PathState、30 GiB cgroup。不能把 ARM 历史 shadow/无硬内存上限窗口与 AMD 同步/30 GiB窗口直接比较。 +- 2026-09-11 对齐动作:ARM-001 现场确认已是 JDK17、RocksDB、shadow 模式且 `volatileSnapshotBenchmark=false`/`asyncPrepareBenchmark=false`,unit 已是 MemoryHigh/Max=30 GiB;代码不接受字面 `mode=sync`。AMD-002 已切换 JDK17+RocksDB9.7.4 live,启动后 60 秒 `85151840→85151866`、0 restart。 +- 对齐后在线观察(metrics):ARM block-process 计数 `530→565`(约 73 秒,`0.48 block/s`),AMD `777→804`(约 76 秒,`0.36 block/s`)。两端仅 Chainbase engine 仍不同,但该自然流量窗口未锁定 tx/block、peer/network 和时间交错,暂作基线不作因果结论。 +- 1k 观察锚点:ARM header `83,851,674`,目标 `83,852,674`;AMD header `85,152,623`,目标 `85,153,623`。两端继续保持 JDK17、29/30 GiB cgroup、同 PathState/Archive/Common 语义;达到目标后按完整 1k 窗口汇总 PushBlock、checkpoint、tx/block、CPU、I/O PSI、RSS/cgroup 与 GC。 + +## 实验顺序 + +1. 固定 JDK8/5.15.10 control 的 config、runtime、head/hash、JVM/cgroup/cache 与 30m 观测合同。 +2. 只切换 JDK17,保持 5.15.10、配置和运行目录语义不变,完成 P2P-disabled 启动与短窗。 +3. 在 JDK17 control 上只切换 RocksDB JNI 9.7.4,完成兼容/恢复门。 +4. 最后单变量调 native options(block cache、write buffer、max open files、compaction),每次保留失败副本。 +5. 通过固定输入后再进行 30m/6h 长期窗口;性能、恢复、容量和生产切换分别判门。 + +## 禁止 + +不得原地转换 LevelDB;不得把 JDK 安装、JNI 升级和 options 调优合并成不可归因的一轮;不得删除 control 或失败 runtime;不得以短窗提升关闭长期性能 Gate。 + +## 下一动作 + +在显式 JDK8 control 下固定一次同步窗口;随后只启用 `-Dtron.java17.x86.candidate=true` 做 JDK17+RocksDB9.7.4 P2P 短窗,再决定是否进入 native options 与长期窗口。候选启动失败或性能不足时保留 runtime/evidence 并恢复 control。 diff --git a/actuator/src/main/java/org/tron/core/actuator/VMActuator.java b/actuator/src/main/java/org/tron/core/actuator/VMActuator.java index d785951027b..d3bccdc7e72 100644 --- a/actuator/src/main/java/org/tron/core/actuator/VMActuator.java +++ b/actuator/src/main/java/org/tron/core/actuator/VMActuator.java @@ -13,6 +13,7 @@ import java.util.Arrays; import java.util.List; import java.util.Objects; +import java.util.function.LongSupplier; import lombok.Getter; import lombok.Setter; import lombok.extern.slf4j.Slf4j; @@ -54,8 +55,10 @@ import org.tron.core.vm.program.ProgramPrecompile; import org.tron.core.vm.program.invoke.ProgramInvoke; import org.tron.core.vm.program.invoke.ProgramInvokeFactory; +import org.tron.core.vm.repository.CurrentRepositoryProvider; import org.tron.core.vm.repository.Repository; import org.tron.core.vm.repository.RepositoryImpl; +import org.tron.core.vm.repository.RepositoryProvider; import org.tron.core.vm.utils.MUtil; import org.tron.protos.Protocol; import org.tron.protos.Protocol.Block; @@ -97,8 +100,15 @@ public class VMActuator implements Actuator2 { private LogInfoTriggerParser logInfoTriggerParser; + private final RepositoryProvider repositoryProvider; + public VMActuator(boolean isConstantCall) { + this(isConstantCall, CurrentRepositoryProvider.INSTANCE); + } + + public VMActuator(boolean isConstantCall, RepositoryProvider repositoryProvider) { this.isConstantCall = isConstantCall; + this.repositoryProvider = Objects.requireNonNull(repositoryProvider, "repositoryProvider"); this.maxEnergyLimit = CommonParameter.getInstance().maxEnergyLimitForConstant; } @@ -111,6 +121,11 @@ private static long getEnergyFee(long callerEnergyUsage, long callerEnergyFrozen .divide(BigInteger.valueOf(callerEnergyTotal)).longValueExact(); } + private long dynamicLong(String key, LongSupplier currentValue) { + return rootRepository.isHistorical() + ? ConfigLoader.property(rootRepository, key) : currentValue.getAsLong(); + } + @Override public void validate(Object object) throws ContractValidateException { @@ -119,16 +134,24 @@ public void validate(Object object) throws ContractValidateException { throw new RuntimeException("TransactionContext is null"); } - // Load Config - ConfigLoader.load(context.getStoreFactory(), isConstantCall); + rootRepository = Objects.requireNonNull(repositoryProvider.createRoot(context), + "repositoryProvider returned null"); + if (rootRepository.isHistorical()) { + if (!isConstantCall) { + throw new ContractValidateException("Historical execution must be a constant call"); + } + ConfigLoader.load(rootRepository); + } else { + ConfigLoader.load(context.getStoreFactory(), isConstantCall); + } // Warm up registry class OperationRegistry.init(); trx = context.getTrxCap().getInstance(); // If tx`s fee limit is set, use it to calc max energy limit for constant call if (isConstantCall && trx.getRawData().getFeeLimit() > 0) { maxEnergyLimit = min(maxEnergyLimit, trx.getRawData().getFeeLimit() - / context.getStoreFactory().getChainBaseManager() - .getDynamicPropertiesStore().getEnergyFee(), VMConfig.disableJavaLangMath()); + / dynamicLong("ENERGY_FEE", () -> context.getStoreFactory().getChainBaseManager() + .getDynamicPropertiesStore().getEnergyFee()), VMConfig.disableJavaLangMath()); } blockCap = context.getBlockCap(); if ((VMConfig.allowTvmFreeze() || VMConfig.allowTvmFreezeV2()) @@ -137,8 +160,10 @@ public void validate(Object object) throws ContractValidateException { } //Route Type ContractType contractType = this.trx.getRawData().getContract(0).getType(); - //Prepare Repository - rootRepository = RepositoryImpl.createRoot(context.getStoreFactory()); + if (rootRepository.isHistorical() && contractType != ContractType.TriggerSmartContract) { + throw new ContractValidateException( + "Historical execution only supports TriggerSmartContract"); + } enableEventListener = context.isEventPluginLoaded(); @@ -462,7 +487,8 @@ private void create() private void call() throws ContractValidateException { - if (!rootRepository.getDynamicPropertiesStore().supportVM()) { + if (dynamicLong("ALLOW_CREATION_OF_CONTRACTS", + () -> rootRepository.getDynamicPropertiesStore().getAllowCreationOfContracts()) != 1L) { logger.info("vm work is off, need to be opened by the committee"); throw new ContractValidateException("VM work is off, need to be opened by the committee"); } @@ -507,10 +533,12 @@ private void call() byte[] code = rootRepository.getCode(contractAddress); if (isNotEmpty(code)) { long feeLimit = trx.getRawData().getFeeLimit(); - if (feeLimit < 0 || feeLimit > rootRepository.getDynamicPropertiesStore().getMaxFeeLimit()) { + long maxFeeLimit = dynamicLong("MAX_FEE_LIMIT", + () -> rootRepository.getDynamicPropertiesStore().getMaxFeeLimit()); + if (feeLimit < 0 || feeLimit > maxFeeLimit) { logger.info("invalid feeLimit {}", feeLimit); throw new ContractValidateException("feeLimit must be >= 0 and <= " - + rootRepository.getDynamicPropertiesStore().getMaxFeeLimit()); + + maxFeeLimit); } AccountCapsule caller = rootRepository.getAccount(callerAddress); long energyLimit; @@ -523,7 +551,8 @@ private void call() } long thisTxCPULimitInUs = calculateCpuLimitInUs(isConstantCall, - rootRepository.getDynamicPropertiesStore().getMaxCpuTimeOfOneTx(), + dynamicLong("MAX_CPU_TIME_OF_ONE_TX", + () -> rootRepository.getDynamicPropertiesStore().getMaxCpuTimeOfOneTx()), getCpuLimitInUsRatio(), CommonParameter.getInstance().getConstantCallTimeoutMs()); long vmStartInUs = System.nanoTime() / VMConstant.ONE_THOUSAND; long vmShouldEndInUs = vmStartInUs + thisTxCPULimitInUs; diff --git a/actuator/src/main/java/org/tron/core/vm/HistoricalCapabilityException.java b/actuator/src/main/java/org/tron/core/vm/HistoricalCapabilityException.java new file mode 100644 index 00000000000..e53eb852ed6 --- /dev/null +++ b/actuator/src/main/java/org/tron/core/vm/HistoricalCapabilityException.java @@ -0,0 +1,9 @@ +package org.tron.core.vm; + +/** Fail-closed rejection for a state capability unavailable to historical execution. */ +public class HistoricalCapabilityException extends RuntimeException { + + public HistoricalCapabilityException(String message) { + super(message); + } +} diff --git a/actuator/src/main/java/org/tron/core/vm/HistoricalExecutionGuard.java b/actuator/src/main/java/org/tron/core/vm/HistoricalExecutionGuard.java new file mode 100644 index 00000000000..49490f8bc1e --- /dev/null +++ b/actuator/src/main/java/org/tron/core/vm/HistoricalExecutionGuard.java @@ -0,0 +1,28 @@ +package org.tron.core.vm; + +/** Runtime allowlist for capabilities reachable only after bytecode dispatch. */ +public final class HistoricalExecutionGuard { + + private HistoricalExecutionGuard() { + } + + public static void requirePrecompileAllowed( + PrecompiledContracts.PrecompiledContract contract) { + if (contract instanceof PrecompiledContracts.Identity + || contract instanceof PrecompiledContracts.Sha256 + || contract instanceof PrecompiledContracts.Ripempd160 + || contract instanceof PrecompiledContracts.ECRecover + || contract instanceof PrecompiledContracts.ModExp + || contract instanceof PrecompiledContracts.BN128Addition + || contract instanceof PrecompiledContracts.BN128Multiplication + || contract instanceof PrecompiledContracts.BN128Pairing + || contract instanceof PrecompiledContracts.EthRipemd160 + || contract instanceof PrecompiledContracts.Blake2F + || contract instanceof PrecompiledContracts.P256Verify) { + return; + } + throw new HistoricalCapabilityException( + "Precompile is not allowed by historical execution: " + + contract.getClass().getSimpleName()); + } +} diff --git a/actuator/src/main/java/org/tron/core/vm/OperationActions.java b/actuator/src/main/java/org/tron/core/vm/OperationActions.java index 88c3c55899e..461f340b2af 100644 --- a/actuator/src/main/java/org/tron/core/vm/OperationActions.java +++ b/actuator/src/main/java/org/tron/core/vm/OperationActions.java @@ -1070,6 +1070,10 @@ public static void revertAction(Program program) { } public static void suicideAction(Program program) { + if (program.getContractState().isHistorical()) { + throw new HistoricalCapabilityException( + "SELFDESTRUCT is not supported by historical execution"); + } if (program.isStaticCall()) { throw new Program.StaticCallModificationException(); } @@ -1085,6 +1089,10 @@ public static void suicideAction(Program program) { } public static void suicideAction2(Program program) { + if (program.getContractState().isHistorical()) { + throw new HistoricalCapabilityException( + "SELFDESTRUCT is not supported by historical execution"); + } if (program.isStaticCall()) { throw new Program.StaticCallModificationException(); } diff --git a/actuator/src/main/java/org/tron/core/vm/config/ConfigLoader.java b/actuator/src/main/java/org/tron/core/vm/config/ConfigLoader.java index 35480935742..1d035d10c19 100644 --- a/actuator/src/main/java/org/tron/core/vm/config/ConfigLoader.java +++ b/actuator/src/main/java/org/tron/core/vm/config/ConfigLoader.java @@ -1,11 +1,11 @@ package org.tron.core.vm.config; -import static org.tron.core.capsule.ReceiptCapsule.checkForEnergyLimit; - import lombok.extern.slf4j.Slf4j; import org.tron.common.parameter.CommonParameter; import org.tron.core.store.DynamicPropertiesStore; import org.tron.core.store.StoreFactory; +import org.tron.core.vm.HistoricalCapabilityException; +import org.tron.core.vm.repository.Repository; @Slf4j(topic = "VMConfigLoader") public class ConfigLoader { @@ -21,8 +21,9 @@ public static void load(StoreFactory storeFactory, boolean isolate) { DynamicPropertiesStore ds = storeFactory.getChainBaseManager().getDynamicPropertiesStore(); VMConfig.setVmTrace(CommonParameter.getInstance().isVmTrace()); if (ds != null) { - VMConfig.initVmHardFork(checkForEnergyLimit(ds)); VMConfig.Snapshot snapshot = new VMConfig.Snapshot(); + snapshot.energyLimitHardFork = ds.getLatestBlockHeaderNumber() + >= CommonParameter.getInstance().getBlockNumForEnergyLimit(); snapshot.allowMultiSign = ds.getAllowMultiSign() == 1; snapshot.allowTvmTransferTrc10 = ds.getAllowTvmTransferTrc10() == 1; snapshot.allowTvmConstantinople = ds.getAllowTvmConstantinople() == 1; @@ -53,9 +54,62 @@ public static void load(StoreFactory storeFactory, boolean isolate) { if (isolate) { VMConfig.setLocalSnapshot(snapshot); } else { + VMConfig.initVmHardFork(snapshot.energyLimitHardFork); VMConfig.setGlobalSnapshot(snapshot); } } } } + + /** Loads an isolated VM view exclusively from a Repository's request-owned state source. */ + public static void load(Repository repository) { + if (disable) { + throw new HistoricalCapabilityException( + "Historical VM config loading cannot be disabled"); + } + VMConfig.setVmTrace(CommonParameter.getInstance().isVmTrace()); + VMConfig.Snapshot snapshot = new VMConfig.Snapshot(); + snapshot.energyLimitHardFork = property(repository, "latest_block_header_number") + >= CommonParameter.getInstance().getBlockNumForEnergyLimit(); + snapshot.allowMultiSign = enabled(repository, "ALLOW_MULTI_SIGN"); + snapshot.allowTvmTransferTrc10 = enabled(repository, "ALLOW_TVM_TRANSFER_TRC10"); + snapshot.allowTvmConstantinople = enabled(repository, "ALLOW_TVM_CONSTANTINOPLE"); + snapshot.allowTvmSolidity059 = enabled(repository, "ALLOW_TVM_SOLIDITY_059"); + snapshot.allowShieldedTRC20Transaction = + enabled(repository, "ALLOW_SHIELDED_TRC20_TRANSACTION"); + snapshot.allowTvmIstanbul = enabled(repository, "ALLOW_TVM_ISTANBUL"); + snapshot.allowTvmFreeze = enabled(repository, "ALLOW_TVM_FREEZE"); + snapshot.allowTvmVote = enabled(repository, "ALLOW_TVM_VOTE"); + snapshot.allowTvmLondon = enabled(repository, "ALLOW_TVM_LONDON"); + snapshot.allowTvmCompatibleEvm = enabled(repository, "ALLOW_TVM_COMPATIBLE_EVM"); + snapshot.allowHigherLimitForMaxCpuTimeOfOneTx = + enabled(repository, "ALLOW_HIGHER_LIMIT_FOR_MAX_CPU_TIME_OF_ONE_TX"); + snapshot.allowTvmFreezeV2 = property(repository, "UNFREEZE_DELAY_DAYS") > 0; + snapshot.allowOptimizedReturnValueOfChainId = + enabled(repository, "ALLOW_OPTIMIZED_RETURN_VALUE_OF_CHAIN_ID"); + snapshot.allowDynamicEnergy = enabled(repository, "ALLOW_DYNAMIC_ENERGY"); + snapshot.dynamicEnergyThreshold = property(repository, "DYNAMIC_ENERGY_THRESHOLD"); + snapshot.dynamicEnergyIncreaseFactor = property(repository, "DYNAMIC_ENERGY_INCREASE_FACTOR"); + snapshot.dynamicEnergyMaxFactor = property(repository, "DYNAMIC_ENERGY_MAX_FACTOR"); + snapshot.allowTvmShanghai = enabled(repository, "ALLOW_TVM_SHANGHAI"); + snapshot.allowEnergyAdjustment = enabled(repository, "ALLOW_ENERGY_ADJUSTMENT"); + snapshot.allowStrictMath = enabled(repository, "ALLOW_STRICT_MATH"); + snapshot.allowTvmCancun = enabled(repository, "ALLOW_TVM_CANCUN"); + snapshot.disableJavaLangMath = enabled(repository, "CONSENSUS_LOGIC_OPTIMIZATION"); + snapshot.allowTvmBlob = enabled(repository, "ALLOW_TVM_BLOB"); + snapshot.allowTvmSelfdestructRestriction = + enabled(repository, "ALLOW_TVM_SELFDESTRUCT_RESTRICTION"); + snapshot.allowTvmOsaka = enabled(repository, "ALLOW_TVM_OSAKA"); + snapshot.allowHardenResourceCalculation = + enabled(repository, "ALLOW_HARDEN_RESOURCE_CALCULATION"); + VMConfig.setLocalSnapshot(snapshot); + } + + public static long property(Repository repository, String key) { + return repository.getDynamicPropertyLong(key); + } + + private static boolean enabled(Repository repository, String key) { + return property(repository, key) == 1L; + } } diff --git a/actuator/src/main/java/org/tron/core/vm/program/ContractState.java b/actuator/src/main/java/org/tron/core/vm/program/ContractState.java index c6347b9a072..c4a6394d55d 100644 --- a/actuator/src/main/java/org/tron/core/vm/program/ContractState.java +++ b/actuator/src/main/java/org/tron/core/vm/program/ContractState.java @@ -41,6 +41,11 @@ public void setProgramListener(ProgramListener listener) { this.programListener = listener; } + @Override + public boolean isHistorical() { + return repository.isHistorical(); + } + @Override public AssetIssueCapsule getAssetIssue(byte[] tokenId) { return repository.getAssetIssue(tokenId); diff --git a/actuator/src/main/java/org/tron/core/vm/program/Program.java b/actuator/src/main/java/org/tron/core/vm/program/Program.java index 590859a9fef..ab2d58f9cac 100644 --- a/actuator/src/main/java/org/tron/core/vm/program/Program.java +++ b/actuator/src/main/java/org/tron/core/vm/program/Program.java @@ -58,6 +58,8 @@ import org.tron.core.exception.TronException; import org.tron.core.utils.TransactionUtil; import org.tron.core.vm.EnergyCost; +import org.tron.core.vm.HistoricalCapabilityException; +import org.tron.core.vm.HistoricalExecutionGuard; import org.tron.core.vm.MessageCall; import org.tron.core.vm.Op; import org.tron.core.vm.OperationRegistry; @@ -811,6 +813,10 @@ public void createContract(DataWord value, DataWord memStart, DataWord memSize) private void createContractImpl(DataWord value, byte[] programCode, byte[] newAddress, boolean isCreate2) { + if (getContractState().isHistorical()) { + throw new HistoricalCapabilityException( + "CREATE and CREATE2 are not supported by historical execution"); + } byte[] senderAddress = getContextAddress(); if (logger.isDebugEnabled()) { @@ -1660,6 +1666,10 @@ public void callToPrecompiledAddress(MessageCall msg, PrecompiledContracts.PrecompiledContract contract) { returnDataBuffer = null; // reset return buffer right before the call + if (getContractState().isHistorical()) { + HistoricalExecutionGuard.requirePrecompileAllowed(contract); + } + if (getCallDeep() == MAX_DEPTH) { stackPushZero(); this.refundEnergy(msg.getEnergy().longValue(), " call deep limit reach"); @@ -2363,11 +2373,11 @@ public long updateContextContractFactor() { if (contractStateCapsule == null) { contractStateCapsule = new ContractStateCapsule( - contractState.getDynamicPropertiesStore().getCurrentCycleNumber()); + contractState.getDynamicPropertyLong("CURRENT_CYCLE_NUMBER")); contractState.updateContractState(getContextAddress(), contractStateCapsule); } else { if (contractStateCapsule.catchUpToCycle( - contractState.getDynamicPropertiesStore().getCurrentCycleNumber(), + contractState.getDynamicPropertyLong("CURRENT_CYCLE_NUMBER"), VMConfig.getDynamicEnergyThreshold(), VMConfig.getDynamicEnergyIncreaseFactor(), VMConfig.getDynamicEnergyMaxFactor(), diff --git a/actuator/src/main/java/org/tron/core/vm/program/Storage.java b/actuator/src/main/java/org/tron/core/vm/program/Storage.java index 572af048081..8146dba0bd0 100644 --- a/actuator/src/main/java/org/tron/core/vm/program/Storage.java +++ b/actuator/src/main/java/org/tron/core/vm/program/Storage.java @@ -1,41 +1,50 @@ package org.tron.core.vm.program; -import static java.lang.System.arraycopy; - import java.util.HashMap; import java.util.Map; import lombok.Getter; import lombok.Setter; -import org.tron.common.crypto.Hash; import org.tron.common.runtime.vm.DataWord; -import org.tron.common.utils.ByteUtil; import org.tron.core.capsule.StorageRowCapsule; +import org.tron.core.store.StorageRowKeyCodec; import org.tron.core.store.StorageRowStore; public class Storage { - private static final int PREFIX_BYTES = 16; + @FunctionalInterface + public interface RowLoader { + + StorageRowCapsule get(byte[] physicalKey); + } + @Getter private final Map rowCache = new HashMap<>(); @Getter private byte[] addrHash; @Getter private StorageRowStore store; + private RowLoader rowLoader; @Getter private byte[] address; @Setter private int contractVersion; public Storage(byte[] address, StorageRowStore store) { - addrHash = addrHash(address); + this(address, store, store::get); + } + + public Storage(byte[] address, StorageRowStore store, RowLoader rowLoader) { + addrHash = StorageRowKeyCodec.addressHash(address, null); this.address = address; this.store = store; + this.rowLoader = rowLoader; } public Storage(Storage storage) { this.addrHash = storage.addrHash.clone(); this.address = storage.getAddress().clone(); this.store = storage.store; + this.rowLoader = storage.rowLoader; this.contractVersion = storage.contractVersion; storage.getRowCache().forEach((DataWord rowKey, StorageRowCapsule row) -> { StorageRowCapsule newRow = new StorageRowCapsule(row); @@ -44,37 +53,19 @@ public Storage(Storage storage) { } private byte[] compose(byte[] key, byte[] addrHash) { - if (contractVersion == 1) { - key = Hash.sha3(key); - } - byte[] result = new byte[key.length]; - arraycopy(addrHash, 0, result, 0, PREFIX_BYTES); - arraycopy(key, PREFIX_BYTES, result, PREFIX_BYTES, PREFIX_BYTES); - return result; - } - - // 32 bytes - private static byte[] addrHash(byte[] address) { - return Hash.sha3(address); - } - - private static byte[] addrHash(byte[] address, byte[] trxHash) { - if (ByteUtil.isNullOrZeroArray(trxHash)) { - return Hash.sha3(address); - } - return Hash.sha3(ByteUtil.merge(address, trxHash)); + return StorageRowKeyCodec.physicalKeyFromAddressHash(addrHash, key, contractVersion); } public void generateAddrHash(byte[] trxId) { // update addreHash for create2 - addrHash = addrHash(address, trxId); + addrHash = StorageRowKeyCodec.addressHash(address, trxId); } public DataWord getValue(DataWord key) { if (rowCache.containsKey(key)) { return new DataWord(rowCache.get(key).getValue()); } else { - StorageRowCapsule row = store.get(compose(key.getData(), addrHash)); + StorageRowCapsule row = rowLoader.get(compose(key.getData(), addrHash)); if (row == null || row.getInstance() == null) { return null; } @@ -94,6 +85,9 @@ public void put(DataWord key, DataWord value) { } public void commit() { + if (store == null) { + throw new IllegalStateException("Read-only historical storage cannot be committed"); + } rowCache.forEach((DataWord rowKey, StorageRowCapsule row) -> { if (row.isDirty()) { if (new DataWord(row.getValue()).isZero()) { diff --git a/actuator/src/main/java/org/tron/core/vm/repository/CurrentRepositoryProvider.java b/actuator/src/main/java/org/tron/core/vm/repository/CurrentRepositoryProvider.java new file mode 100644 index 00000000000..5d2a862f2f8 --- /dev/null +++ b/actuator/src/main/java/org/tron/core/vm/repository/CurrentRepositoryProvider.java @@ -0,0 +1,11 @@ +package org.tron.core.vm.repository; + +/** Default provider preserving the existing current Chainbase execution path. */ +public enum CurrentRepositoryProvider implements RepositoryProvider { + INSTANCE; + + @Override + public Repository createRoot(org.tron.core.db.TransactionContext context) { + return RepositoryImpl.createRoot(context.getStoreFactory()); + } +} diff --git a/actuator/src/main/java/org/tron/core/vm/repository/CurrentStoreStateSource.java b/actuator/src/main/java/org/tron/core/vm/repository/CurrentStoreStateSource.java new file mode 100644 index 00000000000..467e02ff697 --- /dev/null +++ b/actuator/src/main/java/org/tron/core/vm/repository/CurrentStoreStateSource.java @@ -0,0 +1,62 @@ +package org.tron.core.vm.repository; + +import org.tron.core.ChainBaseManager; +import org.tron.core.capsule.AccountCapsule; +import org.tron.core.capsule.BytesCapsule; +import org.tron.core.capsule.CodeCapsule; +import org.tron.core.capsule.ContractCapsule; +import org.tron.core.capsule.ContractStateCapsule; +import org.tron.core.capsule.StorageRowCapsule; +import org.tron.core.exception.BadItemException; +import org.tron.core.exception.ItemNotFoundException; +import org.tron.core.store.StoreFactory; + +/** Existing current Chainbase stores adapted to RepositoryStateSource. */ +final class CurrentStoreStateSource implements RepositoryStateSource { + + private final ChainBaseManager manager; + + CurrentStoreStateSource(StoreFactory storeFactory) { + this.manager = storeFactory.getChainBaseManager(); + } + + @Override + public AccountCapsule getAccount(byte[] address) { + return manager.getAccountStore().get(address); + } + + @Override + public BytesCapsule getDynamicProperty(byte[] key) { + try { + return manager.getDynamicPropertiesStore().get(key); + } catch (BadItemException | ItemNotFoundException ignored) { + return null; + } + } + + @Override + public ContractCapsule getContract(byte[] address) { + return manager.getContractStore().get(address); + } + + @Override + public ContractStateCapsule getContractState(byte[] address) { + return manager.getContractStateStore().get(address); + } + + @Override + public byte[] getCode(byte[] address) { + CodeCapsule code = manager.getCodeStore().get(address); + return code == null ? null : code.getData(); + } + + @Override + public StorageRowCapsule getStorageRow(byte[] physicalKey) { + return manager.getStorageRowStore().get(physicalKey); + } + + @Override + public boolean isReadOnly() { + return false; + } +} diff --git a/actuator/src/main/java/org/tron/core/vm/repository/HistoricalArchiveStateSource.java b/actuator/src/main/java/org/tron/core/vm/repository/HistoricalArchiveStateSource.java new file mode 100644 index 00000000000..1702d608515 --- /dev/null +++ b/actuator/src/main/java/org/tron/core/vm/repository/HistoricalArchiveStateSource.java @@ -0,0 +1,55 @@ +package org.tron.core.vm.repository; + +import org.tron.core.capsule.AccountCapsule; +import org.tron.core.capsule.BytesCapsule; +import org.tron.core.capsule.ContractCapsule; +import org.tron.core.capsule.ContractStateCapsule; +import org.tron.core.capsule.StorageRowCapsule; +import org.tron.core.db2.archive.HistoricalQuerySession; + +/** Exact historical state reads backed only by one request-owned Archive session. */ +final class HistoricalArchiveStateSource implements RepositoryStateSource { + + private final HistoricalQuerySession session; + + HistoricalArchiveStateSource(HistoricalQuerySession session) { + this.session = java.util.Objects.requireNonNull(session, "session"); + session.requirePinnedIdentity(); + } + + @Override + public AccountCapsule getAccount(byte[] address) { + return session.getAccount(address).map(AccountCapsule::new).orElse(null); + } + + @Override + public BytesCapsule getDynamicProperty(byte[] key) { + return session.getExact("properties", key).map(BytesCapsule::new).orElse(null); + } + + @Override + public ContractCapsule getContract(byte[] address) { + return session.getContract(address).map(ContractCapsule::new).orElse(null); + } + + @Override + public ContractStateCapsule getContractState(byte[] address) { + return session.getContractState(address).map(ContractStateCapsule::new).orElse(null); + } + + @Override + public byte[] getCode(byte[] address) { + return session.getCode(address).orElse(null); + } + + @Override + public StorageRowCapsule getStorageRow(byte[] physicalKey) { + return session.getExact("storage-row", physicalKey) + .map(StorageRowCapsule::new).orElse(null); + } + + @Override + public boolean isReadOnly() { + return true; + } +} diff --git a/actuator/src/main/java/org/tron/core/vm/repository/HistoricalRepositoryProvider.java b/actuator/src/main/java/org/tron/core/vm/repository/HistoricalRepositoryProvider.java new file mode 100644 index 00000000000..03a521cc0d6 --- /dev/null +++ b/actuator/src/main/java/org/tron/core/vm/repository/HistoricalRepositoryProvider.java @@ -0,0 +1,20 @@ +package org.tron.core.vm.repository; + +import org.tron.core.db.TransactionContext; +import org.tron.core.db.TransactionContext.ExecutionMode; + +/** Provider for a request-owned, exact historical Repository root. */ +public enum HistoricalRepositoryProvider implements RepositoryProvider { + INSTANCE; + + @Override + public Repository createRoot(TransactionContext context) { + if (context.getExecutionMode() != ExecutionMode.HISTORICAL_CONSTANT + || context.getHistoricalQuerySession() == null) { + throw new IllegalArgumentException( + "Historical Repository requires a HISTORICAL_CONSTANT context"); + } + return RepositoryImpl.createHistoricalRoot(context.getStoreFactory(), + context.getHistoricalQuerySession()); + } +} diff --git a/actuator/src/main/java/org/tron/core/vm/repository/Repository.java b/actuator/src/main/java/org/tron/core/vm/repository/Repository.java index 8f91d59d0b8..c5c161e2a7c 100644 --- a/actuator/src/main/java/org/tron/core/vm/repository/Repository.java +++ b/actuator/src/main/java/org/tron/core/vm/repository/Repository.java @@ -2,6 +2,7 @@ import org.apache.commons.lang3.tuple.Pair; import org.tron.common.runtime.vm.DataWord; +import org.tron.common.utils.ByteArray; import org.tron.core.capsule.*; import org.tron.core.store.*; import org.tron.core.vm.program.Storage; @@ -9,6 +10,10 @@ public interface Repository { + default boolean isHistorical() { + return false; + } + AssetIssueCapsule getAssetIssue(byte[] tokenId); AssetIssueV2Store getAssetIssueV2Store(); @@ -27,6 +32,20 @@ public interface Repository { BytesCapsule getDynamicProperty(byte[] bytesKey); + default long getDynamicPropertyLong(String key) { + BytesCapsule value = getDynamicProperty( + key.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + if (value == null) { + Long defaultValue = isHistorical() + ? DynamicPropertiesStore.getLongPropertyDefault(key) : null; + if (defaultValue != null) { + return defaultValue; + } + throw new IllegalArgumentException("Dynamic property is missing: " + key); + } + return ByteArray.toLong(value.getData()); + } + DelegatedResourceCapsule getDelegatedResource(byte[] key); VotesCapsule getVotes(byte[] address); diff --git a/actuator/src/main/java/org/tron/core/vm/repository/RepositoryImpl.java b/actuator/src/main/java/org/tron/core/vm/repository/RepositoryImpl.java index 62e7ce6ec08..5b5380002a0 100644 --- a/actuator/src/main/java/org/tron/core/vm/repository/RepositoryImpl.java +++ b/actuator/src/main/java/org/tron/core/vm/repository/RepositoryImpl.java @@ -12,7 +12,6 @@ import java.util.HashMap; import java.util.HashSet; import java.util.Optional; -import lombok.Getter; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.tuple.Pair; import org.bouncycastle.util.Strings; @@ -46,6 +45,7 @@ import org.tron.core.db.BlockStore; import org.tron.core.db.KhaosDatabase; import org.tron.core.db.TransactionTrace; +import org.tron.core.db2.archive.HistoricalQuerySession; import org.tron.core.exception.BadItemException; import org.tron.core.exception.ItemNotFoundException; import org.tron.core.exception.StoreException; @@ -65,6 +65,7 @@ import org.tron.core.store.VotesStore; import org.tron.core.store.WitnessStore; import org.tron.core.vm.config.VMConfig; +import org.tron.core.vm.HistoricalCapabilityException; import org.tron.core.vm.program.Program.IllegalOperationException; import org.tron.core.vm.program.Storage; import org.tron.protos.Protocol; @@ -87,42 +88,27 @@ public class RepositoryImpl implements Repository { private static final byte[] TOTAL_TRON_POWER_WEIGHT = "TOTAL_TRON_POWER_WEIGHT".getBytes(); private StoreFactory storeFactory; - @Getter private DynamicPropertiesStore dynamicPropertiesStore; - @Getter private AccountStore accountStore; - @Getter private AssetIssueStore assetIssueStore; - @Getter private AssetIssueV2Store assetIssueV2Store; - @Getter private AbiStore abiStore; - @Getter private CodeStore codeStore; - @Getter private ContractStore contractStore; - @Getter private ContractStateStore contractStateStore; - @Getter private StorageRowStore storageRowStore; - @Getter private BlockStore blockStore; - @Getter private KhaosDatabase khaosDb; - @Getter private BlockIndexStore blockIndexStore; - @Getter private WitnessStore witnessStore; - @Getter private DelegatedResourceStore delegatedResourceStore; - @Getter private VotesStore votesStore; - @Getter private DelegationStore delegationStore; - @Getter private DelegatedResourceAccountIndexStore delegatedResourceAccountIndexStore; private Repository parent = null; + private RepositoryStateSource stateSource; + private boolean readOnlyRoot; private final HashMap> accountCache = new HashMap<>(); private final HashMap> codeCache = new HashMap<>(); @@ -144,16 +130,117 @@ public static void removeLruCache(byte[] address) { } public RepositoryImpl(StoreFactory storeFactory, RepositoryImpl repository) { - init(storeFactory, repository); + this(storeFactory, repository, + repository == null ? new CurrentStoreStateSource(storeFactory) : repository.stateSource, + repository != null && repository.readOnlyRoot); + } + + private RepositoryImpl(StoreFactory storeFactory, Repository parent, + RepositoryStateSource stateSource, boolean readOnlyRoot) { + init(storeFactory, parent, !readOnlyRoot); + this.stateSource = stateSource; + this.readOnlyRoot = readOnlyRoot; } public static RepositoryImpl createRoot(StoreFactory storeFactory) { return new RepositoryImpl(storeFactory, null); } - protected void init(StoreFactory storeFactory, RepositoryImpl parent) { + public static RepositoryImpl createHistoricalRoot(StoreFactory storeFactory, + HistoricalQuerySession session) { + return new RepositoryImpl(storeFactory, null, new HistoricalArchiveStateSource(session), true); + } + + @Override + public DynamicPropertiesStore getDynamicPropertiesStore() { + return currentStore(dynamicPropertiesStore, "DynamicPropertiesStore"); + } + + public AccountStore getAccountStore() { + return currentStore(accountStore, "AccountStore"); + } + + @Override + public AssetIssueStore getAssetIssueStore() { + return currentStore(assetIssueStore, "AssetIssueStore"); + } + + @Override + public AssetIssueV2Store getAssetIssueV2Store() { + return currentStore(assetIssueV2Store, "AssetIssueV2Store"); + } + + public AbiStore getAbiStore() { + return currentStore(abiStore, "AbiStore"); + } + + public CodeStore getCodeStore() { + return currentStore(codeStore, "CodeStore"); + } + + public ContractStore getContractStore() { + return currentStore(contractStore, "ContractStore"); + } + + public ContractStateStore getContractStateStore() { + return currentStore(contractStateStore, "ContractStateStore"); + } + + public StorageRowStore getStorageRowStore() { + return currentStore(storageRowStore, "StorageRowStore"); + } + + public BlockStore getBlockStore() { + return currentStore(blockStore, "BlockStore"); + } + + public KhaosDatabase getKhaosDb() { + return currentStore(khaosDb, "KhaosDatabase"); + } + + public BlockIndexStore getBlockIndexStore() { + return currentStore(blockIndexStore, "BlockIndexStore"); + } + + public WitnessStore getWitnessStore() { + return currentStore(witnessStore, "WitnessStore"); + } + + public DelegatedResourceStore getDelegatedResourceStore() { + return currentStore(delegatedResourceStore, "DelegatedResourceStore"); + } + + public VotesStore getVotesStore() { + return currentStore(votesStore, "VotesStore"); + } + + @Override + public DelegationStore getDelegationStore() { + return currentStore(delegationStore, "DelegationStore"); + } + + public DelegatedResourceAccountIndexStore getDelegatedResourceAccountIndexStore() { + return currentStore(delegatedResourceAccountIndexStore, + "DelegatedResourceAccountIndexStore"); + } + + private T currentStore(T store, String capability) { + if (readOnlyRoot) { + throw new HistoricalCapabilityException( + "Direct " + capability + " access is not supported by historical execution"); + } + return store; + } + + protected void init(StoreFactory storeFactory, Repository parent) { + init(storeFactory, parent, true); + } + + private void init(StoreFactory storeFactory, Repository parent, boolean bindCurrentStores) { if (storeFactory != null) { this.storeFactory = storeFactory; + } + if (bindCurrentStores) { ChainBaseManager manager = storeFactory.getChainBaseManager(); dynamicPropertiesStore = manager.getDynamicPropertiesStore(); accountStore = manager.getAccountStore(); @@ -176,9 +263,14 @@ protected void init(StoreFactory storeFactory, RepositoryImpl parent) { this.parent = parent; } + @Override + public boolean isHistorical() { + return readOnlyRoot; + } + @Override public Repository newRepositoryChild() { - return new RepositoryImpl(storeFactory, this); + return new RepositoryImpl(storeFactory, this, stateSource, readOnlyRoot); } @Override @@ -316,7 +408,7 @@ public AccountCapsule getAccount(byte[] address) { if (parent != null) { accountCapsule = parent.getAccount(address); } else { - accountCapsule = getAccountStore().get(address); + accountCapsule = stateSource.getAccount(address); } if (accountCapsule != null) { @@ -336,11 +428,9 @@ public BytesCapsule getDynamicProperty(byte[] word) { if (parent != null) { bytesCapsule = parent.getDynamicProperty(word); } else { - try { - bytesCapsule = getDynamicPropertiesStore().get(word); - } catch (BadItemException | ItemNotFoundException e) { + bytesCapsule = stateSource.getDynamicProperty(word); + if (bytesCapsule == null) { logger.warn("Not found dynamic property:" + Strings.fromUTF8ByteArray(word)); - bytesCapsule = null; } } @@ -392,7 +482,7 @@ public VotesCapsule getVotes(byte[] address) { @Override public WitnessCapsule getWitness(byte[] address) { - return witnessStore.get(address); + return getWitnessStore().get(address); } @Override @@ -508,7 +598,7 @@ public ContractCapsule getContract(byte[] address) { if (parent != null) { contractCapsule = parent.getContract(address); } else { - contractCapsule = getContractStore().get(address); + contractCapsule = stateSource.getContract(address); } if (contractCapsule != null) { @@ -528,7 +618,7 @@ public ContractStateCapsule getContractState(byte[] address) { if (parent != null) { contractStateCapsule = parent.getContractState(address); } else { - contractStateCapsule = getContractStateStore().get(address); + contractStateCapsule = stateSource.getContractState(address); } if (contractStateCapsule != null) { @@ -657,11 +747,7 @@ public byte[] getCode(byte[] address) { if (parent != null) { code = parent.getCode(address); } else { - if (null == getCodeStore().get(address)) { - code = null; - } else { - code = getCodeStore().get(address).getData(); - } + code = stateSource.getCode(address); } if (code != null) { codeCache.put(key, Value.create(code)); @@ -715,7 +801,8 @@ public Storage getStorage(byte[] address) { storage = parentStorage; } } else { - storage = new Storage(address, getStorageRowStore()); + StorageRowStore persistenceStore = stateSource.isReadOnly() ? null : getStorageRowStore(); + storage = new Storage(address, persistenceStore, stateSource::getStorageRow); } ContractCapsule contract = getContract(address); if (contract != null) { @@ -764,6 +851,9 @@ public void setParent(Repository repository) { @Override public void commit() { + if (parent == null && readOnlyRoot) { + throw new IllegalStateException("Historical Repository root cannot be committed"); + } Repository repository = null; if (parent != null) { repository = parent; @@ -891,6 +981,10 @@ public byte[] getBlackHoleAddress() { @Override public BlockCapsule getBlockByNum(long num) { + if (readOnlyRoot) { + throw new HistoricalCapabilityException( + "BLOCKHASH history is not supported by historical execution"); + } try { Sha256Hash hash = getBlockIdByNum(num); BlockCapsule block = this.khaosDb.getBlock(hash); diff --git a/actuator/src/main/java/org/tron/core/vm/repository/RepositoryProvider.java b/actuator/src/main/java/org/tron/core/vm/repository/RepositoryProvider.java new file mode 100644 index 00000000000..15f6c953290 --- /dev/null +++ b/actuator/src/main/java/org/tron/core/vm/repository/RepositoryProvider.java @@ -0,0 +1,10 @@ +package org.tron.core.vm.repository; + +import org.tron.core.db.TransactionContext; + +/** Creates the request-owned root Repository used by one VM execution. */ +@FunctionalInterface +public interface RepositoryProvider { + + Repository createRoot(TransactionContext context); +} diff --git a/actuator/src/main/java/org/tron/core/vm/repository/RepositoryStateSource.java b/actuator/src/main/java/org/tron/core/vm/repository/RepositoryStateSource.java new file mode 100644 index 00000000000..7cc36a020b1 --- /dev/null +++ b/actuator/src/main/java/org/tron/core/vm/repository/RepositoryStateSource.java @@ -0,0 +1,25 @@ +package org.tron.core.vm.repository; + +import org.tron.core.capsule.AccountCapsule; +import org.tron.core.capsule.BytesCapsule; +import org.tron.core.capsule.ContractCapsule; +import org.tron.core.capsule.ContractStateCapsule; +import org.tron.core.capsule.StorageRowCapsule; + +/** Root-miss state reads for a Repository overlay. */ +interface RepositoryStateSource { + + AccountCapsule getAccount(byte[] address); + + BytesCapsule getDynamicProperty(byte[] key); + + ContractCapsule getContract(byte[] address); + + ContractStateCapsule getContractState(byte[] address); + + byte[] getCode(byte[] address); + + StorageRowCapsule getStorageRow(byte[] physicalKey); + + boolean isReadOnly(); +} diff --git a/build.gradle b/build.gradle index e143ab3a947..92fb56849d9 100644 --- a/build.gradle +++ b/build.gradle @@ -16,7 +16,9 @@ allprojects { def arch = System.getProperty("os.arch").toLowerCase(Locale.ROOT) def javaVersion = JavaVersion.current() def isArm64 = Architectures.AARCH64.isAlias(arch) -def archSource = isArm64 ? "arm" : "x86" +def requestedRocksdbVersion = findProperty('rocksdbVersion') +def java17Candidate = findProperty('java17Candidate') == 'true' +def archSource = isArm64 ? "arm" : (requestedRocksdbVersion == '9.7.4' ? "x86-high" : "x86") def isMac = OperatingSystem.current().isMacOsX() ext.archInfo = [ @@ -36,14 +38,20 @@ ext.archInfo = [ ] ], requires: [ - JavaVersion: isArm64 ? JavaVersion.VERSION_17 : JavaVersion.VERSION_1_8, - RocksdbVersion: isArm64 ? '9.7.4' : '5.15.10', + JavaVersion: (isArm64 || java17Candidate) + ? JavaVersion.VERSION_17 : JavaVersion.VERSION_1_8, + // Allow an explicitly pinned validation candidate to use the same JNI + // generation across architectures; the default remains the legacy x86 + // baseline until the candidate passes the AMD compatibility gate. + RocksdbVersion: requestedRocksdbVersion ?: (isArm64 ? '9.7.4' : '5.15.10'), // https://github.com/grpc/grpc-java/issues/7690 // https://github.com/grpc/grpc-java/pull/12319, Add support for macOS aarch64 with universal binary // https://github.com/grpc/grpc-java/pull/11371 , 1.64.x is not supported CentOS 7. ProtocGenVersion: isArm64 || isMac ? '1.81.0' : '1.60.0' ], - VMOptions: isArm64 ? "${rootDir}/gradle/jdk17/java-tron.vmoptions" : "${rootDir}/gradle/java-tron.vmoptions" + VMOptions: (isArm64 || java17Candidate) + ? "${rootDir}/gradle/jdk17/java-tron.vmoptions" + : "${rootDir}/gradle/java-tron.vmoptions" ] if (!archInfo.java.is(archInfo.requires.JavaVersion)) { diff --git a/chainbase/src/main/java/org/tron/common/storage/EngineSourceIdentityFile.java b/chainbase/src/main/java/org/tron/common/storage/EngineSourceIdentityFile.java new file mode 100644 index 00000000000..c59b4d54baa --- /dev/null +++ b/chainbase/src/main/java/org/tron/common/storage/EngineSourceIdentityFile.java @@ -0,0 +1,182 @@ +package org.tron.common.storage; + +import com.google.common.hash.Hashing; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.channels.FileLock; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.text.Normalizer; +import java.util.Arrays; +import java.util.Locale; +import java.util.UUID; + +/** Durable engine identity used to bind archive generations across process restarts. */ +public final class EngineSourceIdentityFile { + + private static final int MAGIC = 0x454e4749; // ENGI + private static final short VERSION = 1; + private static final int MAX_SIZE = 4096; + private static final String FILE_NAME = ".archive-engine.identity"; + private static final String LOCK_NAME = ".archive-engine.identity.lock"; + + private EngineSourceIdentityFile() { + } + + public static synchronized String loadOrCreate(Path databaseDirectory, String engine, + String dbName) + throws IOException { + if (databaseDirectory == null) { + throw new IllegalArgumentException("databaseDirectory must not be null"); + } + String normalizedEngine = normalizeEngine(engine); + String normalizedDbName = normalizeDbName(dbName); + Files.createDirectories(databaseDirectory); + Path identityFile = databaseDirectory.resolve(FILE_NAME); + try (FileChannel lockChannel = FileChannel.open(databaseDirectory.resolve(LOCK_NAME), + StandardOpenOption.CREATE, StandardOpenOption.WRITE); + FileLock ignored = lockChannel.lock()) { + if (!Files.exists(identityFile)) { + Identity created = new Identity(normalizedEngine, normalizedDbName, UUID.randomUUID()); + persistNew(databaseDirectory, identityFile, created); + } + } + Identity identity = load(identityFile); + if (!normalizedEngine.equals(identity.engine) || !normalizedDbName.equals(identity.dbName)) { + throw new IOException("Archive engine source identity does not match engine/dbName"); + } + return identity.engine.toLowerCase(Locale.ROOT) + ":" + identity.dbName + ":" + + identity.uuid; + } + + static Path identityPath(Path databaseDirectory) { + return databaseDirectory.resolve(FILE_NAME); + } + + private static void persistNew(Path directory, Path destination, Identity identity) + throws IOException { + byte[] encoded = encode(identity); + Path temporary = directory.resolve(FILE_NAME + ".tmp." + UUID.randomUUID()); + try { + try (FileChannel channel = FileChannel.open(temporary, StandardOpenOption.CREATE_NEW, + StandardOpenOption.WRITE)) { + ByteBuffer buffer = ByteBuffer.wrap(encoded); + while (buffer.hasRemaining()) { + channel.write(buffer); + } + channel.force(true); + } + try { + Files.move(temporary, destination, StandardCopyOption.ATOMIC_MOVE); + } catch (FileAlreadyExistsException raced) { + // Another opener established the immutable identity first; validate it below. + } catch (AtomicMoveNotSupportedException unsupported) { + throw new IOException("Engine identity filesystem does not support atomic create", + unsupported); + } + syncDirectory(directory); + } finally { + Files.deleteIfExists(temporary); + } + } + + private static Identity load(Path identityFile) throws IOException { + if (!Files.isRegularFile(identityFile)) { + throw new IOException("Archive engine source identity is missing or not a regular file"); + } + byte[] encoded = Files.readAllBytes(identityFile); + if (encoded.length < 32 || encoded.length > MAX_SIZE) { + throw new IOException("Archive engine source identity length is invalid"); + } + byte[] payload = Arrays.copyOf(encoded, encoded.length - Integer.BYTES); + int checksum = ByteBuffer.wrap(encoded, payload.length, Integer.BYTES).getInt(); + if (checksum != Hashing.crc32c().hashBytes(payload).asInt()) { + throw new IOException("Archive engine source identity checksum mismatch"); + } + try { + DataInputStream input = new DataInputStream(new ByteArrayInputStream(encoded)); + if (input.readInt() != MAGIC || input.readShort() != VERSION || input.readShort() != 0) { + throw new IOException("Unsupported archive engine source identity"); + } + String engine = normalizeEngine(input.readUTF()); + String dbName = normalizeDbName(input.readUTF()); + UUID uuid = new UUID(input.readLong(), input.readLong()); + if (input.available() != Integer.BYTES) { + throw new IOException("Archive engine source identity payload mismatch"); + } + return new Identity(engine, dbName, uuid); + } catch (IllegalArgumentException invalid) { + throw new IOException("Archive engine source identity fields are invalid", invalid); + } + } + + private static byte[] encode(Identity identity) { + try { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + DataOutputStream output = new DataOutputStream(bytes); + output.writeInt(MAGIC); + output.writeShort(VERSION); + output.writeShort(0); + output.writeUTF(identity.engine); + output.writeUTF(identity.dbName); + output.writeLong(identity.uuid.getMostSignificantBits()); + output.writeLong(identity.uuid.getLeastSignificantBits()); + output.flush(); + byte[] payload = bytes.toByteArray(); + output.writeInt(Hashing.crc32c().hashBytes(payload).asInt()); + output.flush(); + return bytes.toByteArray(); + } catch (IOException impossible) { + throw new IllegalStateException("Unexpected engine identity encoding failure", impossible); + } + } + + private static String normalizeEngine(String engine) { + if (engine == null) { + throw new IllegalArgumentException("engine must not be null"); + } + String normalized = engine.trim().toUpperCase(Locale.ROOT); + if (!"LEVELDB".equals(normalized) && !"ROCKSDB".equals(normalized)) { + throw new IllegalArgumentException("Unsupported archive engine identity: " + engine); + } + return normalized; + } + + private static String normalizeDbName(String dbName) { + if (dbName == null) { + throw new IllegalArgumentException("dbName must not be null"); + } + String normalized = Normalizer.normalize(dbName, Normalizer.Form.NFC); + if (normalized.isEmpty() || !normalized.equals(dbName)) { + throw new IllegalArgumentException("dbName must be non-empty canonical NFC"); + } + return normalized; + } + + private static void syncDirectory(Path directory) throws IOException { + try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) { + channel.force(true); + } + } + + private static final class Identity { + private final String engine; + private final String dbName; + private final UUID uuid; + + private Identity(String engine, String dbName, UUID uuid) { + this.engine = engine; + this.dbName = dbName; + this.uuid = uuid; + } + } +} diff --git a/chainbase/src/main/java/org/tron/common/storage/leveldb/LevelDbDataSourceImpl.java b/chainbase/src/main/java/org/tron/common/storage/leveldb/LevelDbDataSourceImpl.java index aa85ac08f45..35e7fbced64 100644 --- a/chainbase/src/main/java/org/tron/common/storage/leveldb/LevelDbDataSourceImpl.java +++ b/chainbase/src/main/java/org/tron/common/storage/leveldb/LevelDbDataSourceImpl.java @@ -17,9 +17,11 @@ import static org.fusesource.leveldbjni.JniDBFactory.factory; -import com.google.common.annotations.VisibleForTesting; -import com.google.common.collect.Sets; -import com.google.common.primitives.Bytes; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.collect.Maps; +import com.google.common.collect.Sets; +import com.google.common.primitives.Bytes; +import com.google.common.primitives.UnsignedBytes; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; @@ -35,8 +37,9 @@ import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; -import java.util.concurrent.locks.ReadWriteLock; -import java.util.concurrent.locks.ReentrantReadWriteLock; +import java.util.concurrent.locks.ReadWriteLock; +import java.util.concurrent.locks.ReentrantReadWriteLock; +import java.util.concurrent.locks.StampedLock; import java.util.stream.Collectors; import java.util.stream.Stream; import java.util.stream.StreamSupport; @@ -45,12 +48,14 @@ import org.iq80.leveldb.DB; import org.iq80.leveldb.DBIterator; import org.iq80.leveldb.Options; -import org.iq80.leveldb.ReadOptions; +import org.iq80.leveldb.ReadOptions; +import org.iq80.leveldb.Snapshot; import org.iq80.leveldb.WriteBatch; import org.iq80.leveldb.WriteOptions; import org.tron.common.es.ExecutorServiceManager; import org.tron.common.parameter.CommonParameter; -import org.tron.common.storage.WriteOptionsWrapper; +import org.tron.common.storage.EngineSourceIdentityFile; +import org.tron.common.storage.WriteOptionsWrapper; import org.tron.common.storage.metric.DbStat; import org.tron.common.utils.FileUtil; import org.tron.common.utils.StorageUtils; @@ -75,8 +80,10 @@ public class LevelDbDataSourceImpl extends DbStat implements DbSourceInter> range(byte[] lowerInclusive, + byte[] upperExclusive, int maxEntries) { + if (closed) { + throw new IllegalStateException("LevelDB snapshot lease is closed"); + } + if (lowerInclusive == null || maxEntries <= 0) { + throw new IllegalArgumentException("Invalid LevelDB snapshot range"); + } + List> result = new ArrayList<>(); + try (DBIterator iterator = pinnedDatabase.iterator(readOptions)) { + iterator.seek(lowerInclusive); + while (iterator.hasNext() && result.size() < maxEntries) { + Map.Entry entry = iterator.next(); + if (upperExclusive != null + && UnsignedBytes.lexicographicalComparator().compare(entry.getKey(), + upperExclusive) >= 0) { + break; + } + result.add(Maps.immutableEntry( + Arrays.copyOf(entry.getKey(), entry.getKey().length), + Arrays.copyOf(entry.getValue(), entry.getValue().length))); + } + } catch (IOException failure) { + throw new RuntimeException("Failed to read pinned LevelDB range " + dataBaseName, + failure); + } + return Collections.unmodifiableList(result); + } + + public String getSourceIdentity() { + return sourceIdentity; + } + + @Override + public synchronized void close() throws IOException { + if (closed) { + return; + } + closed = true; + try { + snapshot.close(); + } finally { + snapshotLifecycleLock.unlockRead(lifecycleStamp); + } + } + } /** * Returns an iterator over the database. diff --git a/chainbase/src/main/java/org/tron/common/storage/rocksdb/RocksDbDataSourceImpl.java b/chainbase/src/main/java/org/tron/common/storage/rocksdb/RocksDbDataSourceImpl.java index c7ca698cc3d..18cf6da50b0 100644 --- a/chainbase/src/main/java/org/tron/common/storage/rocksdb/RocksDbDataSourceImpl.java +++ b/chainbase/src/main/java/org/tron/common/storage/rocksdb/RocksDbDataSourceImpl.java @@ -1,8 +1,10 @@ package org.tron.common.storage.rocksdb; import com.google.common.annotations.VisibleForTesting; +import com.google.common.collect.Maps; import com.google.common.collect.Sets; import com.google.common.primitives.Bytes; +import com.google.common.primitives.UnsignedBytes; import java.io.File; import java.io.IOException; import java.nio.file.Files; @@ -18,6 +20,7 @@ import java.util.Set; import java.util.concurrent.locks.ReadWriteLock; import java.util.concurrent.locks.ReentrantReadWriteLock; +import java.util.concurrent.locks.StampedLock; import java.util.stream.Collectors; import lombok.NoArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -27,11 +30,13 @@ import org.rocksdb.RocksDB; import org.rocksdb.RocksDBException; import org.rocksdb.RocksIterator; +import org.rocksdb.Snapshot; import org.rocksdb.Status; import org.rocksdb.WriteBatch; import org.rocksdb.WriteOptions; import org.tron.common.error.TronDBException; import org.tron.common.setting.RocksDbSettings; +import org.tron.common.storage.EngineSourceIdentityFile; import org.tron.common.storage.WriteOptionsWrapper; import org.tron.common.storage.metric.DbStat; import org.tron.common.utils.FileUtil; @@ -52,7 +57,9 @@ public class RocksDbDataSourceImpl extends DbStat implements DbSourceInter> range(byte[] lowerInclusive, + byte[] upperExclusive, int maxEntries) { + if (closed) { + throw new IllegalStateException("RocksDB snapshot lease is closed"); + } + if (lowerInclusive == null || maxEntries <= 0) { + throw new IllegalArgumentException("Invalid RocksDB snapshot range"); + } + List> result = new ArrayList<>(); + try (RocksIterator iterator = pinnedDatabase.newIterator(readOptions)) { + iterator.seek(lowerInclusive); + while (iterator.isValid() && result.size() < maxEntries) { + byte[] key = iterator.key(); + if (upperExclusive != null + && UnsignedBytes.lexicographicalComparator().compare(key, upperExclusive) >= 0) { + break; + } + byte[] value = iterator.value(); + result.add(Maps.immutableEntry( + Arrays.copyOf(key, key.length), + Arrays.copyOf(value, value.length))); + iterator.next(); + } + iterator.status(); + } catch (RocksDBException failure) { + throw new RuntimeException("Failed to read pinned RocksDB range " + dataBaseName, + failure); + } + return Collections.unmodifiableList(result); + } + + public String getSourceIdentity() { + return sourceIdentity; + } + + @Override + public synchronized void close() { + if (closed) { + return; + } + closed = true; + RuntimeException failure = null; + try { + readOptions.close(); + } catch (RuntimeException closeFailure) { + failure = closeFailure; + } + try { + pinnedDatabase.releaseSnapshot(snapshot); + } catch (RuntimeException closeFailure) { + if (failure == null) { + failure = closeFailure; + } else { + failure.addSuppressed(closeFailure); + } + } finally { + snapshotLifecycleLock.unlockRead(lifecycleStamp); + } + if (failure != null) { + throw failure; + } + } + } + /** * Returns an iterator over the database. * diff --git a/chainbase/src/main/java/org/tron/common/utils/StorageUtils.java b/chainbase/src/main/java/org/tron/common/utils/StorageUtils.java index 0c7c77bd23f..e6821f916d1 100644 --- a/chainbase/src/main/java/org/tron/common/utils/StorageUtils.java +++ b/chainbase/src/main/java/org/tron/common/utils/StorageUtils.java @@ -1,6 +1,5 @@ package org.tron.common.utils; -import static org.tron.common.parameter.CommonParameter.ENERGY_LIMIT_HARD_FORK; import static org.tron.core.db.common.DbSourceInter.LEVELDB; import java.io.File; @@ -9,6 +8,7 @@ import org.slf4j.LoggerFactory; import org.tron.common.parameter.CommonParameter; import org.tron.core.Constant; +import org.tron.core.vm.config.VMConfig; public class StorageUtils { @@ -16,7 +16,7 @@ public class StorageUtils { private static final org.slf4j.Logger levelDbLogger = LoggerFactory.getLogger(LEVELDB); public static boolean getEnergyLimitHardFork() { - return ENERGY_LIMIT_HARD_FORK; + return VMConfig.getEnergyLimitHardFork(); } public static String getOutputDirectoryByDbName(String dbName) { diff --git a/chainbase/src/main/java/org/tron/core/capsule/BlockCapsule.java b/chainbase/src/main/java/org/tron/core/capsule/BlockCapsule.java index e6cbd52e595..7abdba45698 100755 --- a/chainbase/src/main/java/org/tron/core/capsule/BlockCapsule.java +++ b/chainbase/src/main/java/org/tron/core/capsule/BlockCapsule.java @@ -261,6 +261,20 @@ public void setAccountStateRoot(byte[] root) { this.block.getBlockHeader().toBuilder().setRawData(blockHeaderRaw)).build(); } + /** Sets optional non-consensus path-state metadata outside the signed raw header. */ + public void setStateRoot(byte[] root) { + if (root == null || root.length != Sha256Hash.LENGTH) { + throw new IllegalArgumentException("state root must be exactly 32 bytes"); + } + this.block = this.block.toBuilder().setBlockHeader( + this.block.getBlockHeader().toBuilder().setStateRoot(ByteString.copyFrom(root))).build(); + } + + /** Returns a copy of the optional non-consensus path-state metadata. */ + public byte[] getStateRoot() { + return this.block.getBlockHeader().getStateRoot().toByteArray(); + } + /* only for genesis */ public void setWitness(String witness) { BlockHeader.raw blockHeaderRaw = diff --git a/chainbase/src/main/java/org/tron/core/db/RevokingDatabase.java b/chainbase/src/main/java/org/tron/core/db/RevokingDatabase.java index ff2f026192c..7a6b13bf6be 100755 --- a/chainbase/src/main/java/org/tron/core/db/RevokingDatabase.java +++ b/chainbase/src/main/java/org/tron/core/db/RevokingDatabase.java @@ -39,6 +39,8 @@ public interface RevokingDatabase { void setMaxFlushCount(int maxFlushCount); + void flushPending(); + void shutdown(); } diff --git a/chainbase/src/main/java/org/tron/core/db/TransactionContext.java b/chainbase/src/main/java/org/tron/core/db/TransactionContext.java index f2b467c7fc0..29c4f66c2ea 100644 --- a/chainbase/src/main/java/org/tron/core/db/TransactionContext.java +++ b/chainbase/src/main/java/org/tron/core/db/TransactionContext.java @@ -4,26 +4,53 @@ import org.tron.common.runtime.ProgramResult; import org.tron.core.capsule.BlockCapsule; import org.tron.core.capsule.TransactionCapsule; +import org.tron.core.db2.archive.HistoricalQuerySession; import org.tron.core.store.StoreFactory; @Data public class TransactionContext { + public enum ExecutionMode { + CURRENT_CONSENSUS, + CURRENT_CONSTANT, + HISTORICAL_CONSTANT + } + private BlockCapsule blockCap; private TransactionCapsule trxCap; private StoreFactory storeFactory; private ProgramResult programResult = new ProgramResult(); private boolean isStatic; private boolean eventPluginLoaded; + private final ExecutionMode executionMode; + private final HistoricalQuerySession historicalQuerySession; public TransactionContext(BlockCapsule blockCap, TransactionCapsule trxCap, StoreFactory storeFactory, boolean isStatic, boolean eventPluginLoaded) { + this(blockCap, trxCap, storeFactory, isStatic, eventPluginLoaded, + isStatic ? ExecutionMode.CURRENT_CONSTANT : ExecutionMode.CURRENT_CONSENSUS, null); + } + + public TransactionContext(BlockCapsule blockCap, TransactionCapsule trxCap, + StoreFactory storeFactory, boolean isStatic, boolean eventPluginLoaded, + ExecutionMode executionMode, HistoricalQuerySession historicalQuerySession) { this.blockCap = blockCap; this.trxCap = trxCap; this.storeFactory = storeFactory; this.isStatic = isStatic; this.eventPluginLoaded = eventPluginLoaded; + this.executionMode = java.util.Objects.requireNonNull(executionMode, "executionMode"); + this.historicalQuerySession = historicalQuerySession; + if (executionMode == ExecutionMode.HISTORICAL_CONSTANT) { + if (!isStatic || historicalQuerySession == null) { + throw new IllegalArgumentException( + "HISTORICAL_CONSTANT requires static execution and a historical query session"); + } + } else if (historicalQuerySession != null) { + throw new IllegalArgumentException( + "A historical query session is only valid for HISTORICAL_CONSTANT execution"); + } } } diff --git a/chainbase/src/main/java/org/tron/core/db/TronDatabase.java b/chainbase/src/main/java/org/tron/core/db/TronDatabase.java index 0a78570b8ed..705b4149523 100644 --- a/chainbase/src/main/java/org/tron/core/db/TronDatabase.java +++ b/chainbase/src/main/java/org/tron/core/db/TronDatabase.java @@ -64,6 +64,13 @@ public void updateByBatch(Map rows) { this.dbSource.updateByBatch(rows, writeOptions); } + /** Writes one checkpoint batch with sync enabled regardless of the ordinary DB setting. */ + public void updateByBatchSynced(Map rows) { + try (WriteOptionsWrapper synced = WriteOptionsWrapper.getInstance().sync(true)) { + this.dbSource.updateByBatch(rows, synced); + } + } + /** * reset the database. */ diff --git a/chainbase/src/main/java/org/tron/core/db/TronStoreWithRevoking.java b/chainbase/src/main/java/org/tron/core/db/TronStoreWithRevoking.java index 72e7a1cd82f..4d5b4bbcaf4 100644 --- a/chainbase/src/main/java/org/tron/core/db/TronStoreWithRevoking.java +++ b/chainbase/src/main/java/org/tron/core/db/TronStoreWithRevoking.java @@ -81,6 +81,9 @@ public String getDbName() { @PostConstruct private void init() { + if (revokingDB instanceof Chainbase) { + ((Chainbase) revokingDB).setRegistrationSource(getClass().getName()); + } revokingDatabase.add(revokingDB); dbStatService.register(db); } diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/AccountAssetArchiveProjector.java b/chainbase/src/main/java/org/tron/core/db2/archive/AccountAssetArchiveProjector.java new file mode 100644 index 00000000000..ee50d2d0717 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/AccountAssetArchiveProjector.java @@ -0,0 +1,199 @@ +package org.tron.core.db2.archive; + +import com.google.protobuf.InvalidProtocolBufferException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import org.tron.core.db2.archive.P66AccountAssetCodec.AssetRow; +import org.tron.core.db2.archive.P66AccountAssetCodec.DecodedAssetRow; +import org.tron.core.db2.archive.P66AccountAssetCodec.Phase; +import org.tron.core.db2.common.WrappedByteArray; +import org.tron.protos.Protocol.Account; + +/** + * Projects the non-Chainbase {@code account-asset} mutations which SnapshotRoot otherwise creates + * implicitly while merging account snapshots. + */ +public final class AccountAssetArchiveProjector { + + public static final String ACCOUNT_DB = "account"; + public static final String ACCOUNT_ASSET_DB = "account-asset"; + private final P66AccountAssetCodec codec = new P66AccountAssetCodec(); + + Projection project(byte[] accountKey, byte[] rawOld, BlockChangeView.PostValue rawPost, + boolean targetAssetOptimizationEnabled, + Map oldPhysicalAssetsForAddress) { + Account oldAccount = parse(rawOld); + Account postAccount = rawPost.isPresent() ? parse(rawPost.getValue()) : null; + return projectParsed(accountKey, rawOld, rawPost, targetAssetOptimizationEnabled, + oldPhysicalAssetsForAddress, oldAccount, postAccount); + } + + Projection projectWithOldPhysicalAssetsSource(byte[] accountKey, byte[] rawOld, + BlockChangeView.PostValue rawPost, boolean targetAssetOptimizationEnabled, + AccountAssetOldPhysicalAssetsSource oldPhysicalAssetsSource) { + Account oldAccount = parse(rawOld); + Account postAccount = rawPost.isPresent() ? parse(rawPost.getValue()) : null; + Map oldPhysicalAssets = Collections.emptyMap(); + if (requiresOldPhysicalAssets(oldAccount, postAccount)) { + oldPhysicalAssets = AccountAssetOldPhysicalAssetsSource.captureRequired( + oldPhysicalAssetsSource, accountKey); + } + return projectParsed(accountKey, rawOld, rawPost, targetAssetOptimizationEnabled, + oldPhysicalAssets, oldAccount, postAccount); + } + + private Projection projectParsed(byte[] accountKey, byte[] rawOld, + BlockChangeView.PostValue rawPost, boolean targetAssetOptimizationEnabled, + Map oldPhysicalAssetsForAddress, Account oldAccount, + Account postAccount) { + boolean projectPost = postAccount != null + && (postAccount.getAssetOptimized() || targetAssetOptimizationEnabled); + + boolean requiresOldPhysicalAssets = requiresOldPhysicalAssets(oldAccount, postAccount); + if (requiresOldPhysicalAssets && oldPhysicalAssetsForAddress == null) { + throw new ArchivePersistenceException( + "Optimized Account projection requires explicit old physical assets"); + } + Map physicalSnapshot = copyPhysicalAssets(accountKey, + oldPhysicalAssetsForAddress == null ? Collections.emptyMap() + : oldPhysicalAssetsForAddress); + if (!physicalSnapshot.isEmpty() + && (oldAccount == null || !oldAccount.getAssetOptimized())) { + throw new ArchivePersistenceException( + "Unoptimized Account must not have old physical AccountAsset rows"); + } + + Map oldAssets = physicalAssets(accountKey, oldAccount, + oldAccount != null && oldAccount.getAssetOptimized(), physicalSnapshot); + Map postAssets = physicalAssets(accountKey, postAccount, projectPost, + physicalSnapshot); + + Set assetKeys = new TreeSet<>((left, right) -> + BlockReverseDiff.compareUnsigned(left.getBytes(), right.getBytes())); + assetKeys.addAll(oldAssets.keySet()); + assetKeys.addAll(postAssets.keySet()); + List reverseAssets = new ArrayList<>(); + List changedAssetRows = new ArrayList<>(); + for (WrappedByteArray assetKey : assetKeys) { + byte[] oldValue = oldAssets.get(assetKey); + byte[] postValue = postAssets.get(assetKey); + if (!Arrays.equals(oldValue, postValue)) { + reverseAssets.add(new BlockReverseDiff.Entry(assetKey.getBytes(), + OldValue.fromNullable(oldValue))); + changedAssetRows.add(new AssetRow(assetKey.getBytes(), postValue == null + ? BlockChangeView.PostValue.absent() + : BlockChangeView.PostValue.present(postValue))); + } + } + + OldValue canonicalOld = oldAccount == null ? OldValue.absent() + : OldValue.present(canonicalAccount(accountKey, oldAccount, rawOld, + oldAccount.getAssetOptimized())); + BlockChangeView.PostValue canonicalPost = postAccount == null + ? BlockChangeView.PostValue.absent() + : BlockChangeView.PostValue.present(canonicalAccount(accountKey, postAccount, + rawPost.getValue(), projectPost)); + if (canonicalPost.isPresent()) { + codec.requireCanonicalLayout(phase(postAccount, projectPost), + accountKey, canonicalPost.getValue(), changedAssetRows); + } + return new Projection(canonicalOld, canonicalPost, reverseAssets, changedAssetRows); + } + + boolean requiresOldPhysicalAssets(byte[] rawOld, BlockChangeView.PostValue rawPost) { + return requiresOldPhysicalAssets(parse(rawOld), + rawPost.isPresent() ? parse(rawPost.getValue()) : null); + } + + private boolean requiresOldPhysicalAssets(Account oldAccount, Account postAccount) { + return oldAccount != null && oldAccount.getAssetOptimized() + || postAccount != null && postAccount.getAssetOptimized(); + } + + private Map copyPhysicalAssets(byte[] accountKey, + Map oldPhysicalAssetsForAddress) { + Map copy = new HashMap<>(); + oldPhysicalAssetsForAddress.forEach((key, value) -> { + if (key == null || value == null) { + throw new ArchivePersistenceException("Old physical AccountAsset input contains null"); + } + byte[] physicalKey = key.getBytes(); + DecodedAssetRow decoded = codec.decodePresentAssetRow(physicalKey, value); + if (!Arrays.equals(decoded.getAccountAddress(), accountKey)) { + throw new ArchivePersistenceException( + "Old physical AccountAsset input does not belong to changed Account"); + } + copy.put(WrappedByteArray.copyOf(physicalKey), Arrays.copyOf(value, value.length)); + }); + return copy; + } + + private Map physicalAssets(byte[] accountKey, Account account, + boolean projected, Map physicalSnapshot) { + Map result = new HashMap<>(); + if (account == null || !projected) { + return result; + } + if (account.getAssetOptimized()) { + physicalSnapshot.forEach((key, value) -> result.put( + WrappedByteArray.copyOf(key.getBytes()), Arrays.copyOf(value, value.length))); + } + account.getAssetV2Map().forEach((token, balance) -> { + AssetRow encoded = codec.encodeAssetRow(account.getAssetOptimized() + ? Phase.P66_ON : Phase.P66_ACTIVATION, + accountKey, token, balance); + WrappedByteArray key = WrappedByteArray.copyOf(encoded.getPhysicalRawKey()); + if (!encoded.getPostValue().isPresent()) { + result.remove(key); + } else { + result.put(key, encoded.getPostValue().getValue()); + } + }); + return result; + } + + private byte[] canonicalAccount(byte[] accountKey, Account account, byte[] rawAccount, + boolean projected) { + return codec.canonicalizeAccount(phase(account, projected), accountKey, account, rawAccount); + } + + private Phase phase(Account account, boolean projected) { + if (!projected) { + return Phase.P66_OFF; + } + return account.getAssetOptimized() ? Phase.P66_ON : Phase.P66_ACTIVATION; + } + + private Account parse(byte[] value) { + if (value == null) { + return null; + } + try { + return Account.parseFrom(value); + } catch (InvalidProtocolBufferException e) { + throw new ArchivePersistenceException( + "Invalid account value while projecting archive state", e); + } + } + + static final class Projection { + final OldValue oldAccount; + final BlockChangeView.PostValue postAccount; + final List reverseAssets; + final List changedAssetRows; + + private Projection(OldValue oldAccount, BlockChangeView.PostValue postAccount, + List reverseAssets, List changedAssetRows) { + this.oldAccount = oldAccount; + this.postAccount = postAccount; + this.reverseAssets = Collections.unmodifiableList(new ArrayList<>(reverseAssets)); + this.changedAssetRows = Collections.unmodifiableList(new ArrayList<>(changedAssetRows)); + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/AccountAssetOldPhysicalAssetsSource.java b/chainbase/src/main/java/org/tron/core/db2/archive/AccountAssetOldPhysicalAssetsSource.java new file mode 100644 index 00000000000..3598b640838 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/AccountAssetOldPhysicalAssetsSource.java @@ -0,0 +1,28 @@ +package org.tron.core.db2.archive; + +import java.util.Map; +import java.util.Objects; +import org.tron.core.db2.common.WrappedByteArray; + +/** Commit-time source for one changed account's old physical account-asset rows. */ +@FunctionalInterface +public interface AccountAssetOldPhysicalAssetsSource { + + /** Returns the complete old physical rows for {@code accountKey}. */ + Map capture(byte[] accountKey); + + static Map captureRequired( + AccountAssetOldPhysicalAssetsSource source, byte[] accountKey) { + try { + Map captured = Objects.requireNonNull( + Objects.requireNonNull(source, "source").capture(accountKey), + "old physical AccountAsset input"); + return captured; + } catch (ArchivePersistenceException failure) { + throw failure; + } catch (RuntimeException failure) { + throw new ArchivePersistenceException( + "Failed to capture old physical AccountAsset input", failure); + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveAuthorityHandleSources.java b/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveAuthorityHandleSources.java new file mode 100644 index 00000000000..2e94a0d06d0 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveAuthorityHandleSources.java @@ -0,0 +1,130 @@ +package org.tron.core.db2.archive; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.TreeMap; +import org.tron.core.db2.archive.ArchiveAuthoritySnapshotCollector.HistorySource; +import org.tron.core.db2.archive.ArchiveAuthoritySnapshotCollector.LatestSource; +import org.tron.core.db2.archive.ArchiveAuthoritySnapshotCollector.ProgressSource; +import org.tron.core.db2.archive.ArchiveAuthoritySnapshotCollector.ServingSource; +import org.tron.core.db2.archive.ArchiveReadSnapshot.PinnedLatestState; +import org.tron.core.db2.archive.ArchiveReadSnapshot.PinnedLatestStateFactory; + +/** Read-only collector adapters over already-opened archive authority handles. */ +public final class ArchiveAuthorityHandleSources + implements HistorySource, ProgressSource, ServingSource, LatestSource { + + private final CommittedHistoryAuthority history; + private final ArchiveTargetMutationPlanFile planFile; + private final ArchiveProgressFile checkpointFile; + private final ArchiveProgressFile readerFile; + private final Map participantSources; + private final PersistentServingKeyIndexCatalog catalog; + private final PinnedLatestStateFactory latestFactory; + + public ArchiveAuthorityHandleSources(CommittedHistoryAuthority history, Path checkpointPath, + Map participantSources, + Path readerVisiblePath, PersistentServingKeyIndexCatalog catalog, + PinnedLatestStateFactory latestFactory) { + this.history = Objects.requireNonNull(history, "history"); + this.planFile = new ArchiveTargetMutationPlanFile( + Objects.requireNonNull(checkpointPath, "checkpointPath")); + ArchiveProgressEnvelopeCodec codec = new ArchiveProgressEnvelopeCodec(); + this.checkpointFile = new ArchiveProgressFile(checkpointPath, codec); + this.readerFile = new ArchiveProgressFile( + Objects.requireNonNull(readerVisiblePath, "readerVisiblePath"), codec); + this.participantSources = exactParticipantSources(participantSources); + this.catalog = Objects.requireNonNull(catalog, "catalog"); + this.latestFactory = Objects.requireNonNull(latestFactory, "latestFactory"); + } + + @Override + public HistoryCoverage coverage() { + return history.coverage(); + } + + @Override + public HistoryCommitMarker first() { + long firstEpoch = history.firstEpoch(); + return firstEpoch < 0 ? null : history.get(firstEpoch); + } + + @Override + public HistoryCommitMarker head() { + return history.head(); + } + + @Override + public boolean mutationPlanPresent() throws IOException { + return planFile.loadIfPresent() != null; + } + + @Override + public ArchiveProgressEnvelope applyCheckpoint() throws IOException { + return checkpointFile.load(); + } + + @Override + public Map participantProgress() throws IOException { + Map loaded = new LinkedHashMap<>(); + for (Map.Entry entry + : participantSources.entrySet()) { + loaded.put(entry.getKey(), entry.getValue().loadProgress()); + } + return loaded; + } + + @Override + public ArchiveProgressEnvelope readerVisible() throws IOException { + return readerFile.load(); + } + + @Override + public ArchiveAuthoritySourceBundle.ServingGenerationSnapshot current() throws IOException { + ArchiveProgressEnvelope reader = readerFile.load(); + try (PersistentServingKeyIndexGeneration generation = catalog.pin(reader)) { + return snapshot(generation); + } + } + + @Override + public byte[] sourceIdentityDigest() throws IOException { + ArchiveProgressEnvelope reader = readerFile.load(); + try (PersistentServingKeyIndexGeneration generation = catalog.pin(reader); + PinnedLatestState latest = latestFactory.pin(generation)) { + if (latest.getBlockNumber() != generation.getIndexedThrough() + || !Arrays.equals(latest.getBlockHash(), generation.getHeadHash())) { + throw new ArchivePersistenceException( + "Pinned latest source does not match serving generation head"); + } + byte[] digest = latest.getSourceIdentityDigest(); + return digest == null ? null : Arrays.copyOf(digest, digest.length); + } + } + + private static ArchiveAuthoritySourceBundle.ServingGenerationSnapshot snapshot( + PersistentServingKeyIndexGeneration generation) { + return new ArchiveAuthoritySourceBundle.ServingGenerationSnapshot( + generation.getScopeIdentity(), generation.getParticipatingDatabases(), + generation.getIndexedFrom(), generation.getIndexedThrough(), generation.getHeadHash(), + generation.getAuthoritativePrefixDigest(), generation.getLatestSourceIdentityDigest()); + } + + private static Map exactParticipantSources( + Map actual) { + TreeMap sorted = new TreeMap<>(); + Objects.requireNonNull(actual, "participantSources").forEach(sorted::put); + List participants = ArchiveParticipantDescriptor.current().getParticipants(); + if (!new ArrayList<>(sorted.keySet()).equals(participants) || sorted.containsValue(null)) { + throw new IllegalArgumentException("Archive participant source set is not exact-27"); + } + return Collections.unmodifiableMap(new LinkedHashMap<>(sorted)); + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveAuthoritySnapshotCollector.java b/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveAuthoritySnapshotCollector.java new file mode 100644 index 00000000000..05bba52197a --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveAuthoritySnapshotCollector.java @@ -0,0 +1,177 @@ +package org.tron.core.db2.archive; + +import java.io.IOException; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** Collects one drift-checked, read-only snapshot of archive startup authorities. */ +public final class ArchiveAuthoritySnapshotCollector { + + private final HistorySource history; + private final ProgressSource progress; + private final ServingSource serving; + private final LatestSource latest; + + public ArchiveAuthoritySnapshotCollector(HistorySource history, ProgressSource progress, + ServingSource serving, LatestSource latest) { + this.history = Objects.requireNonNull(history, "history"); + this.progress = Objects.requireNonNull(progress, "progress"); + this.serving = Objects.requireNonNull(serving, "serving"); + this.latest = Objects.requireNonNull(latest, "latest"); + } + + /** + * Reads mutable boundary authorities twice and rejects the complete result if any changed. + * Source implementations must be read-only; this class never opens or repairs storage. + */ + public ArchiveAuthoritySourceBundle collect() throws IOException { + HistoryCoverage coverageBefore = required(history.coverage(), "history coverage"); + HistoryCommitMarker first = required(history.first(), "first history marker"); + HistoryCommitMarker headBefore = required(history.head(), "history head"); + ArchiveProgressEnvelope readerBefore = required(progress.readerVisible(), "reader visible"); + ArchiveAuthoritySourceBundle.ServingGenerationSnapshot servingBefore = required( + serving.current(), "serving generation"); + byte[] latestBefore = required(latest.sourceIdentityDigest(), + "latest source identity digest"); + + boolean planBefore = progress.mutationPlanPresent(); + ArchiveProgressEnvelope checkpoint = required(progress.applyCheckpoint(), + "apply checkpoint"); + Map participantProgress = exactParticipantProgress( + progress.participantProgress()); + + boolean planAfter = progress.mutationPlanPresent(); + byte[] latestAfter = required(latest.sourceIdentityDigest(), + "latest source identity digest"); + ArchiveAuthoritySourceBundle.ServingGenerationSnapshot servingAfter = required( + serving.current(), "serving generation"); + ArchiveProgressEnvelope readerAfter = required(progress.readerVisible(), "reader visible"); + HistoryCommitMarker headAfter = required(history.head(), "history head"); + HistoryCoverage coverageAfter = required(history.coverage(), "history coverage"); + + if (planBefore != planAfter + || !sameCoverage(coverageBefore, coverageAfter) + || !sameMarker(headBefore, headAfter) + || !sameProgress(readerBefore, readerAfter) + || !sameServing(servingBefore, servingAfter) + || !Arrays.equals(latestBefore, latestAfter)) { + throw new ArchivePersistenceException( + "Archive authority changed while collecting startup snapshot"); + } + return new ArchiveAuthoritySourceBundle(planBefore, coverageBefore, first, headBefore, + checkpoint, participantProgress, readerBefore, servingBefore, latestBefore); + } + + private static boolean sameCoverage(HistoryCoverage left, HistoryCoverage right) { + return left.getFirstEpoch() == right.getFirstEpoch() + && left.getRecordCount() == right.getRecordCount() + && left.getHeadEpoch() == right.getHeadEpoch() + && Arrays.equals(left.getHeadHash(), right.getHeadHash()); + } + + private static Map exactParticipantProgress( + Map actual) { + required(actual, "participant progress"); + List participants = ArchiveParticipantDescriptor.current().getParticipants(); + if (!actual.keySet().equals(new LinkedHashSet<>(participants))) { + throw new ArchivePersistenceException("Participant progress set is not exact-27"); + } + Map copy = new LinkedHashMap<>(); + for (String participant : participants) { + copy.put(participant, required(actual.get(participant), participant + " progress")); + } + return copy; + } + + private static boolean sameMarker(HistoryCommitMarker left, HistoryCommitMarker right) { + return left.getMeta().equals(right.getMeta()) + && left.getPreviousEpoch() == right.getPreviousEpoch() + && sameHistoryLocation(left.getHistoryLocation(), right.getHistoryLocation()) + && sameIndexLocation(left.getIndexLocation(), right.getIndexLocation()) + && Arrays.equals(left.getBatchId(), right.getBatchId()) + && left.getDatabases().equals(right.getDatabases()); + } + + private static boolean sameHistoryLocation(HistoryLocation left, HistoryLocation right) { + return left.getSegmentId() == right.getSegmentId() + && left.getOffset() == right.getOffset() + && left.getRecordLength() == right.getRecordLength() + && left.getBodyChecksum() == right.getBodyChecksum() + && Arrays.equals(left.getBodyDigest(), right.getBodyDigest()); + } + + private static boolean sameIndexLocation(HistoryIndexLocation left, + HistoryIndexLocation right) { + return left.getOffset() == right.getOffset() + && left.getRecordLength() == right.getRecordLength() + && Arrays.equals(left.getDigest(), right.getDigest()); + } + + private static boolean sameProgress(ArchiveProgressEnvelope left, + ArchiveProgressEnvelope right) { + return left.getKind() == right.getKind() + && Objects.equals(left.getParticipant(), right.getParticipant()) + && left.getEpoch() == right.getEpoch() + && Arrays.equals(left.getBlockHash(), right.getBlockHash()) + && Arrays.equals(left.getBatchId(), right.getBatchId()) + && Arrays.equals(left.getPayloadDigest(), right.getPayloadDigest()) + && Arrays.equals(left.getMutationPlanDigest(), right.getMutationPlanDigest()) + && left.getParticipants().equals(right.getParticipants()) + && left.getScopeIdentity().equals(right.getScopeIdentity()); + } + + private static boolean sameServing( + ArchiveAuthoritySourceBundle.ServingGenerationSnapshot left, + ArchiveAuthoritySourceBundle.ServingGenerationSnapshot right) { + return Objects.equals(left.getScopeIdentity(), right.getScopeIdentity()) + && Objects.equals(left.getParticipants(), right.getParticipants()) + && left.getIndexedFromEpoch() == right.getIndexedFromEpoch() + && left.getIndexedThroughEpoch() == right.getIndexedThroughEpoch() + && Arrays.equals(left.getHeadHash(), right.getHeadHash()) + && Arrays.equals(left.getAuthoritativePrefixDigest(), + right.getAuthoritativePrefixDigest()) + && Arrays.equals(left.getLatestSourceIdentityDigest(), + right.getLatestSourceIdentityDigest()); + } + + private static T required(T value, String name) { + if (value == null) { + throw new ArchivePersistenceException("Missing archive authority: " + name); + } + return value; + } + + /** Read-only committed-history identities. */ + public interface HistorySource { + HistoryCoverage coverage() throws IOException; + + HistoryCommitMarker first() throws IOException; + + HistoryCommitMarker head() throws IOException; + } + + /** Read-only plan and C/D/R identities. */ + public interface ProgressSource { + boolean mutationPlanPresent() throws IOException; + + ArchiveProgressEnvelope applyCheckpoint() throws IOException; + + Map participantProgress() throws IOException; + + ArchiveProgressEnvelope readerVisible() throws IOException; + } + + /** Read-only current serving generation identity. */ + public interface ServingSource { + ArchiveAuthoritySourceBundle.ServingGenerationSnapshot current() throws IOException; + } + + /** Read-only identity of the pinned latest-state source set. */ + public interface LatestSource { + byte[] sourceIdentityDigest() throws IOException; + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveAuthoritySourceBundle.java b/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveAuthoritySourceBundle.java new file mode 100644 index 00000000000..0958e37cbd0 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveAuthoritySourceBundle.java @@ -0,0 +1,138 @@ +package org.tron.core.db2.archive; + +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Immutable, read-only snapshots of authorities needed for archive startup admission. + * + *

The bundle deliberately does not know how to open engines or repair files. A future runtime + * adapter must collect every value from already-opened read-only sources before calling the + * validator. + */ +public final class ArchiveAuthoritySourceBundle { + + private final boolean mutationPlanPresent; + private final HistoryCoverage historyCoverage; + private final HistoryCommitMarker firstHistoryMarker; + private final HistoryCommitMarker headHistoryMarker; + private final ArchiveProgressEnvelope applyCheckpoint; + private final Map participantProgress; + private final ArchiveProgressEnvelope readerVisible; + private final ServingGenerationSnapshot servingGeneration; + private final byte[] latestSourceIdentityDigest; + + public ArchiveAuthoritySourceBundle(boolean mutationPlanPresent, + HistoryCoverage historyCoverage, HistoryCommitMarker firstHistoryMarker, + HistoryCommitMarker headHistoryMarker, + ArchiveProgressEnvelope applyCheckpoint, + Map participantProgress, + ArchiveProgressEnvelope readerVisible, ServingGenerationSnapshot servingGeneration, + byte[] latestSourceIdentityDigest) { + this.mutationPlanPresent = mutationPlanPresent; + this.historyCoverage = historyCoverage; + this.firstHistoryMarker = firstHistoryMarker; + this.headHistoryMarker = headHistoryMarker; + this.applyCheckpoint = applyCheckpoint; + this.participantProgress = participantProgress == null ? null + : Collections.unmodifiableMap(new LinkedHashMap<>(participantProgress)); + this.readerVisible = readerVisible; + this.servingGeneration = servingGeneration; + this.latestSourceIdentityDigest = copy(latestSourceIdentityDigest); + } + + boolean isMutationPlanPresent() { + return mutationPlanPresent; + } + + HistoryCoverage getHistoryCoverage() { + return historyCoverage; + } + + HistoryCommitMarker getFirstHistoryMarker() { + return firstHistoryMarker; + } + + HistoryCommitMarker getHeadHistoryMarker() { + return headHistoryMarker; + } + + ArchiveProgressEnvelope getApplyCheckpoint() { + return applyCheckpoint; + } + + Map getParticipantProgress() { + return participantProgress; + } + + ArchiveProgressEnvelope getReaderVisible() { + return readerVisible; + } + + ServingGenerationSnapshot getServingGeneration() { + return servingGeneration; + } + + byte[] getLatestSourceIdentityDigest() { + return copy(latestSourceIdentityDigest); + } + + private static byte[] copy(byte[] value) { + return value == null ? null : Arrays.copyOf(value, value.length); + } + + /** Read-only serving generation/catalog identity, independent of its physical engine. */ + public static final class ServingGenerationSnapshot { + private final String scopeIdentity; + private final List participants; + private final long indexedFromEpoch; + private final long indexedThroughEpoch; + private final byte[] headHash; + private final byte[] authoritativePrefixDigest; + private final byte[] latestSourceIdentityDigest; + + public ServingGenerationSnapshot(String scopeIdentity, List participants, + long indexedFromEpoch, long indexedThroughEpoch, byte[] headHash, + byte[] authoritativePrefixDigest, byte[] latestSourceIdentityDigest) { + this.scopeIdentity = scopeIdentity; + this.participants = participants == null ? null + : Collections.unmodifiableList(new java.util.ArrayList<>(participants)); + this.indexedFromEpoch = indexedFromEpoch; + this.indexedThroughEpoch = indexedThroughEpoch; + this.headHash = copy(headHash); + this.authoritativePrefixDigest = copy(authoritativePrefixDigest); + this.latestSourceIdentityDigest = copy(latestSourceIdentityDigest); + } + + String getScopeIdentity() { + return scopeIdentity; + } + + List getParticipants() { + return participants; + } + + long getIndexedFromEpoch() { + return indexedFromEpoch; + } + + long getIndexedThroughEpoch() { + return indexedThroughEpoch; + } + + byte[] getHeadHash() { + return copy(headHash); + } + + byte[] getAuthoritativePrefixDigest() { + return copy(authoritativePrefixDigest); + } + + byte[] getLatestSourceIdentityDigest() { + return copy(latestSourceIdentityDigest); + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveBaseManifest.java b/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveBaseManifest.java new file mode 100644 index 00000000000..e74bc60f337 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveBaseManifest.java @@ -0,0 +1,229 @@ +package org.tron.core.db2.archive; + +import com.google.common.hash.Hashing; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.UUID; + +/** Durable generation base identity for the experimental state archive. */ +final class ArchiveBaseManifest { + + private static final int MAGIC = 0x54414d46; // TAMF + private static final short VERSION = 3; + private static final int MAX_LENGTH = 1024 * 1024; + + private final Path directory; + private final Path path; + private final List participants; + private final String scopeIdentity; + private BaseIdentity base; + + ArchiveBaseManifest(Path directory, List participants) throws IOException { + this.directory = directory; + this.path = directory.resolve("MANIFEST"); + this.participants = new ArrayList<>(participants); + this.scopeIdentity = scopeIdentity(this.participants); + Files.createDirectories(directory); + if (Files.exists(path)) { + base = loadExisting(path, scopeIdentity, this.participants); + } + } + + /** Validates an existing manifest without creating or modifying any filesystem entry. */ + static ExistingBase validateExisting(Path directory, List participants) + throws IOException { + List expectedParticipants = new ArrayList<>(participants); + Path manifest = directory.resolve("MANIFEST"); + if (!Files.isRegularFile(manifest, LinkOption.NOFOLLOW_LINKS)) { + throw new ArchivePersistenceException("Archive manifest is missing or not a regular file"); + } + BaseIdentity existing = loadExisting(manifest, scopeIdentity(expectedParticipants), + expectedParticipants); + return new ExistingBase(existing.epoch, existing.hash); + } + + synchronized void ensureBase(BlockSnapshotMeta firstArchivedBlock) throws IOException { + long epoch = firstArchivedBlock.getEpoch() - 1; + byte[] hash = firstArchivedBlock.getParentHash(); + if (base != null) { + if (base.epoch != epoch || !Arrays.equals(base.hash, hash)) { + throw new ArchivePersistenceException("Archive input does not extend the manifest base"); + } + return; + } + BaseIdentity identity = new BaseIdentity(scopeIdentity, epoch, hash, participants); + byte[] encoded = encode(identity); + Path temporary = directory.resolve(".MANIFEST-" + UUID.randomUUID()); + Files.write(temporary, encoded); + try (java.nio.channels.FileChannel channel = java.nio.channels.FileChannel.open(temporary, + java.nio.file.StandardOpenOption.WRITE)) { + channel.force(true); + } + try { + Files.move(temporary, path, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException failure) { + Files.deleteIfExists(temporary); + throw new IOException("Atomic archive manifest publication is not supported", failure); + } + HistorySegmentStore.syncDirectory(directory); + base = identity; + } + + private static byte[] encode(BaseIdentity identity) throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + DataOutputStream output = new DataOutputStream(bytes); + output.writeInt(MAGIC); + output.writeShort(VERSION); + output.writeShort(0); + output.writeInt(0); + writeString(output, identity.scopeIdentity); + output.writeLong(identity.epoch); + output.write(identity.hash); + output.writeInt(identity.participants.size()); + for (String participant : identity.participants) { + byte[] name = participant.getBytes(StandardCharsets.UTF_8); + output.writeInt(name.length); + output.write(name); + } + output.flush(); + byte[] payload = bytes.toByteArray(); + int length = payload.length + Integer.BYTES; + if (length > MAX_LENGTH) { + throw new IllegalArgumentException("Archive manifest is too large"); + } + ByteBuffer.wrap(payload).putInt(8, length); + ByteArrayOutputStream encoded = new ByteArrayOutputStream(length); + encoded.write(payload); + try (DataOutputStream checksum = new DataOutputStream(encoded)) { + checksum.writeInt(Hashing.crc32c().hashBytes(payload).asInt()); + checksum.flush(); + } + return encoded.toByteArray(); + } + + private static BaseIdentity decode(byte[] encoded) throws IOException { + if (encoded.length < 64 || encoded.length > MAX_LENGTH) { + throw new ArchivePersistenceException("Archive manifest length is invalid"); + } + int checksum = ByteBuffer.wrap(encoded, encoded.length - Integer.BYTES, + Integer.BYTES).getInt(); + byte[] payload = Arrays.copyOf(encoded, encoded.length - Integer.BYTES); + if (checksum != Hashing.crc32c().hashBytes(payload).asInt()) { + throw new ArchivePersistenceException("Archive manifest checksum mismatch"); + } + try (DataInputStream input = new DataInputStream(new ByteArrayInputStream(encoded))) { + if (input.readInt() != MAGIC || input.readShort() != VERSION || input.readShort() != 0 + || input.readInt() != encoded.length) { + throw new ArchivePersistenceException("Unsupported archive manifest header"); + } + String scopeIdentity = readString(input, "Archive manifest scope identity is invalid"); + long epoch = input.readLong(); + byte[] hash = new byte[32]; + input.readFully(hash); + int count = input.readInt(); + if (epoch < -1 || count <= 0 || count > 1024) { + throw new ArchivePersistenceException("Archive manifest identity is invalid"); + } + List participants = new ArrayList<>(count); + String previous = null; + for (int i = 0; i < count; i++) { + int length = input.readInt(); + if (length <= 0 || length > 1024) { + throw new ArchivePersistenceException("Archive manifest participant is invalid"); + } + byte[] name = new byte[length]; + input.readFully(name); + String participant = new String(name, StandardCharsets.UTF_8); + if (previous != null && previous.compareTo(participant) >= 0) { + throw new ArchivePersistenceException("Archive manifest participants are not sorted"); + } + participants.add(participant); + previous = participant; + } + if (input.available() != Integer.BYTES) { + throw new ArchivePersistenceException("Archive manifest payload mismatch"); + } + return new BaseIdentity(scopeIdentity, epoch, hash, participants); + } + } + + private static String scopeIdentity(List participants) { + return ArchiveParticipantDescriptor.scopeIdentity(participants); + } + + private static BaseIdentity loadExisting(Path path, String expectedScope, + List expectedParticipants) throws IOException { + BaseIdentity existing = decode(Files.readAllBytes(path)); + if (!expectedScope.equals(existing.scopeIdentity) + || !expectedParticipants.equals(existing.participants)) { + throw new ArchivePersistenceException("Archive manifest participant set mismatch"); + } + return existing; + } + + private static void writeString(DataOutputStream output, String value) throws IOException { + byte[] encoded = value.getBytes(StandardCharsets.UTF_8); + if (encoded.length == 0 || encoded.length > 1024) { + throw new IllegalArgumentException("Archive manifest string is invalid"); + } + output.writeInt(encoded.length); + output.write(encoded); + } + + private static String readString(DataInputStream input, String error) throws IOException { + int length = input.readInt(); + if (length <= 0 || length > 1024 || length > input.available() - Integer.BYTES) { + throw new ArchivePersistenceException(error); + } + byte[] encoded = new byte[length]; + input.readFully(encoded); + return new String(encoded, StandardCharsets.UTF_8); + } + + private static final class BaseIdentity { + private final String scopeIdentity; + private final long epoch; + private final byte[] hash; + private final List participants; + + private BaseIdentity(String scopeIdentity, long epoch, byte[] hash, + List participants) { + this.scopeIdentity = scopeIdentity; + this.epoch = epoch; + this.hash = Arrays.copyOf(hash, hash.length); + this.participants = new ArrayList<>(participants); + } + } + + /** Read-only identity returned by validation; it exposes no manifest mutation capability. */ + static final class ExistingBase { + private final long epoch; + private final byte[] hash; + + private ExistingBase(long epoch, byte[] hash) { + this.epoch = epoch; + this.hash = Arrays.copyOf(hash, hash.length); + } + + long getEpoch() { + return epoch; + } + + byte[] getHash() { + return Arrays.copyOf(hash, hash.length); + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveBootstrapAnchor.java b/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveBootstrapAnchor.java new file mode 100644 index 00000000000..f529ac19d32 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveBootstrapAnchor.java @@ -0,0 +1,169 @@ +package org.tron.core.db2.archive; + +import com.google.common.hash.Hashing; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.UUID; + +/** Atomic identity which makes a synthetic empty first H a non-queryable bootstrap anchor. */ +final class ArchiveBootstrapAnchor { + + private static final int MAGIC = 0x54414241; // TABA + private static final short VERSION = 1; + private static final int HEADER_LENGTH = 16; + private static final int MAX_LENGTH = 1024 * 1024; + private static final String FILE_NAME = "bootstrap.anchor"; + + private ArchiveBootstrapAnchor() { + } + + static void store(Path archiveDirectory, HistoryCommitMarker marker, List stores) + throws IOException { + HistoryCommitMarkerCodec markerCodec = new HistoryCommitMarkerCodec(); + byte[] markerBytes = markerCodec.encode(marker); + requireStoreScope(marker, stores); + byte[] encoded = encode(markerBytes); + Files.createDirectories(archiveDirectory); + Path temporary = archiveDirectory.resolve("." + FILE_NAME + "-" + UUID.randomUUID()); + try { + try (FileChannel channel = FileChannel.open(temporary, StandardOpenOption.CREATE_NEW, + StandardOpenOption.WRITE)) { + writeFully(channel, ByteBuffer.wrap(encoded)); + channel.force(true); + } + try { + Files.move(temporary, archiveDirectory.resolve(FILE_NAME), + StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException unsupported) { + throw new ArchivePersistenceException( + "Archive filesystem does not support atomic bootstrap anchor replacement", + unsupported); + } + HistorySegmentStore.syncDirectory(archiveDirectory); + } finally { + Files.deleteIfExists(temporary); + } + } + + static HistoryCommitMarker loadAndValidateIfPresent(Path archiveDirectory, + CommittedHistoryAuthority history, List stores) throws IOException { + Path path = archiveDirectory.resolve(FILE_NAME); + if (!Files.exists(path, LinkOption.NOFOLLOW_LINKS)) { + return null; + } + if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) { + throw new ArchivePersistenceException("Archive bootstrap anchor is not a regular file"); + } + HistoryCommitMarkerCodec markerCodec = new HistoryCommitMarkerCodec(); + HistoryCommitMarker anchor; + try { + anchor = markerCodec.decode(decode(Files.readAllBytes(path))); + } catch (IllegalArgumentException invalid) { + throw new ArchivePersistenceException("Archive bootstrap anchor is corrupt", invalid); + } + HistoryCommitMarker first = history.get(history.firstEpoch()); + if (first == null) { + throw new ArchivePersistenceException("Archive bootstrap anchor has no history marker"); + } + requireStoreScope(anchor, stores); + if (!Arrays.equals(markerCodec.encode(anchor), markerCodec.encode(first))) { + throw new ArchivePersistenceException( + "Archive bootstrap anchor does not match the first history marker"); + } + if (history instanceof ArchiveHistoryWriter) { + BlockReverseDiff diff = ((ArchiveHistoryWriter) history) + .readCommitted(first.getMeta().getEpoch()); + if (!diff.getGroups().isEmpty()) { + throw new ArchivePersistenceException("Archive bootstrap anchor history is not empty"); + } + } + return first; + } + + private static byte[] encode(byte[] markerBytes) { + try { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + DataOutputStream output = new DataOutputStream(bytes); + output.writeInt(MAGIC); + output.writeShort(VERSION); + output.writeShort(0); + output.writeInt(0); + output.writeInt(markerBytes.length); + output.write(markerBytes); + output.flush(); + byte[] payload = bytes.toByteArray(); + int length = payload.length + Integer.BYTES; + if (length > MAX_LENGTH) { + throw new IllegalArgumentException("Archive bootstrap anchor is too large"); + } + ByteBuffer.wrap(payload).putInt(8, length); + bytes.reset(); + output = new DataOutputStream(bytes); + output.write(payload); + output.writeInt(Hashing.crc32c().hashBytes(payload).asInt()); + output.flush(); + return bytes.toByteArray(); + } catch (IOException impossible) { + throw new IllegalStateException("Unexpected bootstrap anchor encoding failure", impossible); + } + } + + private static byte[] decode(byte[] encoded) { + if (encoded == null || encoded.length < HEADER_LENGTH + Integer.BYTES + || encoded.length > MAX_LENGTH) { + throw new IllegalArgumentException("Archive bootstrap anchor length is invalid"); + } + byte[] payload = Arrays.copyOf(encoded, encoded.length - Integer.BYTES); + int checksum = ByteBuffer.wrap(encoded, payload.length, Integer.BYTES).getInt(); + if (checksum != Hashing.crc32c().hashBytes(payload).asInt()) { + throw new IllegalArgumentException("Archive bootstrap anchor checksum mismatch"); + } + try (DataInputStream input = new DataInputStream(new ByteArrayInputStream(encoded))) { + if (input.readInt() != MAGIC || input.readShort() != VERSION || input.readShort() != 0 + || input.readInt() != encoded.length) { + throw new IllegalArgumentException("Unsupported archive bootstrap anchor header"); + } + int markerLength = input.readInt(); + if (markerLength <= 0 || markerLength != input.available() - Integer.BYTES) { + throw new IllegalArgumentException("Archive bootstrap anchor marker length is invalid"); + } + byte[] marker = new byte[markerLength]; + input.readFully(marker); + if (input.available() != Integer.BYTES) { + throw new IllegalArgumentException("Archive bootstrap anchor payload mismatch"); + } + return marker; + } catch (IOException invalid) { + throw new IllegalArgumentException("Archive bootstrap anchor is truncated", invalid); + } + } + + private static void requireStoreScope(HistoryCommitMarker marker, List stores) { + List expected = new ArrayList<>(stores); + Collections.sort(expected); + if (!marker.getDatabases().equals(expected)) { + throw new ArchivePersistenceException("Archive bootstrap anchor Store scope mismatch"); + } + } + + private static void writeFully(FileChannel channel, ByteBuffer buffer) throws IOException { + while (buffer.hasRemaining()) { + channel.write(buffer); + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveCommittedPrefixPublisher.java b/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveCommittedPrefixPublisher.java new file mode 100644 index 00000000000..0da6f8dacc8 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveCommittedPrefixPublisher.java @@ -0,0 +1,10 @@ +package org.tron.core.db2.archive; + +import java.io.IOException; + +/** Publishes derived serving authority for one H/WAL-proven committed prefix. */ +@FunctionalInterface +public interface ArchiveCommittedPrefixPublisher { + + void publish(BlockSnapshotMeta target) throws IOException; +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveFormatAdmissionValidator.java b/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveFormatAdmissionValidator.java new file mode 100644 index 00000000000..9f9a1a19fba --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveFormatAdmissionValidator.java @@ -0,0 +1,234 @@ +package org.tron.core.db2.archive; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Stream; + +/** Read-only first-stage admission for the current archive base format. */ +public final class ArchiveFormatAdmissionValidator { + + private ArchiveFormatAdmissionValidator() { + } + + /** + * Inspects only directory existence and the base MANIFEST. CURRENT_BASE is deliberately not a + * claim that plan/C/D/R, serving generations, or latest-source authorities are startup-ready. + */ + public static Result inspect(Path archiveDirectory) { + return inspectBase(archiveDirectory).result; + } + + /** + * Compares explicit read-only authority snapshots after validating the current exact-27 base. + * No source is opened, repaired, created, or rewritten by this method. + */ + public static Result inspect(Path archiveDirectory, ArchiveAuthoritySourceBundle authorities) { + BaseInspection baseInspection = inspectBase(archiveDirectory); + if (baseInspection.result.getStatus() != Status.CURRENT_BASE) { + return baseInspection.result; + } + try { + requireReady(baseInspection.base, authorities); + return Result.currentReady(); + } catch (RuntimeException failure) { + return Result.quarantine(Reason.INCOMPLETE_OR_INCONSISTENT_AUTHORITIES, + failure.getMessage()); + } + } + + private static BaseInspection inspectBase(Path archiveDirectory) { + Objects.requireNonNull(archiveDirectory, "archiveDirectory"); + if (!Files.exists(archiveDirectory, LinkOption.NOFOLLOW_LINKS)) { + return BaseInspection.of(Result.emptyNew(), null); + } + if (Files.isSymbolicLink(archiveDirectory) + || !Files.isDirectory(archiveDirectory, LinkOption.NOFOLLOW_LINKS)) { + return BaseInspection.of(Result.quarantine(Reason.INVALID_DIRECTORY, + "Archive path is not a real directory"), null); + } + final boolean empty; + try (Stream entries = Files.list(archiveDirectory)) { + empty = !entries.findAny().isPresent(); + } catch (IOException failure) { + return BaseInspection.of(Result.quarantine(Reason.INSPECTION_FAILED, + failure.getMessage()), null); + } + if (empty) { + return BaseInspection.of(Result.emptyNew(), null); + } + Path manifest = archiveDirectory.resolve("MANIFEST"); + if (!Files.isRegularFile(manifest, LinkOption.NOFOLLOW_LINKS)) { + return BaseInspection.of(Result.quarantine(Reason.NONEMPTY_WITHOUT_MANIFEST, + "Non-empty archive directory has no regular MANIFEST"), null); + } + try { + ArchiveBaseManifest.ExistingBase base = ArchiveBaseManifest.validateExisting(archiveDirectory, + ArchiveParticipantDescriptor.current().getParticipants()); + return BaseInspection.of(Result.currentBase(), base); + } catch (IOException | RuntimeException failure) { + return BaseInspection.of(Result.quarantine(Reason.UNSUPPORTED_OR_CORRUPT_MANIFEST, + failure.getMessage()), null); + } + } + + private static void requireReady(ArchiveBaseManifest.ExistingBase base, + ArchiveAuthoritySourceBundle authorities) { + Objects.requireNonNull(authorities, "authorities"); + if (authorities.isMutationPlanPresent()) { + throw new ArchivePersistenceException("Active mutation plan requires recovery"); + } + HistoryCoverage coverage = Objects.requireNonNull(authorities.getHistoryCoverage(), + "historyCoverage"); + HistoryCommitMarker first = Objects.requireNonNull(authorities.getFirstHistoryMarker(), + "firstHistoryMarker"); + HistoryCommitMarker head = Objects.requireNonNull(authorities.getHeadHistoryMarker(), + "headHistoryMarker"); + List participants = ArchiveParticipantDescriptor.current().getParticipants(); + ArchiveParticipantDescriptor.current().requireExactParticipants(first.getDatabases()); + ArchiveParticipantDescriptor.current().requireExactParticipants(head.getDatabases()); + long expectedRecordCount; + try { + expectedRecordCount = Math.addExact( + Math.subtractExact(coverage.getHeadEpoch(), coverage.getFirstEpoch()), 1); + } catch (ArithmeticException failure) { + throw new ArchivePersistenceException("History coverage range overflows", failure); + } + if (coverage.getFirstEpoch() != first.getMeta().getEpoch() + || coverage.getHeadEpoch() != head.getMeta().getEpoch() + || coverage.getRecordCount() != expectedRecordCount + || !Arrays.equals(coverage.getHeadHash(), head.getMeta().getBlockHash()) + || first.getMeta().getEpoch() != base.getEpoch() + 1 + || first.getPreviousEpoch() != base.getEpoch() + || !Arrays.equals(first.getMeta().getParentHash(), base.getHash()) + || head.getMeta().getEpoch() < first.getMeta().getEpoch()) { + throw new ArchivePersistenceException("History markers do not extend the manifest base"); + } + + ArchiveProgressEnvelope checkpoint = Objects.requireNonNull( + authorities.getApplyCheckpoint(), "applyCheckpoint"); + byte[] planDigest = checkpoint.getMutationPlanDigest(); + requireProgress(checkpoint, ArchiveProgressEnvelope.Kind.APPLY_CHECKPOINT, null, head, + planDigest, participants); + Map progress = Objects.requireNonNull( + authorities.getParticipantProgress(), "participantProgress"); + if (!progress.keySet().equals(new java.util.LinkedHashSet<>(participants))) { + throw new ArchivePersistenceException("Participant progress set is not exact-27"); + } + for (String participant : participants) { + requireProgress(Objects.requireNonNull(progress.get(participant), participant), + ArchiveProgressEnvelope.Kind.PARTICIPANT_PROGRESS, participant, head, planDigest, + participants); + } + requireProgress(Objects.requireNonNull(authorities.getReaderVisible(), "readerVisible"), + ArchiveProgressEnvelope.Kind.READER_VISIBLE, null, head, planDigest, participants); + + ArchiveAuthoritySourceBundle.ServingGenerationSnapshot serving = Objects.requireNonNull( + authorities.getServingGeneration(), "servingGeneration"); + byte[] latestSource = exactDigest(authorities.getLatestSourceIdentityDigest(), + "latestSourceIdentityDigest"); + if (!ArchiveParticipantDescriptor.FORMAT_ID.equals(serving.getScopeIdentity()) + || !participants.equals(serving.getParticipants()) + || serving.getIndexedFromEpoch() != base.getEpoch() + || serving.getIndexedThroughEpoch() != head.getMeta().getEpoch() + || !Arrays.equals(serving.getHeadHash(), head.getMeta().getBlockHash()) + || !Arrays.equals(serving.getLatestSourceIdentityDigest(), latestSource)) { + throw new ArchivePersistenceException("Serving generation authority mismatch"); + } + } + + private static void requireProgress(ArchiveProgressEnvelope progress, + ArchiveProgressEnvelope.Kind kind, String participant, HistoryCommitMarker head, + byte[] planDigest, List participants) { + progress.requireIdentity(kind, participant, head.getMeta().getEpoch(), + head.getMeta().getBlockHash(), head.getBatchId(), + head.getHistoryLocation().getBodyDigest(), planDigest, participants); + } + + private static byte[] exactDigest(byte[] digest, String name) { + if (digest == null || digest.length != 32) { + throw new ArchivePersistenceException(name + " must be exactly 32 bytes"); + } + byte[] zero = new byte[32]; + if (Arrays.equals(digest, zero)) { + throw new ArchivePersistenceException(name + " must not be zero"); + } + return digest; + } + + public enum Status { + EMPTY_NEW, + CURRENT_BASE, + CURRENT_READY, + QUARANTINE_REQUIRED + } + + public enum Reason { + NONE, + INVALID_DIRECTORY, + INSPECTION_FAILED, + NONEMPTY_WITHOUT_MANIFEST, + UNSUPPORTED_OR_CORRUPT_MANIFEST, + INCOMPLETE_OR_INCONSISTENT_AUTHORITIES + } + + /** Immutable admission result; CURRENT_READY requires the explicit authority-bundle overload. */ + public static final class Result { + private final Status status; + private final Reason reason; + private final String detail; + + private Result(Status status, Reason reason, String detail) { + this.status = status; + this.reason = reason; + this.detail = detail; + } + + public Status getStatus() { + return status; + } + + public Reason getReason() { + return reason; + } + + public String getDetail() { + return detail; + } + + private static Result emptyNew() { + return new Result(Status.EMPTY_NEW, Reason.NONE, ""); + } + + private static Result currentBase() { + return new Result(Status.CURRENT_BASE, Reason.NONE, ""); + } + + private static Result currentReady() { + return new Result(Status.CURRENT_READY, Reason.NONE, ""); + } + + private static Result quarantine(Reason reason, String detail) { + return new Result(Status.QUARANTINE_REQUIRED, reason, detail == null ? "" : detail); + } + } + + private static final class BaseInspection { + private final Result result; + private final ArchiveBaseManifest.ExistingBase base; + + private BaseInspection(Result result, ArchiveBaseManifest.ExistingBase base) { + this.result = result; + this.base = base; + } + + private static BaseInspection of(Result result, ArchiveBaseManifest.ExistingBase base) { + return new BaseInspection(result, base); + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveGenerationCapsule.java b/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveGenerationCapsule.java new file mode 100644 index 00000000000..a4d9d1e6bf8 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveGenerationCapsule.java @@ -0,0 +1,71 @@ +package org.tron.core.db2.archive; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.Objects; + +/** Acquires one reader-visible serving/history/latest resource capsule from durable R. */ +public final class ArchiveGenerationCapsule { + + private final PersistentServingKeyIndexCatalog catalog; + private final Path archiveDirectory; + private final long maxSegmentSize; + private final ArchiveReadSnapshot.PinnedLatestStateFactory latestFactory; + private final ReaderAuthority readerAuthority; + + public ArchiveGenerationCapsule(PersistentServingKeyIndexCatalog catalog, + Path readerVisiblePath, Path archiveDirectory, long maxSegmentSize, + ArchiveReadSnapshot.PinnedLatestStateFactory latestFactory) { + this(catalog, archiveDirectory, maxSegmentSize, latestFactory, + new ArchiveProgressFile(Objects.requireNonNull(readerVisiblePath, "readerVisiblePath"), + new ArchiveProgressEnvelopeCodec())::load); + } + + public ArchiveGenerationCapsule(PersistentServingKeyIndexCatalog catalog, + Path archiveDirectory, long maxSegmentSize, + ArchiveReadSnapshot.PinnedLatestStateFactory latestFactory, + ReaderAuthority readerAuthority) { + this.catalog = Objects.requireNonNull(catalog, "catalog"); + this.archiveDirectory = Objects.requireNonNull(archiveDirectory, "archiveDirectory"); + if (maxSegmentSize <= 0) { + throw new IllegalArgumentException("maxSegmentSize must be positive"); + } + this.maxSegmentSize = maxSegmentSize; + this.latestFactory = Objects.requireNonNull(latestFactory, "latestFactory"); + this.readerAuthority = Objects.requireNonNull(readerAuthority, "readerAuthority"); + } + + public ArchiveReadSnapshot pin(long targetBlock) throws IOException { + ArchiveProgressEnvelope readerVisible = Objects.requireNonNull(readerAuthority.read(), + "reader-visible authority"); + return ArchiveReadSnapshot.pin(targetBlock, catalog, readerVisible, archiveDirectory, + maxSegmentSize, serving -> pinLatest(serving)); + } + + private ArchiveReadSnapshot.PinnedLatestState pinLatest( + PersistentServingKeyIndexGeneration serving) throws IOException { + if (!serving.isLatestSourceIdentityBound()) { + throw new ArchivePersistenceException( + "Serving generation is not bound to latest engine source identities"); + } + ArchiveReadSnapshot.PinnedLatestState latest = latestFactory.pin(serving); + if (!java.util.Arrays.equals(serving.getLatestSourceIdentityDigest(), + latest.getSourceIdentityDigest())) { + try { + latest.close(); + } catch (IOException closeFailure) { + ArchivePersistenceException mismatch = new ArchivePersistenceException( + "Latest engine source identity digest mismatch"); + mismatch.addSuppressed(closeFailure); + throw mismatch; + } + throw new ArchivePersistenceException("Latest engine source identity digest mismatch"); + } + return latest; + } + + @FunctionalInterface + public interface ReaderAuthority { + ArchiveProgressEnvelope read() throws IOException; + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveHistoryScanAnchor.java b/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveHistoryScanAnchor.java new file mode 100644 index 00000000000..bcf8e2827db --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveHistoryScanAnchor.java @@ -0,0 +1,205 @@ +package org.tron.core.db2.archive; + +import com.google.common.hash.Hashing; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.util.Arrays; + +/** Atomic scan anchor for a previously validated committed history prefix. */ +final class ArchiveHistoryScanAnchor { + + private static final int MAGIC = 0x54415341; // TASA + private static final short VERSION = 1; + private static final int HEADER_LENGTH = 36; + private static final int MAX_LENGTH = 2 * 1024 * 1024; + private static final String FILE_NAME = "history.scan-anchor"; + private static final String TEMP_FILE_NAME = "history.scan-anchor.tmp"; + + private final long firstEpoch; + private final long recordCount; + private final int commitRecordLength; + private final HistoryCommitMarker marker; + private final byte[] encodedMarker; + + private ArchiveHistoryScanAnchor(long firstEpoch, long recordCount, int commitRecordLength, + HistoryCommitMarker marker, byte[] encodedMarker) { + this.firstEpoch = firstEpoch; + this.recordCount = recordCount; + this.commitRecordLength = commitRecordLength; + this.marker = marker; + this.encodedMarker = Arrays.copyOf(encodedMarker, encodedMarker.length); + } + + static ArchiveHistoryScanAnchor load(Path archiveDirectory, + HistoryCommitMarkerCodec markerCodec) throws IOException { + Path path = archiveDirectory.resolve(FILE_NAME); + if (!Files.exists(path)) { + return null; + } + long size = Files.size(path); + if (size < HEADER_LENGTH + Integer.BYTES || size > MAX_LENGTH) { + throw new ArchivePersistenceException("Archive history scan anchor length is invalid"); + } + byte[] encoded = Files.readAllBytes(path); + try { + return decode(encoded, markerCodec); + } catch (IllegalArgumentException invalid) { + throw new ArchivePersistenceException("Archive history scan anchor is corrupt", invalid); + } + } + + static ArchiveHistoryScanAnchor persist(Path archiveDirectory, long firstEpoch, + long recordCount, int commitRecordLength, HistoryCommitMarker marker, + HistoryCommitMarkerCodec markerCodec) throws IOException { + if (marker == null || recordCount <= 0 || commitRecordLength <= 0 + || firstEpoch + recordCount - 1 != marker.getMeta().getEpoch()) { + throw new IllegalArgumentException("Invalid archive history scan anchor state"); + } + byte[] markerBytes = markerCodec.encode(marker); + if (markerBytes.length != commitRecordLength) { + throw new IllegalArgumentException("Scan anchor commit record length mismatch"); + } + byte[] encoded = encode(firstEpoch, recordCount, commitRecordLength, markerBytes); + Files.createDirectories(archiveDirectory); + Path temporary = archiveDirectory.resolve(TEMP_FILE_NAME); + try (FileChannel channel = FileChannel.open(temporary, StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE)) { + writeFully(channel, ByteBuffer.wrap(encoded)); + channel.force(true); + } + Path target = archiveDirectory.resolve(FILE_NAME); + try { + Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException unsupported) { + throw new ArchivePersistenceException( + "Archive filesystem does not support atomic history scan anchor replacement", + unsupported); + } + HistorySegmentStore.syncDirectory(archiveDirectory); + return new ArchiveHistoryScanAnchor(firstEpoch, recordCount, commitRecordLength, marker, + markerBytes); + } + + long getFirstEpoch() { + return firstEpoch; + } + + long getRecordCount() { + return recordCount; + } + + int getCommitRecordLength() { + return commitRecordLength; + } + + HistoryCommitMarker getMarker() { + return marker; + } + + byte[] getEncodedMarker() { + return Arrays.copyOf(encodedMarker, encodedMarker.length); + } + + ArchiveHistoryScanAnchor forHistoryLocation(HistoryLocation historyLocation) { + HistoryCommitMarker laneMarker = new HistoryCommitMarker(marker.getMeta(), + marker.getPreviousEpoch(), historyLocation, marker.getIndexLocation(), + marker.getBatchId(), marker.getDatabases()); + return new ArchiveHistoryScanAnchor(firstEpoch, recordCount, commitRecordLength, + laneMarker, encodedMarker); + } + + private static byte[] encode(long firstEpoch, long recordCount, int commitRecordLength, + byte[] markerBytes) { + try { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + DataOutputStream output = new DataOutputStream(bytes); + output.writeInt(MAGIC); + output.writeShort(VERSION); + output.writeShort(0); + output.writeInt(0); + output.writeLong(firstEpoch); + output.writeLong(recordCount); + output.writeInt(commitRecordLength); + output.writeInt(markerBytes.length); + output.write(markerBytes); + output.flush(); + byte[] withoutChecksum = bytes.toByteArray(); + int length = withoutChecksum.length + Integer.BYTES; + if (length > MAX_LENGTH) { + throw new IllegalArgumentException("Archive history scan anchor is too large"); + } + ByteBuffer.wrap(withoutChecksum).putInt(8, length); + bytes.reset(); + output = new DataOutputStream(bytes); + output.write(withoutChecksum); + output.writeInt(crc32c(withoutChecksum)); + output.flush(); + return bytes.toByteArray(); + } catch (IOException impossible) { + throw new IllegalStateException("Unexpected scan anchor encoding failure", impossible); + } + } + + private static ArchiveHistoryScanAnchor decode(byte[] encoded, + HistoryCommitMarkerCodec markerCodec) { + if (encoded == null || encoded.length < HEADER_LENGTH + Integer.BYTES + || encoded.length > MAX_LENGTH) { + throw new IllegalArgumentException("History scan anchor length is invalid"); + } + int checksum = ByteBuffer.wrap(encoded, encoded.length - Integer.BYTES, + Integer.BYTES).getInt(); + byte[] withoutChecksum = Arrays.copyOf(encoded, encoded.length - Integer.BYTES); + if (checksum != crc32c(withoutChecksum)) { + throw new IllegalArgumentException("History scan anchor checksum mismatch"); + } + try { + DataInputStream input = new DataInputStream(new ByteArrayInputStream(encoded)); + if (input.readInt() != MAGIC || input.readShort() != VERSION || input.readShort() != 0 + || input.readInt() != encoded.length) { + throw new IllegalArgumentException("Unsupported history scan anchor header"); + } + long firstEpoch = input.readLong(); + long recordCount = input.readLong(); + int recordLength = input.readInt(); + int markerLength = input.readInt(); + if (firstEpoch < 0 || recordCount <= 0 || recordLength <= 0 + || markerLength != recordLength || markerLength > input.available() - Integer.BYTES) { + throw new IllegalArgumentException("Invalid history scan anchor fields"); + } + byte[] markerBytes = new byte[markerLength]; + input.readFully(markerBytes); + if (input.available() != Integer.BYTES) { + throw new IllegalArgumentException("History scan anchor payload mismatch"); + } + HistoryCommitMarker marker = markerCodec.decode(markerBytes); + if (firstEpoch + recordCount - 1 != marker.getMeta().getEpoch()) { + throw new IllegalArgumentException("History scan anchor ordinal mismatch"); + } + return new ArchiveHistoryScanAnchor(firstEpoch, recordCount, recordLength, marker, + markerBytes); + } catch (IOException invalid) { + throw new IllegalArgumentException("History scan anchor is truncated", invalid); + } + } + + private static int crc32c(byte[] bytes) { + return Hashing.crc32c().hashBytes(bytes).asInt(); + } + + private static void writeFully(FileChannel channel, ByteBuffer buffer) throws IOException { + while (buffer.hasRemaining()) { + channel.write(buffer); + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveHistoryTruncator.java b/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveHistoryTruncator.java new file mode 100644 index 00000000000..2cbd17a11e8 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveHistoryTruncator.java @@ -0,0 +1,69 @@ +package org.tron.core.db2.archive; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Objects; + +/** Shrinks commit authority before removing stale authoritative-index and body suffixes. */ +public final class ArchiveHistoryTruncator { + + private final HistoryCommitStore commits; + private final HistoryIndexStore index; + private final HistoryBodyStore bodies; + private final FaultHook faultHook; + + public ArchiveHistoryTruncator(HistoryCommitStore commits, HistoryIndexStore index, + HistoryBodyStore bodies) { + this(commits, index, bodies, stage -> { }); + } + + ArchiveHistoryTruncator(HistoryCommitStore commits, HistoryIndexStore index, + HistoryBodyStore bodies, FaultHook faultHook) { + this.commits = Objects.requireNonNull(commits, "commits"); + this.index = Objects.requireNonNull(index, "index"); + this.bodies = Objects.requireNonNull(bodies, "bodies"); + this.faultHook = Objects.requireNonNull(faultHook, "faultHook"); + } + + public void truncateAfter(long lastEpoch) throws IOException { + HistoryCommitMarker target = commits.get(lastEpoch); + if (target == null) { + throw new ArchivePersistenceException( + "Archive truncation target is outside committed history: " + lastEpoch); + } + HistoryIndexRecord targetIndex = index.read(target.getIndexLocation()); + BlockReverseDiff targetBody = bodies.read(target.getHistoryLocation()); + if (!target.getMeta().equals(targetIndex.getMeta()) + || !target.getMeta().equals(targetBody.getMeta()) + || !sameLocation(target.getHistoryLocation(), targetIndex.getHistoryLocation())) { + throw new ArchivePersistenceException( + "Archive truncation target identity does not match index and body"); + } + + commits.truncateAfter(lastEpoch); + faultHook.afterDurableStage(Stage.COMMIT_AUTHORITY); + index.truncateAfter(target.getIndexLocation(), commits.size()); + faultHook.afterDurableStage(Stage.AUTHORITATIVE_INDEX); + bodies.truncateAfter(target.getHistoryLocation(), commits.size()); + faultHook.afterDurableStage(Stage.HISTORY_BODY); + } + + private static boolean sameLocation(HistoryLocation expected, HistoryLocation actual) { + return expected.getSegmentId() == actual.getSegmentId() + && expected.getOffset() == actual.getOffset() + && expected.getRecordLength() == actual.getRecordLength() + && expected.getBodyChecksum() == actual.getBodyChecksum() + && Arrays.equals(expected.getBodyDigest(), actual.getBodyDigest()); + } + + public enum Stage { + COMMIT_AUTHORITY, + AUTHORITATIVE_INDEX, + HISTORY_BODY + } + + @FunctionalInterface + interface FaultHook { + void afterDurableStage(Stage stage) throws IOException; + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveHistoryWriter.java b/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveHistoryWriter.java new file mode 100644 index 00000000000..2facabe2f7e --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveHistoryWriter.java @@ -0,0 +1,554 @@ +package org.tron.core.db2.archive; + +import java.io.Closeable; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +/** + * Ordered history body/index/marker writer. A marker is the durable history boundary H; reader + * visibility R is a separate recovery authority and is not yet integrated into this prototype. + */ +public final class ArchiveHistoryWriter + implements DurableBlockReverseDiffSink, CommittedHistoryAuthority, Closeable { + + static final int MAX_RESTART_TAIL_RECORDS = 1024; + + private final HistoryBodyStore bodies; + private final HistoryIndexStore index; + private final HistoryCommitStore commits; + private final ArchiveBaseManifest manifest; + private final Path archiveDirectory; + private final HistoryCommitMarkerCodec commitCodec; + private final List participatingDatabases; + private final DurabilityHook hook; + private final Long bootstrapFloor; + + public ArchiveHistoryWriter(Path archiveDirectory, long maxSegmentSize, + Set participatingDatabases) throws IOException { + this(archiveDirectory, maxSegmentSize, participatingDatabases, (stage, meta) -> { }); + } + + ArchiveHistoryWriter(Path archiveDirectory, long maxSegmentSize, + Set participatingDatabases, DurabilityHook hook) throws IOException { + this.archiveDirectory = archiveDirectory; + this.commitCodec = new HistoryCommitMarkerCodec(); + this.participatingDatabases = new ArrayList<>(participatingDatabases); + this.participatingDatabases.sort(String::compareTo); + this.manifest = new ArchiveBaseManifest(archiveDirectory, this.participatingDatabases); + new ArchiveTruncationRecovery(archiveDirectory, maxSegmentSize).recover(); + ArchiveHistoryScanAnchor checkpoint = ArchiveHistoryScanAnchor.load(archiveDirectory, + commitCodec); + this.bodies = new PartitionedHistoryBodyStore(archiveDirectory, new BlockHistoryCodec(), + maxSegmentSize, checkpoint); + this.index = new HistoryIndexStore(archiveDirectory, new HistoryIndexCodec(), checkpoint); + this.commits = new HistoryCommitStore(archiveDirectory, commitCodec, checkpoint); + this.hook = hook; + recoverPreparedSuffix(); + persistHistoryScanAnchor(); + if (commits.head() != null) { + manifest.ensureBase(commits.get(commits.firstEpoch()).getMeta()); + } + HistoryCommitMarker bootstrap; + try { + bootstrap = ArchiveBootstrapAnchor.loadAndValidateIfPresent( + archiveDirectory, this, this.participatingDatabases); + } catch (IOException | RuntimeException failure) { + closeAfterFailedConstruction(failure); + throw failure; + } + this.bootstrapFloor = bootstrap == null ? null : bootstrap.getMeta().getEpoch(); + } + + @Override + public synchronized void accept(BlockReverseDiff diff) { + acceptAll(java.util.Collections.singletonList(diff)); + } + + @Override + public synchronized void acceptAll(List diffs) { + if (diffs.isEmpty()) { + return; + } + if (commits.head() == null) { + try { + manifest.ensureBase(diffs.get(0).getMeta()); + } catch (IOException failure) { + throw new ArchivePersistenceException("Failed to establish archive base manifest", failure); + } + } + BlockSnapshotMeta previous = commits.head() == null ? null : commits.head().getMeta(); + for (BlockReverseDiff diff : diffs) { + validateNext(previous, diff.getMeta()); + previous = diff.getMeta(); + } + try { + for (int start = 0; start < diffs.size(); start += MAX_RESTART_TAIL_RECORDS) { + int end = Math.min(diffs.size(), start + MAX_RESTART_TAIL_RECORDS); + persistChunk(diffs.subList(start, end)); + } + } catch (IOException | RuntimeException e) { + handleWriteFailure(diffs.get(diffs.size() - 1).getMeta(), e); + } + } + + @Override + public synchronized void revert(BlockSnapshotMeta meta) { + try { + HistoryCommitMarker head = commits.head(); + if (head != null && head.getMeta().equals(meta)) { + HistoryCommitMarker previous = commits.get(meta.getEpoch() - 1); + commits.removeHead(meta); + persistHistoryScanAnchor(); + previous = commits.head(); + index.truncateAfter(previous == null ? null : previous.getIndexLocation(), commits.size()); + bodies.truncateAfter(previous == null ? null : previous.getHistoryLocation(), + commits.size()); + return; + } + + HistorySegmentStore.ScannedRecord bodyHead = bodies.getScanResult().getHead(); + HistoryIndexStore.ScannedIndexRecord indexHead = index.getScanResult().getHead(); + if (bodyHead != null && bodyHead.getDiff().getMeta().equals(meta)) { + HistoryCommitMarker committed = commits.head(); + index.truncateAfter(committed == null ? null : committed.getIndexLocation()); + bodies.truncateAfter(committed == null ? null : committed.getHistoryLocation()); + return; + } + if (indexHead != null && indexHead.getRecord().getMeta().equals(meta)) { + throw new ArchivePersistenceException("Index/body archive heads differ during revert"); + } + throw new ArchivePersistenceException("Archive revert does not target the current head"); + } catch (IOException e) { + throw new ArchivePersistenceException("Failed to revert archive history", e); + } + } + + public synchronized HistoryCommitMarker committedHead() { + return head(); + } + + synchronized HistoryCommitMarker committedMarker(long epoch) { + HistoryCommitMarker marker = get(epoch); + if (marker == null) { + return null; + } + return commitCodec.decode(commitCodec.encode(marker)); + } + + @Override + public synchronized HistoryCommitMarker head() { + return commits.head(); + } + + @Override + public synchronized HistoryCommitMarker get(long epoch) { + return commits.get(epoch); + } + + @Override + public synchronized long firstEpoch() { + return commits.firstEpoch(); + } + + Path getArchiveDirectory() { + return archiveDirectory; + } + + @Override + public synchronized HistoryCoverage coverage() { + return commits.coverage(); + } + + @Override + public synchronized void awaitCommitted(long epoch) { + HistoryCommitMarker head = commits.head(); + if (head == null || head.getMeta().getEpoch() < epoch) { + throw new ArchivePersistenceException("Archive history has not committed epoch " + epoch); + } + } + + @Override + public DurableHistoryMarkerRangeEvidence createMarkerRangeEvidence(int maxMarkers) { + return new DurableHistoryMarkerRangeEvidence(this, maxMarkers); + } + + @Override + public void releaseThrough(long epoch) { + // The synchronous writer has no queue bookkeeping to release. + } + + public synchronized BlockReverseDiff readCommitted(long epoch) { + HistoryCommitMarker marker = commits.get(epoch); + if (marker == null) { + throw new IllegalArgumentException("History epoch is not committed: " + epoch); + } + try { + HistoryIndexRecord indexRecord = index.read(marker.getIndexLocation()); + validateMarkerReferences(marker, indexRecord); + BlockReverseDiff diff = bodies.read(marker.getHistoryLocation()); + if (!marker.getMeta().equals(diff.getMeta())) { + throw new ArchivePersistenceException("Marker does not match history body metadata"); + } + return diff; + } catch (IOException e) { + throw new ArchivePersistenceException("Failed to read committed archive history", e); + } + } + + public synchronized OldValue readAccountAt(long targetBlock, byte[] address, + byte[] accountAtCommittedHead) { + HistoryCommitMarker head = commits.head(); + if (head == null) { + throw new IllegalStateException("State archive has no committed history"); + } + long base = bootstrapFloor == null ? commits.firstEpoch() - 1 : bootstrapFloor; + if (targetBlock < base || targetBlock > head.getMeta().getEpoch()) { + throw new IllegalArgumentException("Account query is outside archive coverage"); + } + try { + for (long epoch = targetBlock + 1; epoch <= head.getMeta().getEpoch(); epoch++) { + HistoryCommitMarker marker = commits.get(epoch); + if (marker == null) { + throw new ArchivePersistenceException("Committed account history contains a gap"); + } + HistoryIndexRecord indexRecord = index.read(marker.getIndexLocation()); + validateMarkerReferences(marker, indexRecord); + if (contains(indexRecord, HistoricalAccountBalanceReader.ACCOUNT_DATABASE, address)) { + return findOldValue(readCommitted(epoch), + HistoricalAccountBalanceReader.ACCOUNT_DATABASE, address); + } + } + return OldValue.fromNullable(accountAtCommittedHead); + } catch (IOException failure) { + throw new ArchivePersistenceException("Failed to query historical account", failure); + } + } + + public synchronized BlockSnapshotMeta committedHeadMeta() { + HistoryCommitMarker marker = commits.head(); + return marker == null ? null : marker.getMeta(); + } + + /** Builds one immutable persistent serving generation from the current committed prefix H. */ + public synchronized PersistentServingKeyIndexGeneration buildServingGeneration( + Path shadowDirectory, String generationId) throws IOException { + return buildServingGeneration(shadowDirectory, generationId, new byte[32]); + } + + public synchronized PersistentServingKeyIndexGeneration buildServingGeneration( + Path shadowDirectory, String generationId, byte[] latestSourceIdentityDigest) + throws IOException { + ServingSource source = servingSource(); + return PersistentServingKeyIndexGeneration.build(shadowDirectory, generationId, + source.baseEpoch, source.baseHash, source.committed, index::read, + participatingDatabases, latestSourceIdentityDigest); + } + + /** + * Plans the exact-27 serving delta after durable I without scanning history before I. + * + *

The returned plan contains no persistent encoding. The caller must atomically apply and + * publish the complete plan before advancing durable serving authority. + */ + public synchronized ServingIndexIncrementalPlan planServingIncrement(long indexedThrough, + byte[] indexedHeadHash) throws IOException { + HistoryCommitMarker head = commits.head(); + if (head == null) { + throw new IllegalStateException("Cannot plan a serving increment from empty history"); + } + long firstEpoch = commits.firstEpoch(); + long baseEpoch = firstEpoch - 1; + byte[] expectedHash; + if (indexedThrough == baseEpoch) { + expectedHash = commits.get(firstEpoch).getMeta().getParentHash(); + } else if (indexedThrough >= firstEpoch + && indexedThrough <= head.getMeta().getEpoch()) { + HistoryCommitMarker indexed = commits.get(indexedThrough); + if (indexed == null) { + throw new ArchivePersistenceException("Serving I is outside committed history"); + } + expectedHash = indexed.getMeta().getBlockHash(); + } else { + throw new ArchivePersistenceException("Serving I is outside committed history coverage"); + } + if (!Arrays.equals(expectedHash, indexedHeadHash)) { + throw new ArchivePersistenceException("Serving I hash differs from committed history"); + } + + List suffix = new ArrayList<>(); + for (long epoch = indexedThrough + 1; epoch <= head.getMeta().getEpoch(); epoch++) { + HistoryCommitMarker marker = commits.get(epoch); + if (marker == null) { + throw new ArchivePersistenceException("Committed history suffix contains a gap"); + } + suffix.add(marker); + } + return ServingIndexIncrementalPlan.plan(indexedThrough, indexedHeadHash, + participatingDatabases, suffix, index::read); + } + + /** Plans a deterministic v5 rebuild from the archive coverage base through current H. */ + public synchronized ServingIndexIncrementalPlan planServingRebuild() throws IOException { + ServingSource source = servingSource(); + return planServingIncrement(source.baseEpoch, source.baseHash); + } + + /** Recomputes the exact all-Store serving source identity without writing a generation. */ + public synchronized ServingKeyIndexGeneration buildServingIdentity(String generationId) + throws IOException { + ServingSource source = servingSource(); + return ServingKeyIndexGeneration.rebuild(generationId, source.baseEpoch, source.baseHash, + source.committed, index::read, participatingDatabases, + ServingKeyIndexGeneration.IndexLayout.prototypeDefaults()); + } + + private ServingSource servingSource() { + HistoryCommitMarker first = commits.head() == null ? null : commits.get(commits.firstEpoch()); + if (first == null) { + throw new IllegalStateException("Cannot build a serving generation from empty history"); + } + long firstEpoch = bootstrapFloor == null ? commits.firstEpoch() : bootstrapFloor + 1; + long lastEpoch = commits.head().getMeta().getEpoch(); + List committed = new ArrayList<>(); + for (long epoch = firstEpoch; epoch <= lastEpoch; epoch++) { + committed.add(commits.get(epoch)); + } + long baseEpoch = firstEpoch - 1; + byte[] baseHash = bootstrapFloor == null + ? first.getMeta().getParentHash() : first.getMeta().getBlockHash(); + return new ServingSource(baseEpoch, baseHash, committed); + } + + private static final class ServingSource { + private final long baseEpoch; + private final byte[] baseHash; + private final List committed; + + private ServingSource(long baseEpoch, byte[] baseHash, + List committed) { + this.baseEpoch = baseEpoch; + this.baseHash = baseHash; + this.committed = committed; + } + } + + long getStartupScannedRecords() { + return bodies.getStartupScannedRecords() + index.getStartupScannedRecords() + + commits.getStartupScannedRecords(); + } + + private void persistHistoryScanAnchor() throws IOException { + HistoryCommitMarker head = commits.head(); + if (head != null) { + ArchiveHistoryScanAnchor.persist(archiveDirectory, commits.firstEpoch(), commits.size(), + commits.getRecordLength(), head, commitCodec); + } else { + Files.deleteIfExists(archiveDirectory.resolve("history.scan-anchor")); + HistorySegmentStore.syncDirectory(archiveDirectory); + } + } + + private void persistChunk(List diffs) throws IOException { + List bodyLocations = new ArrayList<>(diffs.size()); + List indexLocations = new ArrayList<>(diffs.size()); + for (BlockReverseDiff diff : diffs) { + hook.before(Stage.APPEND_BODY, diff.getMeta()); + HistoryLocation bodyLocation = bodies.append(diff); + bodyLocations.add(bodyLocation); + hook.before(Stage.APPEND_INDEX, diff.getMeta()); + indexLocations.add(index.append(HistoryIndexRecord.from(diff, bodyLocation))); + } + BlockSnapshotMeta lastMeta = diffs.get(diffs.size() - 1).getMeta(); + hook.before(Stage.SYNC_BODY, lastMeta); + bodies.sync(); + hook.before(Stage.SYNC_INDEX, lastMeta); + index.sync(); + HistoryCommitMarker head = commits.head(); + long previousEpoch = head == null ? diffs.get(0).getMeta().getEpoch() - 1 + : head.getMeta().getEpoch(); + List markers = new ArrayList<>(diffs.size()); + for (int i = 0; i < diffs.size(); i++) { + BlockReverseDiff diff = diffs.get(i); + hook.before(Stage.COMMIT_MARKER, diff.getMeta()); + markers.add(new HistoryCommitMarker(diff.getMeta(), previousEpoch, + bodyLocations.get(i), indexLocations.get(i), batchId(), participatingDatabases)); + previousEpoch = diff.getMeta().getEpoch(); + } + commits.commitAll(markers); + persistHistoryScanAnchor(); + } + + private void validateNext(BlockSnapshotMeta previous, BlockSnapshotMeta meta) { + if (previous == null) { + return; + } + if (meta.getEpoch() != previous.getEpoch() + 1 + || meta.getBlockNumber() != previous.getBlockNumber() + 1 + || !Arrays.equals(meta.getParentHash(), previous.getBlockHash())) { + throw new ArchivePersistenceException("Archive block metadata is not contiguous"); + } + } + + private void handleWriteFailure(BlockSnapshotMeta meta, Exception failure) { + if (commits.mayContain(meta.getEpoch())) { + throw new ArchivePersistenceException( + "History marker may be durable; refusing to roll back committed archive", failure); + } + try { + HistoryCommitMarker committed = commits.head(); + index.truncateAfter(committed == null ? null : committed.getIndexLocation()); + bodies.truncateAfter(committed == null ? null : committed.getHistoryLocation()); + } catch (IOException cleanupFailure) { + failure.addSuppressed(cleanupFailure); + } + throw new ArchivePersistenceException("Failed to persist archive history", failure); + } + + private void recoverPreparedSuffix() throws IOException { + HistorySegmentStore.ScanResult bodyScan = bodies.getScanResult(); + HistoryIndexStore.ScanResult indexScan = index.getScanResult(); + long committedCount = commits.size(); + if (bodyScan.getRecordCount() < committedCount + || indexScan.getRecordCount() < committedCount) { + throw new ArchivePersistenceException("Committed marker references missing body/index data"); + } + + if (bodyScan.getInvalidTail() != null) { + if (bodyScan.getRecordCount() < committedCount) { + throw new ArchivePersistenceException("Committed history body is corrupt"); + } + } + if (indexScan.getInvalidTailOffset() != null) { + if (indexScan.getRecordCount() < committedCount) { + throw new ArchivePersistenceException("Committed history index is corrupt"); + } + } + HistoryCommitMarker head = commits.head(); + if (head != null) { + HistoryIndexRecord indexRecord = index.read(head.getIndexLocation()); + validateMarkerReferences(head, indexRecord); + BlockReverseDiff body = bodies.read(head.getHistoryLocation()); + if (!head.getMeta().equals(body.getMeta())) { + throw new ArchivePersistenceException("Commit head does not match history body metadata"); + } + } + index.truncateAfter(head == null ? null : head.getIndexLocation(), commits.size()); + bodies.truncateAfter(head == null ? null : head.getHistoryLocation(), commits.size()); + } + + private void closeAfterFailedConstruction(Exception failure) { + try { + index.close(); + } catch (IOException closeFailure) { + failure.addSuppressed(closeFailure); + } + try { + bodies.close(); + } catch (IOException closeFailure) { + failure.addSuppressed(closeFailure); + } + try { + commits.close(); + } catch (IOException closeFailure) { + failure.addSuppressed(closeFailure); + } + } + + private static OldValue findOldValue(BlockReverseDiff diff, String dbName, byte[] rawKey) { + for (BlockReverseDiff.DbGroup group : diff.getGroups()) { + if (!dbName.equals(group.getDbName())) { + continue; + } + for (BlockReverseDiff.Entry entry : group.getEntries()) { + if (Arrays.equals(rawKey, entry.getKey())) { + return entry.getOldValue(); + } + } + } + throw new ArchivePersistenceException("Account index references a missing history key"); + } + + private static boolean contains(HistoryIndexRecord record, String dbName, byte[] rawKey) { + for (HistoryIndexRecord.KeyGroup group : record.getGroups()) { + if (!dbName.equals(group.getDbName())) { + continue; + } + for (byte[] key : group.getKeys()) { + if (Arrays.equals(rawKey, key)) { + return true; + } + } + } + return false; + } + + private void validateMarkerReferences(HistoryCommitMarker marker, + HistoryIndexRecord indexRecord) { + if (!marker.getMeta().equals(indexRecord.getMeta()) + || !same(marker.getHistoryLocation(), indexRecord.getHistoryLocation())) { + throw new ArchivePersistenceException("Marker does not match authoritative index delta"); + } + } + + private static boolean same(HistoryLocation left, HistoryLocation right) { + return left.getSegmentId() == right.getSegmentId() + && left.getOffset() == right.getOffset() + && left.getRecordLength() == right.getRecordLength() + && left.getBodyChecksum() == right.getBodyChecksum() + && Arrays.equals(left.getBodyDigest(), right.getBodyDigest()); + } + + private static boolean same(HistoryIndexLocation left, HistoryIndexLocation right) { + return left.getOffset() == right.getOffset() + && left.getRecordLength() == right.getRecordLength() + && Arrays.equals(left.getDigest(), right.getDigest()); + } + + private static byte[] batchId() { + UUID uuid = UUID.randomUUID(); + return ByteBuffer.allocate(16).putLong(uuid.getMostSignificantBits()) + .putLong(uuid.getLeastSignificantBits()).array(); + } + + @Override + public synchronized void close() throws IOException { + IOException failure = null; + try { + index.close(); + } catch (IOException e) { + failure = e; + } + try { + bodies.close(); + } catch (IOException e) { + if (failure == null) { + failure = e; + } else { + failure.addSuppressed(e); + } + } + commits.close(); + if (failure != null) { + throw failure; + } + } + + enum Stage { + APPEND_BODY, + APPEND_INDEX, + SYNC_BODY, + SYNC_INDEX, + COMMIT_MARKER + } + + interface DurabilityHook { + void before(Stage stage, BlockSnapshotMeta meta) throws IOException; + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveParticipantDescriptor.java b/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveParticipantDescriptor.java new file mode 100644 index 00000000000..d809f073003 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveParticipantDescriptor.java @@ -0,0 +1,147 @@ +package org.tron.core.db2.archive; + +import com.google.common.hash.Hasher; +import com.google.common.hash.Hashing; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** Approved archive participant identity with stable Store IDs. */ +final class ArchiveParticipantDescriptor { + + static final String FORMAT_ID = "archive-state/exact-27-abi-retained/v1"; + static final int ABI_STORE_ID = 1; + + private static final ArchiveParticipantDescriptor CURRENT = + new ArchiveParticipantDescriptor(); + + private final Map activeByStoreId; + private final Map tombstonesByStoreId; + private final Set activeDatabases; + private final Set excludedDatabases; + private final List participants; + + private ArchiveParticipantDescriptor() { + LinkedHashMap stores = new LinkedHashMap<>(); + stores.put(ABI_STORE_ID, "abi"); + stores.put(2, "accountid-index"); + stores.put(3, "account-index"); + stores.put(4, "account"); + stores.put(5, "account-asset"); + stores.put(6, "asset-issue"); + stores.put(7, "asset-issue-v2"); + stores.put(8, "code"); + stores.put(9, "contract-state"); + stores.put(10, "contract"); + stores.put(11, "DelegatedResourceAccountIndex"); + stores.put(12, "DelegatedResource"); + stores.put(13, "delegation"); + stores.put(14, "properties"); + stores.put(15, "exchange"); + stores.put(16, "exchange-v2"); + stores.put(17, "market_account"); + stores.put(18, "market_order"); + stores.put(19, "market_pair_price_to_order"); + stores.put(20, "market_pair_to_price"); + stores.put(21, "proposal"); + stores.put(22, "storage-row"); + stores.put(23, "votes"); + stores.put(24, "witness_schedule"); + stores.put(25, "witness"); + stores.put(26, "nullifier"); + stores.put(27, "IncrementalMerkleTree"); + activeByStoreId = Collections.unmodifiableMap(stores); + + tombstonesByStoreId = Collections.emptyMap(); + + activeDatabases = Collections.unmodifiableSet( + new LinkedHashSet<>(activeByStoreId.values())); + excludedDatabases = Collections.unmodifiableSet( + new LinkedHashSet<>(tombstonesByStoreId.values())); + List sorted = new ArrayList<>(activeDatabases); + Collections.sort(sorted); + participants = Collections.unmodifiableList(sorted); + validateStoreIds(); + } + + static ArchiveParticipantDescriptor current() { + return CURRENT; + } + + Set getActiveDatabases() { + return activeDatabases; + } + + Set getExcludedDatabases() { + return excludedDatabases; + } + + List getParticipants() { + return participants; + } + + static String scopeIdentity(List participants) { + if (current().getParticipants().equals(participants)) { + return FORMAT_ID; + } + Hasher hasher = Hashing.sha256().newHasher(); + for (String participant : participants) { + byte[] encoded = participant.getBytes(StandardCharsets.UTF_8); + hasher.putInt(encoded.length).putBytes(encoded); + } + return "experimental/" + hasher.hash(); + } + + Map getTombstonesByStoreId() { + return tombstonesByStoreId; + } + + int getStoreId(String dbName) { + for (Map.Entry entry : activeByStoreId.entrySet()) { + if (entry.getValue().equals(dbName)) { + return entry.getKey(); + } + } + for (Map.Entry entry : tombstonesByStoreId.entrySet()) { + if (entry.getValue().equals(dbName)) { + return entry.getKey(); + } + } + throw new IllegalArgumentException("Unknown archive database: " + dbName); + } + + void requireExactParticipants(Collection actual) { + List sorted = new ArrayList<>(Objects.requireNonNull(actual, "actual")); + Collections.sort(sorted); + if (!participants.equals(sorted)) { + throw new ArchivePersistenceException( + "Archive participant descriptor does not match " + FORMAT_ID); + } + } + + private void validateStoreIds() { + Set allIds = new LinkedHashSet<>(activeByStoreId.keySet()); + allIds.addAll(tombstonesByStoreId.keySet()); + List expected = new ArrayList<>(); + for (int storeId = 1; storeId <= 27; storeId++) { + expected.add(storeId); + } + if (!allIds.equals(new LinkedHashSet<>(expected)) + || activeDatabases.size() != 27 + || !tombstonesByStoreId.isEmpty() + || !Collections.disjoint(activeDatabases, excludedDatabases) + || !activeDatabases.contains("abi") + || !activeDatabases.containsAll( + Arrays.asList("asset-issue", "asset-issue-v2"))) { + throw new IllegalStateException("Invalid exact-27 archive participant descriptor"); + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveParticipantMutation.java b/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveParticipantMutation.java new file mode 100644 index 00000000000..615819f0f32 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveParticipantMutation.java @@ -0,0 +1,32 @@ +package org.tron.core.db2.archive; + +import java.util.Arrays; +import java.util.Objects; + +/** Immutable engine-neutral business mutation for one archive participant batch. */ +public final class ArchiveParticipantMutation { + + private final byte[] key; + private final byte[] value; + + private ArchiveParticipantMutation(byte[] key, byte[] value) { + this.key = Arrays.copyOf(Objects.requireNonNull(key, "key"), key.length); + this.value = value == null ? null : Arrays.copyOf(value, value.length); + } + + public static ArchiveParticipantMutation put(byte[] key, byte[] value) { + return new ArchiveParticipantMutation(key, Objects.requireNonNull(value, "value")); + } + + public static ArchiveParticipantMutation delete(byte[] key) { + return new ArchiveParticipantMutation(key, null); + } + + byte[] getKey() { + return Arrays.copyOf(key, key.length); + } + + byte[] getValue() { + return value == null ? null : Arrays.copyOf(value, value.length); + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveParticipantProgressSource.java b/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveParticipantProgressSource.java new file mode 100644 index 00000000000..7692ccad814 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveParticipantProgressSource.java @@ -0,0 +1,10 @@ +package org.tron.core.db2.archive; + +import java.io.IOException; + +/** Engine-neutral durable participant D authority source. */ +@FunctionalInterface +public interface ArchiveParticipantProgressSource { + + ArchiveProgressEnvelope loadProgress() throws IOException; +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/ArchivePersistenceException.java b/chainbase/src/main/java/org/tron/core/db2/archive/ArchivePersistenceException.java new file mode 100644 index 00000000000..59d983d06fe --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/ArchivePersistenceException.java @@ -0,0 +1,13 @@ +package org.tron.core.db2.archive; + +/** Fatal archive persistence or continuity failure. */ +public class ArchivePersistenceException extends RuntimeException { + + public ArchivePersistenceException(String message, Throwable cause) { + super(message, cause); + } + + public ArchivePersistenceException(String message) { + super(message); + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/ArchivePointSnapshot.java b/chainbase/src/main/java/org/tron/core/db2/archive/ArchivePointSnapshot.java new file mode 100644 index 00000000000..27f233f9575 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/ArchivePointSnapshot.java @@ -0,0 +1,27 @@ +package org.tron.core.db2.archive; + +import java.io.Closeable; +import java.io.IOException; +import java.util.List; +import org.tron.core.db2.archive.HistoricalRangeOverlay.Entry; +import org.tron.core.db2.archive.HistoricalRangeOverlay.KeyRange; +import org.tron.core.db2.archive.HistoricalRangeOverlay.Limits; + +/** Request-owned point-read contract shared by legacy and common-checkpoint generations. */ +public interface ArchivePointSnapshot extends Closeable { + + OldValue get(String dbName, byte[] physicalRawKey) throws IOException; + + default List range(String dbName, KeyRange range, Limits limits) throws IOException { + throw new UnsupportedOperationException( + "Range reads are unavailable for this archive generation"); + } + + long getTargetBlock(); + + long getPinnedBlock(); + + byte[] getPinnedHash(); + + void requirePinnedIdentity(); +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveProgressEnvelope.java b/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveProgressEnvelope.java new file mode 100644 index 00000000000..c7a720b137c --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveProgressEnvelope.java @@ -0,0 +1,155 @@ +package org.tron.core.db2.archive; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** Prototype identity envelope for apply checkpoint C or one participant progress D[i]. */ +public final class ArchiveProgressEnvelope { + + public enum Kind { + APPLY_CHECKPOINT, + PARTICIPANT_PROGRESS, + READER_VISIBLE + } + + private final Kind kind; + private final String participant; + private final long epoch; + private final byte[] blockHash; + private final byte[] batchId; + private final byte[] payloadDigest; + private final byte[] mutationPlanDigest; + private final List participants; + private final String scopeIdentity; + + public ArchiveProgressEnvelope(Kind kind, String participant, long epoch, byte[] blockHash, + byte[] batchId, byte[] payloadDigest, List participants) { + this(kind, participant, epoch, blockHash, batchId, payloadDigest, null, participants); + } + + public ArchiveProgressEnvelope(Kind kind, String participant, long epoch, byte[] blockHash, + byte[] batchId, byte[] payloadDigest, byte[] mutationPlanDigest, + List participants) { + this(kind, participant, epoch, blockHash, batchId, payloadDigest, mutationPlanDigest, + participants, ArchiveParticipantDescriptor.scopeIdentity(participants)); + } + + ArchiveProgressEnvelope(Kind kind, String participant, long epoch, byte[] blockHash, + byte[] batchId, byte[] payloadDigest, byte[] mutationPlanDigest, + List participants, String scopeIdentity) { + this.kind = Objects.requireNonNull(kind, "kind"); + if (epoch < 0) { + throw new IllegalArgumentException("Archive progress epoch must be non-negative"); + } + this.epoch = epoch; + this.blockHash = exactBytes(blockHash, 32, "blockHash"); + this.batchId = exactBytes(batchId, 16, "batchId"); + this.payloadDigest = exactBytes(payloadDigest, 32, "payloadDigest"); + this.mutationPlanDigest = mutationPlanDigest == null ? null + : exactBytes(mutationPlanDigest, 32, "mutationPlanDigest"); + this.participants = validateParticipants(participants); + if (scopeIdentity == null || scopeIdentity.isEmpty()) { + throw new IllegalArgumentException("Archive scope identity must not be empty"); + } + this.scopeIdentity = scopeIdentity; + if (kind != Kind.PARTICIPANT_PROGRESS) { + if (participant != null) { + throw new IllegalArgumentException("Global archive progress must not name one participant"); + } + this.participant = null; + } else { + if (participant == null || participant.isEmpty() + || !this.participants.contains(participant)) { + throw new IllegalArgumentException("Participant progress identity is invalid"); + } + this.participant = participant; + } + } + + public Kind getKind() { + return kind; + } + + public String getParticipant() { + return participant; + } + + public long getEpoch() { + return epoch; + } + + public byte[] getBlockHash() { + return Arrays.copyOf(blockHash, blockHash.length); + } + + public byte[] getBatchId() { + return Arrays.copyOf(batchId, batchId.length); + } + + public byte[] getPayloadDigest() { + return Arrays.copyOf(payloadDigest, payloadDigest.length); + } + + public byte[] getMutationPlanDigest() { + return mutationPlanDigest == null ? null + : Arrays.copyOf(mutationPlanDigest, mutationPlanDigest.length); + } + + public List getParticipants() { + return participants; + } + + public String getScopeIdentity() { + return scopeIdentity; + } + + public void requireIdentity(Kind expectedKind, String expectedParticipant, long expectedEpoch, + byte[] expectedBlockHash, byte[] expectedBatchId, byte[] expectedPayloadDigest, + List expectedParticipants) { + requireIdentity(expectedKind, expectedParticipant, expectedEpoch, expectedBlockHash, + expectedBatchId, expectedPayloadDigest, mutationPlanDigest, expectedParticipants); + } + + public void requireIdentity(Kind expectedKind, String expectedParticipant, long expectedEpoch, + byte[] expectedBlockHash, byte[] expectedBatchId, byte[] expectedPayloadDigest, + byte[] expectedMutationPlanDigest, List expectedParticipants) { + if (kind != expectedKind || !Objects.equals(participant, expectedParticipant) + || epoch != expectedEpoch || !Arrays.equals(blockHash, expectedBlockHash) + || !Arrays.equals(batchId, expectedBatchId) + || !Arrays.equals(payloadDigest, expectedPayloadDigest) + || !Arrays.equals(mutationPlanDigest, expectedMutationPlanDigest) + || !scopeIdentity.equals(ArchiveParticipantDescriptor.scopeIdentity(expectedParticipants)) + || !participants.equals(expectedParticipants)) { + throw new ArchivePersistenceException("Archive progress identity mismatch"); + } + } + + private static byte[] exactBytes(byte[] value, int length, String name) { + if (value == null || value.length != length) { + throw new IllegalArgumentException(name + " must be exactly " + length + " bytes"); + } + return Arrays.copyOf(value, value.length); + } + + private static List validateParticipants(List participants) { + Objects.requireNonNull(participants, "participants"); + if (participants.isEmpty()) { + throw new IllegalArgumentException("Archive participant set must not be empty"); + } + List copy = new ArrayList<>(participants.size()); + String previous = null; + for (String participant : participants) { + if (participant == null || participant.isEmpty() + || previous != null && previous.compareTo(participant) >= 0) { + throw new IllegalArgumentException( + "Archive participants must be non-empty, unique, and sorted"); + } + copy.add(participant); + previous = participant; + } + return Collections.unmodifiableList(copy); + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveProgressEnvelopeCodec.java b/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveProgressEnvelopeCodec.java new file mode 100644 index 00000000000..66cee42c666 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveProgressEnvelopeCodec.java @@ -0,0 +1,183 @@ +package org.tron.core.db2.archive; + +import com.google.common.hash.Hashing; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.EOFException; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import org.tron.core.db2.archive.ArchiveProgressEnvelope.Kind; + +/** Checksummed prototype codec for archive C, D[i], and R progress identities. */ +public final class ArchiveProgressEnvelopeCodec { + + private static final int MAGIC = 0x54415047; // TAPG + private static final short VERSION = 3; + private static final int HEADER_LENGTH = 12; + private static final int MAX_FIELD_LENGTH = 1024; + private static final int MAX_PARTICIPANTS = 1024; + static final int MAX_ENCODED_LENGTH = 1024 * 1024; + + public byte[] encode(ArchiveProgressEnvelope envelope) { + try { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + DataOutputStream output = new DataOutputStream(bytes); + output.writeInt(MAGIC); + byte[] mutationPlanDigest = envelope.getMutationPlanDigest(); + output.writeShort(VERSION); + output.writeByte(kindCode(envelope.getKind())); + output.writeByte(0); + output.writeInt(0); + writeString(output, envelope.getScopeIdentity()); + output.writeLong(envelope.getEpoch()); + output.write(envelope.getBlockHash()); + output.write(envelope.getBatchId()); + output.write(envelope.getPayloadDigest()); + output.writeBoolean(mutationPlanDigest != null); + if (mutationPlanDigest != null) { + output.write(mutationPlanDigest); + } + writeString(output, envelope.getParticipant() == null ? "" : envelope.getParticipant()); + output.writeInt(envelope.getParticipants().size()); + for (String participant : envelope.getParticipants()) { + writeString(output, participant); + } + output.flush(); + byte[] payload = bytes.toByteArray(); + int length = payload.length + Integer.BYTES; + if (length > MAX_ENCODED_LENGTH) { + throw new IllegalArgumentException("Archive progress envelope is too large"); + } + ByteBuffer.wrap(payload).putInt(8, length); + bytes.reset(); + output = new DataOutputStream(bytes); + output.write(payload); + output.writeInt(crc32c(payload)); + output.flush(); + return bytes.toByteArray(); + } catch (IOException impossible) { + throw new IllegalStateException("Unexpected progress envelope encoding failure", impossible); + } + } + + public ArchiveProgressEnvelope decode(byte[] encoded) { + if (encoded == null || encoded.length < HEADER_LENGTH + Integer.BYTES + || encoded.length > MAX_ENCODED_LENGTH) { + throw new IllegalArgumentException("Archive progress envelope length is invalid"); + } + int expectedChecksum = ByteBuffer.wrap(encoded, encoded.length - Integer.BYTES, + Integer.BYTES).getInt(); + byte[] payload = Arrays.copyOf(encoded, encoded.length - Integer.BYTES); + if (expectedChecksum != crc32c(payload)) { + throw new IllegalArgumentException("Archive progress envelope checksum mismatch"); + } + try { + DataInputStream input = new DataInputStream(new ByteArrayInputStream(encoded)); + int magic = input.readInt(); + short version = input.readShort(); + if (magic != MAGIC || version != VERSION) { + throw new IllegalArgumentException("Unsupported archive progress envelope header"); + } + Kind kind = decodeKind(input.readUnsignedByte()); + if (input.readUnsignedByte() != 0 || input.readInt() != encoded.length) { + throw new IllegalArgumentException("Unsupported archive progress envelope header"); + } + String scopeIdentity = readString(input, false); + long epoch = input.readLong(); + byte[] blockHash = readExact(input, 32); + byte[] batchId = readExact(input, 16); + byte[] payloadDigest = readExact(input, 32); + int hasMutationPlanDigest = input.readUnsignedByte(); + if (hasMutationPlanDigest > 1) { + throw new IllegalArgumentException("Archive progress plan digest marker is invalid"); + } + byte[] mutationPlanDigest = hasMutationPlanDigest == 1 ? readExact(input, 32) : null; + String participant = readString(input, true); + int count = input.readInt(); + if (count <= 0 || count > MAX_PARTICIPANTS) { + throw new IllegalArgumentException("Archive progress participant count is invalid"); + } + List participants = new ArrayList<>(count); + for (int index = 0; index < count; index++) { + participants.add(readString(input, false)); + } + if (input.available() != Integer.BYTES) { + throw new IllegalArgumentException("Archive progress envelope payload mismatch"); + } + String expectedScope = ArchiveParticipantDescriptor.scopeIdentity(participants); + if (!scopeIdentity.equals(expectedScope)) { + throw new IllegalArgumentException("Archive progress scope identity mismatch"); + } + return new ArchiveProgressEnvelope(kind, participant.isEmpty() ? null : participant, epoch, + blockHash, batchId, payloadDigest, mutationPlanDigest, participants, scopeIdentity); + } catch (EOFException truncated) { + throw new IllegalArgumentException("Archive progress envelope is truncated", truncated); + } catch (IOException invalid) { + throw new IllegalArgumentException("Invalid archive progress envelope", invalid); + } + } + + private static int kindCode(Kind kind) { + switch (kind) { + case APPLY_CHECKPOINT: + return 1; + case PARTICIPANT_PROGRESS: + return 2; + case READER_VISIBLE: + return 3; + default: + throw new IllegalArgumentException("Unknown archive progress envelope kind"); + } + } + + private static Kind decodeKind(int code) { + if (code == 1) { + return Kind.APPLY_CHECKPOINT; + } + if (code == 2) { + return Kind.PARTICIPANT_PROGRESS; + } + if (code == 3) { + return Kind.READER_VISIBLE; + } + throw new IllegalArgumentException("Unknown archive progress envelope kind"); + } + + private static void writeString(DataOutputStream output, String value) throws IOException { + byte[] encoded = value.getBytes(StandardCharsets.UTF_8); + if (encoded.length > MAX_FIELD_LENGTH) { + throw new IllegalArgumentException("Archive progress string is too large"); + } + output.writeInt(encoded.length); + output.write(encoded); + } + + private static String readString(DataInputStream input, boolean allowEmpty) throws IOException { + int length = input.readInt(); + if (length < 0 || length > MAX_FIELD_LENGTH || !allowEmpty && length == 0) { + throw new IllegalArgumentException("Archive progress string length is invalid"); + } + byte[] encoded = readExact(input, length); + String decoded = new String(encoded, StandardCharsets.UTF_8); + if (!Arrays.equals(encoded, decoded.getBytes(StandardCharsets.UTF_8))) { + throw new IllegalArgumentException("Archive progress string is not valid UTF-8"); + } + return decoded; + } + + private static byte[] readExact(DataInputStream input, int length) throws IOException { + byte[] value = new byte[length]; + input.readFully(value); + return value; + } + + private static int crc32c(byte[] value) { + return Hashing.crc32c().hashBytes(value).asInt(); + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveProgressFile.java b/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveProgressFile.java new file mode 100644 index 00000000000..ed8e3d40567 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveProgressFile.java @@ -0,0 +1,78 @@ +package org.tron.core.db2.archive; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.util.Objects; + +/** Atomic-file prototype for one C or D[i] envelope. */ +final class ArchiveProgressFile { + + private final Path path; + private final Path temporary; + private final ArchiveProgressEnvelopeCodec codec; + private final FaultHook faultHook; + + ArchiveProgressFile(Path path, ArchiveProgressEnvelopeCodec codec) { + this(path, codec, temporary -> { }); + } + + ArchiveProgressFile(Path path, ArchiveProgressEnvelopeCodec codec, FaultHook faultHook) { + this.path = Objects.requireNonNull(path, "path"); + this.temporary = path.resolveSibling(path.getFileName() + ".tmp"); + this.codec = Objects.requireNonNull(codec, "codec"); + this.faultHook = Objects.requireNonNull(faultHook, "faultHook"); + } + + ArchiveProgressEnvelope load() throws IOException { + if (!Files.exists(path)) { + throw new ArchivePersistenceException("Archive progress envelope is missing: " + path); + } + long size = Files.size(path); + if (size <= 0 || size > ArchiveProgressEnvelopeCodec.MAX_ENCODED_LENGTH) { + throw new ArchivePersistenceException("Archive progress envelope file length is invalid"); + } + try { + return codec.decode(Files.readAllBytes(path)); + } catch (IllegalArgumentException invalid) { + throw new ArchivePersistenceException("Archive progress envelope is corrupt", invalid); + } + } + + void store(ArchiveProgressEnvelope envelope) throws IOException { + byte[] encoded = codec.encode(envelope); + Path directory = Objects.requireNonNull(path.getParent(), "progress directory"); + Files.createDirectories(directory); + try (FileChannel channel = FileChannel.open(temporary, StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE)) { + ByteBuffer buffer = ByteBuffer.wrap(encoded); + while (buffer.hasRemaining()) { + channel.write(buffer); + } + channel.force(true); + } + faultHook.afterTemporaryForce(temporary); + try { + Files.move(temporary, path, StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException unsupported) { + throw new ArchivePersistenceException( + "Archive progress filesystem does not support atomic replacement", unsupported); + } + HistorySegmentStore.syncDirectory(directory); + } + + Path getTemporaryPath() { + return temporary; + } + + @FunctionalInterface + interface FaultHook { + void afterTemporaryForce(Path temporary) throws IOException; + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveQueryLimitExceededException.java b/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveQueryLimitExceededException.java new file mode 100644 index 00000000000..ea46c71bf3c --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveQueryLimitExceededException.java @@ -0,0 +1,9 @@ +package org.tron.core.db2.archive; + +/** Raised when a historical query cannot produce a complete result inside its resource budget. */ +public class ArchiveQueryLimitExceededException extends RuntimeException { + + public ArchiveQueryLimitExceededException(String message) { + super(message); + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveReadContext.java b/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveReadContext.java new file mode 100644 index 00000000000..d7603d5fe88 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveReadContext.java @@ -0,0 +1,250 @@ +package org.tron.core.db2.archive; + +import com.google.protobuf.InvalidProtocolBufferException; +import java.io.Closeable; +import java.io.IOException; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import org.tron.core.store.StorageRowKeyCodec; +import org.tron.protos.contract.SmartContractOuterClass.SmartContract; + +/** Request-owned bindings from every versioned Store to one pinned archive snapshot. */ +public final class ArchiveReadContext implements Closeable { + + private final ArchivePointSnapshot snapshot; + private final Closeable owner; + private final Map> adapters; + private final HistoricalAccountAssetBalanceResolver accountAssetResolver = + new HistoricalAccountAssetBalanceResolver(); + private final HistoricalAccountAssetPrefixResolver accountAssetPrefixResolver = + new HistoricalAccountAssetPrefixResolver(); + private boolean closed; + + private ArchiveReadContext(ArchivePointSnapshot snapshot, + Collection> adapters, Closeable owner) { + this.snapshot = Objects.requireNonNull(snapshot, "snapshot"); + this.owner = Objects.requireNonNull(owner, "owner"); + this.adapters = validateAdapters(adapters); + } + + /** Takes ownership of {@code snapshot}, including when adapter validation fails. */ + public static ArchiveReadContext open(ArchiveReadSnapshot snapshot, + Collection> adapters) throws IOException { + try { + return new ArchiveReadContext(snapshot, adapters, snapshot); + } catch (RuntimeException failure) { + closeAfterFailedOpen(snapshot, failure); + throw failure; + } + } + + /** Takes ownership of one common-checkpoint point snapshot. */ + public static ArchiveReadContext open(StateArchiveCheckpointReadSnapshot snapshot, + Collection> adapters) throws IOException { + try { + return new ArchiveReadContext(snapshot, adapters, snapshot); + } catch (RuntimeException failure) { + closeAfterFailedOpen(snapshot, failure); + throw failure; + } + } + + /** Takes ownership of {@code lease} so closing this context also releases gate accounting. */ + public static ArchiveReadContext open(ArchiveRuntimeQueryGate.Lease lease, + Collection> adapters) throws IOException { + Objects.requireNonNull(lease, "lease"); + try { + return new ArchiveReadContext(lease.getSnapshot(), adapters, lease); + } catch (RuntimeException failure) { + closeAfterFailedOpen(lease, failure); + throw failure; + } + } + + public synchronized HistoricalStore store(StoreAdapter adapter) { + ensureOpen(); + Objects.requireNonNull(adapter, "adapter"); + if (adapters.get(adapter.getDbName()) != adapter) { + throw new IllegalArgumentException( + "Store adapter does not belong to this archive read context: " + adapter.getDbName()); + } + return new HistoricalStore<>(snapshot, adapter); + } + + public Set getAdapterDbNames() { + return Collections.unmodifiableSet(new LinkedHashSet<>(adapters.keySet())); + } + + /** Internal exact physical read; public RPC layers must use typed resolvers instead. */ + public synchronized OldValue getExact(String dbName, byte[] physicalRawKey) + throws IOException { + ensureOpen(); + if (!adapters.containsKey(Objects.requireNonNull(dbName, "dbName"))) { + throw new IllegalArgumentException( + "Store adapter does not belong to this archive read context: " + dbName); + } + return snapshot.get(dbName, Objects.requireNonNull(physicalRawKey, "physicalRawKey")); + } + + public long getTargetBlock() { + return snapshot.getTargetBlock(); + } + + public long getPinnedBlock() { + return snapshot.getPinnedBlock(); + } + + public synchronized void requirePinnedIdentity() { + ensureOpen(); + snapshot.requirePinnedIdentity(); + } + + /** Resolves exact Account bytes and one P66-aware token balance from this request snapshot. */ + public synchronized HistoricalAccountAssetBalanceResolver.Result resolveAccountAsset( + byte[] address, String tokenId) throws IOException { + ensureOpen(); + return accountAssetResolver.resolve(snapshot, address, tokenId); + } + + /** Resolves all token balances for exactly one Account under explicit query budgets. */ + public synchronized HistoricalAccountAssetPrefixResolver.Result resolveAccountAssets( + byte[] address, HistoricalAccountAssetPrefixResolver.Limits limits) throws IOException { + ensureOpen(); + return accountAssetPrefixResolver.resolve(snapshot, address, limits); + } + + /** Resolves one logical contract slot using contract metadata from this same pinned context. */ + public synchronized Optional getStorage(byte[] contractAddress, byte[] logicalSlot) + throws IOException { + ensureOpen(); + Objects.requireNonNull(contractAddress, "contractAddress"); + Objects.requireNonNull(logicalSlot, "logicalSlot"); + OldValue contractValue = snapshot.get("contract", contractAddress); + if (!contractValue.isPresent()) { + throw new ArchivePersistenceException( + "Historical Contract metadata is absent for logical storage lookup"); + } + SmartContract contract; + try { + contract = SmartContract.parseFrom(contractValue.getValue()); + } catch (InvalidProtocolBufferException e) { + throw new ArchivePersistenceException( + "Historical Contract metadata cannot be decoded", e); + } + byte[] physicalKey = StorageRowKeyCodec.physicalKey(contractAddress, logicalSlot, + contract.getVersion(), contract.getTrxHash().toByteArray()); + OldValue storageValue = snapshot.get("storage-row", physicalKey); + return storageValue.isPresent() + ? Optional.of(storageValue.getValue()) : Optional.empty(); + } + + @Override + public synchronized void close() throws IOException { + if (!closed) { + closed = true; + owner.close(); + } + } + + private void ensureOpen() { + if (closed) { + throw new IllegalStateException("Archive read context is closed"); + } + } + + private static Map> validateAdapters( + Collection> definitions) { + Objects.requireNonNull(definitions, "adapters"); + Map> indexed = new LinkedHashMap<>(); + for (StoreAdapter adapter : definitions) { + Objects.requireNonNull(adapter, "adapter"); + if (indexed.put(adapter.getDbName(), adapter) != null) { + throw new IllegalArgumentException("Duplicate historical Store adapter: " + + adapter.getDbName()); + } + } + Set expected = ArchiveStoreScope.getStateDatabases(); + if (!indexed.keySet().equals(expected)) { + Set missing = new LinkedHashSet<>(expected); + missing.removeAll(indexed.keySet()); + Set unexpected = new LinkedHashSet<>(indexed.keySet()); + unexpected.removeAll(expected); + throw new IllegalArgumentException("Historical Store adapter set mismatch; missing=" + + missing + ", unexpected=" + unexpected); + } + return Collections.unmodifiableMap(indexed); + } + + private static void closeAfterFailedOpen(Closeable owner, + RuntimeException failure) throws IOException { + if (owner == null) { + return; + } + try { + owner.close(); + } catch (IOException closeFailure) { + failure.addSuppressed(closeFailure); + } + } + + /** Immutable adapter definition; one definition must exist for every versioned physical Store. */ + public static final class StoreAdapter { + private final String dbName; + private final ValueDecoder decoder; + + private StoreAdapter(String dbName, ValueDecoder decoder) { + this.dbName = Objects.requireNonNull(dbName, "dbName"); + this.decoder = Objects.requireNonNull(decoder, "decoder"); + if (!ArchiveStoreScope.isStateDatabase(dbName)) { + throw new IllegalArgumentException("Not a versioned archive state database: " + dbName); + } + } + + public static StoreAdapter define(String dbName, ValueDecoder decoder) { + return new StoreAdapter<>(dbName, decoder); + } + + public String getDbName() { + return dbName; + } + } + + /** Read-only point view for one exact physical Store keyspace. */ + public static final class HistoricalStore { + private final ArchivePointSnapshot snapshot; + private final StoreAdapter adapter; + + private HistoricalStore(ArchivePointSnapshot snapshot, StoreAdapter adapter) { + this.snapshot = snapshot; + this.adapter = adapter; + } + + public Optional get(byte[] physicalRawKey) throws IOException { + OldValue value = snapshot.get(adapter.dbName, physicalRawKey); + if (!value.isPresent()) { + return Optional.empty(); + } + T decoded = adapter.decoder.decode(value.getValue()); + if (decoded == null) { + throw new IllegalStateException( + "Historical Store adapter returned null: " + adapter.dbName); + } + return Optional.of(decoded); + } + + public boolean has(byte[] physicalRawKey) throws IOException { + return snapshot.get(adapter.dbName, physicalRawKey).isPresent(); + } + } + + @FunctionalInterface + public interface ValueDecoder { + T decode(byte[] value); + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveReadSnapshot.java b/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveReadSnapshot.java new file mode 100644 index 00000000000..9cecb388cd7 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveReadSnapshot.java @@ -0,0 +1,278 @@ +package org.tron.core.db2.archive; + +import java.io.Closeable; +import java.io.IOException; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import java.util.OptionalLong; +import org.tron.core.db2.archive.HistoricalRangeOverlay.Entry; +import org.tron.core.db2.archive.HistoricalRangeOverlay.KeyRange; +import org.tron.core.db2.archive.HistoricalRangeOverlay.Limits; + +/** One immutable physical-key archive read context pinned at {@code S(P)}. */ +public final class ArchiveReadSnapshot implements ArchivePointSnapshot { + + private final long targetBlock; + private final long pinnedBlock; + private final byte[] pinnedHash; + private final ServingKeyIndex serving; + private final PinnedLatestState latest; + private final PinnedHistory history; + private boolean closed; + + private ArchiveReadSnapshot(long targetBlock, long pinnedBlock, byte[] pinnedHash, + ServingKeyIndex serving, PinnedLatestState latest, PinnedHistory history) { + if (targetBlock > pinnedBlock) { + throw new IllegalArgumentException("Target block must not exceed pinned block"); + } + this.pinnedHash = copyHash(pinnedHash, "pinnedHash"); + this.serving = Objects.requireNonNull(serving, "serving"); + this.latest = Objects.requireNonNull(latest, "latest"); + this.history = Objects.requireNonNull(history, "history"); + validateIdentity(targetBlock, pinnedBlock); + this.targetBlock = targetBlock; + this.pinnedBlock = pinnedBlock; + } + + /** Takes ownership of already pinned resources, including on identity-validation failure. */ + public static ArchiveReadSnapshot pin(long targetBlock, long pinnedBlock, byte[] pinnedHash, + ServingKeyIndex serving, PinnedLatestState latest, PinnedHistory history) + throws IOException { + try { + return new ArchiveReadSnapshot(targetBlock, pinnedBlock, pinnedHash, serving, latest, + history); + } catch (RuntimeException failure) { + closeAfterFailedPin(serving, history, latest, failure); + throw failure; + } + } + + /** Pins catalog, authoritative history, and latest-engine resources as one request unit. */ + public static ArchiveReadSnapshot pin(long targetBlock, + PersistentServingKeyIndexCatalog catalog, ArchiveProgressEnvelope readerVisible, + PinnedHistoryFactory historyFactory, PinnedLatestStateFactory latestFactory) + throws IOException { + Objects.requireNonNull(catalog, "catalog"); + Objects.requireNonNull(historyFactory, "historyFactory"); + Objects.requireNonNull(latestFactory, "latestFactory"); + PersistentServingKeyIndexGeneration serving = catalog.pin(readerVisible); + PinnedHistory history; + try { + history = historyFactory.pin(serving); + } catch (IOException | RuntimeException failure) { + serving.close(); + throw failure; + } + PinnedLatestState latest; + try { + latest = latestFactory.pin(serving); + } catch (IOException | RuntimeException failure) { + try { + history.close(); + } catch (IOException closeFailure) { + failure.addSuppressed(closeFailure); + } + serving.close(); + throw failure; + } + return pin(targetBlock, serving.getIndexedThrough(), serving.getHeadHash(), serving, latest, + history); + } + + /** Pins persistent commit/index/segment handles plus one caller-owned latest engine snapshot. */ + public static ArchiveReadSnapshot pin(long targetBlock, + PersistentServingKeyIndexCatalog catalog, ArchiveProgressEnvelope readerVisible, + java.nio.file.Path archiveDirectory, long maxSegmentSize, + PinnedLatestStateFactory latestFactory) throws IOException { + return pin(targetBlock, catalog, readerVisible, + serving -> PersistentCommittedHistoryReader.open( + archiveDirectory, maxSegmentSize, serving), latestFactory); + } + + public synchronized OldValue get(String dbName, byte[] physicalRawKey) throws IOException { + ensureOpen(); + Objects.requireNonNull(dbName, "dbName"); + Objects.requireNonNull(physicalRawKey, "physicalRawKey"); + OptionalLong first = serving.firstChangeAfter( + dbName, physicalRawKey, targetBlock, pinnedBlock); + OldValue value = first.isPresent() + ? history.read(dbName, physicalRawKey, first.getAsLong()) + : latest.get(dbName, physicalRawKey); + if (value == null) { + throw new IllegalStateException("Pinned latest state returned null"); + } + return value; + } + + public synchronized List range(String dbName, KeyRange range, Limits limits) + throws IOException { + ensureOpen(); + List pinnedLatest = latest.range(dbName, range.getLowerInclusive(), + range.getUpperExclusive(), limits.getMaxCandidateKeys()); + if (pinnedLatest == null) { + throw new IllegalStateException("Pinned latest range returned null"); + } + return HistoricalRangeOverlay.materialize(dbName, targetBlock, pinnedBlock, range, + pinnedLatest, serving, history::read, limits); + } + + public long getTargetBlock() { + return targetBlock; + } + + public long getPinnedBlock() { + return pinnedBlock; + } + + public byte[] getPinnedHash() { + return Arrays.copyOf(pinnedHash, pinnedHash.length); + } + + /** Revalidates that all request-owned resources still expose the pinned generation identity. */ + public synchronized void requirePinnedIdentity() { + ensureOpen(); + validateIdentity(targetBlock, pinnedBlock); + } + + @Override + public synchronized void close() throws IOException { + if (closed) { + return; + } + closed = true; + IOException failure = null; + try { + history.close(); + } catch (IOException e) { + failure = e; + } + try { + latest.close(); + } catch (IOException e) { + if (failure == null) { + failure = e; + } else { + failure.addSuppressed(e); + } + } + try { + serving.close(); + } catch (IOException e) { + if (failure == null) { + failure = e; + } else { + failure.addSuppressed(e); + } + } + if (failure != null) { + throw failure; + } + } + + private void validateIdentity(long target, long pinned) { + if (target < serving.getIndexedFrom() || pinned != serving.getIndexedThrough()) { + throw new IllegalArgumentException( + "Prototype read snapshot requires complete serving coverage through P"); + } + if (latest.getBlockNumber() != pinned || history.getIndexedFrom() != serving.getIndexedFrom() + || history.getIndexedThrough() != pinned + || !Arrays.equals(pinnedHash, serving.getHeadHash()) + || !Arrays.equals(pinnedHash, latest.getBlockHash()) + || !Arrays.equals(pinnedHash, history.getHeadHash()) + || !Arrays.equals(serving.getAuthoritativePrefixDigest(), + history.getAuthoritativePrefixDigest())) { + throw new IllegalArgumentException("Archive read snapshot identity mismatch"); + } + } + + private void ensureOpen() { + if (closed) { + throw new IllegalStateException("Archive read snapshot is closed"); + } + } + + private static byte[] copyHash(byte[] hash, String name) { + if (hash == null || hash.length != 32) { + throw new IllegalArgumentException(name + " must be exactly 32 bytes"); + } + return Arrays.copyOf(hash, hash.length); + } + + private static void closeAfterFailedPin(ServingKeyIndex serving, PinnedHistory history, + PinnedLatestState latest, RuntimeException failure) throws IOException { + IOException closeFailure = null; + if (history != null) { + try { + history.close(); + } catch (IOException e) { + closeFailure = e; + } + } + if (latest != null) { + try { + latest.close(); + } catch (IOException e) { + if (closeFailure == null) { + closeFailure = e; + } else { + closeFailure.addSuppressed(e); + } + } + } + if (serving != null) { + try { + serving.close(); + } catch (IOException e) { + if (closeFailure == null) { + closeFailure = e; + } else { + closeFailure.addSuppressed(e); + } + } + } + if (closeFailure != null) { + failure.addSuppressed(closeFailure); + } + } + + public interface PinnedLatestState extends Closeable { + long getBlockNumber(); + + byte[] getBlockHash(); + + /** Empty means the legacy/in-memory prototype is not bound to engine source identities. */ + default byte[] getSourceIdentityDigest() { + return new byte[0]; + } + + OldValue get(String dbName, byte[] physicalRawKey) throws IOException; + + List range(String dbName, byte[] lowerInclusive, byte[] upperExclusive, + int maxEntries) + throws IOException; + } + + public interface PinnedHistory extends Closeable { + long getIndexedFrom(); + + long getIndexedThrough(); + + byte[] getHeadHash(); + + byte[] getAuthoritativePrefixDigest(); + + OldValue read(String dbName, byte[] physicalRawKey, long firstChangeBlock) + throws IOException; + } + + @FunctionalInterface + public interface PinnedHistoryFactory { + PinnedHistory pin(PersistentServingKeyIndexGeneration serving) throws IOException; + } + + @FunctionalInterface + public interface PinnedLatestStateFactory { + PinnedLatestState pin(PersistentServingKeyIndexGeneration serving) throws IOException; + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveRestartCheckpoint.java b/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveRestartCheckpoint.java new file mode 100644 index 00000000000..cb0f71a96c6 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveRestartCheckpoint.java @@ -0,0 +1,197 @@ +package org.tron.core.db2.archive; + +import com.google.common.hash.Hashing; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.util.Arrays; + +/** Atomic restart anchor for a previously validated committed history prefix. */ +final class ArchiveRestartCheckpoint { + + private static final int MAGIC = 0x54415246; // TARF + private static final short VERSION = 1; + private static final int HEADER_LENGTH = 36; + private static final int MAX_LENGTH = 2 * 1024 * 1024; + private static final String FILE_NAME = "restart.checkpoint"; + private static final String TEMP_FILE_NAME = "restart.checkpoint.tmp"; + + private final long firstEpoch; + private final long recordCount; + private final int commitRecordLength; + private final HistoryCommitMarker marker; + private final byte[] encodedMarker; + + private ArchiveRestartCheckpoint(long firstEpoch, long recordCount, int commitRecordLength, + HistoryCommitMarker marker, byte[] encodedMarker) { + this.firstEpoch = firstEpoch; + this.recordCount = recordCount; + this.commitRecordLength = commitRecordLength; + this.marker = marker; + this.encodedMarker = Arrays.copyOf(encodedMarker, encodedMarker.length); + } + + static ArchiveRestartCheckpoint load(Path archiveDirectory, + HistoryCommitMarkerCodec markerCodec) throws IOException { + Path path = archiveDirectory.resolve(FILE_NAME); + if (!Files.exists(path)) { + return null; + } + long size = Files.size(path); + if (size < HEADER_LENGTH + Integer.BYTES || size > MAX_LENGTH) { + throw new ArchivePersistenceException("Archive restart checkpoint length is invalid"); + } + byte[] encoded = Files.readAllBytes(path); + try { + return decode(encoded, markerCodec); + } catch (IllegalArgumentException invalid) { + throw new ArchivePersistenceException("Archive restart checkpoint is corrupt", invalid); + } + } + + static ArchiveRestartCheckpoint persist(Path archiveDirectory, long firstEpoch, + long recordCount, int commitRecordLength, HistoryCommitMarker marker, + HistoryCommitMarkerCodec markerCodec) throws IOException { + if (marker == null || recordCount <= 0 || commitRecordLength <= 0 + || firstEpoch + recordCount - 1 != marker.getMeta().getEpoch()) { + throw new IllegalArgumentException("Invalid archive restart checkpoint state"); + } + byte[] markerBytes = markerCodec.encode(marker); + if (markerBytes.length != commitRecordLength) { + throw new IllegalArgumentException("Checkpoint commit record length mismatch"); + } + byte[] encoded = encode(firstEpoch, recordCount, commitRecordLength, markerBytes); + Files.createDirectories(archiveDirectory); + Path temporary = archiveDirectory.resolve(TEMP_FILE_NAME); + try (FileChannel channel = FileChannel.open(temporary, StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE)) { + writeFully(channel, ByteBuffer.wrap(encoded)); + channel.force(true); + } + Path target = archiveDirectory.resolve(FILE_NAME); + try { + Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException unsupported) { + throw new ArchivePersistenceException( + "Archive filesystem does not support atomic restart checkpoint replacement", + unsupported); + } + HistorySegmentStore.syncDirectory(archiveDirectory); + return new ArchiveRestartCheckpoint(firstEpoch, recordCount, commitRecordLength, marker, + markerBytes); + } + + long getFirstEpoch() { + return firstEpoch; + } + + long getRecordCount() { + return recordCount; + } + + int getCommitRecordLength() { + return commitRecordLength; + } + + HistoryCommitMarker getMarker() { + return marker; + } + + byte[] getEncodedMarker() { + return Arrays.copyOf(encodedMarker, encodedMarker.length); + } + + private static byte[] encode(long firstEpoch, long recordCount, int commitRecordLength, + byte[] markerBytes) { + try { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + DataOutputStream output = new DataOutputStream(bytes); + output.writeInt(MAGIC); + output.writeShort(VERSION); + output.writeShort(0); + output.writeInt(0); + output.writeLong(firstEpoch); + output.writeLong(recordCount); + output.writeInt(commitRecordLength); + output.writeInt(markerBytes.length); + output.write(markerBytes); + output.flush(); + byte[] withoutChecksum = bytes.toByteArray(); + int length = withoutChecksum.length + Integer.BYTES; + if (length > MAX_LENGTH) { + throw new IllegalArgumentException("Archive restart checkpoint is too large"); + } + ByteBuffer.wrap(withoutChecksum).putInt(8, length); + bytes.reset(); + output = new DataOutputStream(bytes); + output.write(withoutChecksum); + output.writeInt(crc32c(withoutChecksum)); + output.flush(); + return bytes.toByteArray(); + } catch (IOException impossible) { + throw new IllegalStateException("Unexpected checkpoint encoding failure", impossible); + } + } + + private static ArchiveRestartCheckpoint decode(byte[] encoded, + HistoryCommitMarkerCodec markerCodec) { + if (encoded == null || encoded.length < HEADER_LENGTH + Integer.BYTES + || encoded.length > MAX_LENGTH) { + throw new IllegalArgumentException("Restart checkpoint length is invalid"); + } + int checksum = ByteBuffer.wrap(encoded, encoded.length - Integer.BYTES, + Integer.BYTES).getInt(); + byte[] withoutChecksum = Arrays.copyOf(encoded, encoded.length - Integer.BYTES); + if (checksum != crc32c(withoutChecksum)) { + throw new IllegalArgumentException("Restart checkpoint checksum mismatch"); + } + try { + DataInputStream input = new DataInputStream(new ByteArrayInputStream(encoded)); + if (input.readInt() != MAGIC || input.readShort() != VERSION || input.readShort() != 0 + || input.readInt() != encoded.length) { + throw new IllegalArgumentException("Unsupported restart checkpoint header"); + } + long firstEpoch = input.readLong(); + long recordCount = input.readLong(); + int recordLength = input.readInt(); + int markerLength = input.readInt(); + if (firstEpoch < 0 || recordCount <= 0 || recordLength <= 0 + || markerLength != recordLength || markerLength > input.available() - Integer.BYTES) { + throw new IllegalArgumentException("Invalid restart checkpoint fields"); + } + byte[] markerBytes = new byte[markerLength]; + input.readFully(markerBytes); + if (input.available() != Integer.BYTES) { + throw new IllegalArgumentException("Restart checkpoint payload mismatch"); + } + HistoryCommitMarker marker = markerCodec.decode(markerBytes); + if (firstEpoch + recordCount - 1 != marker.getMeta().getEpoch()) { + throw new IllegalArgumentException("Restart checkpoint ordinal mismatch"); + } + return new ArchiveRestartCheckpoint(firstEpoch, recordCount, recordLength, marker, + markerBytes); + } catch (IOException invalid) { + throw new IllegalArgumentException("Restart checkpoint is truncated", invalid); + } + } + + private static int crc32c(byte[] bytes) { + return Hashing.crc32c().hashBytes(bytes).asInt(); + } + + private static void writeFully(FileChannel channel, ByteBuffer buffer) throws IOException { + while (buffer.hasRemaining()) { + channel.write(buffer); + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveRuntimeAttachment.java b/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveRuntimeAttachment.java new file mode 100644 index 00000000000..3e3a2c8b7ff --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveRuntimeAttachment.java @@ -0,0 +1,53 @@ +package org.tron.core.db2.archive; + +import java.util.Objects; + +/** Borrowed archive runtime collaborators installed into SnapshotManager as one unit. */ +public final class ArchiveRuntimeAttachment { + + private final OldValueCollector collector; + private final DurableBlockReverseDiffSink sink; + private final ArchiveCommittedPrefixPublisher committedPrefixPublisher; + private final ArchiveCommittedPrefixPublisher readableStatePublisher; + + public ArchiveRuntimeAttachment(OldValueCollector collector, + DurableBlockReverseDiffSink sink) { + this(collector, sink, null, null); + } + + public ArchiveRuntimeAttachment(OldValueCollector collector, + DurableBlockReverseDiffSink sink, + ArchiveCommittedPrefixPublisher committedPrefixPublisher) { + this(collector, sink, committedPrefixPublisher, null); + } + + public ArchiveRuntimeAttachment(OldValueCollector collector, + DurableBlockReverseDiffSink sink, + ArchiveCommittedPrefixPublisher committedPrefixPublisher, + ArchiveCommittedPrefixPublisher readableStatePublisher) { + this.collector = Objects.requireNonNull(collector, "collector"); + this.sink = Objects.requireNonNull(sink, "sink"); + this.committedPrefixPublisher = committedPrefixPublisher; + this.readableStatePublisher = readableStatePublisher; + } + + public OldValueCollector getCollector() { + return collector; + } + + public DurableBlockReverseDiffSink getSink() { + return sink; + } + + public void publishCommittedPrefix(BlockSnapshotMeta target) throws java.io.IOException { + if (committedPrefixPublisher != null) { + committedPrefixPublisher.publish(Objects.requireNonNull(target, "target")); + } + } + + public void publishReadableState(BlockSnapshotMeta target) throws java.io.IOException { + if (readableStatePublisher != null) { + readableStatePublisher.publish(Objects.requireNonNull(target, "target")); + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveRuntimeQueryGate.java b/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveRuntimeQueryGate.java new file mode 100644 index 00000000000..26ff517d382 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveRuntimeQueryGate.java @@ -0,0 +1,149 @@ +package org.tron.core.db2.archive; + +import java.io.Closeable; +import java.io.IOException; +import java.util.Objects; + +/** Tracks request-owned archive snapshots while a runtime enters quiescence. */ +public final class ArchiveRuntimeQueryGate implements Closeable { + + public enum State { + RUNNING, + QUIESCING, + CLOSED + } + + private final SnapshotPinSource source; + private State state = State.RUNNING; + private int activeLeases; + + public ArchiveRuntimeQueryGate(ArchiveGenerationCapsule capsule) { + this(Objects.requireNonNull(capsule, "capsule")::pin); + } + + ArchiveRuntimeQueryGate(SnapshotPinSource source) { + this.source = Objects.requireNonNull(source, "source"); + } + + /** Pins and registers one request atomically against the quiesce transition. */ + public synchronized Lease pin(long targetBlock) throws IOException { + if (state != State.RUNNING) { + throw new IllegalStateException("Archive query gate is not running: " + state); + } + ArchiveReadSnapshot snapshot = Objects.requireNonNull(source.pin(targetBlock), "snapshot"); + activeLeases++; + return new Lease(this, snapshot); + } + + /** Stops admission of new requests without waiting for existing leases. */ + public synchronized void quiesce() { + if (state == State.RUNNING) { + state = State.QUIESCING; + } + } + + public synchronized State getState() { + return state; + } + + public synchronized int getActiveLeaseCount() { + return activeLeases; + } + + public synchronized boolean isDrained() { + return activeLeases == 0; + } + + /** Finishes closure only after quiescence has rejected new pins and every lease has drained. */ + @Override + public synchronized void close() { + quiesce(); + if (state == State.CLOSED) { + return; + } + if (activeLeases != 0) { + throw new IllegalStateException( + "Archive query gate still has active leases: " + activeLeases); + } + state = State.CLOSED; + } + + private synchronized void release() { + if (activeLeases <= 0) { + throw new IllegalStateException("Archive query lease count underflow"); + } + activeLeases--; + } + + @FunctionalInterface + interface SnapshotPinSource { + ArchiveReadSnapshot pin(long targetBlock) throws IOException; + } + + /** One request-owned snapshot whose close releases both resources and gate accounting. */ + public static final class Lease implements ArchivePointSnapshot { + + private final ArchiveRuntimeQueryGate gate; + private final ArchiveReadSnapshot snapshot; + private boolean closed; + + private Lease(ArchiveRuntimeQueryGate gate, ArchiveReadSnapshot snapshot) { + this.gate = gate; + this.snapshot = snapshot; + } + + public synchronized ArchiveReadSnapshot getSnapshot() { + if (closed) { + throw new IllegalStateException("Archive query lease is closed"); + } + return snapshot; + } + + @Override + public OldValue get(String dbName, byte[] physicalRawKey) throws IOException { + return getSnapshot().get(dbName, physicalRawKey); + } + + @Override + public java.util.List range(String dbName, + HistoricalRangeOverlay.KeyRange range, HistoricalRangeOverlay.Limits limits) + throws IOException { + return getSnapshot().range(dbName, range, limits); + } + + @Override + public long getTargetBlock() { + return getSnapshot().getTargetBlock(); + } + + @Override + public long getPinnedBlock() { + return getSnapshot().getPinnedBlock(); + } + + @Override + public byte[] getPinnedHash() { + return getSnapshot().getPinnedHash(); + } + + @Override + public void requirePinnedIdentity() { + getSnapshot().requirePinnedIdentity(); + } + + @Override + public void close() throws IOException { + synchronized (this) { + if (closed) { + return; + } + closed = true; + } + try { + snapshot.close(); + } finally { + gate.release(); + } + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveStateBarrier.java b/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveStateBarrier.java new file mode 100644 index 00000000000..8ed1d8cc5b1 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveStateBarrier.java @@ -0,0 +1,16 @@ +package org.tron.core.db2.archive; + +import java.io.IOException; + +/** Runs one latest-state acquisition inside the canonical SnapshotManager state boundary. */ +@FunctionalInterface +public interface ArchiveStateBarrier { + + void run(ArchiveStateAction action) throws IOException; + + @FunctionalInterface + interface ArchiveStateAction { + + void run() throws IOException; + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveStoreScope.java b/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveStoreScope.java new file mode 100644 index 00000000000..654dacec998 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveStoreScope.java @@ -0,0 +1,83 @@ +package org.tron.core.db2.archive; + +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.stream.Collectors; +import org.tron.core.db2.core.Chainbase; + +/** Explicit classification of every database registered with {@code SnapshotManager}. */ +public final class ArchiveStoreScope { + + private static final ArchiveParticipantDescriptor DESCRIPTOR = + ArchiveParticipantDescriptor.current(); + private static final Set STATE_DATABASES = DESCRIPTOR.getActiveDatabases(); + private static final Set EXCLUDED_DATABASES = DESCRIPTOR.getExcludedDatabases(); + + private static final Set NON_STATE_DATABASES = Collections.unmodifiableSet( + new LinkedHashSet<>(Arrays.asList( + "account-trace", + "accountTrie", + "balance-trace", + "block", + "block-index", + "recent-block", + "recent-transaction", + "section-bloom", + "trans", + "trans-cache", + "transactionHistoryStore", + "transactionRetStore", + "tree-block-index"))); + + private ArchiveStoreScope() { + } + + public static boolean isStateDatabase(String dbName) { + return STATE_DATABASES.contains(dbName); + } + + public static boolean isClassified(String dbName) { + return STATE_DATABASES.contains(dbName) || NON_STATE_DATABASES.contains(dbName) + || EXCLUDED_DATABASES.contains(dbName); + } + + public static Set getStateDatabases() { + return STATE_DATABASES; + } + + public static Set getNonStateDatabases() { + return NON_STATE_DATABASES; + } + + public static boolean isExcludedDatabase(String dbName) { + return EXCLUDED_DATABASES.contains(dbName); + } + + public static Set getExcludedDatabases() { + return EXCLUDED_DATABASES; + } + + public static void validate(Collection databases) { + Set duplicates = databases.stream() + .collect(Collectors.groupingBy(Chainbase::getDbName, Collectors.counting())) + .entrySet().stream() + .filter(entry -> entry.getValue() > 1) + .map(java.util.Map.Entry::getKey) + .collect(Collectors.toCollection(LinkedHashSet::new)); + if (!duplicates.isEmpty()) { + throw new IllegalStateException("Duplicate Chainbase dbName(s): " + duplicates); + } + + Set unknown = databases.stream() + .filter(database -> !isClassified(database.getDbName())) + .map(database -> database.getDbName() + " (" + database.getRegistrationSource() + ")") + .collect(Collectors.toCollection(LinkedHashSet::new)); + if (!unknown.isEmpty()) { + throw new IllegalStateException( + "Archive state scope has unclassified Chainbase dbName(s): " + unknown); + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveTargetMutationPlan.java b/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveTargetMutationPlan.java new file mode 100644 index 00000000000..a67ae76dfa7 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveTargetMutationPlan.java @@ -0,0 +1,97 @@ +package org.tron.core.db2.archive; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.TreeMap; +import org.tron.core.db2.archive.P66AccountAssetCodec.Phase; +import org.tron.core.db2.archive.ArchiveProgressEnvelope.Kind; + +/** Immutable target H identity plus exact per-participant business mutations. */ +final class ArchiveTargetMutationPlan { + + private final ArchiveProgressEnvelope target; + private final String accountAssetFormatId; + private final Phase targetPhase; + private final Map> mutations; + + ArchiveTargetMutationPlan(ArchiveProgressEnvelope target, String accountAssetFormatId, + Phase targetPhase, Map> mutations) { + this.target = Objects.requireNonNull(target, "target"); + this.accountAssetFormatId = Objects.requireNonNull(accountAssetFormatId, + "accountAssetFormatId"); + if (accountAssetFormatId.isEmpty()) { + throw new IllegalArgumentException("AccountAsset transition format must not be empty"); + } + this.targetPhase = Objects.requireNonNull(targetPhase, "targetPhase"); + if (target.getKind() != Kind.APPLY_CHECKPOINT || target.getParticipant() != null) { + throw new IllegalArgumentException("Mutation plan target must be a global checkpoint"); + } + if (target.getMutationPlanDigest() != null) { + throw new IllegalArgumentException("Mutation plan target must not contain its own digest"); + } + TreeMap> checked = new TreeMap<>( + Objects.requireNonNull(mutations, "mutations")); + if (!new ArrayList<>(checked.keySet()).equals(target.getParticipants()) + || checked.containsValue(null)) { + throw new IllegalArgumentException("Mutation plan participant set mismatch"); + } + Map> copy = new LinkedHashMap<>(); + for (String participant : target.getParticipants()) { + List values = new ArrayList<>( + Objects.requireNonNull(checked.get(participant), "participant mutations")); + if (values.contains(null)) { + throw new IllegalArgumentException("Mutation plan contains null mutation"); + } + values.sort((left, right) -> BlockReverseDiff.compareUnsigned( + left.getKey(), right.getKey())); + byte[] previous = null; + for (ArchiveParticipantMutation mutation : values) { + byte[] key = mutation.getKey(); + if (previous != null && BlockReverseDiff.compareUnsigned(previous, key) == 0) { + throw new IllegalArgumentException("Mutation plan contains duplicate physical key"); + } + previous = key; + } + copy.put(participant, Collections.unmodifiableList(values)); + } + this.mutations = Collections.unmodifiableMap(copy); + } + + ArchiveProgressEnvelope getTarget() { + return target; + } + + String getAccountAssetFormatId() { + return accountAssetFormatId; + } + + Phase getTargetPhase() { + return targetPhase; + } + + List getMutations(String participant) { + List values = mutations.get(participant); + if (values == null) { + throw new ArchivePersistenceException("Unknown mutation-plan participant: " + participant); + } + return values; + } + + Map> getMutations() { + return mutations; + } + + byte[] digest() { + return new ArchiveTargetMutationPlanCodec().digest(this); + } + + void requireIdentity(HistoryCommitMarker marker, List participants) { + target.requireIdentity(Kind.APPLY_CHECKPOINT, null, marker.getMeta().getEpoch(), + marker.getMeta().getBlockHash(), marker.getBatchId(), + marker.getHistoryLocation().getBodyDigest(), participants); + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveTargetMutationPlanCodec.java b/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveTargetMutationPlanCodec.java new file mode 100644 index 00000000000..3c48b83925c --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveTargetMutationPlanCodec.java @@ -0,0 +1,192 @@ +package org.tron.core.db2.archive; + +import com.google.common.hash.Hashing; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.EOFException; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.tron.core.db2.archive.P66AccountAssetCodec.Phase; + +/** Checksummed bounded codec for one durable target mutation plan. */ +final class ArchiveTargetMutationPlanCodec { + + private static final int MAGIC = 0x54414d50; // TAMP + private static final short VERSION = 2; + private static final int HEADER_LENGTH = 12; + private static final int MAX_PARTICIPANTS = 1024; + private static final int MAX_MUTATIONS = 1_000_000; + private static final int MAX_FIELD_LENGTH = 64 * 1024 * 1024; + private static final int MAX_IDENTITY_LENGTH = 256; + static final int MAX_ENCODED_LENGTH = 128 * 1024 * 1024; + private final ArchiveProgressEnvelopeCodec progressCodec = new ArchiveProgressEnvelopeCodec(); + + byte[] encode(ArchiveTargetMutationPlan plan) { + try { + byte[] target = progressCodec.encode(plan.getTarget()); + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + DataOutputStream output = new DataOutputStream(bytes); + output.writeInt(MAGIC); + output.writeShort(VERSION); + output.writeShort(0); + output.writeInt(0); + writeIdentity(output, plan.getAccountAssetFormatId()); + writeIdentity(output, plan.getTargetPhase().name()); + writeBytes(output, target); + output.writeInt(plan.getTarget().getParticipants().size()); + for (String participant : plan.getTarget().getParticipants()) { + List mutations = plan.getMutations(participant); + if (mutations.size() > MAX_MUTATIONS) { + throw new IllegalArgumentException("Mutation plan contains too many mutations"); + } + output.writeInt(mutations.size()); + for (ArchiveParticipantMutation mutation : mutations) { + writeBytes(output, mutation.getKey()); + byte[] value = mutation.getValue(); + output.writeInt(value == null ? -1 : value.length); + if (value != null) { + output.write(value); + } + } + } + output.flush(); + byte[] payload = bytes.toByteArray(); + int length = Math.addExact(payload.length, Integer.BYTES); + if (length > MAX_ENCODED_LENGTH) { + throw new IllegalArgumentException("Mutation plan is too large"); + } + ByteBuffer.wrap(payload).putInt(8, length); + bytes.reset(); + output = new DataOutputStream(bytes); + output.write(payload); + output.writeInt(crc32c(payload)); + output.flush(); + return bytes.toByteArray(); + } catch (IOException impossible) { + throw new IllegalStateException("Unexpected mutation-plan encoding failure", impossible); + } + } + + ArchiveTargetMutationPlan decode(byte[] encoded) { + if (encoded == null || encoded.length < HEADER_LENGTH + Integer.BYTES + || encoded.length > MAX_ENCODED_LENGTH) { + throw new IllegalArgumentException("Mutation-plan length is invalid"); + } + int checksum = ByteBuffer.wrap(encoded, encoded.length - Integer.BYTES, + Integer.BYTES).getInt(); + byte[] payload = Arrays.copyOf(encoded, encoded.length - Integer.BYTES); + if (checksum != crc32c(payload)) { + throw new IllegalArgumentException("Mutation-plan checksum mismatch"); + } + try { + DataInputStream input = new DataInputStream(new ByteArrayInputStream(encoded)); + if (input.readInt() != MAGIC || input.readShort() != VERSION || input.readShort() != 0 + || input.readInt() != encoded.length) { + throw new IllegalArgumentException("Unsupported mutation-plan header"); + } + String formatId = readIdentity(input); + if (!P66AccountAssetCodec.FORMAT_ID.equals(formatId)) { + throw new IllegalArgumentException("Unsupported AccountAsset transition format"); + } + Phase targetPhase; + try { + targetPhase = Phase.valueOf(readIdentity(input)); + } catch (IllegalArgumentException invalidPhase) { + throw new IllegalArgumentException("Unsupported AccountAsset target phase", invalidPhase); + } + ArchiveProgressEnvelope target = progressCodec.decode(readBytes(input)); + int participantCount = input.readInt(); + if (participantCount <= 0 || participantCount > MAX_PARTICIPANTS + || participantCount != target.getParticipants().size()) { + throw new IllegalArgumentException("Mutation-plan participant count mismatch"); + } + Map> mutations = new LinkedHashMap<>(); + for (String participant : target.getParticipants()) { + int count = input.readInt(); + if (count < 0 || count > MAX_MUTATIONS) { + throw new IllegalArgumentException("Mutation-plan mutation count is invalid"); + } + List values = new ArrayList<>(count); + for (int index = 0; index < count; index++) { + byte[] key = readBytes(input); + int valueLength = input.readInt(); + if (valueLength < -1 || valueLength > MAX_FIELD_LENGTH + || valueLength > input.available() - Integer.BYTES) { + throw new IllegalArgumentException("Mutation-plan value length is invalid"); + } + values.add(valueLength == -1 ? ArchiveParticipantMutation.delete(key) + : ArchiveParticipantMutation.put(key, readExact(input, valueLength))); + } + mutations.put(participant, values); + } + if (input.available() != Integer.BYTES) { + throw new IllegalArgumentException("Mutation-plan payload mismatch"); + } + return new ArchiveTargetMutationPlan(target, formatId, targetPhase, mutations); + } catch (EOFException truncated) { + throw new IllegalArgumentException("Mutation plan is truncated", truncated); + } catch (IOException invalid) { + throw new IllegalArgumentException("Invalid mutation plan", invalid); + } + } + + byte[] digest(ArchiveTargetMutationPlan plan) { + return Hashing.sha256().hashBytes(encode(plan)).asBytes(); + } + + private static void writeBytes(DataOutputStream output, byte[] value) throws IOException { + if (value.length > MAX_FIELD_LENGTH) { + throw new IllegalArgumentException("Mutation-plan field is too large"); + } + output.writeInt(value.length); + output.write(value); + } + + private static void writeIdentity(DataOutputStream output, String value) throws IOException { + byte[] encoded = value.getBytes(StandardCharsets.US_ASCII); + if (!value.equals(new String(encoded, StandardCharsets.US_ASCII)) + || encoded.length == 0 || encoded.length > MAX_IDENTITY_LENGTH) { + throw new IllegalArgumentException("Mutation-plan identity is invalid"); + } + writeBytes(output, encoded); + } + + private static String readIdentity(DataInputStream input) throws IOException { + byte[] encoded = readBytes(input); + if (encoded.length == 0 || encoded.length > MAX_IDENTITY_LENGTH) { + throw new IllegalArgumentException("Mutation-plan identity is invalid"); + } + String value = new String(encoded, StandardCharsets.US_ASCII); + if (!Arrays.equals(encoded, value.getBytes(StandardCharsets.US_ASCII))) { + throw new IllegalArgumentException("Mutation-plan identity is not ASCII"); + } + return value; + } + + private static byte[] readBytes(DataInputStream input) throws IOException { + int length = input.readInt(); + if (length < 0 || length > MAX_FIELD_LENGTH + || length > input.available() - Integer.BYTES) { + throw new IllegalArgumentException("Mutation-plan field length is invalid"); + } + return readExact(input, length); + } + + private static byte[] readExact(DataInputStream input, int length) throws IOException { + byte[] value = new byte[length]; + input.readFully(value); + return value; + } + + private static int crc32c(byte[] value) { + return Hashing.crc32c().hashBytes(value).asInt(); + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveTargetMutationPlanFile.java b/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveTargetMutationPlanFile.java new file mode 100644 index 00000000000..b3cd417d984 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveTargetMutationPlanFile.java @@ -0,0 +1,97 @@ +package org.tron.core.db2.archive; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.util.Objects; + +/** Atomic durable authority for one in-flight target mutation plan. */ +final class ArchiveTargetMutationPlanFile { + + private static final String FILE_NAME = "target.mutation-plan"; + private final Path path; + private final Path temporary; + private final ArchiveTargetMutationPlanCodec codec = new ArchiveTargetMutationPlanCodec(); + private final FaultHook faultHook; + + ArchiveTargetMutationPlanFile(Path checkpointPath) { + this(checkpointPath, (stage, path) -> { }); + } + + ArchiveTargetMutationPlanFile(Path checkpointPath, FaultHook faultHook) { + Path directory = Objects.requireNonNull(checkpointPath, "checkpointPath").getParent(); + if (directory == null) { + throw new IllegalArgumentException("Checkpoint path must have a parent"); + } + this.path = directory.resolve(FILE_NAME); + this.temporary = directory.resolve(FILE_NAME + ".tmp"); + this.faultHook = Objects.requireNonNull(faultHook, "faultHook"); + } + + void store(ArchiveTargetMutationPlan plan) throws IOException { + byte[] encoded = codec.encode(plan); + Files.createDirectories(path.getParent()); + try (FileChannel channel = FileChannel.open(temporary, StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE)) { + ByteBuffer buffer = ByteBuffer.wrap(encoded); + while (buffer.hasRemaining()) { + channel.write(buffer); + } + channel.force(true); + } + faultHook.after(Stage.AFTER_TEMPORARY_FORCE, temporary); + try { + Files.move(temporary, path, StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException unsupported) { + throw new ArchivePersistenceException( + "Archive filesystem does not support atomic mutation-plan replacement", unsupported); + } + HistorySegmentStore.syncDirectory(path.getParent()); + faultHook.after(Stage.AFTER_REPLACE, path); + } + + ArchiveTargetMutationPlan loadRequired() throws IOException { + if (!Files.exists(path)) { + throw new ArchivePersistenceException("Archive target mutation plan is missing"); + } + long size = Files.size(path); + if (size <= 0 || size > ArchiveTargetMutationPlanCodec.MAX_ENCODED_LENGTH) { + throw new ArchivePersistenceException("Archive target mutation-plan length is invalid"); + } + try { + return codec.decode(Files.readAllBytes(path)); + } catch (IllegalArgumentException invalid) { + throw new ArchivePersistenceException("Archive target mutation plan is corrupt", invalid); + } + } + + ArchiveTargetMutationPlan loadIfPresent() throws IOException { + return Files.exists(path) ? loadRequired() : null; + } + + void retire() throws IOException { + Files.deleteIfExists(path); + Files.deleteIfExists(temporary); + HistorySegmentStore.syncDirectory(path.getParent()); + } + + Path getPath() { + return path; + } + + enum Stage { + AFTER_TEMPORARY_FORCE, + AFTER_REPLACE + } + + @FunctionalInterface + interface FaultHook { + void after(Stage stage, Path path) throws IOException; + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveTruncationIntent.java b/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveTruncationIntent.java new file mode 100644 index 00000000000..a5b467d66d8 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveTruncationIntent.java @@ -0,0 +1,228 @@ +package org.tron.core.db2.archive; + +import com.google.common.hash.Hashing; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.util.Arrays; + +/** Atomic startup intent identifying one validated history truncation target. */ +final class ArchiveTruncationIntent { + + private static final int MAGIC = 0x54415449; // TATI + private static final short VERSION = 1; + private static final String FILE_NAME = "truncation.intent"; + private static final String TEMP_FILE_NAME = "truncation.intent.tmp"; + private static final int HEADER_LENGTH = 36; + private static final int MAX_LENGTH = 2 * 1024 * 1024; + + private final long firstEpoch; + private final long recordCount; + private final int recordLength; + private final HistoryCommitMarker marker; + private final byte[] encodedMarker; + + private ArchiveTruncationIntent(long firstEpoch, long recordCount, int recordLength, + HistoryCommitMarker marker, byte[] encodedMarker) { + this.firstEpoch = firstEpoch; + this.recordCount = recordCount; + this.recordLength = recordLength; + this.marker = marker; + this.encodedMarker = Arrays.copyOf(encodedMarker, encodedMarker.length); + } + + static ArchiveTruncationIntent prepare(Path archiveDirectory, HistoryCommitStore commits, + HistoryIndexStore index, HistoryBodyStore bodies, long targetEpoch, + HistoryCommitMarkerCodec markerCodec) throws IOException { + return prepare(archiveDirectory, commits, index, bodies, targetEpoch, markerCodec, + temporary -> { }); + } + + static ArchiveTruncationIntent prepare(Path archiveDirectory, HistoryCommitStore commits, + HistoryIndexStore index, HistoryBodyStore bodies, long targetEpoch, + HistoryCommitMarkerCodec markerCodec, FaultHook faultHook) throws IOException { + HistoryCommitMarker marker = commits.get(targetEpoch); + if (marker == null) { + throw new ArchivePersistenceException( + "Archive truncation intent target is outside committed history"); + } + HistoryIndexRecord indexRecord = index.read(marker.getIndexLocation()); + BlockReverseDiff body = bodies.read(marker.getHistoryLocation()); + if (!marker.getMeta().equals(indexRecord.getMeta()) + || !marker.getMeta().equals(body.getMeta()) + || !sameLocation(marker.getHistoryLocation(), indexRecord.getHistoryLocation())) { + throw new ArchivePersistenceException("Archive truncation intent target is inconsistent"); + } + long count = targetEpoch - commits.firstEpoch() + 1; + byte[] markerBytes = markerCodec.encode(marker); + ArchiveTruncationIntent intent = new ArchiveTruncationIntent(commits.firstEpoch(), count, + commits.getRecordLength(), marker, markerBytes); + intent.persist(archiveDirectory, faultHook); + return intent; + } + + static ArchiveTruncationIntent load(Path archiveDirectory, + HistoryCommitMarkerCodec markerCodec) throws IOException { + Path path = archiveDirectory.resolve(FILE_NAME); + if (!Files.exists(path)) { + return null; + } + try { + return decode(Files.readAllBytes(path), markerCodec); + } catch (IllegalArgumentException invalid) { + throw new ArchivePersistenceException("Archive truncation intent is corrupt", invalid); + } + } + + void clear(Path archiveDirectory) throws IOException { + Files.deleteIfExists(archiveDirectory.resolve(FILE_NAME)); + HistorySegmentStore.syncDirectory(archiveDirectory); + } + + ArchiveHistoryScanAnchor persistCheckpoint(Path archiveDirectory, + HistoryCommitMarkerCodec markerCodec) throws IOException { + return ArchiveHistoryScanAnchor.persist(archiveDirectory, firstEpoch, recordCount, + recordLength, marker, markerCodec); + } + + long commitEndOffset() { + return recordCount * (long) recordLength; + } + + long markerOffset() { + return (recordCount - 1) * (long) recordLength; + } + + long getRecordCount() { + return recordCount; + } + + HistoryCommitMarker getMarker() { + return marker; + } + + byte[] getEncodedMarker() { + return Arrays.copyOf(encodedMarker, encodedMarker.length); + } + + private void persist(Path archiveDirectory, FaultHook faultHook) throws IOException { + byte[] encoded = encode(); + Files.createDirectories(archiveDirectory); + Path temporary = archiveDirectory.resolve(TEMP_FILE_NAME); + try (FileChannel channel = FileChannel.open(temporary, StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE)) { + writeFully(channel, ByteBuffer.wrap(encoded)); + channel.force(true); + } + faultHook.afterTemporaryForce(temporary); + try { + Files.move(temporary, archiveDirectory.resolve(FILE_NAME), + StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException unsupported) { + throw new ArchivePersistenceException( + "Archive filesystem does not support atomic truncation intent", unsupported); + } + HistorySegmentStore.syncDirectory(archiveDirectory); + } + + private byte[] encode() { + try { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + DataOutputStream output = new DataOutputStream(bytes); + output.writeInt(MAGIC); + output.writeShort(VERSION); + output.writeShort(0); + output.writeInt(0); + output.writeLong(firstEpoch); + output.writeLong(recordCount); + output.writeInt(recordLength); + output.writeInt(encodedMarker.length); + output.write(encodedMarker); + output.flush(); + byte[] payload = bytes.toByteArray(); + int length = payload.length + Integer.BYTES; + if (length > MAX_LENGTH) { + throw new IllegalArgumentException("Archive truncation intent is too large"); + } + ByteBuffer.wrap(payload).putInt(8, length); + bytes.reset(); + output = new DataOutputStream(bytes); + output.write(payload); + output.writeInt(Hashing.crc32c().hashBytes(payload).asInt()); + output.flush(); + return bytes.toByteArray(); + } catch (IOException impossible) { + throw new IllegalStateException("Unexpected truncation intent encoding failure", impossible); + } + } + + private static ArchiveTruncationIntent decode(byte[] encoded, + HistoryCommitMarkerCodec markerCodec) { + if (encoded == null || encoded.length < HEADER_LENGTH + Integer.BYTES + || encoded.length > MAX_LENGTH) { + throw new IllegalArgumentException("Archive truncation intent length is invalid"); + } + byte[] payload = Arrays.copyOf(encoded, encoded.length - Integer.BYTES); + int checksum = ByteBuffer.wrap(encoded, encoded.length - Integer.BYTES, + Integer.BYTES).getInt(); + if (checksum != Hashing.crc32c().hashBytes(payload).asInt()) { + throw new IllegalArgumentException("Archive truncation intent checksum mismatch"); + } + try { + DataInputStream input = new DataInputStream(new ByteArrayInputStream(encoded)); + if (input.readInt() != MAGIC || input.readShort() != VERSION || input.readShort() != 0 + || input.readInt() != encoded.length) { + throw new IllegalArgumentException("Unsupported archive truncation intent header"); + } + long firstEpoch = input.readLong(); + long recordCount = input.readLong(); + int recordLength = input.readInt(); + int markerLength = input.readInt(); + if (firstEpoch < 0 || recordCount <= 0 || recordLength <= 0 + || markerLength != recordLength || markerLength > input.available() - Integer.BYTES) { + throw new IllegalArgumentException("Invalid archive truncation intent fields"); + } + byte[] markerBytes = new byte[markerLength]; + input.readFully(markerBytes); + if (input.available() != Integer.BYTES) { + throw new IllegalArgumentException("Archive truncation intent payload mismatch"); + } + HistoryCommitMarker marker = markerCodec.decode(markerBytes); + if (firstEpoch + recordCount - 1 != marker.getMeta().getEpoch()) { + throw new IllegalArgumentException("Archive truncation intent ordinal mismatch"); + } + return new ArchiveTruncationIntent(firstEpoch, recordCount, recordLength, marker, + markerBytes); + } catch (IOException invalid) { + throw new IllegalArgumentException("Archive truncation intent is truncated", invalid); + } + } + + private static void writeFully(FileChannel channel, ByteBuffer buffer) throws IOException { + while (buffer.hasRemaining()) { + channel.write(buffer); + } + } + + private static boolean sameLocation(HistoryLocation expected, HistoryLocation actual) { + return expected.getSegmentId() == actual.getSegmentId() + && expected.getOffset() == actual.getOffset() + && expected.getRecordLength() == actual.getRecordLength() + && expected.getBodyChecksum() == actual.getBodyChecksum() + && Arrays.equals(expected.getBodyDigest(), actual.getBodyDigest()); + } + + @FunctionalInterface + interface FaultHook { + void afterTemporaryForce(Path temporary) throws IOException; + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveTruncationRecovery.java b/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveTruncationRecovery.java new file mode 100644 index 00000000000..4a3841dd294 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveTruncationRecovery.java @@ -0,0 +1,96 @@ +package org.tron.core.db2.archive; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.Arrays; +import java.util.Objects; + +/** Completes an atomic truncation intent before normal checkpoint-based startup. */ +public final class ArchiveTruncationRecovery { + + private final Path archiveDirectory; + private final long maxSegmentSize; + private final HistoryCommitMarkerCodec markerCodec = new HistoryCommitMarkerCodec(); + private final FaultHook faultHook; + + public ArchiveTruncationRecovery(Path archiveDirectory, long maxSegmentSize) { + this(archiveDirectory, maxSegmentSize, stage -> { }); + } + + ArchiveTruncationRecovery(Path archiveDirectory, long maxSegmentSize, FaultHook faultHook) { + this.archiveDirectory = Objects.requireNonNull(archiveDirectory, "archiveDirectory"); + if (maxSegmentSize <= 0) { + throw new IllegalArgumentException("maxSegmentSize must be positive"); + } + this.maxSegmentSize = maxSegmentSize; + this.faultHook = Objects.requireNonNull(faultHook, "faultHook"); + } + + public boolean recover() throws IOException { + ArchiveTruncationIntent intent = ArchiveTruncationIntent.load(archiveDirectory, markerCodec); + if (intent == null) { + return false; + } + shrinkCommitLog(intent); + faultHook.afterDurableStage(Stage.COMMIT_SHRUNK); + ArchiveHistoryScanAnchor checkpoint = intent.persistCheckpoint(archiveDirectory, markerCodec); + faultHook.afterDurableStage(Stage.CHECKPOINT_PUBLISHED); + + try (HistoryIndexStore index = new HistoryIndexStore( + archiveDirectory, new HistoryIndexCodec(), checkpoint)) { + index.truncateAfter(intent.getMarker().getIndexLocation(), intent.getRecordCount()); + } + faultHook.afterDurableStage(Stage.INDEX_TRUNCATED); + try (HistoryBodyStore bodies = new PartitionedHistoryBodyStore(archiveDirectory, + new BlockHistoryCodec(), maxSegmentSize, checkpoint)) { + bodies.truncateAfter(intent.getMarker().getHistoryLocation(), intent.getRecordCount()); + } + faultHook.afterDurableStage(Stage.BODY_TRUNCATED); + intent.clear(archiveDirectory); + return true; + } + + private void shrinkCommitLog(ArchiveTruncationIntent intent) throws IOException { + Path path = archiveDirectory.resolve("commits/commit.log"); + if (!Files.exists(path)) { + throw new ArchivePersistenceException("Committed history log is missing during truncation"); + } + try (FileChannel channel = FileChannel.open(path, StandardOpenOption.READ, + StandardOpenOption.WRITE)) { + if (channel.size() < intent.commitEndOffset()) { + throw new ArchivePersistenceException( + "Committed history log is shorter than truncation intent"); + } + ByteBuffer marker = ByteBuffer.allocate(intent.getEncodedMarker().length); + channel.position(intent.markerOffset()); + while (marker.hasRemaining()) { + if (channel.read(marker) < 0) { + throw new ArchivePersistenceException( + "Committed history target is truncated during recovery"); + } + } + if (!Arrays.equals(marker.array(), intent.getEncodedMarker())) { + throw new ArchivePersistenceException( + "Committed history target does not match truncation intent"); + } + channel.truncate(intent.commitEndOffset()); + channel.force(true); + } + } + + public enum Stage { + COMMIT_SHRUNK, + CHECKPOINT_PUBLISHED, + INDEX_TRUNCATED, + BODY_TRUNCATED + } + + @FunctionalInterface + interface FaultHook { + void afterDurableStage(Stage stage) throws IOException; + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveWalBinding.java b/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveWalBinding.java new file mode 100644 index 00000000000..7fb2b0e83e9 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveWalBinding.java @@ -0,0 +1,178 @@ +package org.tron.core.db2.archive; + +import com.google.common.hash.Hasher; +import com.google.common.hash.Hashing; +import com.google.common.primitives.Ints; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** Immutable history identity stored in the same Chainbase checkpoint batch as state changes. */ +public final class ArchiveWalBinding { + + static final String CHECKPOINT_DATABASE = "__state_archive_wal__"; + private static final byte[] CHECKPOINT_KEY = checkpointKey(); + + private final BlockSnapshotMeta first; + private final BlockSnapshotMeta last; + private final long predecessorEpoch; + private final byte[] predecessorHash; + private final byte[] batchDigest; + private final byte[] storeScopeDigest; + private final byte[] historyRefsDigest; + private final byte[] blockIndexRefsDigest; + + ArchiveWalBinding(BlockSnapshotMeta first, BlockSnapshotMeta last, long predecessorEpoch, + byte[] predecessorHash, byte[] batchDigest, byte[] storeScopeDigest, + byte[] historyRefsDigest, byte[] blockIndexRefsDigest) { + this.first = Objects.requireNonNull(first, "first"); + this.last = Objects.requireNonNull(last, "last"); + this.predecessorEpoch = predecessorEpoch; + this.predecessorHash = hash(predecessorHash, "predecessorHash"); + this.batchDigest = hash(batchDigest, "batchDigest"); + this.storeScopeDigest = hash(storeScopeDigest, "storeScopeDigest"); + this.historyRefsDigest = hash(historyRefsDigest, "historyRefsDigest"); + this.blockIndexRefsDigest = hash(blockIndexRefsDigest, "blockIndexRefsDigest"); + if (predecessorEpoch != first.getEpoch() - 1 + || !Arrays.equals(this.predecessorHash, first.getParentHash()) + || first.getEpoch() > last.getEpoch() + || first.getBlockNumber() > last.getBlockNumber()) { + throw new IllegalArgumentException("Archive WAL binding range is invalid"); + } + } + + public static ArchiveWalBinding fromMarkers(List source) { + List markers = new ArrayList<>( + Objects.requireNonNull(source, "markers")); + if (markers.isEmpty()) { + throw new IllegalArgumentException("Archive WAL binding markers must not be empty"); + } + HistoryCommitMarkerCodec codec = new HistoryCommitMarkerCodec(); + Hasher batch = Hashing.sha256().newHasher(); + Hasher history = Hashing.sha256().newHasher(); + Hasher index = Hashing.sha256().newHasher(); + HistoryCommitMarker previous = null; + List databases = null; + for (HistoryCommitMarker marker : markers) { + HistoryCommitMarker current = Objects.requireNonNull(marker, "marker"); + if (previous != null && (current.getMeta().getEpoch() + != previous.getMeta().getEpoch() + 1 + || current.getMeta().getBlockNumber() + != previous.getMeta().getBlockNumber() + 1 + || current.getPreviousEpoch() != previous.getMeta().getEpoch() + || !Arrays.equals(current.getMeta().getParentHash(), + previous.getMeta().getBlockHash()))) { + throw new IllegalArgumentException("Archive WAL binding markers are not contiguous"); + } + if (databases == null) { + databases = current.getDatabases(); + } else if (!databases.equals(current.getDatabases())) { + throw new IllegalArgumentException("Archive WAL binding Store scope changed in batch"); + } + batch.putBytes(codec.encode(current)); + putHistoryReference(history, current); + putBlockIndexReference(index, current); + previous = current; + } + return new ArchiveWalBinding(markers.get(0).getMeta(), previous.getMeta(), + markers.get(0).getPreviousEpoch(), markers.get(0).getMeta().getParentHash(), + batch.hash().asBytes(), scopeDigest(databases), history.hash().asBytes(), + index.hash().asBytes()); + } + + public BlockSnapshotMeta getFirst() { + return first; + } + + public BlockSnapshotMeta getLast() { + return last; + } + + public long getPredecessorEpoch() { + return predecessorEpoch; + } + + public byte[] getPredecessorHash() { + return Arrays.copyOf(predecessorHash, predecessorHash.length); + } + + public byte[] getBatchDigest() { + return Arrays.copyOf(batchDigest, batchDigest.length); + } + + public byte[] getStoreScopeDigest() { + return Arrays.copyOf(storeScopeDigest, storeScopeDigest.length); + } + + public byte[] getHistoryRefsDigest() { + return Arrays.copyOf(historyRefsDigest, historyRefsDigest.length); + } + + public byte[] getBlockIndexRefsDigest() { + return Arrays.copyOf(blockIndexRefsDigest, blockIndexRefsDigest.length); + } + + public static byte[] getCheckpointKey() { + return Arrays.copyOf(CHECKPOINT_KEY, CHECKPOINT_KEY.length); + } + + public static boolean isCheckpointKey(byte[] key) { + return Arrays.equals(CHECKPOINT_KEY, key); + } + + public static ArchiveWalBinding fromCheckpointBatch(Map batch) { + for (Map.Entry entry : batch.entrySet()) { + if (isCheckpointKey(entry.getKey())) { + return new ArchiveWalBindingCodec().decode(entry.getValue()); + } + } + return null; + } + + private static byte[] checkpointKey() { + byte[] database = CHECKPOINT_DATABASE.getBytes(StandardCharsets.UTF_8); + byte[] field = "binding".getBytes(StandardCharsets.UTF_8); + byte[] key = new byte[Integer.BYTES + database.length + field.length]; + System.arraycopy(Ints.toByteArray(database.length), 0, key, 0, Integer.BYTES); + System.arraycopy(database, 0, key, Integer.BYTES, database.length); + System.arraycopy(field, 0, key, Integer.BYTES + database.length, field.length); + return key; + } + + private static byte[] scopeDigest(List databases) { + List sorted = new ArrayList<>(Objects.requireNonNull(databases, "databases")); + Collections.sort(sorted); + Hasher digest = Hashing.sha256().newHasher(); + digest.putInt(sorted.size()); + for (String database : sorted) { + byte[] encoded = database.getBytes(StandardCharsets.UTF_8); + digest.putInt(encoded.length).putBytes(encoded); + } + return digest.hash().asBytes(); + } + + private static void putHistoryReference(Hasher digest, HistoryCommitMarker marker) { + HistoryLocation location = marker.getHistoryLocation(); + digest.putLong(marker.getMeta().getEpoch()).putInt(location.getSegmentId()) + .putLong(location.getOffset()).putInt(location.getRecordLength()) + .putInt(location.getBodyChecksum()).putBytes(location.getBodyDigest()); + } + + private static void putBlockIndexReference(Hasher digest, HistoryCommitMarker marker) { + HistoryIndexLocation location = marker.getIndexLocation(); + digest.putLong(marker.getMeta().getEpoch()).putLong(marker.getMeta().getBlockNumber()) + .putBytes(marker.getMeta().getBlockHash()).putLong(location.getOffset()) + .putInt(location.getRecordLength()).putBytes(location.getDigest()); + } + + private static byte[] hash(byte[] value, String name) { + if (value == null || value.length != 32) { + throw new IllegalArgumentException(name + " must be exactly 32 bytes"); + } + return Arrays.copyOf(value, value.length); + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveWalBindingCodec.java b/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveWalBindingCodec.java new file mode 100644 index 00000000000..a916df34a7f --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveWalBindingCodec.java @@ -0,0 +1,99 @@ +package org.tron.core.db2.archive; + +import com.google.common.hash.Hashing; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.EOFException; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.Arrays; + +/** Versioned and checksummed codec for the Chainbase checkpoint Archive binding. */ +public final class ArchiveWalBindingCodec { + + private static final int MAGIC = 0x54415742; // TAWB + private static final short VERSION = 1; + private static final int ENCODED_LENGTH = 360; + + public byte[] encode(ArchiveWalBinding binding) { + try { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(ENCODED_LENGTH); + DataOutputStream output = new DataOutputStream(bytes); + output.writeInt(MAGIC); + output.writeShort(VERSION); + output.writeShort(0); + output.writeInt(ENCODED_LENGTH); + writeMeta(output, binding.getFirst()); + writeMeta(output, binding.getLast()); + output.writeLong(binding.getPredecessorEpoch()); + output.write(binding.getPredecessorHash()); + output.write(binding.getBatchDigest()); + output.write(binding.getStoreScopeDigest()); + output.write(binding.getHistoryRefsDigest()); + output.write(binding.getBlockIndexRefsDigest()); + output.flush(); + byte[] payload = bytes.toByteArray(); + output.writeInt(Hashing.crc32c().hashBytes(payload).asInt()); + output.flush(); + return bytes.toByteArray(); + } catch (IOException impossible) { + throw new IllegalStateException("Unexpected Archive WAL binding encoding failure", impossible); + } + } + + public ArchiveWalBinding decode(byte[] encoded) { + if (encoded == null || encoded.length != ENCODED_LENGTH) { + throw new IllegalArgumentException("Archive WAL binding length is invalid"); + } + byte[] payload = Arrays.copyOf(encoded, encoded.length - Integer.BYTES); + int checksum = ByteBuffer.wrap(encoded, payload.length, Integer.BYTES).getInt(); + if (checksum != Hashing.crc32c().hashBytes(payload).asInt()) { + throw new IllegalArgumentException("Archive WAL binding checksum mismatch"); + } + try (DataInputStream input = new DataInputStream(new ByteArrayInputStream(encoded))) { + if (input.readInt() != MAGIC || input.readShort() != VERSION || input.readShort() != 0 + || input.readInt() != encoded.length) { + throw new IllegalArgumentException("Unsupported Archive WAL binding header"); + } + BlockSnapshotMeta first = readMeta(input); + BlockSnapshotMeta last = readMeta(input); + long predecessorEpoch = input.readLong(); + byte[] predecessorHash = readExact(input, 32); + byte[] batchDigest = readExact(input, 32); + byte[] scopeDigest = readExact(input, 32); + byte[] historyDigest = readExact(input, 32); + byte[] indexDigest = readExact(input, 32); + if (input.available() != Integer.BYTES) { + throw new IllegalArgumentException("Archive WAL binding payload mismatch"); + } + return new ArchiveWalBinding(first, last, predecessorEpoch, predecessorHash, + batchDigest, scopeDigest, historyDigest, indexDigest); + } catch (EOFException truncated) { + throw new IllegalArgumentException("Archive WAL binding is truncated", truncated); + } catch (IOException invalid) { + throw new IllegalArgumentException("Archive WAL binding is invalid", invalid); + } + } + + private static void writeMeta(DataOutputStream output, BlockSnapshotMeta meta) + throws IOException { + output.writeLong(meta.getEpoch()); + output.writeLong(meta.getBlockNumber()); + output.write(meta.getBlockHash()); + output.write(meta.getParentHash()); + output.writeLong(meta.getTimestamp()); + } + + private static BlockSnapshotMeta readMeta(DataInputStream input) throws IOException { + return new BlockSnapshotMeta(input.readLong(), input.readLong(), readExact(input, 32), + readExact(input, 32), input.readLong()); + } + + private static byte[] readExact(DataInputStream input, int length) throws IOException { + byte[] value = new byte[length]; + input.readFully(value); + return value; + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveWalStartupValidator.java b/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveWalStartupValidator.java new file mode 100644 index 00000000000..8cca6f8d383 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/ArchiveWalStartupValidator.java @@ -0,0 +1,97 @@ +package org.tron.core.db2.archive; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; + +/** Validates recovered Chainbase WAL identity before the normal archive writer is attached. */ +final class ArchiveWalStartupValidator { + + private static final int MAX_FLUSH_MARKERS = 500; + + private ArchiveWalStartupValidator() { + } + + static void requireFixedPoint(ArchiveHistoryWriter history, BlockSnapshotMeta canonicalHead, + ArchiveWalBinding recoveredBinding, List stores) throws IOException { + ArchiveHistoryWriter writer = Objects.requireNonNull(history, "history"); + BlockSnapshotMeta canonical = Objects.requireNonNull(canonicalHead, "canonicalHead"); + List expectedStores = new ArrayList<>(Objects.requireNonNull(stores, "stores")); + HistoryCommitMarker head = writer.committedHead(); + if (head == null || !canonical.equals(head.getMeta())) { + throw new ArchivePersistenceException( + "Archive committed history does not match canonical Chainbase head"); + } + + HistoryCommitMarker first = writer.get(writer.firstEpoch()); + if (first == null) { + throw new ArchivePersistenceException("Archive committed history base marker is missing"); + } + ArchiveBaseManifest.ExistingBase manifest = ArchiveBaseManifest.validateExisting( + writer.getArchiveDirectory(), expectedStores); + if (manifest.getEpoch() != first.getPreviousEpoch() + || !Arrays.equals(manifest.getHash(), first.getMeta().getParentHash())) { + throw new ArchivePersistenceException( + "Archive MANIFEST base does not match committed history"); + } + + HistoryCommitMarker bootstrap = ArchiveBootstrapAnchor.loadAndValidateIfPresent( + writer.getArchiveDirectory(), writer, expectedStores); + if (recoveredBinding == null) { + HistoryCoverage coverage = writer.coverage(); + if (bootstrap == null || coverage.getRecordCount() != 1 + || !bootstrap.getMeta().equals(head.getMeta())) { + throw new ArchivePersistenceException( + "Archive WAL binding is missing outside the exact fresh bootstrap baseline"); + } + return; + } + if (bootstrap != null && writer.coverage().getRecordCount() == 1) { + throw new ArchivePersistenceException( + "Fresh bootstrap baseline must not claim a normal Archive WAL binding"); + } + if (!recoveredBinding.getLast().equals(head.getMeta())) { + throw new ArchivePersistenceException( + "Recovered Archive WAL binding does not end at committed H/canonical P"); + } + long count; + try { + count = Math.addExact(Math.subtractExact(recoveredBinding.getLast().getEpoch(), + recoveredBinding.getFirst().getEpoch()), 1L); + } catch (ArithmeticException invalid) { + throw new ArchivePersistenceException("Recovered Archive WAL binding range overflows", + invalid); + } + if (count <= 0 || count > MAX_FLUSH_MARKERS) { + throw new ArchivePersistenceException("Recovered Archive WAL binding range is invalid"); + } + List markers = new ArrayList<>((int) count); + for (long epoch = recoveredBinding.getFirst().getEpoch(); + epoch <= recoveredBinding.getLast().getEpoch(); epoch++) { + HistoryCommitMarker marker = writer.get(epoch); + if (marker == null) { + throw new ArchivePersistenceException( + "Recovered Archive WAL binding references missing committed history"); + } + markers.add(marker); + } + ArchiveWalBinding expected = ArchiveWalBinding.fromMarkers(markers); + if (!sameIdentity(recoveredBinding, expected)) { + throw new ArchivePersistenceException( + "Recovered Archive WAL binding differs from committed history refs"); + } + } + + private static boolean sameIdentity(ArchiveWalBinding left, ArchiveWalBinding right) { + return left.getFirst().equals(right.getFirst()) + && left.getLast().equals(right.getLast()) + && left.getPredecessorEpoch() == right.getPredecessorEpoch() + && Arrays.equals(left.getPredecessorHash(), right.getPredecessorHash()) + && Arrays.equals(left.getBatchDigest(), right.getBatchDigest()) + && Arrays.equals(left.getStoreScopeDigest(), right.getStoreScopeDigest()) + && Arrays.equals(left.getHistoryRefsDigest(), right.getHistoryRefsDigest()) + && Arrays.equals(left.getBlockIndexRefsDigest(), right.getBlockIndexRefsDigest()); + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/AsyncArchiveHistorySink.java b/chainbase/src/main/java/org/tron/core/db2/archive/AsyncArchiveHistorySink.java new file mode 100644 index 00000000000..fa6f44db049 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/AsyncArchiveHistorySink.java @@ -0,0 +1,268 @@ +package org.tron.core.db2.archive; + +import java.io.Closeable; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; + +/** Bounded single-writer queue with explicit durable-epoch waiting and head-only reorg handling. */ +public final class AsyncArchiveHistorySink implements DurableBlockReverseDiffSink, Closeable { + + private final ArchiveHistoryWriter writer; + private final BlockingQueue queue; + private final Map submitted = new LinkedHashMap<>(); + private final Thread worker; + private volatile Throwable fatalFailure; + private volatile boolean closed; + private BlockSnapshotMeta acceptedHead; + + public AsyncArchiveHistorySink(ArchiveHistoryWriter writer, int capacity) { + if (capacity <= 0) { + throw new IllegalArgumentException("capacity must be positive"); + } + this.writer = writer; + this.queue = new ArrayBlockingQueue<>(capacity); + HistoryCommitMarker committedHead = writer.committedHead(); + this.acceptedHead = committedHead == null ? null : committedHead.getMeta(); + this.worker = new Thread(this::run, "archive-history-writer"); + this.worker.setDaemon(true); + this.worker.start(); + } + + @Override + public void accept(BlockReverseDiff diff) { + acceptAll(java.util.Collections.singletonList(diff)); + } + + @Override + public void acceptAll(List diffs) { + if (diffs.isEmpty()) { + return; + } + WorkItem item = new WorkItem(diffs); + synchronized (submitted) { + ensureOperational(); + BlockSnapshotMeta previous = acceptedHead; + for (BlockReverseDiff diff : item.diffs) { + if (previous != null) { + validateContinuity(previous, diff.getMeta()); + } + if (submitted.containsKey(diff.getMeta().getEpoch())) { + throw new ArchivePersistenceException("Duplicate submitted archive epoch"); + } + previous = diff.getMeta(); + } + item.diffs.forEach(diff -> submitted.put(diff.getMeta().getEpoch(), item)); + acceptedHead = item.lastMeta(); + } + try { + queue.put(item); + } catch (InterruptedException e) { + synchronized (submitted) { + removeSubmitted(item); + WorkItem previous = lastSubmitted(); + acceptedHead = previous == null ? committedMeta() : previous.lastMeta(); + } + Thread.currentThread().interrupt(); + throw new ArchivePersistenceException("Interrupted by archive queue backpressure", e); + } + } + + @Override + public void revert(BlockSnapshotMeta meta) { + WorkItem item; + synchronized (submitted) { + ensureOperational(); + item = lastSubmitted(); + if (item == null || item.diffs.size() != 1 || !item.lastMeta().equals(meta)) { + throw new ArchivePersistenceException("Archive reorg must remove the submitted head"); + } + if (queue.remove(item)) { + removeSubmitted(item); + WorkItem previous = lastSubmitted(); + acceptedHead = previous == null ? committedMeta() : previous.lastMeta(); + item.completion.cancel(false); + return; + } + } + await(item); + writer.revert(meta); + synchronized (submitted) { + removeSubmitted(item); + WorkItem previous = lastSubmitted(); + acceptedHead = previous == null ? committedMeta() : previous.lastMeta(); + } + } + + @Override + public void awaitCommitted(long epoch) { + List required = new ArrayList<>(); + synchronized (submitted) { + ensureOperational(); + submitted.forEach((candidate, item) -> { + if (candidate <= epoch && !required.contains(item)) { + required.add(item); + } + }); + } + required.forEach(this::await); + ensureOperational(); + } + + @Override + public DurableHistoryMarkerRangeEvidence createMarkerRangeEvidence(int maxMarkers) { + ensureOperational(); + return new DurableHistoryMarkerRangeEvidence(writer, maxMarkers); + } + + /** Releases completed queue bookkeeping after the corresponding disk epoch is durable. */ + @Override + public void releaseThrough(long epoch) { + synchronized (submitted) { + submitted.entrySet().removeIf(entry -> entry.getKey() <= epoch + && entry.getValue().completion.isDone() + && !entry.getValue().completion.isCompletedExceptionally()); + } + } + + public int queueSize() { + return queue.size(); + } + + private void run() { + while (true) { + WorkItem item; + try { + item = queue.take(); + } catch (InterruptedException e) { + if (closed || fatalFailure != null) { + return; + } + continue; + } + if (item.poison) { + return; + } + try { + writer.acceptAll(item.diffs); + item.completion.complete(null); + } catch (Throwable failure) { + fatalFailure = failure; + item.completion.completeExceptionally(failure); + failQueued(failure); + return; + } + } + } + + private void failQueued(Throwable failure) { + WorkItem item; + while ((item = queue.poll()) != null) { + if (!item.poison) { + item.completion.completeExceptionally(failure); + } + } + } + + private void await(WorkItem item) { + try { + item.completion.get(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new ArchivePersistenceException("Interrupted while waiting for durable history", e); + } catch (ExecutionException e) { + throw new ArchivePersistenceException("Archive writer failed", e.getCause()); + } catch (java.util.concurrent.CancellationException e) { + throw new ArchivePersistenceException("Archive history item was reverted", e); + } + } + + private void validateContinuity(BlockSnapshotMeta previous, BlockSnapshotMeta current) { + if (current.getEpoch() != previous.getEpoch() + 1 + || current.getBlockNumber() != previous.getBlockNumber() + 1 + || !Arrays.equals(current.getParentHash(), previous.getBlockHash())) { + throw new ArchivePersistenceException("Submitted archive metadata is not contiguous"); + } + } + + private WorkItem lastSubmitted() { + WorkItem last = null; + for (WorkItem item : submitted.values()) { + last = item; + } + return last; + } + + private void removeSubmitted(WorkItem item) { + item.diffs.forEach(diff -> submitted.remove(diff.getMeta().getEpoch())); + } + + private BlockSnapshotMeta committedMeta() { + HistoryCommitMarker marker = writer.committedHead(); + return marker == null ? null : marker.getMeta(); + } + + private void ensureOperational() { + if (fatalFailure != null) { + throw new ArchivePersistenceException("Archive writer is in a failed state", fatalFailure); + } + if (closed) { + throw new ArchivePersistenceException("Archive writer queue is closed"); + } + } + + @Override + public synchronized void close() throws IOException { + if (closed) { + return; + } + closed = true; + if (fatalFailure == null) { + try { + while (worker.isAlive() + && !queue.offer(WorkItem.poison(), 100, java.util.concurrent.TimeUnit.MILLISECONDS)) { + // Let the writer drain queued blocks before adding the terminal item. + } + worker.join(); + } catch (InterruptedException e) { + worker.interrupt(); + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while closing archive writer queue", e); + } + } else { + worker.interrupt(); + } + writer.close(); + } + + private static final class WorkItem { + private final List diffs; + private final boolean poison; + private final CompletableFuture completion = new CompletableFuture<>(); + + private WorkItem(List diffs) { + this.diffs = java.util.Collections.unmodifiableList(new ArrayList<>(diffs)); + this.poison = false; + } + + private WorkItem() { + this.diffs = java.util.Collections.emptyList(); + this.poison = true; + } + + private static WorkItem poison() { + return new WorkItem(); + } + + private BlockSnapshotMeta lastMeta() { + return diffs.get(diffs.size() - 1).getMeta(); + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/BlockChangeView.java b/chainbase/src/main/java/org/tron/core/db2/archive/BlockChangeView.java new file mode 100644 index 00000000000..86997922cae --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/BlockChangeView.java @@ -0,0 +1,145 @@ +package org.tron.core.db2.archive; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import org.tron.core.db2.archive.BlockSnapshotMeta; +import org.tron.core.db2.common.Value; +import org.tron.core.db2.common.WrappedByteArray; +import org.tron.core.db2.core.Chainbase; +import org.tron.core.db2.core.Snapshot; +import org.tron.core.db2.core.SnapshotImpl; + +/** + * Immutable block boundary handed to an old-value collector. + * + *

Changed keys and post values are copied. Each database group deliberately retains a strong + * reference to the block layer's previous snapshot until collection finishes. + */ +public final class BlockChangeView { + + private final BlockSnapshotMeta meta; + private final List databases; + + private BlockChangeView(BlockSnapshotMeta meta, List databases) { + this.meta = Objects.requireNonNull(meta, "meta"); + this.databases = Collections.unmodifiableList(new ArrayList<>(databases)); + } + + public static BlockChangeView capture(BlockSnapshotMeta meta, List databases) { + List changes = new ArrayList<>(); + for (Chainbase database : databases) { + if (!ArchiveStoreScope.isStateDatabase(database.getDbName())) { + continue; + } + Snapshot head = database.getHead(); + if (!Snapshot.isImpl(head)) { + throw new IllegalStateException( + "Block snapshot head is not SnapshotImpl for dbName=" + database.getDbName()); + } + SnapshotImpl layer = (SnapshotImpl) head; + List entries = new ArrayList<>(); + layer.getDb().forEach(entry -> entries.add(new Change(entry.getKey().getBytes(), + entry.getValue().getOperator() == Value.Operator.DELETE + ? PostValue.absent() : PostValue.present(entry.getValue().getBytes())))); + entries.sort((left, right) -> BlockReverseDiff.compareUnsigned(left.key, right.key)); + changes.add(new DatabaseChanges(database.getDbName(), layer.getPrevious(), entries)); + } + changes.sort((left, right) -> left.dbName.compareTo(right.dbName)); + return new BlockChangeView(meta, changes); + } + + public BlockSnapshotMeta getMeta() { + return meta; + } + + public List getDatabases() { + return databases; + } + + public static final class DatabaseChanges { + private final String dbName; + private final Snapshot previous; + private final ConcurrentMap previousValues = new ConcurrentHashMap<>(); + private final List changes; + + private DatabaseChanges(String dbName, Snapshot previous, List changes) { + this.dbName = dbName; + this.previous = Objects.requireNonNull(previous, "previous"); + this.changes = Collections.unmodifiableList(new ArrayList<>(changes)); + } + + public String getDbName() { + return dbName; + } + + public byte[] getPrevious(byte[] key) { + PostValue value = previousValues.computeIfAbsent( + WrappedByteArray.copyOf(key), ignored -> { + byte[] bytes = previous.get(key); + return bytes == null ? PostValue.absent() : PostValue.present(bytes); + }); + return value.isPresent() ? value.getValue() : null; + } + + public List getChanges() { + return changes; + } + } + + public static final class Change { + private final byte[] key; + private final PostValue postValue; + + private Change(byte[] key, PostValue postValue) { + Objects.requireNonNull(key, "key"); + this.key = Arrays.copyOf(key, key.length); + this.postValue = postValue; + } + + public byte[] getKey() { + return Arrays.copyOf(key, key.length); + } + + public PostValue getPostValue() { + return postValue; + } + } + + public static final class PostValue { + private static final PostValue ABSENT = new PostValue(false, null); + + private final boolean present; + private final byte[] value; + + private PostValue(boolean present, byte[] value) { + this.present = present; + this.value = value; + } + + public static PostValue absent() { + return ABSENT; + } + + public static PostValue present(byte[] value) { + Objects.requireNonNull(value, "value"); + return new PostValue(true, Arrays.copyOf(value, value.length)); + } + + public boolean isPresent() { + return present; + } + + public byte[] getValue() { + if (!present) { + throw new IllegalStateException("absent post value has no bytes"); + } + return Arrays.copyOf(value, value.length); + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/BlockHistoryCodec.java b/chainbase/src/main/java/org/tron/core/db2/archive/BlockHistoryCodec.java new file mode 100644 index 00000000000..5dda90fc41c --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/BlockHistoryCodec.java @@ -0,0 +1,322 @@ +package org.tron.core.db2.archive; + +import com.google.common.hash.Hashing; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.EOFException; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.zip.DataFormatException; +import java.util.zip.Deflater; +import java.util.zip.Inflater; +import org.tron.core.db2.archive.BlockReverseDiff.DbGroup; +import org.tron.core.db2.archive.BlockReverseDiff.Entry; + +/** Deterministic, checksummed codec for one block history record. */ +public final class BlockHistoryCodec { + + public static final int MAGIC = 0x54415248; // TARH + public static final short VERSION = 1; + public static final int HEADER_LENGTH = 128; + public static final int CHECKSUM_LENGTH = Integer.BYTES; + public static final int DEFAULT_MAX_RECORD_LENGTH = 64 * 1024 * 1024; + + private static final short FLAG_DEFLATE = 1; + private static final int HASH_LENGTH = 32; + + private final int maxRecordLength; + + public BlockHistoryCodec() { + this(DEFAULT_MAX_RECORD_LENGTH); + } + + public BlockHistoryCodec(int maxRecordLength) { + if (maxRecordLength <= HEADER_LENGTH + CHECKSUM_LENGTH) { + throw new IllegalArgumentException("maxRecordLength is too small"); + } + this.maxRecordLength = maxRecordLength; + } + + public byte[] encode(BlockReverseDiff diff) { + try { + byte[] rawPayload = encodePayload(diff); + byte[] payload = deflate(rawPayload); + long recordLength = HEADER_LENGTH + (long) payload.length + CHECKSUM_LENGTH; + checkRecordLength(recordLength); + + ByteArrayOutputStream bytes = new ByteArrayOutputStream((int) recordLength); + DataOutputStream output = new DataOutputStream(bytes); + output.writeInt(MAGIC); + output.writeShort(VERSION); + output.writeShort(FLAG_DEFLATE); + output.writeInt(HEADER_LENGTH); + output.writeLong(payload.length); + output.writeLong(diff.getMeta().getEpoch()); + output.writeLong(diff.getMeta().getBlockNumber()); + output.write(diff.getMeta().getBlockHash()); + output.write(diff.getMeta().getParentHash()); + output.writeLong(diff.getMeta().getTimestamp()); + output.writeInt(diff.getGroups().size()); + output.writeLong(entryCount(diff)); + output.writeLong(rawPayload.length); + output.write(payload); + output.flush(); + + byte[] withoutChecksum = bytes.toByteArray(); + output.writeInt(crc32c(withoutChecksum)); + output.flush(); + return bytes.toByteArray(); + } catch (IOException e) { + throw new IllegalStateException("Unexpected in-memory history encoding failure", e); + } + } + + public BlockReverseDiff decode(byte[] record) { + if (record == null || record.length < HEADER_LENGTH + CHECKSUM_LENGTH) { + throw new IllegalArgumentException("History record is truncated"); + } + checkRecordLength(record.length); + int expectedChecksum = ByteBuffer.wrap(record, record.length - CHECKSUM_LENGTH, + CHECKSUM_LENGTH).getInt(); + int actualChecksum = crc32c(Arrays.copyOf(record, record.length - CHECKSUM_LENGTH)); + if (expectedChecksum != actualChecksum) { + throw new IllegalArgumentException("History record checksum mismatch"); + } + + try { + DataInputStream input = new DataInputStream(new ByteArrayInputStream(record)); + if (input.readInt() != MAGIC) { + throw new IllegalArgumentException("Invalid history record magic"); + } + short version = input.readShort(); + if (version != VERSION) { + throw new IllegalArgumentException("Unsupported history record version: " + version); + } + short flags = input.readShort(); + if (flags != FLAG_DEFLATE) { + throw new IllegalArgumentException("Unsupported history record flags: " + flags); + } + int headerLength = input.readInt(); + if (headerLength != HEADER_LENGTH) { + throw new IllegalArgumentException("Invalid history header length: " + headerLength); + } + long payloadLength = input.readLong(); + long expectedLength = HEADER_LENGTH + payloadLength + CHECKSUM_LENGTH; + if (payloadLength < 0 || expectedLength != record.length) { + throw new IllegalArgumentException("Invalid history payload length"); + } + long epoch = input.readLong(); + long blockNumber = input.readLong(); + byte[] blockHash = readExact(input, HASH_LENGTH); + byte[] parentHash = readExact(input, HASH_LENGTH); + long timestamp = input.readLong(); + int groupCount = input.readInt(); + long entryCount = input.readLong(); + long rawPayloadLength = input.readLong(); + if (groupCount < 0 || entryCount < 0 || rawPayloadLength < 0 + || rawPayloadLength > maxRecordLength) { + throw new IllegalArgumentException("Invalid history header counts"); + } + byte[] payload = readExact(input, (int) payloadLength); + byte[] rawPayload = inflate(payload, (int) rawPayloadLength); + List groups = decodePayload(rawPayload, groupCount, entryCount); + BlockSnapshotMeta meta = new BlockSnapshotMeta(epoch, blockNumber, blockHash, parentHash, + timestamp); + return new BlockReverseDiff(meta, groups); + } catch (EOFException e) { + throw new IllegalArgumentException("History record is truncated", e); + } catch (IOException e) { + throw new IllegalArgumentException("Invalid history record", e); + } + } + + /** Returns the complete record length described by a fixed header. */ + public int recordLength(byte[] header) { + if (header.length < HEADER_LENGTH) { + throw new IllegalArgumentException("History header is truncated"); + } + ByteBuffer buffer = ByteBuffer.wrap(header); + if (buffer.getInt() != MAGIC) { + throw new IllegalArgumentException("Invalid history record magic"); + } + if (buffer.getShort() != VERSION) { + throw new IllegalArgumentException("Unsupported history record version"); + } + if (buffer.getShort() != FLAG_DEFLATE) { + throw new IllegalArgumentException("Unsupported history record flags"); + } + if (buffer.getInt() != HEADER_LENGTH) { + throw new IllegalArgumentException("Invalid history header length"); + } + long payloadLength = buffer.getLong(); + long length = HEADER_LENGTH + payloadLength + CHECKSUM_LENGTH; + checkRecordLength(length); + return (int) length; + } + + private byte[] encodePayload(BlockReverseDiff diff) throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + DataOutputStream output = new DataOutputStream(bytes); + String previousDb = null; + for (DbGroup group : diff.getGroups()) { + if (previousDb != null && previousDb.compareTo(group.getDbName()) >= 0) { + throw new IllegalArgumentException("History database groups are not strictly sorted"); + } + previousDb = group.getDbName(); + byte[] dbName = group.getDbName().getBytes(StandardCharsets.UTF_8); + writeUnsignedVarInt(output, dbName.length); + output.write(dbName); + writeUnsignedVarInt(output, group.getEntries().size()); + byte[] previousKey = null; + for (Entry entry : group.getEntries()) { + byte[] key = entry.getKey(); + if (previousKey != null && BlockReverseDiff.compareUnsigned(previousKey, key) >= 0) { + throw new IllegalArgumentException("History entry keys are not strictly sorted"); + } + previousKey = key; + writeUnsignedVarInt(output, key.length); + output.write(key); + if (entry.getOldValue().isPresent()) { + byte[] value = entry.getOldValue().getValue(); + writeUnsignedVarInt(output, value.length + 1); + output.write(value); + } else { + writeUnsignedVarInt(output, 0); + } + } + } + output.flush(); + return bytes.toByteArray(); + } + + private List decodePayload(byte[] payload, int groupCount, long expectedEntryCount) + throws IOException { + DataInputStream input = new DataInputStream(new ByteArrayInputStream(payload)); + List groups = new ArrayList<>(groupCount); + long actualEntryCount = 0; + String previousDb = null; + for (int groupIndex = 0; groupIndex < groupCount; groupIndex++) { + String dbName = new String(readLengthPrefixed(input), StandardCharsets.UTF_8); + if (previousDb != null && previousDb.compareTo(dbName) >= 0) { + throw new IllegalArgumentException("Decoded database groups are not strictly sorted"); + } + previousDb = dbName; + int count = readUnsignedVarInt(input); + List entries = new ArrayList<>(count); + byte[] previousKey = null; + for (int entryIndex = 0; entryIndex < count; entryIndex++) { + byte[] key = readLengthPrefixed(input); + if (previousKey != null && BlockReverseDiff.compareUnsigned(previousKey, key) >= 0) { + throw new IllegalArgumentException("Decoded history keys are not strictly sorted"); + } + previousKey = key; + int encodedOldLength = readUnsignedVarInt(input); + OldValue oldValue = encodedOldLength == 0 ? OldValue.absent() + : OldValue.present(readExact(input, encodedOldLength - 1)); + entries.add(new Entry(key, oldValue)); + } + actualEntryCount += count; + groups.add(new DbGroup(dbName, entries)); + } + if (input.available() != 0 || actualEntryCount != expectedEntryCount) { + throw new IllegalArgumentException("History payload count or length mismatch"); + } + return groups; + } + + private byte[] readLengthPrefixed(DataInputStream input) throws IOException { + int length = readUnsignedVarInt(input); + if (length > maxRecordLength) { + throw new IllegalArgumentException("History field exceeds maximum record length"); + } + return readExact(input, length); + } + + private byte[] deflate(byte[] input) { + Deflater deflater = new Deflater(Deflater.BEST_SPEED, true); + deflater.setInput(input); + deflater.finish(); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + byte[] buffer = new byte[8192]; + while (!deflater.finished()) { + int count = deflater.deflate(buffer); + output.write(buffer, 0, count); + } + deflater.end(); + return output.toByteArray(); + } + + private byte[] inflate(byte[] input, int expectedLength) { + Inflater inflater = new Inflater(true); + inflater.setInput(input); + byte[] output = new byte[expectedLength]; + try { + int count = inflater.inflate(output); + if (count != expectedLength || !inflater.finished() || inflater.getRemaining() != 0) { + throw new IllegalArgumentException("History compressed payload length mismatch"); + } + return output; + } catch (DataFormatException e) { + throw new IllegalArgumentException("Invalid compressed history payload", e); + } finally { + inflater.end(); + } + } + + private static long entryCount(BlockReverseDiff diff) { + return diff.getGroups().stream().mapToLong(group -> group.getEntries().size()).sum(); + } + + private static byte[] readExact(DataInputStream input, int length) throws IOException { + if (length < 0) { + throw new IllegalArgumentException("Negative history field length"); + } + byte[] bytes = new byte[length]; + input.readFully(bytes); + return bytes; + } + + private static void writeUnsignedVarInt(DataOutputStream output, int value) throws IOException { + if (value < 0) { + throw new IllegalArgumentException("Negative unsigned varint"); + } + int remaining = value; + while ((remaining & 0xffffff80) != 0) { + output.writeByte((remaining & 0x7f) | 0x80); + remaining >>>= 7; + } + output.writeByte(remaining); + } + + private static int readUnsignedVarInt(DataInputStream input) throws IOException { + int value = 0; + for (int shift = 0; shift < 35; shift += 7) { + int next = input.readUnsignedByte(); + if (shift == 28 && (next & 0xf0) != 0) { + throw new IllegalArgumentException("Unsigned varint overflows int"); + } + value |= (next & 0x7f) << shift; + if ((next & 0x80) == 0) { + return value; + } + } + throw new IllegalArgumentException("Unsigned varint is too long"); + } + + private static int crc32c(byte[] bytes) { + return Hashing.crc32c().hashBytes(bytes).asInt(); + } + + private void checkRecordLength(long length) { + if (length < HEADER_LENGTH + CHECKSUM_LENGTH || length > maxRecordLength + || length > Integer.MAX_VALUE) { + throw new IllegalArgumentException("History record exceeds configured maximum: " + length); + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/BlockReverseDiff.java b/chainbase/src/main/java/org/tron/core/db2/archive/BlockReverseDiff.java new file mode 100644 index 00000000000..26621d695d2 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/BlockReverseDiff.java @@ -0,0 +1,80 @@ +package org.tron.core.db2.archive; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; +import org.tron.core.db2.archive.BlockSnapshotMeta; + +/** Immutable reverse diff for one canonical block. */ +public final class BlockReverseDiff { + + private final BlockSnapshotMeta meta; + private final List groups; + public BlockReverseDiff(BlockSnapshotMeta meta, List groups) { + this.meta = Objects.requireNonNull(meta, "meta"); + List sorted = new ArrayList<>(groups); + sorted.sort(Comparator.comparing(DbGroup::getDbName)); + this.groups = Collections.unmodifiableList(sorted); + } + + public BlockSnapshotMeta getMeta() { + return meta; + } + + public List getGroups() { + return groups; + } + + public static final class DbGroup { + private final String dbName; + private final List entries; + + public DbGroup(String dbName, List entries) { + this.dbName = Objects.requireNonNull(dbName, "dbName"); + List sorted = new ArrayList<>(entries); + sorted.sort((left, right) -> compareUnsigned(left.key, right.key)); + this.entries = Collections.unmodifiableList(sorted); + } + + public String getDbName() { + return dbName; + } + + public List getEntries() { + return entries; + } + } + + public static final class Entry { + private final byte[] key; + private final OldValue oldValue; + + public Entry(byte[] key, OldValue oldValue) { + Objects.requireNonNull(key, "key"); + this.key = Arrays.copyOf(key, key.length); + this.oldValue = Objects.requireNonNull(oldValue, "oldValue"); + } + + public byte[] getKey() { + return Arrays.copyOf(key, key.length); + } + + public OldValue getOldValue() { + return oldValue; + } + } + + static int compareUnsigned(byte[] left, byte[] right) { + int length = Math.min(left.length, right.length); + for (int i = 0; i < length; i++) { + int comparison = Integer.compare(left[i] & 0xff, right[i] & 0xff); + if (comparison != 0) { + return comparison; + } + } + return Integer.compare(left.length, right.length); + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/BlockReverseDiffSink.java b/chainbase/src/main/java/org/tron/core/db2/archive/BlockReverseDiffSink.java new file mode 100644 index 00000000000..b050d825ee4 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/BlockReverseDiffSink.java @@ -0,0 +1,18 @@ +package org.tron.core.db2.archive; + +import java.util.List; +import org.tron.core.db2.archive.BlockSnapshotMeta; + +/** Downstream boundary for an archive writer or a bounded writer queue. */ +public interface BlockReverseDiffSink { + + void accept(BlockReverseDiff diff); + + default void acceptAll(List diffs) { + diffs.forEach(this::accept); + } + + default void revert(BlockSnapshotMeta meta) { + // A durable writer will override this and truncate/discard its uncommitted canonical tail. + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/BoundedBlockReverseDiffQueue.java b/chainbase/src/main/java/org/tron/core/db2/archive/BoundedBlockReverseDiffQueue.java new file mode 100644 index 00000000000..df07af4d3e1 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/BoundedBlockReverseDiffQueue.java @@ -0,0 +1,43 @@ +package org.tron.core.db2.archive; + +import java.util.Objects; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.BlockingQueue; +import org.tron.core.db2.archive.BlockSnapshotMeta; + +/** Bounded, lossless hand-off queue. A full queue applies block-commit backpressure. */ +public final class BoundedBlockReverseDiffQueue implements BlockReverseDiffSink { + + private final BlockingQueue queue; + + public BoundedBlockReverseDiffQueue(int capacity) { + if (capacity <= 0) { + throw new IllegalArgumentException("capacity must be positive"); + } + queue = new ArrayBlockingQueue<>(capacity); + } + + @Override + public void accept(BlockReverseDiff diff) { + Objects.requireNonNull(diff, "diff"); + try { + queue.put(diff); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while applying archive queue backpressure", e); + } + } + + @Override + public void revert(BlockSnapshotMeta meta) { + queue.removeIf(diff -> diff.getMeta().equals(meta)); + } + + public BlockReverseDiff take() throws InterruptedException { + return queue.take(); + } + + public int size() { + return queue.size(); + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/CommittedHistoryAuthority.java b/chainbase/src/main/java/org/tron/core/db2/archive/CommittedHistoryAuthority.java new file mode 100644 index 00000000000..a7df24b4cfe --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/CommittedHistoryAuthority.java @@ -0,0 +1,13 @@ +package org.tron.core.db2.archive; + +/** Read-only committed State History authority shared by normal and recovery paths. */ +public interface CommittedHistoryAuthority { + + HistoryCommitMarker head(); + + HistoryCommitMarker get(long epoch); + + long firstEpoch(); + + HistoryCoverage coverage(); +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/CommittedHistoryReader.java b/chainbase/src/main/java/org/tron/core/db2/archive/CommittedHistoryReader.java new file mode 100644 index 00000000000..7a2a8c97e3b --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/CommittedHistoryReader.java @@ -0,0 +1,217 @@ +package org.tron.core.db2.archive; + +import java.io.Closeable; +import java.io.IOException; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import org.tron.core.db2.archive.BlockReverseDiff.DbGroup; +import org.tron.core.db2.archive.BlockReverseDiff.Entry; +import org.tron.core.db2.archive.HistoryIndexRecord.KeyGroup; + +/** + * Fail-closed authoritative old-value reader for one immutable committed prefix. + * + *

The current prototype validates its source identity by rebuilding a throwaway serving + * generation. A production reader will pin commit/index/segment generations without rebuilding. + */ +public final class CommittedHistoryReader implements ArchiveReadSnapshot.PinnedHistory { + + private final long indexedFrom; + private final long indexedThrough; + private final byte[] headHash; + private final byte[] authoritativePrefixDigest; + private final Map markers; + private final ServingKeyIndexGeneration.AuthoritativeIndexReader indexReader; + private final BodyReader bodyReader; + private final Closeable release; + private boolean closed; + + public CommittedHistoryReader(long baseEpoch, byte[] baseHash, + List committed, + ServingKeyIndexGeneration.AuthoritativeIndexReader indexReader, BodyReader bodyReader) + throws IOException { + this(baseEpoch, baseHash, committed, indexReader, bodyReader, null, () -> { }); + } + + public CommittedHistoryReader(long baseEpoch, byte[] baseHash, + List committed, + ServingKeyIndexGeneration.AuthoritativeIndexReader indexReader, BodyReader bodyReader, + List participatingDatabases) throws IOException { + this(baseEpoch, baseHash, committed, indexReader, bodyReader, participatingDatabases, + () -> { }); + } + + public CommittedHistoryReader(long baseEpoch, byte[] baseHash, + List committed, + ServingKeyIndexGeneration.AuthoritativeIndexReader indexReader, BodyReader bodyReader, + Closeable release) throws IOException { + this(baseEpoch, baseHash, committed, indexReader, bodyReader, null, release); + } + + private CommittedHistoryReader(long baseEpoch, byte[] baseHash, + List committed, + ServingKeyIndexGeneration.AuthoritativeIndexReader indexReader, BodyReader bodyReader, + List participatingDatabases, Closeable release) throws IOException { + Objects.requireNonNull(committed, "committed"); + this.indexReader = Objects.requireNonNull(indexReader, "indexReader"); + this.bodyReader = Objects.requireNonNull(bodyReader, "bodyReader"); + this.release = Objects.requireNonNull(release, "release"); + ServingKeyIndexGeneration verified; + try { + verified = participatingDatabases == null + ? ServingKeyIndexGeneration.rebuild( + "authoritative-history-reader", baseEpoch, baseHash, committed, indexReader) + : ServingKeyIndexGeneration.rebuild( + "authoritative-history-reader", baseEpoch, baseHash, committed, indexReader, + participatingDatabases, + ServingKeyIndexGeneration.IndexLayout.prototypeDefaults()); + } catch (IOException | RuntimeException failure) { + closeAfterFailedConstruction(release, failure); + throw failure; + } + this.indexedFrom = verified.getIndexedFrom(); + this.indexedThrough = verified.getIndexedThrough(); + this.headHash = verified.getHeadHash(); + this.authoritativePrefixDigest = verified.getAuthoritativePrefixDigest(); + this.markers = new HashMap<>(); + for (HistoryCommitMarker marker : committed) { + if (markers.put(marker.getMeta().getEpoch(), marker) != null) { + throw new IllegalArgumentException("Duplicate committed history epoch"); + } + } + } + + @Override + public synchronized OldValue read(String dbName, byte[] rawKey, long firstChangeBlock) + throws IOException { + ensureOpen(); + Objects.requireNonNull(dbName, "dbName"); + Objects.requireNonNull(rawKey, "rawKey"); + HistoryCommitMarker marker = markers.get(firstChangeBlock); + if (marker == null) { + throw new ArchivePersistenceException( + "Serving index references an uncommitted history epoch: " + firstChangeBlock); + } + HistoryIndexRecord index = indexReader.read(marker.getIndexLocation()); + validateMarker(marker, index); + BlockReverseDiff body = bodyReader.read(marker.getHistoryLocation()); + if (!marker.getMeta().equals(body.getMeta()) || !sameKeys(index, body)) { + throw new ArchivePersistenceException("Authoritative history index/body key mismatch"); + } + if (!contains(index, dbName, rawKey)) { + throw new ArchivePersistenceException( + "Serving index key is absent from authoritative history index"); + } + for (DbGroup group : body.getGroups()) { + if (!dbName.equals(group.getDbName())) { + continue; + } + for (Entry entry : group.getEntries()) { + if (Arrays.equals(rawKey, entry.getKey())) { + return entry.getOldValue(); + } + } + } + throw new ArchivePersistenceException( + "Authoritative history body is missing the indexed key"); + } + + @Override + public long getIndexedFrom() { + return indexedFrom; + } + + @Override + public long getIndexedThrough() { + return indexedThrough; + } + + @Override + public byte[] getHeadHash() { + return Arrays.copyOf(headHash, headHash.length); + } + + @Override + public byte[] getAuthoritativePrefixDigest() { + return Arrays.copyOf(authoritativePrefixDigest, authoritativePrefixDigest.length); + } + + @Override + public synchronized void close() throws IOException { + if (!closed) { + closed = true; + release.close(); + } + } + + private void ensureOpen() { + if (closed) { + throw new IllegalStateException("Committed history reader is closed"); + } + } + + private static void validateMarker(HistoryCommitMarker marker, HistoryIndexRecord index) { + if (!marker.getMeta().equals(index.getMeta()) + || !same(marker.getHistoryLocation(), index.getHistoryLocation())) { + throw new ArchivePersistenceException( + "Commit marker does not match authoritative history index"); + } + } + + private static boolean contains(HistoryIndexRecord index, String dbName, byte[] rawKey) { + for (KeyGroup group : index.getGroups()) { + if (dbName.equals(group.getDbName())) { + return group.getKeys().stream().anyMatch(key -> Arrays.equals(key, rawKey)); + } + } + return false; + } + + private static boolean sameKeys(HistoryIndexRecord index, BlockReverseDiff body) { + List indexed = index.getGroups(); + List stored = body.getGroups(); + if (indexed.size() != stored.size()) { + return false; + } + for (int groupIndex = 0; groupIndex < indexed.size(); groupIndex++) { + KeyGroup indexedGroup = indexed.get(groupIndex); + DbGroup storedGroup = stored.get(groupIndex); + List indexedKeys = indexedGroup.getKeys(); + List storedEntries = storedGroup.getEntries(); + if (!indexedGroup.getDbName().equals(storedGroup.getDbName()) + || indexedKeys.size() != storedEntries.size()) { + return false; + } + for (int keyIndex = 0; keyIndex < indexedKeys.size(); keyIndex++) { + if (!Arrays.equals(indexedKeys.get(keyIndex), storedEntries.get(keyIndex).getKey())) { + return false; + } + } + } + return true; + } + + private static boolean same(HistoryLocation left, HistoryLocation right) { + return left.getSegmentId() == right.getSegmentId() + && left.getOffset() == right.getOffset() + && left.getRecordLength() == right.getRecordLength() + && left.getBodyChecksum() == right.getBodyChecksum() + && Arrays.equals(left.getBodyDigest(), right.getBodyDigest()); + } + + private static void closeAfterFailedConstruction(Closeable release, Exception failure) { + try { + release.close(); + } catch (IOException closeFailure) { + failure.addSuppressed(closeFailure); + } + } + + @FunctionalInterface + public interface BodyReader { + BlockReverseDiff read(HistoryLocation location) throws IOException; + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/DurableBlockReverseDiffSink.java b/chainbase/src/main/java/org/tron/core/db2/archive/DurableBlockReverseDiffSink.java new file mode 100644 index 00000000000..abbf4fc7ad4 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/DurableBlockReverseDiffSink.java @@ -0,0 +1,11 @@ +package org.tron.core.db2.archive; + +/** Archive sink whose committed history can gate checkpoint and disk-layer advancement. */ +public interface DurableBlockReverseDiffSink extends BlockReverseDiffSink { + + void awaitCommitted(long epoch); + + DurableHistoryMarkerRangeEvidence createMarkerRangeEvidence(int maxMarkers); + + void releaseThrough(long epoch); +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/DurableHistoryMarkerRangeEvidence.java b/chainbase/src/main/java/org/tron/core/db2/archive/DurableHistoryMarkerRangeEvidence.java new file mode 100644 index 00000000000..bde5743de4c --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/DurableHistoryMarkerRangeEvidence.java @@ -0,0 +1,122 @@ +package org.tron.core.db2.archive; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** Bounded authoritative marker evidence for one exact frozen flush range. */ +public final class DurableHistoryMarkerRangeEvidence { + + private final Source source; + private final int maxMarkers; + private final List participants; + private final HistoryCommitMarkerCodec codec = new HistoryCommitMarkerCodec(); + + public DurableHistoryMarkerRangeEvidence(ArchiveHistoryWriter writer, int maxMarkers) { + this(new WriterSource(writer), maxMarkers); + } + + DurableHistoryMarkerRangeEvidence(Source source, int maxMarkers) { + this.source = Objects.requireNonNull(source, "source"); + if (maxMarkers <= 0) { + throw new IllegalArgumentException("maxMarkers must be positive"); + } + this.maxMarkers = maxMarkers; + participants = ArchiveParticipantDescriptor.current().getParticipants(); + } + + public List read(List expectedMetas) { + List expected = new ArrayList<>( + Objects.requireNonNull(expectedMetas, "expectedMetas")); + validateExpectedRange(expected); + + List markers = new ArrayList<>(expected.size()); + for (BlockSnapshotMeta meta : expected) { + HistoryCommitMarker marker = source.marker(meta.getEpoch()); + validateMarker(meta, marker); + markers.add(marker); + } + + List evidence = new ArrayList<>(markers.size()); + for (int i = 0; i < markers.size(); i++) { + HistoryCommitMarker marker = markers.get(i); + BlockReverseDiff body = source.readCommitted(marker.getMeta().getEpoch()); + if (body == null || !expected.get(i).equals(body.getMeta())) { + throw new ArchivePersistenceException( + "Committed history body does not match marker range"); + } + HistoryCommitMarker reloaded = source.marker(marker.getMeta().getEpoch()); + validateMarker(expected.get(i), reloaded); + if (!Arrays.equals(codec.encode(marker), codec.encode(reloaded))) { + throw new ArchivePersistenceException("History marker changed while building evidence"); + } + evidence.add(codec.decode(codec.encode(reloaded))); + } + return Collections.unmodifiableList(evidence); + } + + private void validateExpectedRange(List expected) { + if (expected.isEmpty()) { + throw new ArchivePersistenceException("Marker evidence range must not be empty"); + } + if (expected.size() > maxMarkers) { + throw new ArchivePersistenceException("Marker evidence range exceeds configured bound"); + } + BlockSnapshotMeta previous = null; + for (BlockSnapshotMeta meta : expected) { + BlockSnapshotMeta current = Objects.requireNonNull(meta, "expectedMeta"); + if (previous != null && !isNext(previous, current)) { + throw new ArchivePersistenceException("Expected marker evidence range is not contiguous"); + } + previous = current; + } + } + + private void validateMarker(BlockSnapshotMeta expected, HistoryCommitMarker marker) { + if (marker == null) { + throw new ArchivePersistenceException( + "Committed history marker is missing for epoch " + expected.getEpoch()); + } + if (!expected.equals(marker.getMeta())) { + throw new ArchivePersistenceException("Committed history marker target mismatch"); + } + if (marker.getPreviousEpoch() != expected.getEpoch() - 1) { + throw new ArchivePersistenceException("Committed history marker predecessor mismatch"); + } + if (!participants.equals(marker.getDatabases())) { + throw new ArchivePersistenceException("Committed history marker participant set mismatch"); + } + } + + private static boolean isNext(BlockSnapshotMeta previous, BlockSnapshotMeta current) { + return current.getEpoch() == previous.getEpoch() + 1 + && current.getBlockNumber() == previous.getBlockNumber() + 1 + && Arrays.equals(current.getParentHash(), previous.getBlockHash()); + } + + interface Source { + HistoryCommitMarker marker(long epoch); + + BlockReverseDiff readCommitted(long epoch); + } + + private static final class WriterSource implements Source { + private final ArchiveHistoryWriter writer; + + private WriterSource(ArchiveHistoryWriter writer) { + this.writer = Objects.requireNonNull(writer, "writer"); + } + + @Override + public HistoryCommitMarker marker(long epoch) { + return writer.committedMarker(epoch); + } + + @Override + public BlockReverseDiff readCommitted(long epoch) { + return writer.readCommitted(epoch); + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/HistoricalAccountAssetBalanceResolver.java b/chainbase/src/main/java/org/tron/core/db2/archive/HistoricalAccountAssetBalanceResolver.java new file mode 100644 index 00000000000..0b995cde370 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/HistoricalAccountAssetBalanceResolver.java @@ -0,0 +1,167 @@ +package org.tron.core.db2.archive; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Collections; +import java.util.Objects; +import org.tron.common.utils.ByteArray; +import org.tron.core.db2.archive.BlockChangeView.PostValue; +import org.tron.core.db2.archive.P66AccountAssetCodec.AssetRow; +import org.tron.core.db2.archive.P66AccountAssetCodec.DecodedAssetRow; +import org.tron.core.db2.archive.P66AccountAssetCodec.Phase; +import org.tron.protos.Protocol.Account; + +/** Resolves one historical TRC10 balance from a single pinned archive generation. */ +public final class HistoricalAccountAssetBalanceResolver { + + static final String PROPERTIES_DATABASE = "properties"; + static final String ACCOUNT_DATABASE = "account"; + static final String ACCOUNT_ASSET_DATABASE = "account-asset"; + + private static final byte[] PROPOSAL_66_PHYSICAL_KEY = + "ALLOW_ASSET_OPTIMIZATION".getBytes(StandardCharsets.UTF_8); + + private final P66AccountAssetCodec codec = new P66AccountAssetCodec(); + + public Result resolve(ArchivePointSnapshot snapshot, byte[] address, String tokenId) + throws IOException { + ArchivePointSnapshot pinned = Objects.requireNonNull(snapshot, "snapshot"); + requireScopedDatabases(); + pinned.requirePinnedIdentity(); + + byte[] directKey = codec.assetPhysicalKey(address, tokenId); + OldValue propertyValue = pinned.get(PROPERTIES_DATABASE, PROPOSAL_66_PHYSICAL_KEY); + OldValue accountValue = pinned.get(ACCOUNT_DATABASE, address); + OldValue directValue = pinned.get(ACCOUNT_ASSET_DATABASE, directKey); + + pinned.requirePinnedIdentity(); + Phase phase = decodeTargetPhase(propertyValue); + if (!accountValue.isPresent()) { + if (directValue.isPresent()) { + throw new ArchivePersistenceException( + "Historical AccountAsset row has no owning Account"); + } + return Result.absent(pinned.getTargetBlock(), address, tokenId, phase); + } + + Account account = codec.decodeCanonicalAccount(phase, address, accountValue.getValue()); + if (phase == Phase.P66_OFF) { + if (directValue.isPresent()) { + throw new ArchivePersistenceException("P66-off historical layout contains a direct row"); + } + Long balance = account.getAssetV2Map().get(tokenId); + return Result.present(pinned.getTargetBlock(), address, tokenId, phase, + accountValue.getValue(), balance == null ? 0L : balance); + } + + long balance = 0L; + if (directValue.isPresent()) { + AssetRow row = new AssetRow(directKey, PostValue.present(directValue.getValue())); + codec.requireCanonicalLayout(phase, address, accountValue.getValue(), + Collections.singletonList(row)); + DecodedAssetRow decoded = codec.decodePresentAssetRow(directKey, directValue.getValue()); + if (!Arrays.equals(address, decoded.getAccountAddress()) + || !tokenId.equals(decoded.getTokenId())) { + throw new ArchivePersistenceException("Historical AccountAsset identity mismatch"); + } + balance = decoded.getBalance(); + } + return Result.present(pinned.getTargetBlock(), address, tokenId, phase, + accountValue.getValue(), balance); + } + + static byte[] proposal66PhysicalKey() { + return Arrays.copyOf(PROPOSAL_66_PHYSICAL_KEY, PROPOSAL_66_PHYSICAL_KEY.length); + } + + static Phase decodeTargetPhase(OldValue propertyValue) { + if (!propertyValue.isPresent()) { + throw new ArchivePersistenceException("Historical proposal-66 property is absent"); + } + byte[] value = propertyValue.getValue(); + if (value.length != Long.BYTES) { + throw new ArchivePersistenceException( + "Historical proposal-66 property must be exactly eight bytes"); + } + long enabled = ByteArray.toLong(value); + if (enabled != 0L && enabled != 1L) { + throw new ArchivePersistenceException("Historical proposal-66 property must be 0 or 1"); + } + return enabled == 0L ? Phase.P66_OFF : Phase.P66_ON; + } + + static void requireScopedDatabases() { + if (!ArchiveStoreScope.isStateDatabase(PROPERTIES_DATABASE) + || !ArchiveStoreScope.isStateDatabase(ACCOUNT_DATABASE) + || !ArchiveStoreScope.isStateDatabase(ACCOUNT_ASSET_DATABASE)) { + throw new IllegalStateException("Historical AccountAsset resolver Store scope mismatch"); + } + } + + public static final class Result { + private final long blockNumber; + private final byte[] address; + private final String tokenId; + private final Phase phase; + private final boolean accountPresent; + private final byte[] accountValue; + private final long balance; + + private Result(long blockNumber, byte[] address, String tokenId, Phase phase, + boolean accountPresent, byte[] accountValue, long balance) { + this.blockNumber = blockNumber; + this.address = Arrays.copyOf(address, address.length); + this.tokenId = tokenId; + this.phase = phase; + this.accountPresent = accountPresent; + this.accountValue = accountValue == null ? null + : Arrays.copyOf(accountValue, accountValue.length); + this.balance = balance; + } + + private static Result absent(long blockNumber, byte[] address, String tokenId, Phase phase) { + return new Result(blockNumber, address, tokenId, phase, false, null, 0L); + } + + private static Result present(long blockNumber, byte[] address, String tokenId, Phase phase, + byte[] accountValue, long balance) { + return new Result(blockNumber, address, tokenId, phase, true, accountValue, balance); + } + + public long getBlockNumber() { + return blockNumber; + } + + public byte[] getAddress() { + return Arrays.copyOf(address, address.length); + } + + public String getTokenId() { + return tokenId; + } + + /** P66_ON also represents the activation target because both use the direct layout. */ + public Phase getPhase() { + return phase; + } + + public boolean isAccountPresent() { + return accountPresent; + } + + public byte[] getAccountValue() { + if (!accountPresent) { + throw new IllegalStateException("Historical account is absent"); + } + return Arrays.copyOf(accountValue, accountValue.length); + } + + public long getBalance() { + if (!accountPresent) { + throw new IllegalStateException("Historical account is absent"); + } + return balance; + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/HistoricalAccountAssetPrefixResolver.java b/chainbase/src/main/java/org/tron/core/db2/archive/HistoricalAccountAssetPrefixResolver.java new file mode 100644 index 00000000000..ebccb6a08a9 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/HistoricalAccountAssetPrefixResolver.java @@ -0,0 +1,230 @@ +package org.tron.core.db2.archive; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import org.tron.core.db2.archive.HistoricalRangeOverlay.KeyRange; +import org.tron.core.db2.archive.P66AccountAssetCodec.DecodedAssetRow; +import org.tron.core.db2.archive.P66AccountAssetCodec.Phase; +import org.tron.protos.Protocol.Account; + +/** Bounded historical AccountAsset address-prefix resolver over one pinned generation. */ +public final class HistoricalAccountAssetPrefixResolver { + + private final P66AccountAssetCodec codec = new P66AccountAssetCodec(); + + public Result resolve(ArchivePointSnapshot snapshot, byte[] address, Limits limits) + throws IOException { + ArchivePointSnapshot pinned = Objects.requireNonNull(snapshot, "snapshot"); + Limits budgets = Objects.requireNonNull(limits, "limits"); + byte[] accountAddress = requireAddress(address); + HistoricalAccountAssetBalanceResolver.requireScopedDatabases(); + pinned.requirePinnedIdentity(); + + OldValue propertyValue = pinned.get( + HistoricalAccountAssetBalanceResolver.PROPERTIES_DATABASE, + HistoricalAccountAssetBalanceResolver.proposal66PhysicalKey()); + OldValue accountValue = pinned.get( + HistoricalAccountAssetBalanceResolver.ACCOUNT_DATABASE, accountAddress); + List directRows = pinned.range( + HistoricalAccountAssetBalanceResolver.ACCOUNT_ASSET_DATABASE, + KeyRange.prefix(accountAddress), budgets.overlayLimits()); + + pinned.requirePinnedIdentity(); + Phase phase = HistoricalAccountAssetBalanceResolver.decodeTargetPhase(propertyValue); + if (!accountValue.isPresent()) { + if (!directRows.isEmpty()) { + throw new ArchivePersistenceException( + "Historical AccountAsset prefix has no owning Account"); + } + return Result.absent(pinned.getTargetBlock(), accountAddress, phase); + } + + byte[] exactAccount = accountValue.getValue(); + Account account = codec.decodeCanonicalAccount(phase, accountAddress, exactAccount); + List balances = phase == Phase.P66_OFF + ? resolveEmbedded(accountAddress, account.getAssetV2Map(), directRows, budgets) + : resolveDirect(accountAddress, directRows, budgets); + return Result.present(pinned.getTargetBlock(), accountAddress, phase, exactAccount, balances); + } + + private List resolveEmbedded(byte[] address, Map embedded, + List directRows, Limits limits) { + if (!directRows.isEmpty()) { + throw new ArchivePersistenceException("P66-off historical layout contains direct rows"); + } + List> sorted = new ArrayList<>(embedded.entrySet()); + sorted.sort(Comparator.comparing(Map.Entry::getKey)); + List result = new ArrayList<>(); + long totalBytes = 0L; + for (Map.Entry entry : sorted) { + String tokenId = Objects.requireNonNull(entry.getKey(), "embedded tokenId"); + Long balance = Objects.requireNonNull(entry.getValue(), "embedded balance"); + byte[] physicalKey = codec.assetPhysicalKey(address, tokenId); + totalBytes = limits.reserve(result.size(), physicalKey.length, Long.BYTES, totalBytes); + result.add(new Balance(tokenId, balance)); + } + return Collections.unmodifiableList(result); + } + + private List resolveDirect(byte[] address, + List directRows, Limits limits) { + List result = new ArrayList<>(); + byte[] previous = null; + long totalBytes = 0L; + for (HistoricalRangeOverlay.Entry row : directRows) { + byte[] key = row.getKey(); + byte[] value = row.getValue(); + if (previous != null && BlockReverseDiff.compareUnsigned(previous, key) >= 0) { + throw new ArchivePersistenceException( + "Historical AccountAsset prefix rows must be strictly sorted and unique"); + } + totalBytes = limits.reserve(result.size(), key.length, value.length, totalBytes); + DecodedAssetRow decoded = codec.decodePresentAssetRow(key, value); + if (!Arrays.equals(address, decoded.getAccountAddress())) { + throw new ArchivePersistenceException("Historical AccountAsset prefix escaped address"); + } + result.add(new Balance(decoded.getTokenId(), decoded.getBalance())); + previous = key; + } + return Collections.unmodifiableList(result); + } + + private static byte[] requireAddress(byte[] address) { + if (address == null || address.length != HistoricalAccountBalanceReader.ADDRESS_LENGTH) { + throw new ArchivePersistenceException("Account address must be exactly 21 bytes"); + } + return Arrays.copyOf(address, address.length); + } + + public static final class Limits { + private final int maxChangedKeys; + private final int maxCandidateKeys; + private final int maxEntries; + private final int maxKeyBytes; + private final int maxValueBytes; + private final long maxTotalBytes; + + public Limits(int maxChangedKeys, int maxCandidateKeys, int maxEntries, int maxKeyBytes, + int maxValueBytes, long maxTotalBytes) { + if (maxChangedKeys <= 0 || maxCandidateKeys <= 0 || maxEntries <= 0 || maxKeyBytes <= 0 + || maxValueBytes <= 0 || maxTotalBytes <= 0) { + throw new IllegalArgumentException("AccountAsset prefix limits must be positive"); + } + this.maxChangedKeys = maxChangedKeys; + this.maxCandidateKeys = maxCandidateKeys; + this.maxEntries = maxEntries; + this.maxKeyBytes = maxKeyBytes; + this.maxValueBytes = maxValueBytes; + this.maxTotalBytes = maxTotalBytes; + } + + private HistoricalRangeOverlay.Limits overlayLimits() { + return new HistoricalRangeOverlay.Limits(maxChangedKeys, maxCandidateKeys, maxEntries); + } + + private long reserve(int currentEntries, int keyBytes, int valueBytes, long currentTotal) { + if (currentEntries >= maxEntries) { + throw new ArchiveQueryLimitExceededException("AccountAsset entry budget exceeded"); + } + if (keyBytes > maxKeyBytes) { + throw new ArchiveQueryLimitExceededException("AccountAsset key-byte budget exceeded"); + } + if (valueBytes > maxValueBytes) { + throw new ArchiveQueryLimitExceededException("AccountAsset value-byte budget exceeded"); + } + final long entryBytes; + final long updated; + try { + entryBytes = Math.addExact((long) keyBytes, valueBytes); + updated = Math.addExact(currentTotal, entryBytes); + } catch (ArithmeticException overflow) { + throw new ArchiveQueryLimitExceededException( + "AccountAsset total-byte budget overflow"); + } + if (updated > maxTotalBytes) { + throw new ArchiveQueryLimitExceededException("AccountAsset total-byte budget exceeded"); + } + return updated; + } + } + + public static final class Balance { + private final String tokenId; + private final long balance; + + private Balance(String tokenId, long balance) { + this.tokenId = tokenId; + this.balance = balance; + } + + public String getTokenId() { + return tokenId; + } + + public long getBalance() { + return balance; + } + } + + public static final class Result { + private final long blockNumber; + private final byte[] address; + private final Phase phase; + private final boolean accountPresent; + private final byte[] accountValue; + private final List balances; + + private Result(long blockNumber, byte[] address, Phase phase, boolean accountPresent, + byte[] accountValue, List balances) { + this.blockNumber = blockNumber; + this.address = Arrays.copyOf(address, address.length); + this.phase = phase; + this.accountPresent = accountPresent; + this.accountValue = accountValue == null ? null + : Arrays.copyOf(accountValue, accountValue.length); + this.balances = balances; + } + + private static Result absent(long blockNumber, byte[] address, Phase phase) { + return new Result(blockNumber, address, phase, false, null, Collections.emptyList()); + } + + private static Result present(long blockNumber, byte[] address, Phase phase, + byte[] accountValue, List balances) { + return new Result(blockNumber, address, phase, true, accountValue, balances); + } + + public long getBlockNumber() { + return blockNumber; + } + + public byte[] getAddress() { + return Arrays.copyOf(address, address.length); + } + + public Phase getPhase() { + return phase; + } + + public boolean isAccountPresent() { + return accountPresent; + } + + public byte[] getAccountValue() { + if (!accountPresent) { + throw new IllegalStateException("Historical account is absent"); + } + return Arrays.copyOf(accountValue, accountValue.length); + } + + public List getBalances() { + return balances; + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/HistoricalAccountBalanceReader.java b/chainbase/src/main/java/org/tron/core/db2/archive/HistoricalAccountBalanceReader.java new file mode 100644 index 00000000000..1a88a64cb7a --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/HistoricalAccountBalanceReader.java @@ -0,0 +1,89 @@ +package org.tron.core.db2.archive; + +import com.google.protobuf.InvalidProtocolBufferException; +import java.io.IOException; +import java.util.Arrays; +import java.util.Objects; +import org.tron.protos.Protocol.Account; + +/** Narrow historical TRX account-balance reader over one pinned archive snapshot. */ +public final class HistoricalAccountBalanceReader { + + public static final String ACCOUNT_DATABASE = "account"; + public static final int ADDRESS_LENGTH = 21; + + private HistoricalAccountBalanceReader() { + } + + public static Result read(ArchivePointSnapshot snapshot, byte[] address) throws IOException { + Objects.requireNonNull(snapshot, "snapshot"); + if (address == null || address.length != ADDRESS_LENGTH) { + throw new IllegalArgumentException("TRON account address must be exactly 21 bytes"); + } + OldValue historical = snapshot.get(ACCOUNT_DATABASE, address); + return decode(snapshot.getTargetBlock(), address, historical); + } + + public static Result decode(long blockNumber, byte[] address, OldValue historical) { + Objects.requireNonNull(historical, "historical"); + if (address == null || address.length != ADDRESS_LENGTH) { + throw new IllegalArgumentException("TRON account address must be exactly 21 bytes"); + } + if (!historical.isPresent()) { + return Result.absent(blockNumber, address); + } + Account account; + try { + account = Account.parseFrom(historical.getValue()); + } catch (InvalidProtocolBufferException failure) { + throw new ArchivePersistenceException("Historical account value is not valid protobuf", + failure); + } + if (!Arrays.equals(address, account.getAddress().toByteArray())) { + throw new ArchivePersistenceException( + "Historical account value address does not match the physical key"); + } + return Result.present(blockNumber, address, account.getBalance()); + } + + public static final class Result { + private final long blockNumber; + private final byte[] address; + private final boolean present; + private final long balance; + + private Result(long blockNumber, byte[] address, boolean present, long balance) { + this.blockNumber = blockNumber; + this.address = Arrays.copyOf(address, address.length); + this.present = present; + this.balance = balance; + } + + private static Result absent(long blockNumber, byte[] address) { + return new Result(blockNumber, address, false, 0); + } + + private static Result present(long blockNumber, byte[] address, long balance) { + return new Result(blockNumber, address, true, balance); + } + + public long getBlockNumber() { + return blockNumber; + } + + public byte[] getAddress() { + return Arrays.copyOf(address, address.length); + } + + public boolean isPresent() { + return present; + } + + public long getBalance() { + if (!present) { + throw new IllegalStateException("Historical account is absent"); + } + return balance; + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/HistoricalQueryBudgetException.java b/chainbase/src/main/java/org/tron/core/db2/archive/HistoricalQueryBudgetException.java new file mode 100644 index 00000000000..abfc65c1337 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/HistoricalQueryBudgetException.java @@ -0,0 +1,9 @@ +package org.tron.core.db2.archive; + +/** A request-owned historical view exceeded its configured resource budget. */ +public class HistoricalQueryBudgetException extends ArchivePersistenceException { + + public HistoricalQueryBudgetException(String message) { + super(message); + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/HistoricalQuerySession.java b/chainbase/src/main/java/org/tron/core/db2/archive/HistoricalQuerySession.java new file mode 100644 index 00000000000..e73bfe01439 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/HistoricalQuerySession.java @@ -0,0 +1,272 @@ +package org.tron.core.db2.archive; + +import com.google.protobuf.InvalidProtocolBufferException; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import org.tron.core.db2.archive.ArchiveReadContext.StoreAdapter; +import org.tron.core.store.StorageRowKeyCodec; +import org.tron.protos.Protocol.Account; +import org.tron.protos.contract.SmartContractOuterClass.ContractState; +import org.tron.protos.contract.SmartContractOuterClass.SmartContract; + +/** One request-owned exact-27 typed view of a canonical historical block-final state. */ +public final class HistoricalQuerySession implements AutoCloseable { + + public static final class Limits { + + private final long maxReads; + private final long maxBytes; + private final long timeoutMillis; + + public Limits(long maxReads, long maxBytes, long timeoutMillis) { + if (maxReads <= 0 || maxBytes <= 0 || timeoutMillis <= 0) { + throw new IllegalArgumentException("Historical query limits must be positive"); + } + this.maxReads = maxReads; + this.maxBytes = maxBytes; + this.timeoutMillis = timeoutMillis; + } + + public static Limits defaults() { + return new Limits(100_000L, 64L * 1024L * 1024L, 10_000L); + } + } + + private static final Map> RAW_ADAPTERS = rawAdapters(); + private static final List> EXACT_ADAPTERS = exactAdapters(); + + private final ArchiveReadContext context; + private final byte[] targetBlockHash; + private final Limits limits; + private final long deadlineNanos; + private long reads; + private long bytes; + private boolean closed; + + private HistoricalQuerySession(ArchiveReadContext context, byte[] targetBlockHash, + Limits limits) { + this.context = Objects.requireNonNull(context, "context"); + this.targetBlockHash = copyHash(targetBlockHash); + this.limits = Objects.requireNonNull(limits, "limits"); + this.deadlineNanos = System.nanoTime() + + java.util.concurrent.TimeUnit.MILLISECONDS.toNanos(limits.timeoutMillis); + context.requirePinnedIdentity(); + } + + /** Takes ownership of {@code lease}, including if session construction fails. */ + public static HistoricalQuerySession open(ArchiveRuntimeQueryGate.Lease lease, + byte[] targetBlockHash) throws IOException { + return open(lease, targetBlockHash, Limits.defaults()); + } + + /** Takes ownership of a common-checkpoint point snapshot. */ + public static HistoricalQuerySession open(StateArchiveCheckpointReadSnapshot snapshot, + byte[] targetBlockHash) throws IOException { + return open(snapshot, targetBlockHash, Limits.defaults()); + } + + /** Takes ownership of a common-checkpoint point snapshot. */ + public static HistoricalQuerySession open(StateArchiveCheckpointReadSnapshot snapshot, + byte[] targetBlockHash, Limits limits) throws IOException { + ArchiveReadContext context = ArchiveReadContext.open(snapshot, EXACT_ADAPTERS); + try { + return new HistoricalQuerySession(context, targetBlockHash, limits); + } catch (RuntimeException failure) { + try { + context.close(); + } catch (IOException closeFailure) { + failure.addSuppressed(closeFailure); + } + throw failure; + } + } + + /** Takes ownership of {@code lease}, including if session construction fails. */ + public static HistoricalQuerySession open(ArchiveRuntimeQueryGate.Lease lease, + byte[] targetBlockHash, Limits limits) throws IOException { + ArchiveReadContext context = ArchiveReadContext.open(lease, EXACT_ADAPTERS); + try { + return new HistoricalQuerySession(context, targetBlockHash, limits); + } catch (RuntimeException failure) { + try { + context.close(); + } catch (IOException closeFailure) { + failure.addSuppressed(closeFailure); + } + throw failure; + } + } + + public long getTargetBlock() { + ensureOpen(); + return context.getTargetBlock(); + } + + public long getPinnedBlock() { + ensureOpen(); + return context.getPinnedBlock(); + } + + public byte[] getTargetBlockHash() { + ensureOpen(); + return Arrays.copyOf(targetBlockHash, targetBlockHash.length); + } + + public Optional getAccount(byte[] address) { + byte[] key = copyKey(address, "address"); + Optional encoded = getExact("account", key); + if (!encoded.isPresent()) { + return Optional.empty(); + } + Account account = decodeAccount(encoded.get()); + if (!account.getAddress().equals(com.google.protobuf.ByteString.copyFrom(key))) { + throw new ArchivePersistenceException( + "Historical Account address does not match the physical key"); + } + return Optional.of(account); + } + + public Optional getContract(byte[] address) { + Optional encoded = getExact("contract", copyKey(address, "address")); + return encoded.map(HistoricalQuerySession::decodeContract); + } + + public Optional getCode(byte[] address) { + return getExact("code", copyKey(address, "address")); + } + + public Optional getContractState(byte[] address) { + Optional encoded = getExact("contract-state", copyKey(address, "address")); + return encoded.map(HistoricalQuerySession::decodeContractState); + } + + /** Contract metadata and storage-row lookup are resolved by the same pinned context. */ + public Optional getStorage(byte[] contractAddress, byte[] logicalSlot) { + ensureOpen(); + byte[] address = copyKey(contractAddress, "contractAddress"); + byte[] slot = copyKey(logicalSlot, "logicalSlot"); + SmartContract contract = getContract(address).orElseThrow(() -> + new ArchivePersistenceException( + "Historical Contract is required to derive storage-row key")); + byte[] transactionHash = contract.getTrxHash().isEmpty() + ? null : contract.getTrxHash().toByteArray(); + byte[] physicalKey = StorageRowKeyCodec.physicalKey(address, slot, + contract.getVersion(), transactionHash); + return getExact("storage-row", physicalKey); + } + + public Optional getExact(String dbName, byte[] physicalRawKey) { + ensureOpen(); + requireReadBudget(); + try { + OldValue value = context.getExact(dbName, copyKey(physicalRawKey, "physicalRawKey")); + if (value.isPresent()) { + accountBytes(value.getValue().length); + } + return value.isPresent() ? Optional.of(value.getValue()) : Optional.empty(); + } catch (IOException failure) { + throw new ArchivePersistenceException("Failed to read historical Store " + dbName, + failure); + } + } + + public void requirePinnedIdentity() { + ensureOpen(); + context.requirePinnedIdentity(); + } + + @Override + public synchronized void close() { + if (closed) { + return; + } + closed = true; + try { + context.close(); + } catch (IOException failure) { + throw new ArchivePersistenceException("Failed to close historical query session", failure); + } + } + + private synchronized void ensureOpen() { + if (closed) { + throw new IllegalStateException("Historical query session is closed"); + } + } + + private synchronized void requireReadBudget() { + if (System.nanoTime() - deadlineNanos > 0) { + throw new HistoricalQueryBudgetException("Historical query deadline exceeded"); + } + if (reads >= limits.maxReads) { + throw new HistoricalQueryBudgetException("Historical query read budget exceeded"); + } + reads++; + } + + private synchronized void accountBytes(long additionalBytes) { + if (additionalBytes > limits.maxBytes - bytes) { + throw new HistoricalQueryBudgetException("Historical query byte budget exceeded"); + } + bytes += additionalBytes; + } + + private static Map> rawAdapters() { + Map> adapters = new LinkedHashMap<>(); + for (String dbName : ArchiveStoreScope.getStateDatabases()) { + adapters.put(dbName, StoreAdapter.define(dbName, HistoricalQuerySession::copyValue)); + } + return Collections.unmodifiableMap(adapters); + } + + private static List> exactAdapters() { + return Collections.unmodifiableList(new ArrayList<>(RAW_ADAPTERS.values())); + } + + private static Account decodeAccount(byte[] value) { + try { + return Account.parseFrom(value); + } catch (InvalidProtocolBufferException failure) { + throw new ArchivePersistenceException("Historical Account cannot be decoded", failure); + } + } + + private static SmartContract decodeContract(byte[] value) { + try { + return SmartContract.parseFrom(value); + } catch (InvalidProtocolBufferException failure) { + throw new ArchivePersistenceException("Historical Contract cannot be decoded", failure); + } + } + + private static ContractState decodeContractState(byte[] value) { + try { + return ContractState.parseFrom(value); + } catch (InvalidProtocolBufferException failure) { + throw new ArchivePersistenceException( + "Historical ContractState cannot be decoded", failure); + } + } + + private static byte[] copyHash(byte[] hash) { + if (hash == null || hash.length != 32) { + throw new IllegalArgumentException("targetBlockHash must be exactly 32 bytes"); + } + return Arrays.copyOf(hash, hash.length); + } + + private static byte[] copyKey(byte[] value, String name) { + return Arrays.copyOf(Objects.requireNonNull(value, name), value.length); + } + + private static byte[] copyValue(byte[] value) { + return Arrays.copyOf(value, value.length); + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/HistoricalRangeOverlay.java b/chainbase/src/main/java/org/tron/core/db2/archive/HistoricalRangeOverlay.java new file mode 100644 index 00000000000..3b152023bf5 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/HistoricalRangeOverlay.java @@ -0,0 +1,183 @@ +package org.tron.core.db2.archive; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import org.tron.core.db2.archive.ServingKeyIndexGeneration.ChangedKey; + +/** + * Backend-neutral prototype for one-store historical range materialization. + * + *

The caller must supply latest entries from the same pinned {@code S(P)} generation as the + * serving index. This class deliberately provides no cross-database merge API and no persistent + * cursor encoding. + */ +public final class HistoricalRangeOverlay { + + private HistoricalRangeOverlay() { + } + + public static List materialize(String dbName, long targetBlock, long upperBound, + KeyRange range, List pinnedLatest, ServingKeyIndex index, + HistoricalValueReader history, Limits limits) throws IOException { + Objects.requireNonNull(dbName, "dbName"); + Objects.requireNonNull(range, "range"); + Objects.requireNonNull(pinnedLatest, "pinnedLatest"); + Objects.requireNonNull(index, "index"); + Objects.requireNonNull(history, "history"); + Objects.requireNonNull(limits, "limits"); + validateLatest(pinnedLatest, range); + + List changed = index.changesInRange(dbName, range.lowerInclusive, + range.upperExclusive, targetBlock, upperBound, limits.maxChangedKeys); + List result = new ArrayList<>(); + int latestIndex = 0; + int changedIndex = 0; + int candidates = 0; + + while (latestIndex < pinnedLatest.size() || changedIndex < changed.size()) { + if (++candidates > limits.maxCandidateKeys) { + throw new ArchiveQueryLimitExceededException("candidate-key budget exceeded"); + } + Entry latest = latestIndex < pinnedLatest.size() ? pinnedLatest.get(latestIndex) : null; + ChangedKey delta = changedIndex < changed.size() ? changed.get(changedIndex) : null; + int comparison = latest == null ? 1 : delta == null ? -1 + : BlockReverseDiff.compareUnsigned(latest.key, delta.getKey()); + + if (comparison < 0) { + addResult(result, latest, limits.maxResults); + latestIndex++; + } else { + byte[] key = delta.getKey(); + OldValue value = history.read(dbName, key, delta.getFirstChangeBlock()); + if (value == null) { + throw new IllegalStateException("historical value reader returned null"); + } + if (value.isPresent()) { + addResult(result, new Entry(key, value.getValue()), limits.maxResults); + } + changedIndex++; + if (comparison == 0) { + latestIndex++; + } + } + } + return Collections.unmodifiableList(result); + } + + private static void validateLatest(List latest, KeyRange range) { + byte[] previous = null; + for (Entry entry : latest) { + Objects.requireNonNull(entry, "latest entry"); + if (!range.contains(entry.key)) { + throw new IllegalArgumentException("latest entry is outside requested range"); + } + if (previous != null && BlockReverseDiff.compareUnsigned(previous, entry.key) >= 0) { + throw new IllegalArgumentException("latest entries must be strictly sorted"); + } + previous = entry.key; + } + } + + private static void addResult(List result, Entry entry, int maxResults) { + if (result.size() == maxResults) { + throw new ArchiveQueryLimitExceededException("result budget exceeded"); + } + result.add(entry); + } + + @FunctionalInterface + public interface HistoricalValueReader { + OldValue read(String dbName, byte[] rawKey, long firstChangeBlock) throws IOException; + } + + public static final class Limits { + private final int maxChangedKeys; + private final int maxCandidateKeys; + private final int maxResults; + + public Limits(int maxChangedKeys, int maxCandidateKeys, int maxResults) { + if (maxChangedKeys <= 0 || maxCandidateKeys <= 0 || maxResults <= 0) { + throw new IllegalArgumentException("historical range limits must be positive"); + } + this.maxChangedKeys = maxChangedKeys; + this.maxCandidateKeys = maxCandidateKeys; + this.maxResults = maxResults; + } + + int getMaxCandidateKeys() { + return maxCandidateKeys; + } + } + + public static final class KeyRange { + private final byte[] lowerInclusive; + private final byte[] upperExclusive; + + private KeyRange(byte[] lowerInclusive, byte[] upperExclusive) { + this.lowerInclusive = Arrays.copyOf(lowerInclusive, lowerInclusive.length); + this.upperExclusive = upperExclusive == null ? null + : Arrays.copyOf(upperExclusive, upperExclusive.length); + if (this.upperExclusive != null + && BlockReverseDiff.compareUnsigned(this.lowerInclusive, this.upperExclusive) > 0) { + throw new IllegalArgumentException("lowerInclusive must not exceed upperExclusive"); + } + } + + public static KeyRange range(byte[] lowerInclusive, byte[] upperExclusive) { + Objects.requireNonNull(lowerInclusive, "lowerInclusive"); + return new KeyRange(lowerInclusive, upperExclusive); + } + + public static KeyRange prefix(byte[] prefix) { + Objects.requireNonNull(prefix, "prefix"); + byte[] upper = Arrays.copyOf(prefix, prefix.length); + for (int i = upper.length - 1; i >= 0; i--) { + if ((upper[i] & 0xff) != 0xff) { + upper[i]++; + upper = Arrays.copyOf(upper, i + 1); + return new KeyRange(prefix, upper); + } + } + return new KeyRange(prefix, null); + } + + public byte[] getLowerInclusive() { + return Arrays.copyOf(lowerInclusive, lowerInclusive.length); + } + + public byte[] getUpperExclusive() { + return upperExclusive == null ? null + : Arrays.copyOf(upperExclusive, upperExclusive.length); + } + + private boolean contains(byte[] key) { + return BlockReverseDiff.compareUnsigned(key, lowerInclusive) >= 0 + && (upperExclusive == null + || BlockReverseDiff.compareUnsigned(key, upperExclusive) < 0); + } + } + + public static final class Entry { + private final byte[] key; + private final byte[] value; + + public Entry(byte[] key, byte[] value) { + Objects.requireNonNull(key, "key"); + Objects.requireNonNull(value, "value"); + this.key = Arrays.copyOf(key, key.length); + this.value = Arrays.copyOf(value, value.length); + } + + public byte[] getKey() { + return Arrays.copyOf(key, key.length); + } + + public byte[] getValue() { + return Arrays.copyOf(value, value.length); + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/HistoryBodyStore.java b/chainbase/src/main/java/org/tron/core/db2/archive/HistoryBodyStore.java new file mode 100644 index 00000000000..f5429a45540 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/HistoryBodyStore.java @@ -0,0 +1,22 @@ +package org.tron.core.db2.archive; + +import java.io.Closeable; +import java.io.IOException; + +/** Physical history-body placement behind the authoritative history writer. */ +interface HistoryBodyStore extends Closeable { + + HistoryLocation append(BlockReverseDiff diff) throws IOException; + + void sync() throws IOException; + + BlockReverseDiff read(HistoryLocation location) throws IOException; + + HistorySegmentStore.ScanResult getScanResult(); + + void truncateAfter(HistoryLocation last) throws IOException; + + void truncateAfter(HistoryLocation last, long knownRecordCount) throws IOException; + + long getStartupScannedRecords(); +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/HistoryCommitMarker.java b/chainbase/src/main/java/org/tron/core/db2/archive/HistoryCommitMarker.java new file mode 100644 index 00000000000..768635d5b0f --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/HistoryCommitMarker.java @@ -0,0 +1,58 @@ +package org.tron.core.db2.archive; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** Reader visibility boundary proving that one body and index delta are durable. */ +public final class HistoryCommitMarker { + + private final BlockSnapshotMeta meta; + private final long previousEpoch; + private final HistoryLocation historyLocation; + private final HistoryIndexLocation indexLocation; + private final byte[] batchId; + private final List databases; + + public HistoryCommitMarker(BlockSnapshotMeta meta, long previousEpoch, + HistoryLocation historyLocation, HistoryIndexLocation indexLocation, byte[] batchId, + List databases) { + this.meta = Objects.requireNonNull(meta, "meta"); + this.previousEpoch = previousEpoch; + this.historyLocation = Objects.requireNonNull(historyLocation, "historyLocation"); + this.indexLocation = Objects.requireNonNull(indexLocation, "indexLocation"); + if (batchId == null || batchId.length != 16) { + throw new IllegalArgumentException("batchId must be exactly 16 bytes"); + } + this.batchId = Arrays.copyOf(Objects.requireNonNull(batchId, "batchId"), batchId.length); + List sorted = new ArrayList<>(databases); + Collections.sort(sorted); + this.databases = Collections.unmodifiableList(sorted); + } + + public BlockSnapshotMeta getMeta() { + return meta; + } + + public long getPreviousEpoch() { + return previousEpoch; + } + + public HistoryLocation getHistoryLocation() { + return historyLocation; + } + + public HistoryIndexLocation getIndexLocation() { + return indexLocation; + } + + public byte[] getBatchId() { + return Arrays.copyOf(batchId, batchId.length); + } + + public List getDatabases() { + return databases; + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/HistoryCommitMarkerCodec.java b/chainbase/src/main/java/org/tron/core/db2/archive/HistoryCommitMarkerCodec.java new file mode 100644 index 00000000000..4c738be9ee6 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/HistoryCommitMarkerCodec.java @@ -0,0 +1,168 @@ +package org.tron.core.db2.archive; + +import com.google.common.hash.Hashing; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.EOFException; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** Checksummed commit-marker codec. */ +public final class HistoryCommitMarkerCodec { + + private static final int MAGIC = 0x54415243; // TARC + private static final short VERSION = 1; + private static final int MAX_MARKER_LENGTH = 1024 * 1024; + static final int HEADER_LENGTH = 12; + + public byte[] encode(HistoryCommitMarker marker) { + try { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + DataOutputStream output = new DataOutputStream(bytes); + output.writeInt(MAGIC); + output.writeShort(VERSION); + output.writeShort(0); + output.writeInt(0); // patched after payload is complete + output.writeLong(marker.getMeta().getEpoch()); + output.writeLong(marker.getPreviousEpoch()); + output.writeLong(marker.getMeta().getBlockNumber()); + output.write(marker.getMeta().getBlockHash()); + output.write(marker.getMeta().getParentHash()); + output.writeLong(marker.getMeta().getTimestamp()); + HistoryLocation body = marker.getHistoryLocation(); + output.writeInt(body.getSegmentId()); + output.writeLong(body.getOffset()); + output.writeInt(body.getRecordLength()); + output.writeInt(body.getBodyChecksum()); + output.write(body.getBodyDigest()); + HistoryIndexLocation index = marker.getIndexLocation(); + output.writeLong(index.getOffset()); + output.writeInt(index.getRecordLength()); + output.write(index.getDigest()); + writeBytes(output, marker.getBatchId()); + output.writeInt(marker.getDatabases().size()); + String previousDb = null; + for (String database : marker.getDatabases()) { + if (previousDb != null && previousDb.compareTo(database) >= 0) { + throw new IllegalArgumentException("Marker databases are not strictly sorted"); + } + previousDb = database; + writeBytes(output, database.getBytes(StandardCharsets.UTF_8)); + } + output.flush(); + byte[] withoutChecksum = bytes.toByteArray(); + int finalLength = withoutChecksum.length + Integer.BYTES; + if (finalLength > MAX_MARKER_LENGTH) { + throw new IllegalArgumentException("History commit marker is too large"); + } + ByteBuffer.wrap(withoutChecksum).putInt(8, finalLength); + output = new DataOutputStream(bytes); + bytes.reset(); + output.write(withoutChecksum); + output.writeInt(crc32c(withoutChecksum)); + output.flush(); + return bytes.toByteArray(); + } catch (IOException e) { + throw new IllegalStateException("Unexpected in-memory marker encoding failure", e); + } + } + + public HistoryCommitMarker decode(byte[] encoded) { + if (encoded == null || encoded.length < 12 + Integer.BYTES + || encoded.length > MAX_MARKER_LENGTH) { + throw new IllegalArgumentException("History commit marker length is invalid"); + } + int expectedChecksum = ByteBuffer.wrap(encoded, encoded.length - Integer.BYTES, + Integer.BYTES).getInt(); + if (expectedChecksum != crc32c(Arrays.copyOf(encoded, encoded.length - Integer.BYTES))) { + throw new IllegalArgumentException("History commit marker checksum mismatch"); + } + try { + DataInputStream input = new DataInputStream(new ByteArrayInputStream(encoded)); + if (input.readInt() != MAGIC || input.readShort() != VERSION || input.readShort() != 0 + || input.readInt() != encoded.length) { + throw new IllegalArgumentException("Unsupported history commit marker header"); + } + long epoch = input.readLong(); + long previousEpoch = input.readLong(); + long blockNumber = input.readLong(); + byte[] blockHash = readExact(input, 32); + byte[] parentHash = readExact(input, 32); + long timestamp = input.readLong(); + HistoryLocation body = new HistoryLocation(input.readInt(), input.readLong(), + input.readInt(), input.readInt(), readExact(input, 32)); + HistoryIndexLocation index = new HistoryIndexLocation(input.readLong(), input.readInt(), + readExact(input, 32)); + byte[] batchId = readBytes(input); + int dbCount = input.readInt(); + if (dbCount < 0) { + throw new IllegalArgumentException("Negative marker database count"); + } + List databases = new ArrayList<>(dbCount); + String previousDb = null; + for (int i = 0; i < dbCount; i++) { + String dbName = new String(readBytes(input), StandardCharsets.UTF_8); + if (previousDb != null && previousDb.compareTo(dbName) >= 0) { + throw new IllegalArgumentException("Marker databases are not strictly sorted"); + } + previousDb = dbName; + databases.add(dbName); + } + if (input.available() != Integer.BYTES) { + throw new IllegalArgumentException("History commit marker payload mismatch"); + } + BlockSnapshotMeta meta = new BlockSnapshotMeta(epoch, blockNumber, blockHash, parentHash, + timestamp); + return new HistoryCommitMarker(meta, previousEpoch, body, index, batchId, databases); + } catch (EOFException e) { + throw new IllegalArgumentException("History commit marker is truncated", e); + } catch (IOException e) { + throw new IllegalArgumentException("Invalid history commit marker", e); + } + } + + /** Returns the complete marker length described by its fixed prefix. */ + public int recordLength(byte[] prefix) { + if (prefix == null || prefix.length < HEADER_LENGTH) { + throw new IllegalArgumentException("History commit marker prefix is truncated"); + } + ByteBuffer buffer = ByteBuffer.wrap(prefix); + if (buffer.getInt() != MAGIC || buffer.getShort() != VERSION || buffer.getShort() != 0) { + throw new IllegalArgumentException("Unsupported history commit marker header"); + } + int length = buffer.getInt(); + if (length < HEADER_LENGTH + Integer.BYTES || length > MAX_MARKER_LENGTH) { + throw new IllegalArgumentException("History commit marker length is invalid"); + } + return length; + } + + private static void writeBytes(DataOutputStream output, byte[] value) throws IOException { + output.writeInt(value.length); + output.write(value); + } + + private static byte[] readBytes(DataInputStream input) throws IOException { + int length = input.readInt(); + if (length < 0 || length > MAX_MARKER_LENGTH) { + throw new IllegalArgumentException("Invalid marker field length"); + } + return readExact(input, length); + } + + private static byte[] readExact(DataInputStream input, int length) throws IOException { + byte[] value = new byte[length]; + input.readFully(value); + return value; + } + + private static int crc32c(byte[] value) { + return Hashing.crc32c().hashBytes(value).asInt(); + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/HistoryCommitStore.java b/chainbase/src/main/java/org/tron/core/db2/archive/HistoryCommitStore.java new file mode 100644 index 00000000000..4e3516139ce --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/HistoryCommitStore.java @@ -0,0 +1,357 @@ +package org.tron.core.db2.archive; + +import java.io.Closeable; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.List; + +/** + * Append-only, checksummed commit log with constant resident state. + * + *

All records in one generation have the same descriptor participant set and therefore the + * same encoded length. Contiguous epochs can be addressed directly without retaining one object + * or creating one directory entry per block. + */ +public final class HistoryCommitStore implements Closeable, CommittedHistoryAuthority { + + private static final String FILE_NAME = "commit.log"; + + private final Path directory; + private final Path logPath; + private final HistoryCommitMarkerCodec codec; + private final FileChannel channel; + private final DirectorySync postForceHook; + private HistoryCommitMarker head; + private long uncertainFrom = -1; + private long uncertainThrough = -1; + private long firstEpoch = -1; + private long recordCount; + private int recordLength; + private long startupScannedRecords; + + public HistoryCommitStore(Path archiveDirectory, HistoryCommitMarkerCodec codec) + throws IOException { + this(archiveDirectory, codec, null, ignored -> { }); + } + + HistoryCommitStore(Path archiveDirectory, HistoryCommitMarkerCodec codec, + DirectorySync postForceHook) throws IOException { + this(archiveDirectory, codec, null, postForceHook); + } + + HistoryCommitStore(Path archiveDirectory, HistoryCommitMarkerCodec codec, + ArchiveHistoryScanAnchor checkpoint) throws IOException { + this(archiveDirectory, codec, checkpoint, ignored -> { }); + } + + HistoryCommitStore(Path archiveDirectory, HistoryCommitMarkerCodec codec, + ArchiveHistoryScanAnchor checkpoint, DirectorySync postForceHook) throws IOException { + this.directory = archiveDirectory.resolve("commits"); + this.logPath = directory.resolve(FILE_NAME); + this.codec = codec; + this.postForceHook = postForceHook; + Files.createDirectories(directory); + boolean created = !Files.exists(logPath); + this.channel = FileChannel.open(logPath, StandardOpenOption.CREATE, StandardOpenOption.READ, + StandardOpenOption.WRITE); + if (created) { + HistorySegmentStore.syncDirectory(directory); + } + scanAndRepairTruncatedTail(checkpoint); + channel.position(channel.size()); + } + + public synchronized void commit(HistoryCommitMarker marker) throws IOException { + commitAll(java.util.Collections.singletonList(marker)); + } + + public synchronized void commitAll(List batch) throws IOException { + if (batch.isEmpty()) { + return; + } + if (uncertainFrom >= 0) { + throw new IllegalStateException("A previous commit record has uncertain durability"); + } + HistoryCommitMarker previous = head; + List encodedBatch = new ArrayList<>(batch.size()); + int expectedLength = recordLength; + for (HistoryCommitMarker marker : batch) { + validateNext(previous, marker); + byte[] encoded = codec.encode(marker); + if (expectedLength != 0 && encoded.length != expectedLength) { + throw new IllegalArgumentException( + "Commit record length changed inside one archive generation"); + } + expectedLength = encoded.length; + encodedBatch.add(encoded); + previous = marker; + } + long offset = channel.size(); + channel.position(offset); + for (byte[] encoded : encodedBatch) { + writeFully(channel, ByteBuffer.wrap(encoded)); + } + uncertainFrom = batch.get(0).getMeta().getEpoch(); + uncertainThrough = batch.get(batch.size() - 1).getMeta().getEpoch(); + try { + channel.force(true); + postForceHook.sync(directory); + } catch (IOException failure) { + throw failure; + } + if (recordCount == 0) { + firstEpoch = batch.get(0).getMeta().getEpoch(); + recordLength = expectedLength; + } + recordCount += batch.size(); + head = batch.get(batch.size() - 1); + uncertainFrom = -1; + uncertainThrough = -1; + } + + public synchronized void removeHead(BlockSnapshotMeta expected) throws IOException { + if (uncertainFrom >= 0) { + throw new IllegalStateException("Cannot revert a commit record with uncertain durability"); + } + if (head == null || !head.getMeta().equals(expected)) { + throw new IllegalStateException("Only the committed history head can be reverted"); + } + long newCount = recordCount - 1; + channel.truncate(newCount * (long) recordLength); + channel.force(true); + recordCount = newCount; + if (newCount == 0) { + head = null; + firstEpoch = -1; + recordLength = 0; + } else { + head = readOrdinal(newCount - 1); + } + channel.position(channel.size()); + } + + /** Durably removes every committed marker after {@code lastEpoch}. */ + public synchronized void truncateAfter(long lastEpoch) throws IOException { + if (uncertainFrom >= 0) { + throw new IllegalStateException("Cannot truncate commit records with uncertain durability"); + } + HistoryCommitMarker last = get(lastEpoch); + if (last == null) { + throw new IllegalArgumentException("Commit truncation target is outside the committed prefix"); + } + long newCount = lastEpoch - firstEpoch + 1; + if (newCount == recordCount) { + return; + } + channel.truncate(newCount * (long) recordLength); + channel.force(true); + postForceHook.sync(directory); + recordCount = newCount; + head = last; + channel.position(channel.size()); + } + + @Override + public synchronized HistoryCommitMarker head() { + return head; + } + + public synchronized long size() { + return recordCount; + } + + @Override + public synchronized long firstEpoch() { + return firstEpoch; + } + + /** Returns one atomic height-coverage snapshot of the validated contiguous commit log. */ + @Override + public synchronized HistoryCoverage coverage() { + return head == null ? null : new HistoryCoverage(firstEpoch, recordCount, + head.getMeta().getEpoch(), head.getMeta().getBlockHash()); + } + + /** Materializes the committed prefix. Do not use this method in the scale ingestion path. */ + public synchronized List getMarkers() { + if (recordCount > Integer.MAX_VALUE) { + throw new IllegalStateException("Commit prefix is too large to materialize"); + } + List markers = new ArrayList<>((int) recordCount); + try { + for (long ordinal = 0; ordinal < recordCount; ordinal++) { + markers.add(readOrdinal(ordinal)); + } + return markers; + } catch (IOException failure) { + throw new ArchivePersistenceException("Failed to materialize commit prefix", failure); + } + } + + @Override + public synchronized HistoryCommitMarker get(long epoch) { + if (recordCount == 0 || epoch < firstEpoch || epoch - firstEpoch >= recordCount) { + return null; + } + try { + return readOrdinal(epoch - firstEpoch); + } catch (IOException failure) { + throw new ArchivePersistenceException("Failed to read history commit epoch " + epoch, + failure); + } + } + + public synchronized boolean mayContain(long epoch) { + return get(epoch) != null + || (uncertainFrom >= 0 && epoch >= uncertainFrom && epoch <= uncertainThrough); + } + + Path getLogPath() { + return logPath; + } + + int getRecordLength() { + return recordLength; + } + + long getStartupScannedRecords() { + return startupScannedRecords; + } + + private void scanAndRepairTruncatedTail(ArchiveHistoryScanAnchor checkpoint) + throws IOException { + long offset = 0; + HistoryCommitMarker previous = null; + int expectedLength = 0; + long count = 0; + long size = channel.size(); + if (checkpoint != null) { + expectedLength = checkpoint.getCommitRecordLength(); + count = checkpoint.getRecordCount(); + firstEpoch = checkpoint.getFirstEpoch(); + long checkpointOffset = (count - 1) * (long) expectedLength; + if (checkpointOffset < 0 || checkpointOffset + expectedLength > size) { + throw new ArchivePersistenceException( + "History scan anchor is outside the committed history log"); + } + byte[] checkpointRecord = read(checkpointOffset, expectedLength); + startupScannedRecords++; + if (!java.util.Arrays.equals(checkpointRecord, checkpoint.getEncodedMarker())) { + throw new ArchivePersistenceException( + "History scan anchor does not match the committed history log"); + } + previous = codec.decode(checkpointRecord); + offset = checkpointOffset + expectedLength; + } + while (offset < size) { + long remaining = size - offset; + if (remaining < HistoryCommitMarkerCodec.HEADER_LENGTH) { + truncateTail(offset); + size = offset; + break; + } + byte[] prefix = read(offset, HistoryCommitMarkerCodec.HEADER_LENGTH); + int length; + try { + length = codec.recordLength(prefix); + } catch (IllegalArgumentException invalidHeader) { + throw new ArchivePersistenceException( + "Committed history log contains an invalid record header at " + offset, + invalidHeader); + } + if (length > remaining) { + truncateTail(offset); + size = offset; + break; + } + if (expectedLength != 0 && length != expectedLength) { + throw new ArchivePersistenceException( + "Commit record length changes inside one archive generation"); + } + HistoryCommitMarker marker; + try { + marker = codec.decode(read(offset, length)); + validateNext(previous, marker); + } catch (IllegalArgumentException invalidRecord) { + throw new ArchivePersistenceException( + "Committed history log contains an invalid record at " + offset, invalidRecord); + } + if (count == 0) { + firstEpoch = marker.getMeta().getEpoch(); + expectedLength = length; + } + previous = marker; + count++; + startupScannedRecords++; + offset += length; + } + recordLength = expectedLength; + recordCount = count; + head = previous; + } + + private void truncateTail(long offset) throws IOException { + channel.truncate(offset); + channel.force(true); + } + + private HistoryCommitMarker readOrdinal(long ordinal) throws IOException { + if (ordinal < 0 || ordinal >= recordCount) { + throw new IllegalArgumentException("Commit ordinal is outside the committed prefix"); + } + return codec.decode(read(ordinal * (long) recordLength, recordLength)); + } + + private byte[] read(long offset, int length) throws IOException { + ByteBuffer buffer = ByteBuffer.allocate(length); + channel.position(offset); + readFully(channel, buffer); + return buffer.array(); + } + + private static void validateNext(HistoryCommitMarker previous, + HistoryCommitMarker current) { + if (previous == null) { + if (current.getPreviousEpoch() >= current.getMeta().getEpoch()) { + throw new IllegalArgumentException("Invalid base commit marker previous epoch"); + } + return; + } + if (current.getMeta().getEpoch() != previous.getMeta().getEpoch() + 1 + || current.getPreviousEpoch() != previous.getMeta().getEpoch() + || current.getMeta().getBlockNumber() != previous.getMeta().getBlockNumber() + 1 + || !java.util.Arrays.equals(current.getMeta().getParentHash(), + previous.getMeta().getBlockHash())) { + throw new IllegalArgumentException("Non-contiguous history commit marker"); + } + } + + private static void writeFully(FileChannel target, ByteBuffer buffer) throws IOException { + while (buffer.hasRemaining()) { + target.write(buffer); + } + } + + private static void readFully(FileChannel source, ByteBuffer buffer) throws IOException { + while (buffer.hasRemaining()) { + if (source.read(buffer) < 0) { + throw new IOException("Unexpected end of history commit log"); + } + } + } + + @Override + public synchronized void close() throws IOException { + channel.close(); + } + + @FunctionalInterface + interface DirectorySync { + void sync(Path directory) throws IOException; + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/HistoryCoverage.java b/chainbase/src/main/java/org/tron/core/db2/archive/HistoryCoverage.java new file mode 100644 index 00000000000..659b6c0b636 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/HistoryCoverage.java @@ -0,0 +1,41 @@ +package org.tron.core.db2.archive; + +import java.util.Arrays; + +/** Immutable height coverage of one validated, contiguous history commit log. */ +public final class HistoryCoverage { + + private final long firstEpoch; + private final long recordCount; + private final long headEpoch; + private final byte[] headHash; + + public HistoryCoverage(long firstEpoch, long recordCount, long headEpoch, byte[] headHash) { + if (firstEpoch < 0 || recordCount <= 0 || headEpoch < firstEpoch) { + throw new IllegalArgumentException("History coverage range is invalid"); + } + if (headHash == null || headHash.length != 32) { + throw new IllegalArgumentException("History coverage head hash must be exactly 32 bytes"); + } + this.firstEpoch = firstEpoch; + this.recordCount = recordCount; + this.headEpoch = headEpoch; + this.headHash = Arrays.copyOf(headHash, headHash.length); + } + + public long getFirstEpoch() { + return firstEpoch; + } + + public long getRecordCount() { + return recordCount; + } + + public long getHeadEpoch() { + return headEpoch; + } + + public byte[] getHeadHash() { + return Arrays.copyOf(headHash, headHash.length); + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/HistoryIndexCodec.java b/chainbase/src/main/java/org/tron/core/db2/archive/HistoryIndexCodec.java new file mode 100644 index 00000000000..19ba9025660 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/HistoryIndexCodec.java @@ -0,0 +1,243 @@ +package org.tron.core.db2.archive; + +import com.google.common.hash.Hashing; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.EOFException; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import org.tron.core.db2.archive.HistoryIndexRecord.KeyGroup; + +/** Deterministic codec for one authoritative state_history.idx delta. */ +public final class HistoryIndexCodec { + + public static final int MAGIC = 0x54415249; // TARI + public static final short VERSION = 1; + public static final int FIXED_HEADER_LENGTH = 160; + public static final int CHECKSUM_LENGTH = Integer.BYTES; + + private final int maxRecordLength; + + public HistoryIndexCodec() { + this(BlockHistoryCodec.DEFAULT_MAX_RECORD_LENGTH); + } + + public HistoryIndexCodec(int maxRecordLength) { + this.maxRecordLength = maxRecordLength; + } + + public byte[] encode(HistoryIndexRecord record) { + try { + byte[] payload = encodePayload(record); + int length = FIXED_HEADER_LENGTH + payload.length + CHECKSUM_LENGTH; + checkLength(length); + ByteArrayOutputStream bytes = new ByteArrayOutputStream(length); + DataOutputStream output = new DataOutputStream(bytes); + output.writeInt(MAGIC); + output.writeShort(VERSION); + output.writeShort(0); + output.writeInt(length); + output.writeLong(record.getMeta().getEpoch()); + output.writeLong(record.getMeta().getBlockNumber()); + output.write(record.getMeta().getBlockHash()); + output.write(record.getMeta().getParentHash()); + output.writeLong(record.getMeta().getTimestamp()); + output.writeInt(record.getHistoryLocation().getSegmentId()); + output.writeLong(record.getHistoryLocation().getOffset()); + output.writeInt(record.getHistoryLocation().getRecordLength()); + output.writeInt(record.getHistoryLocation().getBodyChecksum()); + output.write(record.getHistoryLocation().getBodyDigest()); + output.writeInt(record.getGroups().size()); + output.writeInt(entryCount(record)); + output.write(payload); + output.flush(); + byte[] withoutChecksum = bytes.toByteArray(); + output.writeInt(crc32c(withoutChecksum)); + output.flush(); + return bytes.toByteArray(); + } catch (IOException e) { + throw new IllegalStateException("Unexpected in-memory index encoding failure", e); + } + } + + public HistoryIndexRecord decode(byte[] encoded) { + if (encoded == null || encoded.length < FIXED_HEADER_LENGTH + CHECKSUM_LENGTH) { + throw new IllegalArgumentException("History index record is truncated"); + } + checkLength(encoded.length); + int expectedChecksum = ByteBuffer.wrap(encoded, encoded.length - CHECKSUM_LENGTH, + CHECKSUM_LENGTH).getInt(); + if (expectedChecksum != crc32c(Arrays.copyOf(encoded, + encoded.length - CHECKSUM_LENGTH))) { + throw new IllegalArgumentException("History index checksum mismatch"); + } + try { + DataInputStream input = new DataInputStream(new ByteArrayInputStream(encoded)); + if (input.readInt() != MAGIC || input.readShort() != VERSION || input.readShort() != 0) { + throw new IllegalArgumentException("Unsupported history index header"); + } + int recordLength = input.readInt(); + if (recordLength != encoded.length) { + throw new IllegalArgumentException("History index length mismatch"); + } + long epoch = input.readLong(); + long blockNumber = input.readLong(); + byte[] blockHash = readExact(input, 32); + byte[] parentHash = readExact(input, 32); + long timestamp = input.readLong(); + int segmentId = input.readInt(); + long offset = input.readLong(); + int bodyLength = input.readInt(); + int bodyChecksum = input.readInt(); + byte[] bodyDigest = readExact(input, 32); + int groupCount = input.readInt(); + int entryCount = input.readInt(); + if (groupCount < 0 || entryCount < 0) { + throw new IllegalArgumentException("Invalid history index counts"); + } + List groups = decodePayload(input, groupCount, entryCount); + if (input.available() != CHECKSUM_LENGTH) { + throw new IllegalArgumentException("History index payload length mismatch"); + } + BlockSnapshotMeta meta = new BlockSnapshotMeta(epoch, blockNumber, blockHash, parentHash, + timestamp); + HistoryLocation history = new HistoryLocation(segmentId, offset, bodyLength, bodyChecksum, + bodyDigest); + return new HistoryIndexRecord(meta, history, groups); + } catch (EOFException e) { + throw new IllegalArgumentException("History index record is truncated", e); + } catch (IOException e) { + throw new IllegalArgumentException("Invalid history index record", e); + } + } + + public int recordLength(byte[] prefix) { + if (prefix.length < 12) { + throw new IllegalArgumentException("History index prefix is truncated"); + } + ByteBuffer buffer = ByteBuffer.wrap(prefix); + if (buffer.getInt() != MAGIC || buffer.getShort() != VERSION || buffer.getShort() != 0) { + throw new IllegalArgumentException("Unsupported history index header"); + } + int length = buffer.getInt(); + checkLength(length); + return length; + } + + private byte[] encodePayload(HistoryIndexRecord record) throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + DataOutputStream output = new DataOutputStream(bytes); + String previousDb = null; + for (KeyGroup group : record.getGroups()) { + if (previousDb != null && previousDb.compareTo(group.getDbName()) >= 0) { + throw new IllegalArgumentException("Index database groups are not strictly sorted"); + } + previousDb = group.getDbName(); + byte[] dbName = group.getDbName().getBytes(StandardCharsets.UTF_8); + writeUnsignedVarInt(output, dbName.length); + output.write(dbName); + List keys = group.getKeys(); + writeUnsignedVarInt(output, keys.size()); + byte[] previousKey = null; + for (byte[] key : keys) { + if (previousKey != null && BlockReverseDiff.compareUnsigned(previousKey, key) >= 0) { + throw new IllegalArgumentException("Index keys are not strictly sorted"); + } + previousKey = key; + writeUnsignedVarInt(output, key.length); + output.write(key); + } + } + output.flush(); + return bytes.toByteArray(); + } + + private List decodePayload(DataInputStream input, int groupCount, int expectedEntries) + throws IOException { + List groups = new ArrayList<>(groupCount); + int actualEntries = 0; + String previousDb = null; + for (int groupIndex = 0; groupIndex < groupCount; groupIndex++) { + String dbName = new String(readLengthPrefixed(input), StandardCharsets.UTF_8); + if (previousDb != null && previousDb.compareTo(dbName) >= 0) { + throw new IllegalArgumentException("Decoded index groups are not strictly sorted"); + } + previousDb = dbName; + int count = readUnsignedVarInt(input); + List keys = new ArrayList<>(count); + byte[] previousKey = null; + for (int keyIndex = 0; keyIndex < count; keyIndex++) { + byte[] key = readLengthPrefixed(input); + if (previousKey != null && BlockReverseDiff.compareUnsigned(previousKey, key) >= 0) { + throw new IllegalArgumentException("Decoded index keys are not strictly sorted"); + } + previousKey = key; + keys.add(key); + } + actualEntries += count; + groups.add(new KeyGroup(dbName, keys)); + } + if (actualEntries != expectedEntries) { + throw new IllegalArgumentException("History index entry count mismatch"); + } + return groups; + } + + private byte[] readLengthPrefixed(DataInputStream input) throws IOException { + int length = readUnsignedVarInt(input); + if (length > maxRecordLength) { + throw new IllegalArgumentException("History index field is too large"); + } + return readExact(input, length); + } + + private static byte[] readExact(DataInputStream input, int length) throws IOException { + byte[] bytes = new byte[length]; + input.readFully(bytes); + return bytes; + } + + private static int entryCount(HistoryIndexRecord record) { + return record.getGroups().stream().mapToInt(group -> group.getKeys().size()).sum(); + } + + private static void writeUnsignedVarInt(DataOutputStream output, int value) throws IOException { + int remaining = value; + while ((remaining & 0xffffff80) != 0) { + output.writeByte((remaining & 0x7f) | 0x80); + remaining >>>= 7; + } + output.writeByte(remaining); + } + + private static int readUnsignedVarInt(DataInputStream input) throws IOException { + int value = 0; + for (int shift = 0; shift < 35; shift += 7) { + int next = input.readUnsignedByte(); + if (shift == 28 && (next & 0xf0) != 0) { + throw new IllegalArgumentException("Unsigned varint overflows int"); + } + value |= (next & 0x7f) << shift; + if ((next & 0x80) == 0) { + return value; + } + } + throw new IllegalArgumentException("Unsigned varint is too long"); + } + + private static int crc32c(byte[] bytes) { + return Hashing.crc32c().hashBytes(bytes).asInt(); + } + + private void checkLength(int length) { + if (length < FIXED_HEADER_LENGTH + CHECKSUM_LENGTH || length > maxRecordLength) { + throw new IllegalArgumentException("History index record length is invalid: " + length); + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/HistoryIndexLocation.java b/chainbase/src/main/java/org/tron/core/db2/archive/HistoryIndexLocation.java new file mode 100644 index 00000000000..1205934368b --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/HistoryIndexLocation.java @@ -0,0 +1,37 @@ +package org.tron.core.db2.archive; + +import java.util.Arrays; +import java.util.Objects; + +/** Byte range and digest of an authoritative index delta. */ +public final class HistoryIndexLocation { + + private final long offset; + private final int recordLength; + private final byte[] digest; + + public HistoryIndexLocation(long offset, int recordLength, byte[] digest) { + if (offset < 0 || recordLength <= 0) { + throw new IllegalArgumentException("Invalid history index location"); + } + this.offset = offset; + this.recordLength = recordLength; + this.digest = Arrays.copyOf(Objects.requireNonNull(digest, "digest"), digest.length); + } + + public long getOffset() { + return offset; + } + + public int getRecordLength() { + return recordLength; + } + + public byte[] getDigest() { + return Arrays.copyOf(digest, digest.length); + } + + public long endOffset() { + return offset + recordLength; + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/HistoryIndexRecord.java b/chainbase/src/main/java/org/tron/core/db2/archive/HistoryIndexRecord.java new file mode 100644 index 00000000000..173c2d6e4ff --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/HistoryIndexRecord.java @@ -0,0 +1,66 @@ +package org.tron.core.db2.archive; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** Authoritative block-to-body location and per-key changed delta. */ +public final class HistoryIndexRecord { + + private final BlockSnapshotMeta meta; + private final HistoryLocation historyLocation; + private final List groups; + + public HistoryIndexRecord(BlockSnapshotMeta meta, HistoryLocation historyLocation, + List groups) { + this.meta = Objects.requireNonNull(meta, "meta"); + this.historyLocation = Objects.requireNonNull(historyLocation, "historyLocation"); + this.groups = Collections.unmodifiableList(new ArrayList<>(groups)); + } + + public static HistoryIndexRecord from(BlockReverseDiff diff, HistoryLocation location) { + List groups = new ArrayList<>(); + diff.getGroups().forEach(group -> { + List keys = new ArrayList<>(); + group.getEntries().forEach(entry -> keys.add(entry.getKey())); + groups.add(new KeyGroup(group.getDbName(), keys)); + }); + return new HistoryIndexRecord(diff.getMeta(), location, groups); + } + + public BlockSnapshotMeta getMeta() { + return meta; + } + + public HistoryLocation getHistoryLocation() { + return historyLocation; + } + + public List getGroups() { + return groups; + } + + public static final class KeyGroup { + private final String dbName; + private final List keys; + + public KeyGroup(String dbName, List keys) { + this.dbName = Objects.requireNonNull(dbName, "dbName"); + List copied = new ArrayList<>(); + keys.forEach(key -> copied.add(Arrays.copyOf(key, key.length))); + this.keys = Collections.unmodifiableList(copied); + } + + public String getDbName() { + return dbName; + } + + public List getKeys() { + List copied = new ArrayList<>(); + keys.forEach(key -> copied.add(Arrays.copyOf(key, key.length))); + return copied; + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/HistoryIndexStore.java b/chainbase/src/main/java/org/tron/core/db2/archive/HistoryIndexStore.java new file mode 100644 index 00000000000..e1cf97bb99c --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/HistoryIndexStore.java @@ -0,0 +1,282 @@ +package org.tron.core.db2.archive; + +import java.io.Closeable; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** Append-only authoritative {@code state_history.idx}. */ +public final class HistoryIndexStore implements Closeable { + + private final Path archiveDirectory; + private final Path indexPath; + private final HistoryIndexCodec codec; + private final FileChannel channel; + private ScanResult scanResult; + private long startupScannedRecords; + + public HistoryIndexStore(Path archiveDirectory, HistoryIndexCodec codec) throws IOException { + this(archiveDirectory, codec, null); + } + + HistoryIndexStore(Path archiveDirectory, HistoryIndexCodec codec, + ArchiveHistoryScanAnchor checkpoint) throws IOException { + this.archiveDirectory = archiveDirectory; + this.indexPath = archiveDirectory.resolve("state_history.idx"); + this.codec = codec; + Files.createDirectories(archiveDirectory); + boolean created = !Files.exists(indexPath); + channel = FileChannel.open(indexPath, StandardOpenOption.CREATE, StandardOpenOption.READ, + StandardOpenOption.WRITE); + if (created) { + HistorySegmentStore.syncDirectory(archiveDirectory); + } + scanResult = scan(checkpoint); + channel.position(channel.size()); + } + + public synchronized HistoryIndexLocation append(HistoryIndexRecord record) throws IOException { + if (scanResult.getInvalidTailOffset() != null) { + throw new IllegalStateException("History index has an invalid tail"); + } + if (scanResult.getHead() != null) { + validateContinuity(scanResult.getHead().getRecord().getMeta(), record.getMeta()); + } + byte[] encoded = codec.encode(record); + long offset = channel.size(); + channel.position(offset); + writeFully(channel, ByteBuffer.wrap(encoded)); + HistoryIndexLocation location = new HistoryIndexLocation(offset, encoded.length, + sha256(encoded)); + scanResult = new ScanResult(scanResult.getRecordCount() + 1, + new ScannedIndexRecord(record, location), null, null); + return location; + } + + public synchronized void sync() throws IOException { + channel.force(true); + HistorySegmentStore.syncDirectory(archiveDirectory); + } + + public synchronized HistoryIndexRecord read(HistoryIndexLocation location) throws IOException { + if (location.endOffset() > channel.size()) { + throw new IllegalArgumentException("History index location is outside file bounds"); + } + ByteBuffer buffer = ByteBuffer.allocate(location.getRecordLength()); + channel.position(location.getOffset()); + readFully(channel, buffer); + byte[] encoded = buffer.array(); + if (!Arrays.equals(sha256(encoded), location.getDigest())) { + throw new IllegalArgumentException("History index location digest mismatch"); + } + return codec.decode(encoded); + } + + public synchronized ScanResult getScanResult() { + return scanResult; + } + + public synchronized ScanResult rescan() throws IOException { + scanResult = scan(null); + return scanResult; + } + + public synchronized void truncateInvalidTail() throws IOException { + if (scanResult.getInvalidTailOffset() == null) { + return; + } + channel.truncate(scanResult.getInvalidTailOffset()); + channel.force(true); + scanResult = scan(null); + channel.position(channel.size()); + } + + public synchronized void truncateAfter(HistoryIndexLocation last) throws IOException { + truncateAfter(last, -1); + } + + synchronized void truncateAfter(HistoryIndexLocation last, long knownRecordCount) + throws IOException { + long length = last == null ? 0 : last.endOffset(); + channel.truncate(length); + channel.force(true); + if (last != null && knownRecordCount >= 0) { + HistoryIndexRecord record = read(last); + scanResult = new ScanResult(knownRecordCount, + new ScannedIndexRecord(record, last), null, null); + } else if (last == null && knownRecordCount == 0) { + scanResult = new ScanResult(0, null, null, null); + } else if (scanResult.getHead() == null || last == null + || last.endOffset() != scanResult.getHead().getLocation().endOffset()) { + scanResult = scan(null); + } + channel.position(channel.size()); + } + + long getStartupScannedRecords() { + return startupScannedRecords; + } + + private ScanResult scan(ArchiveHistoryScanAnchor checkpoint) throws IOException { + long recordCount = 0; + ScannedIndexRecord head = null; + Long invalidOffset = null; + String invalidReason = null; + long offset = 0; + BlockSnapshotMeta previous = null; + if (checkpoint != null) { + HistoryCommitMarker marker = checkpoint.getMarker(); + HistoryIndexLocation location = marker.getIndexLocation(); + HistoryIndexRecord record = read(location); + startupScannedRecords++; + if (!marker.getMeta().equals(record.getMeta())) { + throw new ArchivePersistenceException( + "History scan anchor does not match the history index"); + } + recordCount = checkpoint.getRecordCount(); + head = new ScannedIndexRecord(record, location); + previous = record.getMeta(); + offset = location.endOffset(); + } + while (offset < channel.size()) { + long remaining = channel.size() - offset; + if (remaining < 12) { + invalidOffset = offset; + invalidReason = "truncated index header"; + break; + } + ByteBuffer prefix = ByteBuffer.allocate(12); + channel.position(offset); + readFully(channel, prefix); + int recordLength; + try { + recordLength = codec.recordLength(prefix.array()); + } catch (IllegalArgumentException e) { + invalidOffset = offset; + invalidReason = e.getMessage(); + break; + } + if (recordLength > remaining) { + invalidOffset = offset; + invalidReason = "truncated index record"; + break; + } + ByteBuffer recordBuffer = ByteBuffer.allocate(recordLength); + channel.position(offset); + readFully(channel, recordBuffer); + byte[] encoded = recordBuffer.array(); + HistoryIndexRecord record; + try { + record = codec.decode(encoded); + validateContinuity(previous, record.getMeta()); + } catch (IllegalArgumentException e) { + invalidOffset = offset; + invalidReason = e.getMessage(); + break; + } + HistoryIndexLocation location = new HistoryIndexLocation(offset, recordLength, + sha256(encoded)); + head = new ScannedIndexRecord(record, location); + recordCount++; + startupScannedRecords++; + previous = record.getMeta(); + offset += recordLength; + } + return new ScanResult(recordCount, head, invalidOffset, invalidReason); + } + + private void validateContinuity(BlockSnapshotMeta previous, BlockSnapshotMeta current) { + if (previous == null) { + return; + } + if (current.getEpoch() != previous.getEpoch() + 1 + || current.getBlockNumber() != previous.getBlockNumber() + 1 + || !Arrays.equals(current.getParentHash(), previous.getBlockHash())) { + throw new IllegalArgumentException("non-contiguous history index metadata"); + } + } + + private static void writeFully(FileChannel channel, ByteBuffer buffer) throws IOException { + while (buffer.hasRemaining()) { + channel.write(buffer); + } + } + + private static void readFully(FileChannel channel, ByteBuffer buffer) throws IOException { + while (buffer.hasRemaining()) { + if (channel.read(buffer) < 0) { + throw new IOException("Unexpected end of history index"); + } + } + } + + private static byte[] sha256(byte[] bytes) { + try { + return MessageDigest.getInstance("SHA-256").digest(bytes); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 is unavailable", e); + } + } + + @Override + public synchronized void close() throws IOException { + channel.close(); + } + + public static final class ScannedIndexRecord { + private final HistoryIndexRecord record; + private final HistoryIndexLocation location; + + private ScannedIndexRecord(HistoryIndexRecord record, HistoryIndexLocation location) { + this.record = record; + this.location = location; + } + + public HistoryIndexRecord getRecord() { + return record; + } + + public HistoryIndexLocation getLocation() { + return location; + } + } + + public static final class ScanResult { + private final long recordCount; + private final ScannedIndexRecord head; + private final Long invalidTailOffset; + private final String invalidReason; + + private ScanResult(long recordCount, ScannedIndexRecord head, Long invalidTailOffset, + String invalidReason) { + this.recordCount = recordCount; + this.head = head; + this.invalidTailOffset = invalidTailOffset; + this.invalidReason = invalidReason; + } + + public long getRecordCount() { + return recordCount; + } + + public ScannedIndexRecord getHead() { + return head; + } + + public Long getInvalidTailOffset() { + return invalidTailOffset; + } + + public String getInvalidReason() { + return invalidReason; + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/HistoryLocation.java b/chainbase/src/main/java/org/tron/core/db2/archive/HistoryLocation.java new file mode 100644 index 00000000000..0904f6434e5 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/HistoryLocation.java @@ -0,0 +1,51 @@ +package org.tron.core.db2.archive; + +import java.util.Arrays; +import java.util.Objects; + +/** Durable location and identity of one encoded block history body. */ +public final class HistoryLocation { + + private final int segmentId; + private final long offset; + private final int recordLength; + private final int bodyChecksum; + private final byte[] bodyDigest; + + public HistoryLocation(int segmentId, long offset, int recordLength, int bodyChecksum, + byte[] bodyDigest) { + if (segmentId < 0 || offset < 0 || recordLength <= 0) { + throw new IllegalArgumentException("Invalid history location"); + } + this.segmentId = segmentId; + this.offset = offset; + this.recordLength = recordLength; + this.bodyChecksum = bodyChecksum; + this.bodyDigest = Arrays.copyOf(Objects.requireNonNull(bodyDigest, "bodyDigest"), + bodyDigest.length); + } + + public int getSegmentId() { + return segmentId; + } + + public long getOffset() { + return offset; + } + + public int getRecordLength() { + return recordLength; + } + + public int getBodyChecksum() { + return bodyChecksum; + } + + public byte[] getBodyDigest() { + return Arrays.copyOf(bodyDigest, bodyDigest.length); + } + + public long endOffset() { + return offset + recordLength; + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/HistorySegmentStore.java b/chainbase/src/main/java/org/tron/core/db2/archive/HistorySegmentStore.java new file mode 100644 index 00000000000..7c66905d561 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/HistorySegmentStore.java @@ -0,0 +1,444 @@ +package org.tron.core.db2.archive; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; + +/** Append-only, rotating history body segments with strict tail scanning. */ +public final class HistorySegmentStore implements HistoryBodyStore { + + private static final String PREFIX = "history."; + private static final String SUFFIX = ".dat"; + + private final Path directory; + private final BlockHistoryCodec codec; + private final long maxSegmentSize; + + private FileChannel appendChannel; + private int appendSegmentId; + private ScanResult scanResult; + private long startupScannedRecords; + + public HistorySegmentStore(Path archiveDirectory, BlockHistoryCodec codec, long maxSegmentSize) + throws IOException { + this(archiveDirectory, codec, maxSegmentSize, null); + } + + HistorySegmentStore(Path archiveDirectory, BlockHistoryCodec codec, long maxSegmentSize, + ArchiveHistoryScanAnchor checkpoint) throws IOException { + this(archiveDirectory.resolve("history"), codec, maxSegmentSize, checkpoint, true); + } + + static HistorySegmentStore openLibrary(Path archiveDirectory, String libraryName, + BlockHistoryCodec codec, long maxSegmentSize, ArchiveHistoryScanAnchor checkpoint) + throws IOException { + if (libraryName == null || !libraryName.matches("state-archive(?:-[a-z0-9-]+)?")) { + throw new IllegalArgumentException("Invalid state-archive history library name"); + } + return new HistorySegmentStore(archiveDirectory.resolve("history").resolve(libraryName), + codec, maxSegmentSize, checkpoint, true); + } + + private HistorySegmentStore(Path directory, BlockHistoryCodec codec, long maxSegmentSize, + ArchiveHistoryScanAnchor checkpoint, boolean ignored) throws IOException { + if (maxSegmentSize <= 0) { + throw new IllegalArgumentException("maxSegmentSize must be positive"); + } + this.directory = directory; + this.codec = codec; + this.maxSegmentSize = maxSegmentSize; + Files.createDirectories(directory); + scanResult = scan(checkpoint); + openAppendChannel(); + } + + @Override + public synchronized HistoryLocation append(BlockReverseDiff diff) throws IOException { + if (scanResult.getInvalidTail() != null) { + throw new IllegalStateException("History has an invalid tail which must be truncated first"); + } + if (scanResult.getHead() != null) { + validateContinuity(scanResult.getHead().getDiff().getMeta(), diff.getMeta()); + } + byte[] record = codec.encode(diff); + long offset = appendChannel.size(); + if (offset > 0 && offset + record.length > maxSegmentSize) { + rotate(); + offset = 0; + } + appendChannel.position(offset); + writeFully(appendChannel, ByteBuffer.wrap(record)); + HistoryLocation location = location(appendSegmentId, offset, record); + scanResult = new ScanResult(scanResult.getRecordCount() + 1, + new ScannedRecord(diff, location), null); + return location; + } + + @Override + public synchronized void sync() throws IOException { + appendChannel.force(true); + syncDirectory(directory); + } + + @Override + public synchronized BlockReverseDiff read(HistoryLocation location) throws IOException { + return codec.decode(readRecord(location)); + } + + public synchronized byte[] readRecord(HistoryLocation location) throws IOException { + Path path = segmentPath(location.getSegmentId()); + try (FileChannel channel = FileChannel.open(path, StandardOpenOption.READ)) { + if (location.endOffset() > channel.size()) { + throw new IllegalArgumentException("History location is outside segment bounds"); + } + ByteBuffer record = ByteBuffer.allocate(location.getRecordLength()); + channel.position(location.getOffset()); + readFully(channel, record); + byte[] bytes = record.array(); + if (!Arrays.equals(sha256(bytes), location.getBodyDigest())) { + throw new IllegalArgumentException("History location digest mismatch"); + } + return bytes; + } + } + + @Override + public synchronized ScanResult getScanResult() { + return scanResult; + } + + public synchronized ScanResult rescan() throws IOException { + scanResult = scan(null); + return scanResult; + } + + /** Removes only the suffix starting at the invalid record found by {@link #scan()}. */ + public synchronized void truncateInvalidTail() throws IOException { + InvalidTail tail = scanResult.getInvalidTail(); + if (tail == null) { + return; + } + closeAppendChannel(); + truncateFrom(tail.getSegmentId(), tail.getOffset()); + scanResult = scan(null); + openAppendChannel(); + } + + /** Truncates all records after {@code last}; null means remove every body record. */ + @Override + public synchronized void truncateAfter(HistoryLocation last) throws IOException { + truncateAfter(last, -1); + } + + @Override + public synchronized void truncateAfter(HistoryLocation last, long knownRecordCount) + throws IOException { + closeAppendChannel(); + if (last == null) { + for (Path segment : listSegments()) { + Files.deleteIfExists(segment); + } + } else { + truncateFrom(last.getSegmentId(), last.endOffset()); + } + syncDirectory(directory); + if (last != null && knownRecordCount >= 0) { + BlockReverseDiff diff = read(last); + scanResult = new ScanResult(knownRecordCount, new ScannedRecord(diff, last), null); + } else if (last == null && knownRecordCount == 0) { + scanResult = new ScanResult(0, null, null); + } else if (scanResult.getHead() == null || last == null + || last.endOffset() != scanResult.getHead().getLocation().endOffset() + || last.getSegmentId() != scanResult.getHead().getLocation().getSegmentId()) { + scanResult = scan(null); + } + openAppendChannel(); + } + + @Override + public long getStartupScannedRecords() { + return startupScannedRecords; + } + + private ScanResult scan(ArchiveHistoryScanAnchor checkpoint) throws IOException { + long recordCount = 0; + ScannedRecord head = null; + InvalidTail invalidTail = null; + BlockSnapshotMeta previous = null; + List segments = listSegments(); + int startSegmentId = segments.isEmpty() ? 0 : parseSegmentId(segments.get(0)); + long startOffset = 0; + if (checkpoint != null) { + HistoryCommitMarker marker = checkpoint.getMarker(); + BlockReverseDiff diff = read(marker.getHistoryLocation()); + startupScannedRecords++; + if (!marker.getMeta().equals(diff.getMeta())) { + throw new ArchivePersistenceException( + "History scan anchor does not match the history body"); + } + recordCount = checkpoint.getRecordCount(); + head = new ScannedRecord(diff, marker.getHistoryLocation()); + previous = diff.getMeta(); + startSegmentId = marker.getHistoryLocation().getSegmentId(); + startOffset = marker.getHistoryLocation().endOffset(); + } + int expectedSegmentId = startSegmentId; + for (Path segment : segments) { + int segmentId = parseSegmentId(segment); + if (segmentId < startSegmentId) { + continue; + } + if (segmentId != expectedSegmentId) { + return new ScanResult(recordCount, head, new InvalidTail(segmentId, 0, + "non-contiguous segment id")); + } + expectedSegmentId++; + try (FileChannel channel = FileChannel.open(segment, StandardOpenOption.READ)) { + long offset = segmentId == startSegmentId ? startOffset : 0; + while (offset < channel.size()) { + long remaining = channel.size() - offset; + if (remaining < BlockHistoryCodec.HEADER_LENGTH) { + invalidTail = new InvalidTail(segmentId, offset, "truncated record header"); + break; + } + ByteBuffer header = ByteBuffer.allocate(BlockHistoryCodec.HEADER_LENGTH); + channel.position(offset); + readFully(channel, header); + int recordLength; + try { + recordLength = codec.recordLength(header.array()); + } catch (IllegalArgumentException e) { + invalidTail = new InvalidTail(segmentId, offset, e.getMessage()); + break; + } + if (recordLength > remaining) { + invalidTail = new InvalidTail(segmentId, offset, "truncated record body"); + break; + } + ByteBuffer recordBuffer = ByteBuffer.allocate(recordLength); + channel.position(offset); + readFully(channel, recordBuffer); + byte[] record = recordBuffer.array(); + BlockReverseDiff diff; + try { + diff = codec.decode(record); + validateContinuity(previous, diff.getMeta()); + } catch (IllegalArgumentException e) { + invalidTail = new InvalidTail(segmentId, offset, e.getMessage()); + break; + } + HistoryLocation location = location(segmentId, offset, record); + head = new ScannedRecord(diff, location); + recordCount++; + startupScannedRecords++; + previous = diff.getMeta(); + offset += recordLength; + } + } + if (invalidTail != null) { + break; + } + } + return new ScanResult(recordCount, head, invalidTail); + } + + private void validateContinuity(BlockSnapshotMeta previous, BlockSnapshotMeta current) { + if (previous == null) { + return; + } + if (current.getEpoch() != previous.getEpoch() + 1 + || current.getBlockNumber() != previous.getBlockNumber() + 1 + || !Arrays.equals(current.getParentHash(), previous.getBlockHash())) { + throw new IllegalArgumentException("non-contiguous history block metadata"); + } + } + + private void openAppendChannel() throws IOException { + List segments = listSegments(); + appendSegmentId = segments.isEmpty() ? 0 : parseSegmentId(segments.get(segments.size() - 1)); + Path path = segmentPath(appendSegmentId); + boolean created = !Files.exists(path); + appendChannel = FileChannel.open(path, StandardOpenOption.CREATE, StandardOpenOption.READ, + StandardOpenOption.WRITE); + appendChannel.position(appendChannel.size()); + if (created) { + syncDirectory(directory); + } + } + + private void rotate() throws IOException { + appendChannel.force(true); + closeAppendChannel(); + appendSegmentId++; + Path next = segmentPath(appendSegmentId); + appendChannel = FileChannel.open(next, StandardOpenOption.CREATE_NEW, StandardOpenOption.READ, + StandardOpenOption.WRITE); + syncDirectory(directory); + } + + private void truncateFrom(int segmentId, long offset) throws IOException { + for (Path segment : listSegments()) { + int candidate = parseSegmentId(segment); + if (candidate > segmentId) { + Files.deleteIfExists(segment); + } else if (candidate == segmentId) { + try (FileChannel channel = FileChannel.open(segment, StandardOpenOption.WRITE)) { + channel.truncate(offset); + channel.force(true); + } + } + } + syncDirectory(directory); + } + + private List listSegments() throws IOException { + List segments = new ArrayList<>(); + try (DirectoryStream stream = Files.newDirectoryStream(directory, + PREFIX + "*" + SUFFIX)) { + for (Path path : stream) { + parseSegmentId(path); + segments.add(path); + } + } + segments.sort(Comparator.comparingInt(HistorySegmentStore::parseSegmentId)); + return segments; + } + + private Path segmentPath(int segmentId) { + return directory.resolve(String.format("%s%06d%s", PREFIX, segmentId, SUFFIX)); + } + + private static int parseSegmentId(Path path) { + String name = path.getFileName().toString(); + if (!name.startsWith(PREFIX) || !name.endsWith(SUFFIX)) { + throw new IllegalArgumentException("Invalid history segment name: " + name); + } + String id = name.substring(PREFIX.length(), name.length() - SUFFIX.length()); + try { + return Integer.parseInt(id); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Invalid history segment name: " + name, e); + } + } + + private static HistoryLocation location(int segmentId, long offset, byte[] record) { + int checksum = ByteBuffer.wrap(record, record.length - Integer.BYTES, Integer.BYTES).getInt(); + return new HistoryLocation(segmentId, offset, record.length, checksum, sha256(record)); + } + + private static byte[] sha256(byte[] bytes) { + try { + return MessageDigest.getInstance("SHA-256").digest(bytes); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 is unavailable", e); + } + } + + private static void writeFully(FileChannel channel, ByteBuffer buffer) throws IOException { + while (buffer.hasRemaining()) { + channel.write(buffer); + } + } + + private static void readFully(FileChannel channel, ByteBuffer buffer) throws IOException { + while (buffer.hasRemaining()) { + if (channel.read(buffer) < 0) { + throw new IOException("Unexpected end of history segment"); + } + } + } + + static void syncDirectory(Path directory) throws IOException { + try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) { + channel.force(true); + } + } + + private void closeAppendChannel() throws IOException { + if (appendChannel != null) { + appendChannel.close(); + appendChannel = null; + } + } + + @Override + public synchronized void close() throws IOException { + closeAppendChannel(); + } + + public static final class ScannedRecord { + private final BlockReverseDiff diff; + private final HistoryLocation location; + + private ScannedRecord(BlockReverseDiff diff, HistoryLocation location) { + this.diff = diff; + this.location = location; + } + + public BlockReverseDiff getDiff() { + return diff; + } + + public HistoryLocation getLocation() { + return location; + } + } + + public static final class InvalidTail { + private final int segmentId; + private final long offset; + private final String reason; + + private InvalidTail(int segmentId, long offset, String reason) { + this.segmentId = segmentId; + this.offset = offset; + this.reason = reason; + } + + public int getSegmentId() { + return segmentId; + } + + public long getOffset() { + return offset; + } + + public String getReason() { + return reason; + } + } + + public static final class ScanResult { + private final long recordCount; + private final ScannedRecord head; + private final InvalidTail invalidTail; + + private ScanResult(long recordCount, ScannedRecord head, InvalidTail invalidTail) { + this.recordCount = recordCount; + this.head = head; + this.invalidTail = invalidTail; + } + + public long getRecordCount() { + return recordCount; + } + + public ScannedRecord getHead() { + return head; + } + + public InvalidTail getInvalidTail() { + return invalidTail; + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/LatestStateGenerationAdapter.java b/chainbase/src/main/java/org/tron/core/db2/archive/LatestStateGenerationAdapter.java new file mode 100644 index 00000000000..9503306fe7e --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/LatestStateGenerationAdapter.java @@ -0,0 +1,430 @@ +package org.tron.core.db2.archive; + +import java.io.Closeable; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.TreeMap; +import org.tron.common.storage.leveldb.LevelDbDataSourceImpl; +import org.tron.common.storage.rocksdb.RocksDbDataSourceImpl; +import org.tron.core.db.common.DbSourceInter; +import org.tron.core.db2.archive.ArchiveReadSnapshot.PinnedLatestState; +import org.tron.core.db2.archive.ArchiveReadSnapshot.PinnedLatestStateFactory; +import org.tron.core.db2.common.DB; + +/** Fail-closed adapter for one exact latest-state engine generation. */ +public final class LatestStateGenerationAdapter implements PinnedLatestStateFactory { + + private final List participants; + private final Map stores; + private final Map sourceIdentities; + private final byte[] sourceIdentityDigest; + + public LatestStateGenerationAdapter(List participants, + Map stores) { + this.participants = validateParticipants(participants); + TreeMap sorted = new TreeMap<>( + Objects.requireNonNull(stores, "stores")); + if (!new ArrayList<>(sorted.keySet()).equals(this.participants) + || sorted.containsValue(null)) { + throw new IllegalArgumentException("Latest-state snapshot Store set mismatch"); + } + TreeMap identities = new TreeMap<>(); + for (Map.Entry entry : sorted.entrySet()) { + SnapshotCapableStore store = entry.getValue(); + if (!entry.getKey().equals(store.getDbName()) + || store.getSourceIdentity() == null || store.getSourceIdentity().isEmpty()) { + throw new IllegalArgumentException("Latest-state snapshot source identity is invalid"); + } + identities.put(entry.getKey(), store.getSourceIdentity()); + } + this.stores = Collections.unmodifiableMap(sorted); + this.sourceIdentities = Collections.unmodifiableMap(identities); + this.sourceIdentityDigest = sourceIdentityDigest(identities); + } + + /** + * Converts the current DB abstraction only when every Store explicitly implements the stable + * snapshot lifecycle capability. Ordinary {@code get()} is never accepted as a substitute. + */ + public static LatestStateGenerationAdapter fromDatabases(List participants, + Map> databases) throws ArchivePersistenceException { + TreeMap capable = new TreeMap<>(); + for (Map.Entry> entry : + Objects.requireNonNull(databases, "databases").entrySet()) { + if (!(entry.getValue() instanceof SnapshotCapableStore)) { + throw new ArchivePersistenceException( + "DB does not expose a stable snapshot lifecycle: " + entry.getKey()); + } + capable.put(entry.getKey(), (SnapshotCapableStore) entry.getValue()); + } + return new LatestStateGenerationAdapter(participants, capable); + } + + /** Adapts one out-of-registry native Store to the same stable snapshot contract. */ + public static SnapshotCapableStore fromDataSource(String dbName, + DbSourceInter source) throws ArchivePersistenceException { + Objects.requireNonNull(dbName, "dbName"); + Objects.requireNonNull(source, "source"); + if (!dbName.equals(source.getDBName())) { + throw new ArchivePersistenceException("Supplemental latest Store name mismatch: " + dbName); + } + if (source instanceof LevelDbDataSourceImpl) { + LevelDbDataSourceImpl level = (LevelDbDataSourceImpl) source; + return capable(dbName, level.getSnapshotSourceIdentity(), (blockNumber, blockHash) -> { + LevelDbDataSourceImpl.PinnedSnapshot pinned = level.pinSnapshot(); + return snapshot(dbName, pinned.getSourceIdentity(), blockNumber, blockHash, + pinned::get, pinned::range, pinned::close); + }); + } + if (source instanceof RocksDbDataSourceImpl) { + RocksDbDataSourceImpl rocks = (RocksDbDataSourceImpl) source; + return capable(dbName, rocks.getSnapshotSourceIdentity(), (blockNumber, blockHash) -> { + RocksDbDataSourceImpl.PinnedSnapshot pinned = rocks.pinSnapshot(); + return snapshot(dbName, pinned.getSourceIdentity(), blockNumber, blockHash, + pinned::get, pinned::range, pinned::close); + }); + } + throw new ArchivePersistenceException( + "Supplemental latest Store lacks a supported native snapshot engine: " + dbName); + } + + private static SnapshotCapableStore capable(String dbName, String sourceIdentity, + SnapshotFactory factory) { + return new SnapshotCapableStore() { + @Override + public String getDbName() { + return dbName; + } + + @Override + public String getSourceIdentity() { + return sourceIdentity; + } + + @Override + public StoreSnapshot pin(long blockNumber, byte[] blockHash) throws IOException { + return factory.pin(blockNumber, blockHash); + } + }; + } + + private static StoreSnapshot snapshot(String dbName, String sourceIdentity, long blockNumber, + byte[] blockHash, PointReader reader, RangeReader rangeReader, CloseableAction close) { + byte[] expectedHash = Arrays.copyOf(blockHash, blockHash.length); + return new StoreSnapshot() { + @Override + public String getDbName() { + return dbName; + } + + @Override + public String getSourceIdentity() { + return sourceIdentity; + } + + @Override + public long getBlockNumber() { + return blockNumber; + } + + @Override + public byte[] getBlockHash() { + return Arrays.copyOf(expectedHash, expectedHash.length); + } + + @Override + public byte[] get(byte[] physicalRawKey) { + return reader.get(physicalRawKey); + } + + @Override + public List> range(byte[] lowerInclusive, + byte[] upperExclusive, int maxEntries) { + return rangeReader.range(lowerInclusive, upperExclusive, maxEntries); + } + + @Override + public void close() throws IOException { + close.close(); + } + }; + } + + @Override + public PinnedLatestState pin(PersistentServingKeyIndexGeneration serving) throws IOException { + Objects.requireNonNull(serving, "serving"); + return pin(serving.getGenerationId(), serving.getIndexedThrough(), serving.getHeadHash(), + serving.getParticipatingDatabases()); + } + + /** Pins the current native engines for a common-checkpoint request identity. */ + public PinnedLatestState pin(long blockNumber, byte[] blockHash) throws IOException { + String generationId = "common-checkpoint-" + blockNumber + '-' + + com.google.common.io.BaseEncoding.base16().lowerCase().encode(blockHash); + return pin(generationId, blockNumber, blockHash, participants); + } + + PinnedLatestState pin(String generationId, long blockNumber, byte[] blockHash, + List expectedParticipants) throws IOException { + if (generationId == null || generationId.isEmpty() || blockNumber < 0 + || blockHash == null || blockHash.length != 32 + || !participants.equals(expectedParticipants)) { + throw new ArchivePersistenceException("Latest-state generation identity mismatch"); + } + TreeMap acquired = new TreeMap<>(); + try { + for (Map.Entry entry : stores.entrySet()) { + SnapshotCapableStore store = entry.getValue(); + String expectedSource = sourceIdentities.get(entry.getKey()); + if (!expectedSource.equals(store.getSourceIdentity())) { + throw new ArchivePersistenceException( + "Latest-state Store source was replaced before pin: " + entry.getKey()); + } + StoreSnapshot snapshot = Objects.requireNonNull( + store.pin(blockNumber, blockHash), "pinned Store snapshot"); + acquired.put(entry.getKey(), snapshot); + validateSnapshot(entry.getKey(), expectedSource, blockNumber, blockHash, snapshot); + } + return new PinnedGeneration(generationId, blockNumber, blockHash, acquired, + sourceIdentityDigest); + } catch (IOException | RuntimeException failure) { + closeAll(acquired, failure); + throw failure; + } + } + + public byte[] getSourceIdentityDigest() { + return Arrays.copyOf(sourceIdentityDigest, sourceIdentityDigest.length); + } + + List participantsForCoordinator() { + return participants; + } + + Map storesForCoordinator() { + return stores; + } + + private static void validateSnapshot(String dbName, String sourceIdentity, long blockNumber, + byte[] blockHash, StoreSnapshot snapshot) throws ArchivePersistenceException { + if (!dbName.equals(snapshot.getDbName()) + || !sourceIdentity.equals(snapshot.getSourceIdentity()) + || snapshot.getBlockNumber() != blockNumber + || !Arrays.equals(blockHash, snapshot.getBlockHash())) { + throw new ArchivePersistenceException( + "Pinned latest-state Store snapshot identity mismatch: " + dbName); + } + } + + private static List validateParticipants(List participants) { + List copy = new ArrayList<>(Objects.requireNonNull(participants, "participants")); + if (copy.isEmpty()) { + throw new IllegalArgumentException("Latest-state participant set must not be empty"); + } + String previous = null; + for (String participant : copy) { + if (participant == null || participant.isEmpty() + || previous != null && previous.compareTo(participant) >= 0) { + throw new IllegalArgumentException( + "Latest-state participants must be non-empty, unique, and sorted"); + } + previous = participant; + } + return Collections.unmodifiableList(copy); + } + + private static byte[] sourceIdentityDigest(Map identities) { + MessageDigest digest; + try { + digest = MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException impossible) { + throw new IllegalStateException("SHA-256 is unavailable", impossible); + } + digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(identities.size()).array()); + for (Map.Entry entry : identities.entrySet()) { + update(digest, entry.getKey()); + update(digest, entry.getValue()); + } + return digest.digest(); + } + + private static void update(MessageDigest digest, String value) { + byte[] encoded = value.getBytes(StandardCharsets.UTF_8); + digest.update(ByteBuffer.allocate(Integer.BYTES).putInt(encoded.length).array()); + digest.update(encoded); + } + + private static void closeAll(Map snapshots, Exception failure) { + List reverse = new ArrayList<>(snapshots.values()); + Collections.reverse(reverse); + for (StoreSnapshot snapshot : reverse) { + try { + snapshot.close(); + } catch (IOException closeFailure) { + failure.addSuppressed(closeFailure); + } + } + } + + /** Minimum capability that RocksDB/LevelDB wrappers must implement before production pinning. */ + public interface SnapshotCapableStore { + String getDbName(); + + String getSourceIdentity(); + + StoreSnapshot pin(long blockNumber, byte[] blockHash) throws IOException; + } + + /** Stable point-read view whose lifetime prevents the underlying engine from being replaced. */ + public interface StoreSnapshot extends Closeable { + String getDbName(); + + String getSourceIdentity(); + + long getBlockNumber(); + + byte[] getBlockHash(); + + byte[] get(byte[] physicalRawKey) throws IOException; + + default List> range(byte[] lowerInclusive, byte[] upperExclusive, + int maxEntries) throws IOException { + throw new UnsupportedOperationException("Pinned Store range is unsupported"); + } + } + + @FunctionalInterface + private interface SnapshotFactory { + StoreSnapshot pin(long blockNumber, byte[] blockHash) throws IOException; + } + + @FunctionalInterface + private interface PointReader { + byte[] get(byte[] key); + } + + @FunctionalInterface + private interface RangeReader { + List> range(byte[] lowerInclusive, byte[] upperExclusive, + int maxEntries); + } + + @FunctionalInterface + private interface CloseableAction { + void close() throws IOException; + } + + private static final class PinnedGeneration implements PinnedLatestState { + private final String generationId; + private final long blockNumber; + private final byte[] blockHash; + private final Map snapshots; + private final byte[] sourceIdentityDigest; + private boolean closed; + + private PinnedGeneration(String generationId, long blockNumber, byte[] blockHash, + Map snapshots, byte[] sourceIdentityDigest) { + this.generationId = generationId; + this.blockNumber = blockNumber; + this.blockHash = Arrays.copyOf(blockHash, blockHash.length); + this.snapshots = Collections.unmodifiableMap(new TreeMap<>(snapshots)); + this.sourceIdentityDigest = Arrays.copyOf(sourceIdentityDigest, + sourceIdentityDigest.length); + } + + @Override + public long getBlockNumber() { + return blockNumber; + } + + @Override + public byte[] getBlockHash() { + return Arrays.copyOf(blockHash, blockHash.length); + } + + @Override + public byte[] getSourceIdentityDigest() { + return Arrays.copyOf(sourceIdentityDigest, sourceIdentityDigest.length); + } + + @Override + public synchronized OldValue get(String dbName, byte[] physicalRawKey) throws IOException { + ensureOpen(); + StoreSnapshot snapshot = snapshots.get(dbName); + if (snapshot == null) { + throw new ArchivePersistenceException( + "Database is outside pinned latest generation: " + dbName); + } + return OldValue.fromNullable(snapshot.get(physicalRawKey)); + } + + @Override + public synchronized List range(String dbName, + byte[] lowerInclusive, byte[] upperExclusive, int maxEntries) throws IOException { + ensureOpen(); + if (!"account-asset".equals(dbName) || maxEntries <= 0 + || maxEntries == Integer.MAX_VALUE) { + throw new UnsupportedOperationException( + "Latest-generation range is limited to bounded account-asset queries"); + } + StoreSnapshot snapshot = snapshots.get(dbName); + if (snapshot == null) { + throw new ArchivePersistenceException( + "Database is outside pinned latest generation: " + dbName); + } + int scanLimit = maxEntries == Integer.MAX_VALUE ? Integer.MAX_VALUE : maxEntries + 1; + List> raw = snapshot.range( + lowerInclusive, upperExclusive, scanLimit); + if (raw.size() > maxEntries) { + throw new ArchiveQueryLimitExceededException( + "latest AccountAsset candidate-key budget exceeded"); + } + List result = new ArrayList<>(raw.size()); + for (Map.Entry entry : raw) { + result.add(new HistoricalRangeOverlay.Entry(entry.getKey(), entry.getValue())); + } + return Collections.unmodifiableList(result); + } + + @Override + public synchronized void close() throws IOException { + if (closed) { + return; + } + closed = true; + IOException failure = null; + List reverse = new ArrayList<>(snapshots.values()); + Collections.reverse(reverse); + for (StoreSnapshot snapshot : reverse) { + try { + snapshot.close(); + } catch (IOException closeFailure) { + if (failure == null) { + failure = closeFailure; + } else { + failure.addSuppressed(closeFailure); + } + } + } + if (failure != null) { + throw failure; + } + } + + private void ensureOpen() { + if (closed) { + throw new IllegalStateException( + "Pinned latest-state generation is closed: " + generationId); + } + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/LatestStateGenerationCoordinator.java b/chainbase/src/main/java/org/tron/core/db2/archive/LatestStateGenerationCoordinator.java new file mode 100644 index 00000000000..c9a53db3acb --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/LatestStateGenerationCoordinator.java @@ -0,0 +1,376 @@ +package org.tron.core.db2.archive; + +import java.io.Closeable; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.TreeMap; +import org.tron.core.db2.archive.ArchiveProgressEnvelope.Kind; +import org.tron.core.db2.archive.ArchiveReadSnapshot.PinnedLatestState; +import org.tron.core.db2.archive.ArchiveReadSnapshot.PinnedLatestStateFactory; +import org.tron.core.db2.archive.LatestStateGenerationAdapter.SnapshotCapableStore; + +/** Two-phase publisher for a latest-state generation acquired under one global state barrier. */ +public final class LatestStateGenerationCoordinator + implements PinnedLatestStateFactory, Closeable { + + private final List participants; + private final Map stores; + private final ArchiveStateBarrier barrier; + private final AuthorityReader authorityReader; + private final List retired = new ArrayList<>(); + private PublishedGeneration current; + private boolean closed; + + public LatestStateGenerationCoordinator(List participants, + Map stores, ArchiveStateBarrier barrier, + AuthorityReader authorityReader) { + this.participants = validateParticipants(participants); + TreeMap sorted = new TreeMap<>( + Objects.requireNonNull(stores, "stores")); + if (!new ArrayList<>(sorted.keySet()).equals(this.participants) + || sorted.containsValue(null)) { + throw new IllegalArgumentException("Latest-state coordinator Store set mismatch"); + } + this.stores = Collections.unmodifiableMap(sorted); + this.barrier = Objects.requireNonNull(barrier, "barrier"); + this.authorityReader = Objects.requireNonNull(authorityReader, "authorityReader"); + } + + /** Acquires a complete immutable candidate while the caller-supplied global barrier is held. */ + public synchronized Candidate acquire(String generationId) throws IOException { + ensureOpen(); + if (generationId == null || generationId.isEmpty()) { + throw new IllegalArgumentException("Latest-state generation id must not be empty"); + } + Acquisition acquisition = new Acquisition(); + try { + barrier.run(() -> { + ArchiveProgressEnvelope before = readAuthority(); + LatestStateGenerationAdapter adapter = new LatestStateGenerationAdapter(participants, + stores); + acquisition.pinned = adapter.pin(generationId, before.getEpoch(), before.getBlockHash(), + participants); + ArchiveProgressEnvelope after = readAuthority(); + if (!sameAuthority(before, after)) { + throw new ArchivePersistenceException( + "Reader-visible authority drifted while latest generation was pinned"); + } + acquisition.authority = before; + acquisition.sourceIdentityDigest = adapter.getSourceIdentityDigest(); + }); + } catch (IOException | RuntimeException failure) { + if (acquisition.pinned != null) { + try { + acquisition.pinned.close(); + } catch (IOException closeFailure) { + failure.addSuppressed(closeFailure); + } + } + throw failure; + } + return new Candidate(this, generationId, acquisition.authority, + acquisition.sourceIdentityDigest, acquisition.pinned); + } + + /** CAS-publishes a fully acquired candidate after its digest has been bound into serving data. */ + public synchronized boolean publish(String expectedGenerationId, Candidate candidate, + PersistentServingKeyIndexGeneration serving) throws IOException { + ensureOpen(); + Objects.requireNonNull(candidate, "candidate"); + Objects.requireNonNull(serving, "serving"); + String currentId = current == null ? null : current.generationId; + if (!Objects.equals(expectedGenerationId, currentId)) { + return false; + } + candidate.validateOwner(this); + candidate.validateServing(serving); + PublishedGeneration replacement = candidate.transfer(); + PublishedGeneration previous = current; + current = replacement; + if (previous != null) { + previous.retired = true; + retired.add(previous); + reap(previous); + } + return true; + } + + @Override + public synchronized PinnedLatestState pin(PersistentServingKeyIndexGeneration serving) + throws IOException { + Objects.requireNonNull(serving, "serving"); + return pin(serving.getGenerationId(), serving.getIndexedThrough(), serving.getHeadHash(), + serving.getLatestSourceIdentityDigest(), serving.getParticipatingDatabases()); + } + + synchronized PinnedLatestState pin(String generationId, long blockNumber, byte[] blockHash, + byte[] sourceIdentityDigest, List expectedParticipants) throws IOException { + ensureOpen(); + if (current == null || !current.generationId.equals(generationId) + || current.blockNumber != blockNumber || !Arrays.equals(current.blockHash, blockHash) + || !Arrays.equals(current.sourceIdentityDigest, sourceIdentityDigest) + || !participants.equals(expectedParticipants)) { + throw new ArchivePersistenceException("Published latest generation identity mismatch"); + } + current.references++; + return new RequestPin(this, current); + } + + public synchronized String getCurrentGenerationId() { + return current == null ? null : current.generationId; + } + + synchronized int getReferenceCount(String generationId) { + if (current != null && current.generationId.equals(generationId)) { + return current.references; + } + return 0; + } + + @Override + public synchronized void close() throws IOException { + if (closed) { + return; + } + if ((current != null && current.references != 0) + || retired.stream().anyMatch(generation -> generation.references != 0)) { + throw new IOException("Cannot close latest generation coordinator with pinned readers"); + } + closed = true; + if (current != null) { + current.root.close(); + current = null; + } + for (PublishedGeneration generation : new ArrayList<>(retired)) { + if (!generation.closed) { + generation.closed = true; + generation.root.close(); + } + } + retired.clear(); + } + + private ArchiveProgressEnvelope readAuthority() throws IOException { + ArchiveProgressEnvelope authority = Objects.requireNonNull(authorityReader.read(), + "reader-visible authority"); + if (authority.getKind() != Kind.READER_VISIBLE + || !ArchiveParticipantDescriptor.scopeIdentity(participants) + .equals(authority.getScopeIdentity()) + || !participants.equals(authority.getParticipants())) { + throw new ArchivePersistenceException("Invalid reader-visible generation authority"); + } + return authority; + } + + private synchronized void release(PublishedGeneration generation) throws IOException { + if (generation.references <= 0) { + throw new IllegalStateException("Latest generation reference count underflow"); + } + generation.references--; + reap(generation); + } + + private void reap(PublishedGeneration generation) throws IOException { + if (generation.retired && generation.references == 0 && !generation.closed) { + generation.closed = true; + generation.root.close(); + retired.remove(generation); + } + } + + private void ensureOpen() { + if (closed) { + throw new IllegalStateException("Latest generation coordinator is closed"); + } + } + + private static boolean sameAuthority(ArchiveProgressEnvelope left, + ArchiveProgressEnvelope right) { + return left.getKind() == right.getKind() && left.getEpoch() == right.getEpoch() + && Arrays.equals(left.getBlockHash(), right.getBlockHash()) + && Arrays.equals(left.getBatchId(), right.getBatchId()) + && Arrays.equals(left.getPayloadDigest(), right.getPayloadDigest()) + && left.getScopeIdentity().equals(right.getScopeIdentity()) + && left.getParticipants().equals(right.getParticipants()); + } + + private static List validateParticipants(List participants) { + List copy = new ArrayList<>(Objects.requireNonNull(participants, "participants")); + if (copy.isEmpty()) { + throw new IllegalArgumentException("Latest-state participant set must not be empty"); + } + String previous = null; + for (String participant : copy) { + if (participant == null || participant.isEmpty() + || previous != null && previous.compareTo(participant) >= 0) { + throw new IllegalArgumentException( + "Latest-state participants must be non-empty, unique, and sorted"); + } + previous = participant; + } + return Collections.unmodifiableList(copy); + } + + @FunctionalInterface + public interface AuthorityReader { + ArchiveProgressEnvelope read() throws IOException; + } + + private static final class Acquisition { + private ArchiveProgressEnvelope authority; + private byte[] sourceIdentityDigest; + private PinnedLatestState pinned; + } + + /** Acquired native snapshots owned by the caller until successful publication. */ + public static final class Candidate implements Closeable { + private final LatestStateGenerationCoordinator owner; + private final String generationId; + private final ArchiveProgressEnvelope authority; + private final byte[] sourceIdentityDigest; + private PinnedLatestState root; + private boolean published; + + private Candidate(LatestStateGenerationCoordinator owner, String generationId, + ArchiveProgressEnvelope authority, byte[] sourceIdentityDigest, PinnedLatestState root) { + this.owner = owner; + this.generationId = generationId; + this.authority = authority; + this.sourceIdentityDigest = Arrays.copyOf(sourceIdentityDigest, + sourceIdentityDigest.length); + this.root = root; + } + + public long getBlockNumber() { + return authority.getEpoch(); + } + + public String getGenerationId() { + return generationId; + } + + public byte[] getBlockHash() { + return authority.getBlockHash(); + } + + public byte[] getSourceIdentityDigest() { + return Arrays.copyOf(sourceIdentityDigest, sourceIdentityDigest.length); + } + + @Override + public synchronized void close() throws IOException { + if (!published && root != null) { + PinnedLatestState releasing = root; + root = null; + releasing.close(); + } + } + + private synchronized void validateOwner(LatestStateGenerationCoordinator expected) { + if (owner != expected || published || root == null) { + throw new IllegalStateException("Latest generation candidate is not publishable"); + } + } + + private synchronized void validateServing(PersistentServingKeyIndexGeneration serving) { + if (!generationId.equals(serving.getGenerationId()) + || authority.getEpoch() != serving.getIndexedThrough() + || !Arrays.equals(authority.getBlockHash(), serving.getHeadHash()) + || !Arrays.equals(sourceIdentityDigest, serving.getLatestSourceIdentityDigest()) + || !authority.getScopeIdentity().equals(serving.getScopeIdentity()) + || !authority.getParticipants().equals(serving.getParticipatingDatabases())) { + throw new IllegalArgumentException( + "Serving generation does not match latest-state candidate"); + } + } + + private synchronized PublishedGeneration transfer() { + published = true; + PublishedGeneration result = new PublishedGeneration(generationId, authority.getEpoch(), + authority.getBlockHash(), sourceIdentityDigest, root); + root = null; + return result; + } + } + + private static final class PublishedGeneration { + private final String generationId; + private final long blockNumber; + private final byte[] blockHash; + private final byte[] sourceIdentityDigest; + private final PinnedLatestState root; + private int references; + private boolean retired; + private boolean closed; + + private PublishedGeneration(String generationId, long blockNumber, byte[] blockHash, + byte[] sourceIdentityDigest, PinnedLatestState root) { + this.generationId = generationId; + this.blockNumber = blockNumber; + this.blockHash = Arrays.copyOf(blockHash, blockHash.length); + this.sourceIdentityDigest = Arrays.copyOf(sourceIdentityDigest, + sourceIdentityDigest.length); + this.root = root; + } + } + + private static final class RequestPin implements PinnedLatestState { + private final LatestStateGenerationCoordinator owner; + private final PublishedGeneration generation; + private boolean closed; + + private RequestPin(LatestStateGenerationCoordinator owner, + PublishedGeneration generation) { + this.owner = owner; + this.generation = generation; + } + + @Override + public long getBlockNumber() { + return generation.blockNumber; + } + + @Override + public byte[] getBlockHash() { + return Arrays.copyOf(generation.blockHash, generation.blockHash.length); + } + + @Override + public byte[] getSourceIdentityDigest() { + return Arrays.copyOf(generation.sourceIdentityDigest, + generation.sourceIdentityDigest.length); + } + + @Override + public synchronized OldValue get(String dbName, byte[] physicalRawKey) throws IOException { + ensureOpen(); + return generation.root.get(dbName, physicalRawKey); + } + + @Override + public List range(String dbName, byte[] lowerInclusive, + byte[] upperExclusive, int maxEntries) throws IOException { + ensureOpen(); + return generation.root.range(dbName, lowerInclusive, upperExclusive, maxEntries); + } + + @Override + public synchronized void close() throws IOException { + if (!closed) { + closed = true; + owner.release(generation); + } + } + + private void ensureOpen() { + if (closed) { + throw new IllegalStateException("Pinned latest generation is closed"); + } + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/LatestStateGenerationCoordinatorFactory.java b/chainbase/src/main/java/org/tron/core/db2/archive/LatestStateGenerationCoordinatorFactory.java new file mode 100644 index 00000000000..e477110abac --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/LatestStateGenerationCoordinatorFactory.java @@ -0,0 +1,105 @@ +package org.tron.core.db2.archive; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.TreeMap; +import java.util.TreeSet; +import org.tron.core.db2.archive.LatestStateGenerationAdapter.SnapshotCapableStore; +import org.tron.core.db2.common.DB; +import org.tron.core.db2.core.Chainbase; +import org.tron.core.db2.core.Snapshot; +import org.tron.core.db2.core.SnapshotManager; +import org.tron.core.db2.core.SnapshotRoot; + +/** Builds a latest-state coordinator from one frozen SnapshotManager Store registry. */ +public final class LatestStateGenerationCoordinatorFactory { + + private LatestStateGenerationCoordinatorFactory() { + } + + public static LatestStateGenerationCoordinator create(SnapshotManager manager, + Path readerVisiblePath) throws ArchivePersistenceException { + Objects.requireNonNull(readerVisiblePath, "readerVisiblePath"); + ArchiveProgressFile readerVisible = new ArchiveProgressFile(readerVisiblePath, + new ArchiveProgressEnvelopeCodec()); + return create(manager, readerVisible::load); + } + + public static LatestStateGenerationCoordinator create(SnapshotManager manager, + LatestStateGenerationCoordinator.AuthorityReader authorityReader) + throws ArchivePersistenceException { + Objects.requireNonNull(authorityReader, "authorityReader"); + return create(manager, java.util.Collections.emptyMap(), authorityReader); + } + + public static LatestStateGenerationCoordinator create(SnapshotManager manager, + Map supplementalStores, + LatestStateGenerationCoordinator.AuthorityReader authorityReader) + throws ArchivePersistenceException { + LatestStateGenerationAdapter adapter = createAdapter(manager, supplementalStores); + return new LatestStateGenerationCoordinator(adapter.participantsForCoordinator(), + adapter.storesForCoordinator(), manager::withArchiveStateBarrier, authorityReader); + } + + /** Builds a direct request-pinning adapter for the common-checkpoint read gate. */ + public static LatestStateGenerationAdapter createAdapter(SnapshotManager manager, + Map supplementalStores) + throws ArchivePersistenceException { + Objects.requireNonNull(manager, "manager"); + Objects.requireNonNull(supplementalStores, "supplementalStores"); + List registered = new ArrayList<>(manager.getDbs()); + try { + ArchiveStoreScope.validate(registered); + } catch (IllegalStateException invalid) { + throw new ArchivePersistenceException("Invalid SnapshotManager archive Store registry", + invalid); + } + + TreeMap stateDatabases = new TreeMap<>(); + for (Chainbase database : registered) { + if (ArchiveStoreScope.isStateDatabase(database.getDbName())) { + stateDatabases.put(database.getDbName(), database); + } + } + TreeSet expected = new TreeSet<>(ArchiveStoreScope.getStateDatabases()); + if (!expected.containsAll(stateDatabases.keySet())) { + throw new ArchivePersistenceException( + "SnapshotManager archive state Store set is unexpected"); + } + + TreeMap stores = new TreeMap<>(); + for (Map.Entry entry : stateDatabases.entrySet()) { + Snapshot root = entry.getValue().getHead().getRoot(); + if (!Snapshot.isRoot(root)) { + throw new ArchivePersistenceException( + "Archive state Store does not resolve to SnapshotRoot: " + entry.getKey()); + } + DB engine = ((SnapshotRoot) root).getDb(); + if (!(engine instanceof SnapshotCapableStore) + || !entry.getKey().equals(engine.getDbName())) { + throw new ArchivePersistenceException( + "Archive state Store root lacks a supported snapshot engine: " + entry.getKey()); + } + stores.put(entry.getKey(), (SnapshotCapableStore) engine); + } + for (Map.Entry entry : supplementalStores.entrySet()) { + SnapshotCapableStore store = Objects.requireNonNull(entry.getValue(), + "supplemental Store"); + if (!entry.getKey().equals(store.getDbName()) + || stores.putIfAbsent(entry.getKey(), store) != null) { + throw new ArchivePersistenceException( + "Duplicate or mismatched supplemental latest Store: " + entry.getKey()); + } + } + if (!stores.keySet().equals(expected)) { + throw new ArchivePersistenceException( + "SnapshotManager plus supplemental archive Store set is incomplete or unexpected"); + } + + List participants = new ArrayList<>(stores.keySet()); + return new LatestStateGenerationAdapter(participants, stores); + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/OldValue.java b/chainbase/src/main/java/org/tron/core/db2/archive/OldValue.java new file mode 100644 index 00000000000..b04cd650c17 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/OldValue.java @@ -0,0 +1,59 @@ +package org.tron.core.db2.archive; + +import java.util.Arrays; +import java.util.Objects; + +/** An old value which preserves the distinction between absent and present-empty. */ +public final class OldValue { + + private static final OldValue ABSENT = new OldValue(false, null); + + private final boolean present; + private final byte[] value; + + private OldValue(boolean present, byte[] value) { + this.present = present; + this.value = value; + } + + public static OldValue absent() { + return ABSENT; + } + + public static OldValue present(byte[] value) { + Objects.requireNonNull(value, "value"); + return new OldValue(true, Arrays.copyOf(value, value.length)); + } + + public static OldValue fromNullable(byte[] value) { + return value == null ? absent() : present(value); + } + + public boolean isPresent() { + return present; + } + + public byte[] getValue() { + if (!present) { + throw new IllegalStateException("absent old value has no bytes"); + } + return Arrays.copyOf(value, value.length); + } + + @Override + public boolean equals(Object object) { + if (this == object) { + return true; + } + if (!(object instanceof OldValue)) { + return false; + } + OldValue that = (OldValue) object; + return present == that.present && Arrays.equals(value, that.value); + } + + @Override + public int hashCode() { + return 31 * Boolean.hashCode(present) + Arrays.hashCode(value); + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/OldValueCollector.java b/chainbase/src/main/java/org/tron/core/db2/archive/OldValueCollector.java new file mode 100644 index 00000000000..3cd97904535 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/OldValueCollector.java @@ -0,0 +1,7 @@ +package org.tron.core.db2.archive; + +/** Materializes a block reverse diff independently of its persistence format. */ +public interface OldValueCollector { + + BlockReverseDiff collect(BlockChangeView view); +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/P66AccountAssetCodec.java b/chainbase/src/main/java/org/tron/core/db2/archive/P66AccountAssetCodec.java new file mode 100644 index 00000000000..5507b0852ce --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/P66AccountAssetCodec.java @@ -0,0 +1,258 @@ +package org.tron.core.db2.archive; + +import com.google.protobuf.InvalidProtocolBufferException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import org.tron.core.db2.archive.BlockChangeView.PostValue; +import org.tron.protos.Protocol.Account; + +/** Versioned standalone codec for the P66-dependent Account and AccountAsset layout. */ +public final class P66AccountAssetCodec { + + public static final String FORMAT_ID = "archive-state/p66-account-asset/v1"; + private static final int ADDRESS_LENGTH = 21; + private static final int BALANCE_LENGTH = Long.BYTES; + + public enum Phase { + P66_OFF, + P66_ACTIVATION, + P66_ON; + + private boolean directAssetsEnabled() { + return this != P66_OFF; + } + } + + /** Validates and converts one raw execution Account into its target archive representation. */ + public byte[] canonicalizeAccount(Phase phase, byte[] physicalAccountKey, + byte[] rawAccountValue) { + Objects.requireNonNull(rawAccountValue, "rawAccountValue"); + return canonicalizeAccount(phase, physicalAccountKey, parseAccount(rawAccountValue), + rawAccountValue); + } + + byte[] canonicalizeAccount(Phase phase, byte[] physicalAccountKey, Account account, + byte[] rawAccountValue) { + Objects.requireNonNull(phase, "phase"); + byte[] accountKey = requireAddress(physicalAccountKey); + Objects.requireNonNull(rawAccountValue, "rawAccountValue"); + Objects.requireNonNull(account, "account"); + requireAccountAddress(accountKey, account); + if (!phase.directAssetsEnabled()) { + if (account.getAssetOptimized()) { + throw new ArchivePersistenceException( + "P66-off Account must not use the optimized asset layout"); + } + return Arrays.copyOf(rawAccountValue, rawAccountValue.length); + } + return account.toBuilder() + .setAssetOptimized(true) + .clearAsset() + .clearAssetV2() + .build() + .toByteArray(); + } + + /** Encodes one direct-row post state; zero is represented only as ABSENT. */ + public AssetRow encodeAssetRow(Phase phase, byte[] accountAddress, String tokenId, + long balance) { + Objects.requireNonNull(phase, "phase"); + if (!phase.directAssetsEnabled()) { + throw new ArchivePersistenceException("P66-off state must not contain direct asset rows"); + } + byte[] key = assetPhysicalKey(accountAddress, tokenId); + PostValue value = balance == 0 ? PostValue.absent() + : PostValue.present(ByteBuffer.allocate(BALANCE_LENGTH).putLong(balance).array()); + return new AssetRow(key, value); + } + + /** Builds the exact direct-row physical key while rejecting non-canonical token identities. */ + public byte[] assetPhysicalKey(byte[] accountAddress, String tokenId) { + byte[] address = requireAddress(accountAddress); + byte[] token = requireTokenId(tokenId); + return ByteBuffer.allocate(address.length + token.length) + .put(address) + .put(token) + .array(); + } + + /** Decodes an Account and verifies that it is canonical for the target P66 phase. */ + public Account decodeCanonicalAccount(Phase phase, byte[] physicalAccountKey, + byte[] canonicalAccountValue) { + Objects.requireNonNull(phase, "phase"); + byte[] accountKey = requireAddress(physicalAccountKey); + Account account = parseAccount(canonicalAccountValue); + requireAccountAddress(accountKey, account); + if (phase.directAssetsEnabled()) { + if (!account.getAssetOptimized() || !account.getAssetMap().isEmpty() + || !account.getAssetV2Map().isEmpty()) { + throw new ArchivePersistenceException("P66-on durable Account layout is mixed"); + } + } else if (account.getAssetOptimized()) { + throw new ArchivePersistenceException("P66-off durable Account layout is mixed"); + } + return account; + } + + /** Decodes a PRESENT direct row and rejects the non-canonical stored zero representation. */ + public DecodedAssetRow decodePresentAssetRow(byte[] physicalRawKey, byte[] rawValue) { + KeyIdentity identity = decodeKey(physicalRawKey); + if (rawValue == null || rawValue.length != BALANCE_LENGTH) { + throw new ArchivePersistenceException( + "AccountAsset value must be exactly eight bytes"); + } + long balance = ByteBuffer.wrap(rawValue).getLong(); + if (balance == 0) { + throw new ArchivePersistenceException( + "AccountAsset zero balance must be encoded as ABSENT"); + } + return new DecodedAssetRow(identity.address, identity.tokenId, balance); + } + + /** Validates one canonical account plus its sorted direct-row post mutations. */ + public void requireCanonicalLayout(Phase phase, byte[] physicalAccountKey, + byte[] canonicalAccountValue, List directRows) { + Objects.requireNonNull(phase, "phase"); + byte[] accountKey = requireAddress(physicalAccountKey); + decodeCanonicalAccount(phase, accountKey, canonicalAccountValue); + List rows = new ArrayList<>(Objects.requireNonNull(directRows, "directRows")); + if (rows.contains(null)) { + throw new ArchivePersistenceException("Canonical AccountAsset rows contain null"); + } + if (!phase.directAssetsEnabled()) { + if (!rows.isEmpty()) { + throw new ArchivePersistenceException("P66-off durable layout is mixed"); + } + return; + } + byte[] previous = null; + for (AssetRow row : rows) { + byte[] key = row.getPhysicalRawKey(); + KeyIdentity identity = decodeKey(key); + if (!Arrays.equals(accountKey, identity.address) + || previous != null && BlockReverseDiff.compareUnsigned(previous, key) >= 0) { + throw new ArchivePersistenceException( + "Canonical AccountAsset rows must be address-bound, unique, and sorted"); + } + if (row.getPostValue().isPresent()) { + decodePresentAssetRow(key, row.getPostValue().getValue()); + } + previous = key; + } + } + + private static KeyIdentity decodeKey(byte[] physicalRawKey) { + if (physicalRawKey == null || physicalRawKey.length <= ADDRESS_LENGTH) { + throw new ArchivePersistenceException("AccountAsset physical key is too short"); + } + byte[] address = Arrays.copyOf(physicalRawKey, ADDRESS_LENGTH); + byte[] token = Arrays.copyOfRange(physicalRawKey, ADDRESS_LENGTH, physicalRawKey.length); + requireCanonicalTokenBytes(token); + return new KeyIdentity(address, new String(token, StandardCharsets.US_ASCII)); + } + + private static byte[] requireAddress(byte[] address) { + if (address == null || address.length != ADDRESS_LENGTH) { + throw new ArchivePersistenceException("Account address must be exactly 21 bytes"); + } + return Arrays.copyOf(address, address.length); + } + + private static byte[] requireTokenId(String tokenId) { + if (tokenId == null) { + throw new ArchivePersistenceException("AccountAsset token ID is missing"); + } + byte[] encoded = tokenId.getBytes(StandardCharsets.US_ASCII); + if (!tokenId.equals(new String(encoded, StandardCharsets.US_ASCII))) { + throw new ArchivePersistenceException("AccountAsset token ID must be ASCII decimal"); + } + requireCanonicalTokenBytes(encoded); + return encoded; + } + + private static void requireCanonicalTokenBytes(byte[] token) { + if (token.length == 0 || token.length > 1 && token[0] == '0') { + throw new ArchivePersistenceException("AccountAsset token ID is not canonical decimal"); + } + for (byte value : token) { + if (value < '0' || value > '9') { + throw new ArchivePersistenceException("AccountAsset token ID is not canonical decimal"); + } + } + } + + private static Account parseAccount(byte[] value) { + if (value == null) { + throw new ArchivePersistenceException("Account value is missing"); + } + try { + return Account.parseFrom(value); + } catch (InvalidProtocolBufferException invalid) { + throw new ArchivePersistenceException("Account value is not valid protobuf", invalid); + } + } + + private static void requireAccountAddress(byte[] physicalKey, Account account) { + if (!Arrays.equals(physicalKey, account.getAddress().toByteArray())) { + throw new ArchivePersistenceException("Account protobuf address does not match physical key"); + } + } + + public static final class AssetRow { + private final byte[] physicalRawKey; + private final PostValue postValue; + + public AssetRow(byte[] physicalRawKey, PostValue postValue) { + this.physicalRawKey = Arrays.copyOf( + Objects.requireNonNull(physicalRawKey, "physicalRawKey"), physicalRawKey.length); + this.postValue = Objects.requireNonNull(postValue, "postValue"); + } + + public byte[] getPhysicalRawKey() { + return Arrays.copyOf(physicalRawKey, physicalRawKey.length); + } + + public PostValue getPostValue() { + return postValue; + } + } + + public static final class DecodedAssetRow { + private final byte[] accountAddress; + private final String tokenId; + private final long balance; + + private DecodedAssetRow(byte[] accountAddress, String tokenId, long balance) { + this.accountAddress = Arrays.copyOf(accountAddress, accountAddress.length); + this.tokenId = tokenId; + this.balance = balance; + } + + public byte[] getAccountAddress() { + return Arrays.copyOf(accountAddress, accountAddress.length); + } + + public String getTokenId() { + return tokenId; + } + + public long getBalance() { + return balance; + } + } + + private static final class KeyIdentity { + private final byte[] address; + private final String tokenId; + + private KeyIdentity(byte[] address, String tokenId) { + this.address = address; + this.tokenId = tokenId; + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/PartitionedHistoryBodyStore.java b/chainbase/src/main/java/org/tron/core/db2/archive/PartitionedHistoryBodyStore.java new file mode 100644 index 00000000000..e8542386fc2 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/PartitionedHistoryBodyStore.java @@ -0,0 +1,359 @@ +package org.tron.core.db2.archive; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.tron.core.db2.archive.BlockReverseDiff.DbGroup; +import org.tron.core.db2.archive.BlockReverseDiff.Entry; + +/** + * State Archive history placement with one default file library and dedicated hot-Store + * libraries. The default record contains authenticated locations for every dedicated record, so + * the existing authoritative marker commits the complete multi-library body. + */ +final class PartitionedHistoryBodyStore implements HistoryBodyStore { + + static final String DEFAULT_LIBRARY = "state-archive"; + static final List DEDICATED_STORES = Collections.unmodifiableList(Arrays.asList( + "account", "storage-row", "account-asset", "delegation")); + + private static final String REFERENCE_PREFIX = "\u0000state-archive-library/"; + private static final byte[] REFERENCE_KEY = new byte[]{1}; + private static final int ENCODED_LOCATION_LENGTH = Integer.BYTES + Long.BYTES + + Integer.BYTES + Integer.BYTES + 32; + + private final HistorySegmentStore defaultLibrary; + private final Map dedicatedLibraries; + + PartitionedHistoryBodyStore(Path archiveDirectory, BlockHistoryCodec codec, + long maxSegmentSize, ArchiveHistoryScanAnchor checkpoint) throws IOException { + rejectLegacySharedLibrary(archiveDirectory); + rejectLegacyAccountIndex(archiveDirectory); + validateExistingLibraryEntries(archiveDirectory); + this.defaultLibrary = HistorySegmentStore.openLibrary(archiveDirectory, DEFAULT_LIBRARY, + codec, maxSegmentSize, checkpoint); + Map opened = new LinkedHashMap<>(); + try { + Map checkpointLocations = checkpointLocations(checkpoint); + for (String store : DEDICATED_STORES) { + ArchiveHistoryScanAnchor laneCheckpoint = checkpoint == null ? null + : checkpoint.forHistoryLocation(checkpointLocations.get(store)); + opened.put(store, HistorySegmentStore.openLibrary(archiveDirectory, + libraryName(store), codec, maxSegmentSize, laneCheckpoint)); + } + this.dedicatedLibraries = Collections.unmodifiableMap(opened); + validateScannedHeads(); + } catch (IOException | RuntimeException failure) { + closeOpened(opened, failure); + try { + defaultLibrary.close(); + } catch (IOException closeFailure) { + failure.addSuppressed(closeFailure); + } + throw failure; + } + } + + static String libraryName(String store) { + return DEFAULT_LIBRARY + "-" + store; + } + + @Override + public synchronized HistoryLocation append(BlockReverseDiff diff) throws IOException { + Map locations = new LinkedHashMap<>(); + for (Map.Entry library : dedicatedLibraries.entrySet()) { + BlockReverseDiff lane = select(diff, library.getKey()); + locations.put(library.getKey(), library.getValue().append(lane)); + } + List defaultGroups = new ArrayList<>(); + for (DbGroup group : diff.getGroups()) { + if (!dedicatedLibraries.containsKey(group.getDbName())) { + defaultGroups.add(group); + } + } + for (Map.Entry location : locations.entrySet()) { + defaultGroups.add(referenceGroup(location.getKey(), location.getValue())); + } + return defaultLibrary.append(new BlockReverseDiff(diff.getMeta(), defaultGroups)); + } + + @Override + public synchronized void sync() throws IOException { + for (HistorySegmentStore library : dedicatedLibraries.values()) { + library.sync(); + } + defaultLibrary.sync(); + } + + @Override + public synchronized BlockReverseDiff read(HistoryLocation location) throws IOException { + BlockReverseDiff envelope = defaultLibrary.read(location); + DecodedEnvelope decoded = decodeEnvelope(envelope); + List groups = new ArrayList<>(decoded.defaultGroups); + for (String store : DEDICATED_STORES) { + HistoryLocation dedicatedLocation = decoded.locations.get(store); + if (dedicatedLocation == null) { + throw new ArchivePersistenceException( + "State Archive history envelope is missing dedicated library: " + store); + } + BlockReverseDiff lane = dedicatedLibraries.get(store).read(dedicatedLocation); + if (!envelope.getMeta().equals(lane.getMeta())) { + throw new ArchivePersistenceException( + "State Archive dedicated library metadata mismatch: " + store); + } + if (lane.getGroups().size() > 1 + || (!lane.getGroups().isEmpty() + && !store.equals(lane.getGroups().get(0).getDbName()))) { + throw new ArchivePersistenceException( + "State Archive dedicated library contains a foreign Store: " + store); + } + groups.addAll(lane.getGroups()); + } + return new BlockReverseDiff(envelope.getMeta(), groups); + } + + @Override + public synchronized HistorySegmentStore.ScanResult getScanResult() { + return defaultLibrary.getScanResult(); + } + + @Override + public synchronized void truncateAfter(HistoryLocation last) throws IOException { + truncateAfter(last, -1); + } + + @Override + public synchronized void truncateAfter(HistoryLocation last, long knownRecordCount) + throws IOException { + Map dedicated = Collections.emptyMap(); + if (last != null) { + dedicated = decodeEnvelope(defaultLibrary.read(last)).locations; + } + IOException failure = null; + for (Map.Entry library : dedicatedLibraries.entrySet()) { + try { + HistoryLocation laneLast = last == null ? null : dedicated.get(library.getKey()); + if (last != null && laneLast == null) { + throw new ArchivePersistenceException( + "State Archive history envelope is missing dedicated library: " + + library.getKey()); + } + library.getValue().truncateAfter(laneLast, knownRecordCount); + } catch (IOException | RuntimeException problem) { + if (problem instanceof IOException) { + failure = merge(failure, (IOException) problem); + } else { + throw problem; + } + } + } + try { + defaultLibrary.truncateAfter(last, knownRecordCount); + } catch (IOException problem) { + failure = merge(failure, problem); + } + if (failure != null) { + throw failure; + } + } + + @Override + public synchronized long getStartupScannedRecords() { + // The public counter is logical body records scanned, not the number of physical lanes read. + return defaultLibrary.getStartupScannedRecords(); + } + + @Override + public synchronized void close() throws IOException { + IOException failure = null; + for (HistorySegmentStore library : dedicatedLibraries.values()) { + try { + library.close(); + } catch (IOException problem) { + failure = merge(failure, problem); + } + } + try { + defaultLibrary.close(); + } catch (IOException problem) { + failure = merge(failure, problem); + } + if (failure != null) { + throw failure; + } + } + + private void validateScannedHeads() throws IOException { + HistorySegmentStore.ScannedRecord defaultHead = defaultLibrary.getScanResult().getHead(); + if (defaultHead == null) { + return; + } + read(defaultHead.getLocation()); + } + + private Map checkpointLocations( + ArchiveHistoryScanAnchor checkpoint) throws IOException { + if (checkpoint == null) { + return Collections.emptyMap(); + } + BlockReverseDiff envelope = defaultLibrary.read( + checkpoint.getMarker().getHistoryLocation()); + Map locations = decodeEnvelope(envelope).locations; + for (String store : DEDICATED_STORES) { + if (!locations.containsKey(store)) { + throw new ArchivePersistenceException( + "State Archive scan anchor is missing dedicated library: " + store); + } + } + return locations; + } + + private static BlockReverseDiff select(BlockReverseDiff diff, String store) { + for (DbGroup group : diff.getGroups()) { + if (store.equals(group.getDbName())) { + return new BlockReverseDiff(diff.getMeta(), Collections.singletonList(group)); + } + } + return new BlockReverseDiff(diff.getMeta(), Collections.emptyList()); + } + + private static DbGroup referenceGroup(String store, HistoryLocation location) { + return new DbGroup(REFERENCE_PREFIX + store, Collections.singletonList( + new Entry(REFERENCE_KEY, OldValue.present(encodeLocation(location))))); + } + + private static DecodedEnvelope decodeEnvelope(BlockReverseDiff envelope) { + Map locations = new LinkedHashMap<>(); + List groups = new ArrayList<>(); + for (DbGroup group : envelope.getGroups()) { + if (!group.getDbName().startsWith(REFERENCE_PREFIX)) { + groups.add(group); + continue; + } + String store = group.getDbName().substring(REFERENCE_PREFIX.length()); + if (!DEDICATED_STORES.contains(store) || locations.containsKey(store) + || group.getEntries().size() != 1 + || !Arrays.equals(REFERENCE_KEY, group.getEntries().get(0).getKey()) + || !group.getEntries().get(0).getOldValue().isPresent()) { + throw new ArchivePersistenceException("Invalid State Archive history library reference"); + } + locations.put(store, decodeLocation(group.getEntries().get(0).getOldValue().getValue())); + } + return new DecodedEnvelope(groups, locations); + } + + private static byte[] encodeLocation(HistoryLocation location) { + try { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(ENCODED_LOCATION_LENGTH); + DataOutputStream output = new DataOutputStream(bytes); + output.writeInt(location.getSegmentId()); + output.writeLong(location.getOffset()); + output.writeInt(location.getRecordLength()); + output.writeInt(location.getBodyChecksum()); + output.write(location.getBodyDigest()); + output.flush(); + return bytes.toByteArray(); + } catch (IOException impossible) { + throw new IllegalStateException("Unexpected location encoding failure", impossible); + } + } + + private static HistoryLocation decodeLocation(byte[] encoded) { + if (encoded.length != ENCODED_LOCATION_LENGTH) { + throw new ArchivePersistenceException("Invalid State Archive library location length"); + } + try { + DataInputStream input = new DataInputStream(new ByteArrayInputStream(encoded)); + int segment = input.readInt(); + long offset = input.readLong(); + int length = input.readInt(); + int checksum = input.readInt(); + byte[] digest = new byte[32]; + input.readFully(digest); + return new HistoryLocation(segment, offset, length, checksum, digest); + } catch (IOException impossible) { + throw new ArchivePersistenceException("Invalid State Archive library location", impossible); + } + } + + private static void rejectLegacySharedLibrary(Path archiveDirectory) throws IOException { + Path history = archiveDirectory.resolve("history"); + if (!Files.isDirectory(history)) { + return; + } + try (DirectoryStream segments = Files.newDirectoryStream(history, + "history.*.dat")) { + if (segments.iterator().hasNext()) { + throw new ArchivePersistenceException( + "Legacy shared history layout requires explicit offline migration"); + } + } + } + + private static void rejectLegacyAccountIndex(Path archiveDirectory) { + if (Files.exists(archiveDirectory.resolve("account-change-index"))) { + throw new ArchivePersistenceException( + "Legacy account-change-index requires explicit offline removal"); + } + } + + private static void validateExistingLibraryEntries(Path archiveDirectory) throws IOException { + Path history = archiveDirectory.resolve("history"); + if (!Files.isDirectory(history)) { + return; + } + List expected = new ArrayList<>(); + expected.add(DEFAULT_LIBRARY); + for (String store : DEDICATED_STORES) { + expected.add(libraryName(store)); + } + try (DirectoryStream entries = Files.newDirectoryStream(history)) { + for (Path entry : entries) { + if (!Files.isDirectory(entry) || !expected.contains(entry.getFileName().toString())) { + throw new ArchivePersistenceException( + "Unknown State Archive history library entry: " + entry.getFileName()); + } + } + } + } + + private static void closeOpened(Map stores, Exception failure) { + for (HistorySegmentStore store : stores.values()) { + try { + store.close(); + } catch (IOException closeFailure) { + failure.addSuppressed(closeFailure); + } + } + } + + private static IOException merge(IOException current, IOException next) { + if (current == null) { + return next; + } + current.addSuppressed(next); + return current; + } + + private static final class DecodedEnvelope { + private final List defaultGroups; + private final Map locations; + + private DecodedEnvelope(List defaultGroups, + Map locations) { + this.defaultGroups = defaultGroups; + this.locations = locations; + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/PersistentCommittedHistoryReader.java b/chainbase/src/main/java/org/tron/core/db2/archive/PersistentCommittedHistoryReader.java new file mode 100644 index 00000000000..cb35d75198f --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/PersistentCommittedHistoryReader.java @@ -0,0 +1,245 @@ +package org.tron.core.db2.archive; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import org.tron.core.db2.archive.BlockReverseDiff.DbGroup; +import org.tron.core.db2.archive.BlockReverseDiff.Entry; +import org.tron.core.db2.archive.HistoryIndexRecord.KeyGroup; + +/** Request-owned commit/index/segment handles pinned to one persistent serving generation. */ +public final class PersistentCommittedHistoryReader + implements ArchiveReadSnapshot.PinnedHistory { + + private final long indexedFrom; + private final long indexedThrough; + private final byte[] headHash; + private final byte[] sourceDigest; + private final HistoryBodyStore bodies; + private final HistoryIndexStore index; + private final HistoryCommitStore commits; + private boolean closed; + + private PersistentCommittedHistoryReader(Path archiveDirectory, long maxSegmentSize, + PersistentServingKeyIndexGeneration serving) throws IOException { + Objects.requireNonNull(serving, "serving"); + ArchiveHistoryScanAnchor checkpoint = ArchiveHistoryScanAnchor.load(archiveDirectory, + new HistoryCommitMarkerCodec()); + if (checkpoint == null) { + throw new ArchivePersistenceException("Archive history scan anchor is missing"); + } + HistoryBodyStore openedBodies = null; + HistoryIndexStore openedIndex = null; + HistoryCommitStore openedCommits = null; + try { + openedBodies = new PartitionedHistoryBodyStore(archiveDirectory, new BlockHistoryCodec(), + maxSegmentSize, checkpoint); + openedIndex = new HistoryIndexStore(archiveDirectory, new HistoryIndexCodec(), checkpoint); + openedCommits = new HistoryCommitStore(archiveDirectory, new HistoryCommitMarkerCodec(), + checkpoint); + validatePinnedAuthority(serving, openedBodies, openedIndex, openedCommits); + } catch (IOException | RuntimeException failure) { + closeAfterFailedConstruction(openedBodies, openedIndex, openedCommits, failure); + throw failure; + } + this.bodies = openedBodies; + this.index = openedIndex; + this.commits = openedCommits; + this.indexedFrom = serving.getIndexedFrom(); + this.indexedThrough = serving.getIndexedThrough(); + this.headHash = serving.getHeadHash(); + this.sourceDigest = serving.getAuthoritativePrefixDigest(); + } + + public static PersistentCommittedHistoryReader open(Path archiveDirectory, + long maxSegmentSize, PersistentServingKeyIndexGeneration serving) throws IOException { + return new PersistentCommittedHistoryReader(archiveDirectory, maxSegmentSize, serving); + } + + @Override + public synchronized OldValue read(String dbName, byte[] rawKey, long firstChangeBlock) + throws IOException { + ensureOpen(); + Objects.requireNonNull(dbName, "dbName"); + Objects.requireNonNull(rawKey, "rawKey"); + if (firstChangeBlock <= indexedFrom || firstChangeBlock > indexedThrough) { + throw new ArchivePersistenceException( + "Serving index references history outside its pinned generation"); + } + HistoryCommitMarker marker = commits.get(firstChangeBlock); + if (marker == null) { + throw new ArchivePersistenceException("Serving index references an uncommitted epoch"); + } + HistoryIndexRecord indexRecord = index.read(marker.getIndexLocation()); + validateMarker(marker, indexRecord); + BlockReverseDiff body = bodies.read(marker.getHistoryLocation()); + if (!marker.getMeta().equals(body.getMeta()) || !sameKeys(indexRecord, body)) { + throw new ArchivePersistenceException("Authoritative history index/body key mismatch"); + } + if (!contains(indexRecord, dbName, rawKey)) { + throw new ArchivePersistenceException( + "Serving index key is absent from authoritative history index"); + } + for (DbGroup group : body.getGroups()) { + if (dbName.equals(group.getDbName())) { + for (Entry entry : group.getEntries()) { + if (Arrays.equals(rawKey, entry.getKey())) { + return entry.getOldValue(); + } + } + } + } + throw new ArchivePersistenceException("Authoritative history body is missing the indexed key"); + } + + @Override + public long getIndexedFrom() { + return indexedFrom; + } + + @Override + public long getIndexedThrough() { + return indexedThrough; + } + + @Override + public byte[] getHeadHash() { + return Arrays.copyOf(headHash, headHash.length); + } + + @Override + public byte[] getAuthoritativePrefixDigest() { + return Arrays.copyOf(sourceDigest, sourceDigest.length); + } + + @Override + public synchronized void close() throws IOException { + if (closed) { + return; + } + closed = true; + IOException failure = null; + try { + index.close(); + } catch (IOException closeFailure) { + failure = closeFailure; + } + try { + bodies.close(); + } catch (IOException closeFailure) { + failure = add(failure, closeFailure); + } + try { + commits.close(); + } catch (IOException closeFailure) { + failure = add(failure, closeFailure); + } + if (failure != null) { + throw failure; + } + } + + private static void validatePinnedAuthority(PersistentServingKeyIndexGeneration serving, + HistoryBodyStore bodies, HistoryIndexStore index, HistoryCommitStore commits) + throws IOException { + HistoryCommitMarker marker = commits.get(serving.getIndexedThrough()); + if (marker == null || !Arrays.equals(marker.getMeta().getBlockHash(), serving.getHeadHash()) + || !marker.getDatabases().equals(serving.getParticipatingDatabases())) { + throw new ArchivePersistenceException( + "Serving generation does not match committed history authority"); + } + HistoryIndexRecord indexRecord = index.read(marker.getIndexLocation()); + validateMarker(marker, indexRecord); + BlockReverseDiff body = bodies.read(marker.getHistoryLocation()); + if (!marker.getMeta().equals(body.getMeta()) || !sameKeys(indexRecord, body)) { + throw new ArchivePersistenceException( + "Serving generation head does not match authoritative history files"); + } + } + + private static void validateMarker(HistoryCommitMarker marker, HistoryIndexRecord record) { + if (!marker.getMeta().equals(record.getMeta()) + || !same(marker.getHistoryLocation(), record.getHistoryLocation())) { + throw new ArchivePersistenceException( + "Commit marker does not match authoritative history index"); + } + } + + private static boolean contains(HistoryIndexRecord record, String dbName, byte[] rawKey) { + for (KeyGroup group : record.getGroups()) { + if (dbName.equals(group.getDbName())) { + for (byte[] key : group.getKeys()) { + if (Arrays.equals(key, rawKey)) { + return true; + } + } + } + } + return false; + } + + private static boolean sameKeys(HistoryIndexRecord record, BlockReverseDiff body) { + List indexed = record.getGroups(); + List stored = body.getGroups(); + if (indexed.size() != stored.size()) { + return false; + } + for (int groupIndex = 0; groupIndex < indexed.size(); groupIndex++) { + KeyGroup indexedGroup = indexed.get(groupIndex); + DbGroup storedGroup = stored.get(groupIndex); + if (!indexedGroup.getDbName().equals(storedGroup.getDbName()) + || indexedGroup.getKeys().size() != storedGroup.getEntries().size()) { + return false; + } + for (int keyIndex = 0; keyIndex < indexedGroup.getKeys().size(); keyIndex++) { + if (!Arrays.equals(indexedGroup.getKeys().get(keyIndex), + storedGroup.getEntries().get(keyIndex).getKey())) { + return false; + } + } + } + return true; + } + + private static boolean same(HistoryLocation left, HistoryLocation right) { + return left.getSegmentId() == right.getSegmentId() + && left.getOffset() == right.getOffset() + && left.getRecordLength() == right.getRecordLength() + && left.getBodyChecksum() == right.getBodyChecksum() + && Arrays.equals(left.getBodyDigest(), right.getBodyDigest()); + } + + private static void closeAfterFailedConstruction(HistoryBodyStore bodies, + HistoryIndexStore index, HistoryCommitStore commits, Exception failure) { + close(index, failure); + close(bodies, failure); + close(commits, failure); + } + + private static void close(java.io.Closeable resource, Exception failure) { + if (resource == null) { + return; + } + try { + resource.close(); + } catch (IOException closeFailure) { + failure.addSuppressed(closeFailure); + } + } + + private static IOException add(IOException current, IOException addition) { + if (current == null) { + return addition; + } + current.addSuppressed(addition); + return current; + } + + private void ensureOpen() { + if (closed) { + throw new IllegalStateException("Persistent committed history reader is closed"); + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/PersistentServingKeyIndexCatalog.java b/chainbase/src/main/java/org/tron/core/db2/archive/PersistentServingKeyIndexCatalog.java new file mode 100644 index 00000000000..b7ce938d5d3 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/PersistentServingKeyIndexCatalog.java @@ -0,0 +1,412 @@ +package org.tron.core.db2.archive; + +import com.google.common.hash.Hashing; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.Closeable; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Stream; +import org.tron.core.db2.stateroot.PathStateStoreManifest.Engine; + +/** Durable generation catalog with request refcounts and safe retired-generation reaping. */ +public final class PersistentServingKeyIndexCatalog implements Closeable { + + private static final int MAGIC = 0x534b4943; // SKIC + private static final short VERSION = 1; + private static final String CURRENT = "current"; + private static final String CURRENT_TEMP = "current.tmp"; + private static final String GENERATIONS = "generations"; + + private final Path root; + private final Path generations; + private final Engine engine; + private final Map references = new HashMap<>(); + private final Set retired = new HashSet<>(); + private final FaultHook faultHook; + private String currentId; + private boolean closed; + + private PersistentServingKeyIndexCatalog(Path root, String currentId, Engine engine, + FaultHook faultHook) { + this.root = root; + this.generations = root.resolve(GENERATIONS); + this.engine = Objects.requireNonNull(engine, "engine"); + this.currentId = currentId; + this.faultHook = Objects.requireNonNull(faultHook, "faultHook"); + } + + public static PersistentServingKeyIndexCatalog open(Path root) throws IOException { + return open(root, stage -> { }); + } + + static PersistentServingKeyIndexCatalog open(Path root, FaultHook faultHook) + throws IOException { + Objects.requireNonNull(root, "root"); + String currentId = readCurrent(root); + Path current = root.resolve(GENERATIONS).resolve(currentId); + Engine engine = StateArchiveIndexEngineManifest.load(current); + return open(root, engine, faultHook); + } + + static PersistentServingKeyIndexCatalog open(Path root, Engine engine, FaultHook faultHook) + throws IOException { + Objects.requireNonNull(root, "root"); + String currentId = readCurrent(root); + Path current = root.resolve(GENERATIONS).resolve(currentId); + try (PersistentServingKeyIndexGeneration ignored = + PersistentServingKeyIndexGeneration.open(current, engine)) { + // Opening validates both the immutable descriptor and configured native generation. + } + PersistentServingKeyIndexCatalog catalog = + new PersistentServingKeyIndexCatalog(root, currentId, engine, faultHook); + catalog.discoverRetired(); + return catalog; + } + + public static PersistentServingKeyIndexCatalog create(Path root, Path initialShadow, + ArchiveProgressEnvelope readerVisible) throws IOException { + return createInternal(root, initialShadow, readerVisible); + } + + public static PersistentServingKeyIndexCatalog create(Path root, Path initialShadow) + throws IOException { + return createInternal(root, initialShadow, null); + } + + static PersistentServingKeyIndexCatalog create(Path root, Path initialShadow, + FaultHook faultHook) throws IOException { + Objects.requireNonNull(faultHook, "faultHook"); + Objects.requireNonNull(root, "root"); + if (Files.exists(root.resolve(CURRENT))) { + throw new IllegalArgumentException("Serving index catalog already exists"); + } + Files.createDirectories(root.resolve(GENERATIONS)); + Engine engine = StateArchiveIndexEngineManifest.load(initialShadow); + PersistentServingKeyIndexCatalog catalog = + new PersistentServingKeyIndexCatalog(root, null, engine, faultHook); + if (!catalog.publishInternal(null, initialShadow, null)) { + throw new IllegalStateException("Failed to publish initial serving generation"); + } + return catalog; + } + + private static PersistentServingKeyIndexCatalog createInternal(Path root, Path initialShadow, + ArchiveProgressEnvelope readerVisible) throws IOException { + Objects.requireNonNull(root, "root"); + if (Files.exists(root.resolve(CURRENT))) { + throw new IllegalArgumentException("Serving index catalog already exists"); + } + Files.createDirectories(root.resolve(GENERATIONS)); + Engine engine = StateArchiveIndexEngineManifest.load(initialShadow); + PersistentServingKeyIndexCatalog catalog = + new PersistentServingKeyIndexCatalog(root, null, engine, stage -> { }); + if (!catalog.publishInternal(null, initialShadow, readerVisible)) { + throw new IllegalStateException("Failed to publish initial serving generation"); + } + return catalog; + } + + /** Pins one immutable native generation handle and holds its refcount until close. */ + public synchronized PersistentServingKeyIndexGeneration pin( + ArchiveProgressEnvelope readerVisible) throws IOException { + return pinInternal(readerVisible); + } + + public synchronized PersistentServingKeyIndexGeneration pin() throws IOException { + return pinInternal(null); + } + + private PersistentServingKeyIndexGeneration pinInternal( + ArchiveProgressEnvelope readerVisible) throws IOException { + ensureOpen(); + if (currentId == null) { + throw new ArchivePersistenceException("Serving index catalog has no current generation"); + } + String pinnedId = currentId; + references.put(pinnedId, references.getOrDefault(pinnedId, 0) + 1); + PersistentServingKeyIndexGeneration pinned; + try { + pinned = PersistentServingKeyIndexGeneration.openTrusted(generations.resolve(pinnedId), + engine, + () -> release(pinnedId)); + } catch (IOException | RuntimeException failure) { + release(pinnedId); + throw failure; + } + try { + if (readerVisible != null) { + validateReaderVisibility(pinned, readerVisible); + } + return pinned; + } catch (RuntimeException failure) { + pinned.close(); + throw failure; + } + } + + /** Atomically publishes a completed shadow generation if {@code expectedId} is still current. */ + public synchronized boolean publish(String expectedId, Path shadow, + ArchiveProgressEnvelope readerVisible) throws IOException { + return publishInternal(expectedId, shadow, readerVisible); + } + + public synchronized boolean publish(String expectedId, Path shadow) throws IOException { + return publishInternal(expectedId, shadow, null); + } + + private boolean publishInternal(String expectedId, Path shadow, + ArchiveProgressEnvelope readerVisible) throws IOException { + ensureOpen(); + discoverRetired(); + if (!Objects.equals(expectedId, currentId)) { + return false; + } + String replacementId; + long replacementFrom; + long replacementThrough; + try (PersistentServingKeyIndexGeneration replacement = + PersistentServingKeyIndexGeneration.open(shadow, engine)) { + if (readerVisible != null) { + validateReaderVisibility(replacement, readerVisible); + } + replacementId = replacement.getGenerationId(); + replacementFrom = replacement.getIndexedFrom(); + replacementThrough = replacement.getIndexedThrough(); + } + validateGenerationId(replacementId); + if (currentId != null) { + try (PersistentServingKeyIndexGeneration current = + PersistentServingKeyIndexGeneration.open(generations.resolve(currentId), engine)) { + if (replacementFrom != current.getIndexedFrom() + || replacementThrough < current.getIndexedThrough()) { + throw new IllegalArgumentException("Serving generation publication regresses coverage"); + } + } + } + Path destination = generations.resolve(replacementId); + if (Files.exists(destination)) { + throw new IllegalArgumentException("Serving generation already exists: " + replacementId); + } + try { + Files.move(shadow, destination, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException unsupported) { + throw new ArchivePersistenceException( + "Serving index filesystem does not support atomic generation install", unsupported); + } + HistorySegmentStore.syncDirectory(generations); + faultHook.afterDurableStage(PublicationStage.GENERATION_INSTALLED); + persistCurrent(replacementId); + String previous = currentId; + currentId = replacementId; + if (previous != null) { + retired.add(previous); + } + faultHook.afterDurableStage(PublicationStage.CURRENT_PUBLISHED); + if (previous != null) { + reapIfUnused(previous); + } + return true; + } + + public synchronized String getCurrentGenerationId() { + ensureOpen(); + return currentId; + } + + public synchronized int getReferenceCount(String generationId) { + return references.getOrDefault(generationId, 0); + } + + public synchronized boolean generationExists(String generationId) { + return Files.isDirectory(generations.resolve(generationId)); + } + + @Override + public synchronized void close() throws IOException { + if (closed) { + return; + } + if (!references.isEmpty()) { + throw new IllegalStateException("Serving index catalog closed with pinned generations"); + } + closed = true; + for (String generation : new ArrayList<>(retired)) { + reapIfUnused(generation); + } + } + + private synchronized void release(String generationId) { + Integer count = references.get(generationId); + if (count == null || count <= 0) { + throw new IllegalStateException("Serving generation refcount underflow"); + } + if (count == 1) { + references.remove(generationId); + if (retired.contains(generationId)) { + try { + reapIfUnused(generationId); + } catch (IOException failure) { + throw new ArchivePersistenceException("Failed to reap serving generation", failure); + } + } + } else { + references.put(generationId, count - 1); + } + } + + private void discoverRetired() throws IOException { + Files.createDirectories(generations); + try (Stream entries = Files.list(generations)) { + entries.filter(Files::isDirectory) + .map(path -> path.getFileName().toString()) + .filter(id -> !id.equals(currentId)) + .forEach(retired::add); + } + for (String generation : new ArrayList<>(retired)) { + reapIfUnused(generation); + } + } + + private void reapIfUnused(String generationId) throws IOException { + if (generationId.equals(currentId) || references.getOrDefault(generationId, 0) != 0) { + return; + } + Path target = generations.resolve(generationId); + if (Files.exists(target)) { + List paths = new ArrayList<>(); + try (Stream walk = Files.walk(target)) { + walk.sorted(Comparator.reverseOrder()).forEach(paths::add); + } + for (Path path : paths) { + Files.deleteIfExists(path); + } + HistorySegmentStore.syncDirectory(generations); + } + retired.remove(generationId); + } + + private void persistCurrent(String generationId) throws IOException { + byte[] encoded = encodeCurrent(generationId); + Path temporary = root.resolve(CURRENT_TEMP); + try (FileChannel channel = FileChannel.open(temporary, StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE)) { + ByteBuffer buffer = ByteBuffer.wrap(encoded); + while (buffer.hasRemaining()) { + channel.write(buffer); + } + channel.force(true); + } + try { + Files.move(temporary, root.resolve(CURRENT), StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException unsupported) { + throw new ArchivePersistenceException( + "Serving index filesystem does not support atomic catalog publication", unsupported); + } + HistorySegmentStore.syncDirectory(root); + } + + private static String readCurrent(Path root) throws IOException { + Path current = root.resolve(CURRENT); + if (!Files.isRegularFile(current)) { + throw new ArchivePersistenceException("Serving index current-generation pointer is missing"); + } + byte[] encoded = Files.readAllBytes(current); + if (encoded.length < 16) { + throw new ArchivePersistenceException("Serving index current pointer is corrupt"); + } + byte[] payload = java.util.Arrays.copyOf(encoded, encoded.length - Integer.BYTES); + int checksum = ByteBuffer.wrap(encoded, payload.length, Integer.BYTES).getInt(); + if (checksum != Hashing.crc32c().hashBytes(payload).asInt()) { + throw new ArchivePersistenceException("Serving index current pointer checksum mismatch"); + } + try { + DataInputStream input = new DataInputStream(new ByteArrayInputStream(encoded)); + if (input.readInt() != MAGIC || input.readShort() != VERSION || input.readShort() != 0) { + throw new ArchivePersistenceException("Unsupported serving index current pointer"); + } + String generationId = input.readUTF(); + if (input.available() != Integer.BYTES) { + throw new ArchivePersistenceException("Serving index current pointer payload mismatch"); + } + validateGenerationId(generationId); + return generationId; + } catch (IOException invalid) { + throw new ArchivePersistenceException("Serving index current pointer is truncated", invalid); + } + } + + private static byte[] encodeCurrent(String generationId) { + validateGenerationId(generationId); + try { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + DataOutputStream output = new DataOutputStream(bytes); + output.writeInt(MAGIC); + output.writeShort(VERSION); + output.writeShort(0); + output.writeUTF(generationId); + output.flush(); + byte[] payload = bytes.toByteArray(); + output.writeInt(Hashing.crc32c().hashBytes(payload).asInt()); + output.flush(); + return bytes.toByteArray(); + } catch (IOException impossible) { + throw new IllegalStateException("Unexpected serving catalog encoding failure", impossible); + } + } + + private static void validateGenerationId(String generationId) { + if (generationId == null || generationId.isEmpty() || generationId.length() > 128 + || generationId.contains("/") || generationId.contains("\\") + || generationId.equals(".") || generationId.equals("..")) { + throw new IllegalArgumentException("Invalid serving generation id"); + } + } + + private static void validateReaderVisibility(PersistentServingKeyIndexGeneration generation, + ArchiveProgressEnvelope readerVisible) { + Objects.requireNonNull(readerVisible, "readerVisible"); + if (readerVisible.getKind() != ArchiveProgressEnvelope.Kind.READER_VISIBLE + || !readerVisible.getScopeIdentity().equals(generation.getScopeIdentity()) + || !readerVisible.getParticipants().equals(generation.getParticipatingDatabases()) + || generation.getIndexedThrough() > readerVisible.getEpoch() + || generation.getIndexedThrough() == readerVisible.getEpoch() + && !java.util.Arrays.equals(generation.getHeadHash(), readerVisible.getBlockHash())) { + throw new ArchivePersistenceException( + "Serving generation is outside reader-visible recovery authority"); + } + } + + private void ensureOpen() { + if (closed) { + throw new IllegalStateException("Serving index catalog is closed"); + } + } + + enum PublicationStage { + GENERATION_INSTALLED, + CURRENT_PUBLISHED + } + + @FunctionalInterface + interface FaultHook { + void afterDurableStage(PublicationStage stage) throws IOException; + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/PersistentServingKeyIndexGeneration.java b/chainbase/src/main/java/org/tron/core/db2/archive/PersistentServingKeyIndexGeneration.java new file mode 100644 index 00000000000..94ee1fe1eca --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/PersistentServingKeyIndexGeneration.java @@ -0,0 +1,1757 @@ +package org.tron.core.db2.archive; + +import com.google.common.hash.Hashing; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.OptionalLong; +import java.util.stream.Stream; +import org.tron.core.db2.stateroot.PathStateStoreManifest.Engine; + +/** Persistent immutable exact-key serving generation backed by the configured database engine. */ +public final class PersistentServingKeyIndexGeneration implements ServingKeyIndex { + + private static final int MAGIC = 0x534b4947; // SKIG + private static final short VERSION = 4; + private static final short EXACT_VERSION = 5; + private static final int MAX_MANIFEST_SIZE = 1024 * 1024; + private static final byte DATA_PREFIX = 1; + private static final byte RANGE_DATA_PREFIX = 2; + private static final byte KEY_META_PREFIX = 3; + private static final byte KEY_PAGE_PREFIX = 4; + private static final byte STORE_COVERAGE_PREFIX = 5; + private static final int INLINE_EPOCH_LIMIT = 4; + private static final int EPOCHS_PER_PAGE = 512; + private static final byte INLINE = 1; + private static final byte PAGED = 2; + private static final byte[] PRESENT = new byte[]{1}; + private static final String MANIFEST = "generation.meta"; + private static final String MANIFEST_TEMP = "generation.meta.tmp"; + private static final String DATABASE = "keys"; + private static final String ESTIMATED_LIVE_DATA_SIZE = + "rocksdb.estimate-live-data-size"; + private static final String TOTAL_SST_FILES_SIZE = "rocksdb.total-sst-files-size"; + private static final String PENDING_COMPACTION_BYTES = + "rocksdb.estimate-pending-compaction-bytes"; + + private final Path directory; + private final Descriptor descriptor; + private final Engine engine; + private final StateArchiveIndexDatabase.Reader database; + private final Runnable release; + private boolean closed; + + private PersistentServingKeyIndexGeneration(Path directory, Descriptor descriptor, + Engine engine, boolean validateEngine, Runnable release) throws IOException { + this.directory = directory; + this.descriptor = descriptor; + this.engine = Objects.requireNonNull(engine, "engine"); + this.release = Objects.requireNonNull(release, "release"); + if (validateEngine) { + StateArchiveIndexEngineManifest.openOrCreateLegacy(directory, engine); + } + StateArchiveIndexDatabase.Reader opened = null; + try { + opened = StateArchiveIndexDatabase.openImmutableReader(directory.resolve(DATABASE), engine); + if (descriptor.formatVersion == EXACT_VERSION) { + validateExactStoreCoverage(opened, descriptor); + } + this.database = opened; + } catch (IOException | RuntimeException failure) { + if (opened != null) { + opened.close(); + } + throw new IOException("Failed to open serving index generation", failure); + } + } + + public static PersistentServingKeyIndexGeneration build(Path directory, String generationId, + long baseEpoch, byte[] baseHash, Iterable committed, + ServingKeyIndexGeneration.AuthoritativeIndexReader reader, + List participatingDatabases) throws IOException { + return build(directory, generationId, baseEpoch, baseHash, committed, reader, + participatingDatabases, new byte[32]); + } + + public static PersistentServingKeyIndexGeneration build(Path directory, String generationId, + long baseEpoch, byte[] baseHash, Iterable committed, + ServingKeyIndexGeneration.AuthoritativeIndexReader reader, + List participatingDatabases, byte[] latestSourceIdentityDigest) throws IOException { + Objects.requireNonNull(directory, "directory"); + Objects.requireNonNull(committed, "committed"); + Objects.requireNonNull(reader, "reader"); + List participants = sortedParticipants(participatingDatabases); + String scopeIdentity = ArchiveParticipantDescriptor.scopeIdentity(participants); + if (generationId == null || generationId.isEmpty() || baseEpoch < 0) { + throw new IllegalArgumentException("Invalid serving generation identity"); + } + requireHash(baseHash, "baseHash"); + requireHash(latestSourceIdentityDigest, "latestSourceIdentityDigest"); + if (Files.exists(directory)) { + throw new IllegalArgumentException("Serving generation directory already exists"); + } + Files.createDirectories(directory); + Engine engine = configuredEngine(); + StateArchiveIndexEngineManifest.openOrCreate(directory, engine); + + MessageDigest sourceDigest = sha256(); + updateLong(sourceDigest, baseEpoch); + sourceDigest.update(baseHash); + updateStringDigest(sourceDigest, scopeIdentity); + updateParticipantDigest(sourceDigest, participants); + long previousEpoch = baseEpoch; + long previousBlock = baseEpoch; + byte[] previousHash = Arrays.copyOf(baseHash, baseHash.length); + long keyChanges = 0; + try (StateArchiveIndexDatabase.Writer target = + StateArchiveIndexDatabase.openWriter(directory.resolve(DATABASE), engine)) { + for (HistoryCommitMarker marker : committed) { + BlockSnapshotMeta meta = marker.getMeta(); + validateNext(marker, previousEpoch, previousBlock, previousHash, participants); + HistoryIndexRecord record = reader.read(marker.getIndexLocation()); + validateMarker(marker, record, participants); + List mutations = new ArrayList<>(); + for (HistoryIndexRecord.KeyGroup group : record.getGroups()) { + for (byte[] key : group.getKeys()) { + mutations.add(StateArchiveIndexDatabase.put( + dataKey(group.getDbName(), key, meta.getEpoch()), PRESENT)); + mutations.add(StateArchiveIndexDatabase.put( + rangeDataKey(group.getDbName(), key, meta.getEpoch()), PRESENT)); + keyChanges++; + } + } + target.write(mutations, false); + updateSourceDigest(sourceDigest, marker); + previousEpoch = meta.getEpoch(); + previousBlock = meta.getBlockNumber(); + previousHash = meta.getBlockHash(); + } + target.write(Collections.singletonList( + StateArchiveIndexDatabase.put(new byte[]{0}, new byte[]{1})), true); + } + + Descriptor descriptor = new Descriptor(VERSION, scopeIdentity, generationId, baseEpoch, + previousEpoch, + previousHash, sourceDigest.digest(), latestSourceIdentityDigest, participants, keyChanges); + persistDescriptor(directory, descriptor); + HistorySegmentStore.syncDirectory(directory); + return open(directory, engine); + } + + public static PersistentServingKeyIndexGeneration open(Path directory) throws IOException { + return open(directory, () -> { }); + } + + static PersistentServingKeyIndexGeneration open(Path directory, Runnable release) + throws IOException { + return open(directory, configuredEngine(), release); + } + + static PersistentServingKeyIndexGeneration open(Path directory, Engine engine) + throws IOException { + return open(directory, engine, () -> { }); + } + + static PersistentServingKeyIndexGeneration open(Path directory, Engine engine, + Runnable release) throws IOException { + return new PersistentServingKeyIndexGeneration(directory, loadDescriptor(directory), engine, + true, release); + } + + static PersistentServingKeyIndexGeneration openTrusted(Path directory, Engine engine, + Runnable release) throws IOException { + return new PersistentServingKeyIndexGeneration(directory, loadDescriptor(directory), engine, + false, release); + } + + /** Creates one approved v5 exact-only generation from a validated logical increment plan. */ + public static PersistentServingKeyIndexGeneration buildExact(Path directory, + String generationId, ServingIndexIncrementalPlan plan, + byte[] latestSourceIdentityDigest) throws IOException { + return buildExact(directory, generationId, plan, latestSourceIdentityDigest, () -> { }); + } + + static PersistentServingKeyIndexGeneration buildExact(Path directory, String generationId, + ServingIndexIncrementalPlan plan, byte[] latestSourceIdentityDigest, + ExactWriteFaultHook faultHook) throws IOException { + return buildExact(directory, generationId, plan, latestSourceIdentityDigest, + configuredEngine(), faultHook); + } + + static PersistentServingKeyIndexGeneration buildExact(Path directory, String generationId, + ServingIndexIncrementalPlan plan, byte[] latestSourceIdentityDigest, Engine engine, + ExactWriteFaultHook faultHook) throws IOException { + Objects.requireNonNull(directory, "directory"); + Objects.requireNonNull(plan, "plan"); + Objects.requireNonNull(faultHook, "faultHook"); + validateExactIdentity(generationId, plan, latestSourceIdentityDigest); + if (Files.exists(directory)) { + throw new IllegalArgumentException("Serving generation directory already exists"); + } + Files.createDirectories(directory); + StateArchiveIndexEngineManifest.openOrCreate(directory, engine); + byte[] sourceDigest = rollSourceDigest(plan.getSourceSeedDigest(), + plan.getSourceStepDigests()); + long keyChanges; + try (StateArchiveIndexDatabase.Writer target = + StateArchiveIndexDatabase.openWriter(directory.resolve(DATABASE), engine)) { + keyChanges = applyExactPlan(target, generationId, plan, plan.getIndexedFrom(), + sourceDigest, faultHook); + } + Descriptor descriptor = new Descriptor(EXACT_VERSION, + ArchiveParticipantDescriptor.FORMAT_ID, generationId, plan.getIndexedFrom(), + plan.getIndexedThrough(), plan.getHeadHash(), sourceDigest, latestSourceIdentityDigest, + plan.getParticipatingDatabases(), keyChanges); + persistDescriptor(directory, descriptor); + HistorySegmentStore.syncDirectory(directory); + return open(directory, engine); + } + + /** Checkpoints this immutable v5 generation and applies only the validated {@code (I,H]} plan. */ + public synchronized PersistentServingKeyIndexGeneration extendExact(Path directory, + String generationId, ServingIndexIncrementalPlan plan, + byte[] latestSourceIdentityDigest) throws IOException { + return extendExact(directory, generationId, plan, latestSourceIdentityDigest, () -> { }); + } + + synchronized PersistentServingKeyIndexGeneration extendExact(Path directory, + String generationId, ServingIndexIncrementalPlan plan, + byte[] latestSourceIdentityDigest, ExactWriteFaultHook faultHook) throws IOException { + ensureOpen(); + Objects.requireNonNull(directory, "directory"); + Objects.requireNonNull(plan, "plan"); + Objects.requireNonNull(faultHook, "faultHook"); + validateExactIdentity(generationId, plan, latestSourceIdentityDigest); + if (descriptor.formatVersion != EXACT_VERSION + || plan.getIndexedFrom() != descriptor.indexedThrough + || !Arrays.equals(plan.getIndexedFromHash(), descriptor.headHash) + || !plan.getParticipatingDatabases().equals(descriptor.participants)) { + throw new IllegalArgumentException("Exact serving increment does not extend current I"); + } + if (Files.exists(directory)) { + throw new IllegalArgumentException("Serving generation directory already exists"); + } + Files.createDirectories(directory); + StateArchiveIndexEngineManifest.openOrCreate(directory, engine); + StateArchiveIndexDatabase.checkpointImmutable(this.directory.resolve(DATABASE), + directory.resolve(DATABASE), engine); + byte[] sourceDigest = rollSourceDigest(descriptor.sourceDigest, + plan.getSourceStepDigests()); + long added; + try (StateArchiveIndexDatabase.Writer target = + StateArchiveIndexDatabase.openWriter(directory.resolve(DATABASE), engine)) { + added = applyExactPlan(target, generationId, plan, descriptor.indexedFrom, + sourceDigest, faultHook); + } + Descriptor replacement = new Descriptor(EXACT_VERSION, descriptor.scopeIdentity, + generationId, descriptor.indexedFrom, plan.getIndexedThrough(), plan.getHeadHash(), + sourceDigest, latestSourceIdentityDigest, descriptor.participants, + descriptor.keyChanges + added); + persistDescriptor(directory, replacement); + HistorySegmentStore.syncDirectory(directory); + return open(directory, engine); + } + + /** + * Creates a process-owned mutable copy used to publish immutable generations without reopening + * the write database for every checkpoint target. + */ + synchronized RuntimeBuilder openRuntimeBuilder(Path builderDirectory) throws IOException { + ensureOpen(); + if (descriptor.formatVersion != EXACT_VERSION) { + throw new IllegalStateException("Serving runtime builder requires exact-only format"); + } + return RuntimeBuilder.open(builderDirectory, this); + } + + @Override + public synchronized OptionalLong firstChangeAfter(String dbName, byte[] rawKey, + long targetBlock, long upperBound) throws IOException { + ensureOpen(); + Objects.requireNonNull(dbName, "dbName"); + Objects.requireNonNull(rawKey, "rawKey"); + validateCoverage(dbName, targetBlock, upperBound); + if (targetBlock == Long.MAX_VALUE) { + return OptionalLong.empty(); + } + if (descriptor.formatVersion == EXACT_VERSION) { + return firstExactChangeAfter(dbName, rawKey, targetBlock, upperBound); + } + byte[] prefix = dataPrefix(dbName, rawKey); + byte[] seek = ByteBuffer.allocate(prefix.length + Long.BYTES).put(prefix) + .putLong(targetBlock + 1).array(); + try (StateArchiveIndexDatabase.Cursor iterator = database.cursor()) { + iterator.seek(seek); + StateArchiveIndexDatabase.KeyValue entry = iterator.next(); + if (entry == null) { + return OptionalLong.empty(); + } + byte[] found = entry.getKey(); + if (found.length != prefix.length + Long.BYTES || !startsWith(found, prefix)) { + return OptionalLong.empty(); + } + long epoch = ByteBuffer.wrap(found, prefix.length, Long.BYTES).getLong(); + return epoch <= upperBound ? OptionalLong.of(epoch) : OptionalLong.empty(); + } + } + + @Override + public synchronized List changesInRange(String dbName, + byte[] lowerInclusive, byte[] upperExclusive, long targetBlock, long upperBound, + int maxChangedKeys) throws IOException { + ensureOpen(); + Objects.requireNonNull(dbName, "dbName"); + Objects.requireNonNull(lowerInclusive, "lowerInclusive"); + if (maxChangedKeys <= 0) { + throw new IllegalArgumentException("maxChangedKeys must be positive"); + } + if (upperExclusive != null + && BlockReverseDiff.compareUnsigned(lowerInclusive, upperExclusive) > 0) { + throw new IllegalArgumentException("lowerInclusive must not exceed upperExclusive"); + } + if (!supportsRangeQueries()) { + throw new ArchivePersistenceException( + "Serving index generation must be upgraded before range queries"); + } + validateCoverage(dbName, targetBlock, upperBound); + if (targetBlock == Long.MAX_VALUE) { + return Collections.emptyList(); + } + + byte[] databasePrefix = rangeDatabasePrefix(dbName); + List result = new ArrayList<>(); + try (StateArchiveIndexDatabase.Cursor iterator = database.cursor()) { + iterator.seek(concat(databasePrefix, encodeRangeRawKey(lowerInclusive))); + StateArchiveIndexDatabase.KeyValue entry; + while ((entry = iterator.next()) != null) { + RangeDataKey found = decodeRangeDataKey(entry.getKey(), databasePrefix); + if (found == null) { + break; + } + if (upperExclusive != null + && BlockReverseDiff.compareUnsigned(found.rawKey, upperExclusive) >= 0) { + break; + } + if (found.epoch <= targetBlock) { + iterator.seek(rangeDataKey(dbName, found.rawKey, targetBlock + 1)); + entry = iterator.next(); + if (entry == null) { + break; + } + RangeDataKey candidate = decodeRangeDataKey(entry.getKey(), databasePrefix); + if (candidate == null || !Arrays.equals(candidate.rawKey, found.rawKey)) { + continue; + } + found = candidate; + } + if (found.epoch <= upperBound) { + if (result.size() == maxChangedKeys) { + throw new ArchiveQueryLimitExceededException("changed-key budget exceeded"); + } + result.add(new ServingKeyIndexGeneration.ChangedKey(found.rawKey, found.epoch)); + } + iterator.seek(rangeAfterRawKey(databasePrefix, found.rawKey)); + } + } + return Collections.unmodifiableList(result); + } + + @Override + public String getGenerationId() { + return descriptor.generationId; + } + + @Override + public long getIndexedFrom() { + return descriptor.indexedFrom; + } + + @Override + public long getIndexedThrough() { + return descriptor.indexedThrough; + } + + @Override + public byte[] getHeadHash() { + return Arrays.copyOf(descriptor.headHash, descriptor.headHash.length); + } + + @Override + public byte[] getAuthoritativePrefixDigest() { + return Arrays.copyOf(descriptor.sourceDigest, descriptor.sourceDigest.length); + } + + public List getParticipatingDatabases() { + return descriptor.participants; + } + + public String getScopeIdentity() { + return descriptor.scopeIdentity; + } + + public long getKeyChangeCount() { + return descriptor.keyChanges; + } + + public byte[] getLatestSourceIdentityDigest() { + return Arrays.copyOf(descriptor.latestSourceIdentityDigest, + descriptor.latestSourceIdentityDigest.length); + } + + public boolean isLatestSourceIdentityBound() { + for (byte value : descriptor.latestSourceIdentityDigest) { + if (value != 0) { + return true; + } + } + return false; + } + + public boolean supportsRangeQueries() { + return descriptor.formatVersion == VERSION; + } + + public boolean isExactOnlyFormat() { + return descriptor.formatVersion == EXACT_VERSION; + } + + public PersistentStoreCoverage getPersistentStoreCoverage(String dbName) throws IOException { + ensureOpen(); + if (!isExactOnlyFormat()) { + throw new ArchivePersistenceException("Serving generation has no durable Store coverage"); + } + byte[] encoded = database.get(storeCoverageKey(dbName)); + PersistentStoreCoverage coverage = decodeCoverage(encoded); + if (!coverage.dbName.equals(dbName) + || coverage.indexedFrom != descriptor.indexedFrom + || coverage.indexedThrough != descriptor.indexedThrough + || !Arrays.equals(coverage.headHash, descriptor.headHash) + || !Arrays.equals(coverage.sourceDigest, descriptor.sourceDigest) + || !coverage.generationId.equals(descriptor.generationId)) { + throw new ArchivePersistenceException("Serving Store coverage identity mismatch"); + } + return coverage; + } + + /** Performs an explicit read-only scan of v5 metadata for measured observability. */ + public synchronized GenerationStatistics inspectStatistics() throws IOException { + return inspectStatistics(this::readLongProperty); + } + + synchronized GenerationStatistics inspectStatistics(RocksPropertyReader propertyReader) + throws IOException { + ensureOpen(); + Objects.requireNonNull(propertyReader, "propertyReader"); + if (!isExactOnlyFormat()) { + throw new ArchivePersistenceException("Serving statistics require exact-only format"); + } + Map stores = new LinkedHashMap<>(); + for (String participant : descriptor.participants) { + stores.put(participant, inspectStore(participant)); + } + long measuredChanges = stores.values().stream() + .mapToLong(StoreStatistics::getChangeEntryCount).sum(); + if (measuredChanges != descriptor.keyChanges) { + throw new ArchivePersistenceException( + "Serving statistics differ from generation change count"); + } + FileSizeMeasurement files = measureGenerationFiles(directory); + EngineStatistics engine = new EngineStatistics( + readProperty(propertyReader, ESTIMATED_LIVE_DATA_SIZE), + readProperty(propertyReader, TOTAL_SST_FILES_SIZE), + readProperty(propertyReader, PENDING_COMPACTION_BYTES)); + return new GenerationStatistics(descriptor.generationId, descriptor.indexedFrom, + descriptor.indexedThrough, stores, files.apparentBytes, files.allocatedBytes, + files.allocatedExact, engine); + } + + Path getDirectory() { + return directory; + } + + Engine getEngine() { + return engine; + } + + @Override + public synchronized void close() throws IOException { + if (!closed) { + closed = true; + database.close(); + release.run(); + } + } + + private void validateCoverage(String dbName, long targetBlock, long upperBound) { + if (Collections.binarySearch(descriptor.participants, dbName) < 0) { + throw new IllegalArgumentException("Database is outside serving index coverage: " + dbName); + } + if (targetBlock < descriptor.indexedFrom || targetBlock > upperBound + || upperBound > descriptor.indexedThrough) { + throw new IllegalArgumentException("Query range is outside serving index coverage"); + } + } + + private void ensureOpen() { + if (closed) { + throw new IllegalStateException("Serving index generation is closed"); + } + } + + private StoreStatistics inspectStore(String dbName) throws IOException { + byte[] metaPrefix = exactPartitionPrefix(KEY_META_PREFIX, dbName); + byte[] pagePrefix = exactPartitionPrefix(KEY_PAGE_PREFIX, dbName); + long keyMetadata = 0; + long inlineKeys = 0; + long pagedKeys = 0; + long changeEntries = 0; + long expectedPagedEntries = 0; + long pages = 0; + long pagedEntries = 0; + long logicalBytes = 0; + try (StateArchiveIndexDatabase.Cursor iterator = database.cursor()) { + iterator.seek(metaPrefix); + StateArchiveIndexDatabase.KeyValue entry; + while ((entry = iterator.next()) != null && startsWith(entry.getKey(), metaPrefix)) { + byte[] key = entry.getKey(); + byte[] value = entry.getValue(); + KeyMeta meta = decodeKeyMeta(value); + keyMetadata++; + changeEntries += meta.count; + logicalBytes += key.length + value.length; + if (meta.mode == INLINE) { + inlineKeys++; + } else { + pagedKeys++; + expectedPagedEntries += meta.count; + } + } + } + try (StateArchiveIndexDatabase.Cursor iterator = database.cursor()) { + iterator.seek(pagePrefix); + StateArchiveIndexDatabase.KeyValue entry; + while ((entry = iterator.next()) != null && startsWith(entry.getKey(), pagePrefix)) { + byte[] key = entry.getKey(); + byte[] value = entry.getValue(); + pages++; + pagedEntries += decodeEpochPage(value).length; + logicalBytes += key.length + value.length; + } + } + if (pagedEntries != expectedPagedEntries) { + throw new ArchivePersistenceException( + "Serving statistics found inconsistent paged entry totals: " + dbName); + } + byte[] coverageKey = storeCoverageKey(dbName); + byte[] coverageValue = database.get(coverageKey); + decodeCoverage(coverageValue); + logicalBytes += coverageKey.length + coverageValue.length; + return new StoreStatistics(dbName, keyMetadata, inlineKeys, pagedKeys, pages, + changeEntries, logicalBytes); + } + + private static byte[] exactPartitionPrefix(byte prefix, String dbName) { + int storeId = ArchiveParticipantDescriptor.current().getStoreId(dbName); + return ByteBuffer.allocate(1 + Short.BYTES).put(prefix).putShort((short) storeId).array(); + } + + private static FileSizeMeasurement measureGenerationFiles(Path root) throws IOException { + long apparent = 0; + long allocated = 0; + boolean exact = true; + try (Stream paths = Files.walk(root)) { + Iterator iterator = paths.filter(Files::isRegularFile).iterator(); + while (iterator.hasNext()) { + Path file = iterator.next(); + apparent += Files.size(file); + if (exact) { + try { + Number blocks = (Number) Files.getAttribute(file, "unix:blocks", + LinkOption.NOFOLLOW_LINKS); + allocated += blocks.longValue() * 512L; + } catch (UnsupportedOperationException | IllegalArgumentException failure) { + exact = false; + } + } + } + } + return new FileSizeMeasurement(apparent, exact ? allocated : apparent, exact); + } + + private OptionalLong readLongProperty(String name) { + try { + return database.readLongProperty(name); + } catch (IOException | IllegalArgumentException failure) { + return OptionalLong.empty(); + } + } + + private static LongPropertyMeasurement readProperty(RocksPropertyReader reader, String name) + throws IOException { + OptionalLong value = Objects.requireNonNull(reader.read(name), "property value"); + if (value.isPresent() && value.getAsLong() < 0) { + throw new ArchivePersistenceException("Negative RocksDB property: " + name); + } + return value.isPresent() + ? LongPropertyMeasurement.available(value.getAsLong()) + : LongPropertyMeasurement.unavailable(); + } + + private OptionalLong firstExactChangeAfter(String dbName, byte[] rawKey, long targetBlock, + long upperBound) throws IOException { + KeyMeta meta; + byte[] encoded = database.get(keyMetaKey(dbName, rawKey)); + if (encoded == null) { + return OptionalLong.empty(); + } + meta = decodeKeyMeta(encoded); + if (meta.lastEpoch <= targetBlock || meta.firstEpoch > upperBound) { + return OptionalLong.empty(); + } + if (meta.mode == INLINE) { + return firstChange(meta.inlineEpochs, targetBlock, upperBound); + } + int pageCount = pageCount(meta.count); + int low = 0; + int high = pageCount; + while (low < high) { + int middle = (low + high) >>> 1; + long[] page = readPage(dbName, rawKey, middle); + if (page[page.length - 1] <= targetBlock) { + low = middle + 1; + } else { + high = middle; + } + } + if (low == pageCount) { + return OptionalLong.empty(); + } + return firstChange(readPage(dbName, rawKey, low), targetBlock, upperBound); + } + + private long[] readPage(String dbName, byte[] rawKey, int pageIndex) throws IOException { + byte[] encoded = database.get(keyPageKey(dbName, rawKey, pageIndex)); + if (encoded == null) { + throw new ArchivePersistenceException("Exact serving epoch page is missing"); + } + return decodeEpochPage(encoded); + } + + private static long applyExactPlan(StateArchiveIndexDatabase.Writer target, + String generationId, + ServingIndexIncrementalPlan plan, long coverageFrom, byte[] sourceDigest, + ExactWriteFaultHook faultHook) throws IOException { + Map> changes = new LinkedHashMap<>(); + for (Map.Entry> database + : plan.getChangesByDatabase().entrySet()) { + for (ServingIndexIncrementalPlan.KeyChange change : database.getValue()) { + ExactKey key = new ExactKey(database.getKey(), change.getRawKey()); + changes.computeIfAbsent(key, ignored -> new ArrayList<>()).add(change.getEpoch()); + } + } + List mutations = new ArrayList<>(); + for (Map.Entry> entry : changes.entrySet()) { + appendExactChanges(target, mutations, entry.getKey(), entry.getValue()); + } + for (String database : plan.getParticipatingDatabases()) { + PersistentStoreCoverage coverage = new PersistentStoreCoverage(database, + coverageFrom, plan.getIndexedThrough(), plan.getHeadHash(), sourceDigest, + generationId, comparatorId(database)); + mutations.add(StateArchiveIndexDatabase.put(storeCoverageKey(database), + encodeCoverage(coverage))); + } + faultHook.beforeWrite(); + target.write(mutations, true); + return changes.values().stream().mapToLong(List::size).sum(); + } + + static final class RuntimeBuilder implements java.io.Closeable { + + private final Path directory; + private final Engine engine; + private final StateArchiveIndexDatabase.Writer writer; + private Descriptor descriptor; + private boolean closed; + + private RuntimeBuilder(Path directory, Engine engine, + StateArchiveIndexDatabase.Writer writer, Descriptor descriptor) { + this.directory = directory; + this.engine = engine; + this.writer = writer; + this.descriptor = descriptor; + } + + private static RuntimeBuilder open(Path directory, + PersistentServingKeyIndexGeneration source) throws IOException { + Objects.requireNonNull(directory, "directory"); + requireReplaceableBuilderDirectory(directory); + deleteBuilderDirectory(directory); + Files.createDirectories(directory); + StateArchiveIndexEngineManifest.openOrCreate(directory, source.engine); + StateArchiveIndexDatabase.Writer writer = null; + try { + StateArchiveIndexDatabase.checkpointImmutable(source.directory.resolve(DATABASE), + directory.resolve(DATABASE), source.engine); + writer = StateArchiveIndexDatabase.openWriter(directory.resolve(DATABASE), source.engine); + return new RuntimeBuilder(directory, source.engine, writer, source.descriptor); + } catch (IOException | RuntimeException failure) { + if (writer != null) { + try { + writer.close(); + } catch (IOException closeFailure) { + failure.addSuppressed(closeFailure); + } + } + throw failure; + } + } + + synchronized PersistentServingKeyIndexGeneration extendExact(Path shadow, + String generationId, ServingIndexIncrementalPlan plan, + byte[] latestSourceIdentityDigest) throws IOException { + ensureOpen(); + validateExactIdentity(generationId, plan, latestSourceIdentityDigest); + if (plan.getIndexedFrom() != descriptor.indexedThrough + || !Arrays.equals(plan.getIndexedFromHash(), descriptor.headHash) + || !plan.getParticipatingDatabases().equals(descriptor.participants)) { + throw new IllegalArgumentException("Exact serving increment does not extend runtime I"); + } + if (Files.exists(shadow, LinkOption.NOFOLLOW_LINKS)) { + throw new IllegalArgumentException("Serving generation directory already exists"); + } + byte[] sourceDigest = rollSourceDigest(descriptor.sourceDigest, + plan.getSourceStepDigests()); + long added = applyExactPlan(writer, generationId, plan, descriptor.indexedFrom, + sourceDigest, () -> { }); + Descriptor replacement = new Descriptor(EXACT_VERSION, descriptor.scopeIdentity, + generationId, descriptor.indexedFrom, plan.getIndexedThrough(), plan.getHeadHash(), + sourceDigest, latestSourceIdentityDigest, descriptor.participants, + descriptor.keyChanges + added); + descriptor = replacement; + + Files.createDirectories(shadow); + StateArchiveIndexEngineManifest.openOrCreate(shadow, engine); + StateArchiveIndexDatabase.checkpoint(directory.resolve(DATABASE), + shadow.resolve(DATABASE), engine); + persistDescriptor(shadow, replacement); + HistorySegmentStore.syncDirectory(shadow); + return PersistentServingKeyIndexGeneration.open(shadow, engine); + } + + @Override + public synchronized void close() throws IOException { + if (!closed) { + closed = true; + writer.close(); + } + } + + private void ensureOpen() { + if (closed) { + throw new IllegalStateException("Serving runtime builder is closed"); + } + } + + private static void requireReplaceableBuilderDirectory(Path directory) throws IOException { + if (!Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) { + return; + } + if (Files.isSymbolicLink(directory) + || !Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) { + throw new ArchivePersistenceException( + "Serving runtime builder path is not a direct directory"); + } + } + + private static void deleteBuilderDirectory(Path directory) throws IOException { + if (!Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) { + return; + } + List paths = new ArrayList<>(); + try (Stream walk = Files.walk(directory)) { + walk.sorted(java.util.Comparator.reverseOrder()).forEach(paths::add); + } + for (Path path : paths) { + if (Files.isSymbolicLink(path)) { + throw new ArchivePersistenceException( + "Serving runtime builder contains a symbolic link"); + } + Files.delete(path); + } + } + } + + private static void appendExactChanges(StateArchiveIndexDatabase.Writer target, + List batch, ExactKey key, + List appended) throws IOException { + byte[] metaKey = keyMetaKey(key.dbName, key.rawKey); + byte[] existing = target.get(metaKey); + KeyMeta meta = existing == null ? null : decodeKeyMeta(existing); + if (meta == null) { + requireStrictEpochs(appended, Long.MIN_VALUE); + if (appended.size() <= INLINE_EPOCH_LIMIT) { + batch.add(StateArchiveIndexDatabase.put(metaKey, + encodeKeyMeta(KeyMeta.inline(toArray(appended))))); + return; + } + writeAllPages(batch, key, appended, 0); + batch.add(StateArchiveIndexDatabase.put(metaKey, + encodeKeyMeta(KeyMeta.paged(appended.size(), appended.get(0), + appended.get(appended.size() - 1))))); + return; + } + requireStrictEpochs(appended, meta.lastEpoch); + if (meta.mode == INLINE && meta.count + appended.size() <= INLINE_EPOCH_LIMIT) { + List combined = asList(meta.inlineEpochs); + combined.addAll(appended); + batch.add(StateArchiveIndexDatabase.put(metaKey, + encodeKeyMeta(KeyMeta.inline(toArray(combined))))); + return; + } + if (meta.mode == INLINE) { + List combined = asList(meta.inlineEpochs); + combined.addAll(appended); + writeAllPages(batch, key, combined, 0); + } else { + int lastPageIndex = pageCount(meta.count) - 1; + long[] lastPage = decodeEpochPage(target.get( + keyPageKey(key.dbName, key.rawKey, lastPageIndex))); + List combined = asList(lastPage); + combined.addAll(appended); + writeAllPages(batch, key, combined, lastPageIndex); + } + batch.add(StateArchiveIndexDatabase.put(metaKey, + encodeKeyMeta(KeyMeta.paged(meta.count + appended.size(), + meta.firstEpoch, appended.get(appended.size() - 1))))); + } + + private static void writeAllPages(List batch, + ExactKey key, List epochs, int firstPageIndex) { + for (int start = 0, page = firstPageIndex; start < epochs.size(); + start += EPOCHS_PER_PAGE, page++) { + int end = Math.min(start + EPOCHS_PER_PAGE, epochs.size()); + batch.add(StateArchiveIndexDatabase.put(keyPageKey(key.dbName, key.rawKey, page), + encodeEpochPage(toArray(epochs.subList(start, end))))); + } + } + + private static byte[] keyMetaKey(String dbName, byte[] rawKey) { + return exactKey(KEY_META_PREFIX, dbName, rawKey, null); + } + + private static byte[] keyPageKey(String dbName, byte[] rawKey, int pageIndex) { + if (pageIndex < 0) { + throw new IllegalArgumentException("Serving page index must not be negative"); + } + return exactKey(KEY_PAGE_PREFIX, dbName, rawKey, pageIndex); + } + + private static byte[] exactKey(byte prefix, String dbName, byte[] rawKey, Integer pageIndex) { + int storeId = ArchiveParticipantDescriptor.current().getStoreId(dbName); + byte[] encodedKey = encodeRangeRawKey(rawKey); + int suffix = pageIndex == null ? 0 : Integer.BYTES; + ByteBuffer key = ByteBuffer.allocate(1 + Short.BYTES + encodedKey.length + 2 + suffix) + .put(prefix).putShort((short) storeId).put(encodedKey).put((byte) 0).put((byte) 0); + if (pageIndex != null) { + key.putInt(pageIndex); + } + return key.array(); + } + + private static byte[] storeCoverageKey(String dbName) { + int storeId = ArchiveParticipantDescriptor.current().getStoreId(dbName); + return ByteBuffer.allocate(1 + Short.BYTES).put(STORE_COVERAGE_PREFIX) + .putShort((short) storeId).array(); + } + + private static byte[] encodeKeyMeta(KeyMeta meta) { + try { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + DataOutputStream output = new DataOutputStream(bytes); + output.writeByte(meta.mode); + output.writeLong(meta.count); + output.writeLong(meta.firstEpoch); + output.writeLong(meta.lastEpoch); + if (meta.mode == INLINE) { + output.writeInt(meta.inlineEpochs.length); + for (long epoch : meta.inlineEpochs) { + output.writeLong(epoch); + } + } + output.flush(); + return withChecksum(bytes.toByteArray()); + } catch (IOException impossible) { + throw new IllegalStateException("Unexpected exact serving metadata encoding failure", + impossible); + } + } + + private static KeyMeta decodeKeyMeta(byte[] encoded) { + byte[] payload = checkedPayload(encoded, "exact serving key metadata"); + try { + DataInputStream input = new DataInputStream(new ByteArrayInputStream(payload)); + byte mode = input.readByte(); + long count = input.readLong(); + long first = input.readLong(); + long last = input.readLong(); + if (count <= 0 || first < 0 || last < first || mode != INLINE && mode != PAGED) { + throw new ArchivePersistenceException("Invalid exact serving key metadata"); + } + long[] inline = null; + if (mode == INLINE) { + int size = input.readInt(); + if (size != count || size <= 0 || size > INLINE_EPOCH_LIMIT) { + throw new ArchivePersistenceException("Invalid inline serving metadata"); + } + inline = new long[size]; + for (int i = 0; i < size; i++) { + inline[i] = input.readLong(); + } + requireStrictEpochs(asList(inline), Long.MIN_VALUE); + } + if (input.available() != 0) { + throw new ArchivePersistenceException("Exact serving metadata payload mismatch"); + } + return new KeyMeta(mode, count, first, last, inline); + } catch (IOException failure) { + throw new ArchivePersistenceException("Exact serving metadata is truncated", failure); + } + } + + private static byte[] encodeEpochPage(long[] epochs) { + ByteBuffer payload = ByteBuffer.allocate(Integer.BYTES + epochs.length * Long.BYTES) + .putInt(epochs.length); + for (long epoch : epochs) { + payload.putLong(epoch); + } + return withChecksum(payload.array()); + } + + private static long[] decodeEpochPage(byte[] encoded) { + byte[] payload = checkedPayload(encoded, "exact serving epoch page"); + ByteBuffer input = ByteBuffer.wrap(payload); + if (input.remaining() < Integer.BYTES) { + throw new ArchivePersistenceException("Exact serving epoch page is truncated"); + } + int count = input.getInt(); + if (count <= 0 || count > EPOCHS_PER_PAGE + || input.remaining() != count * Long.BYTES) { + throw new ArchivePersistenceException("Invalid exact serving epoch page"); + } + long[] epochs = new long[count]; + for (int i = 0; i < count; i++) { + epochs[i] = input.getLong(); + } + requireStrictEpochs(asList(epochs), Long.MIN_VALUE); + return epochs; + } + + private static byte[] encodeCoverage(PersistentStoreCoverage coverage) { + try { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + DataOutputStream output = new DataOutputStream(bytes); + output.writeUTF(coverage.dbName); + output.writeLong(coverage.indexedFrom); + output.writeLong(coverage.indexedThrough); + output.write(coverage.headHash); + output.write(coverage.sourceDigest); + output.writeUTF(coverage.generationId); + output.writeUTF(coverage.comparatorId); + output.flush(); + return withChecksum(bytes.toByteArray()); + } catch (IOException impossible) { + throw new IllegalStateException("Unexpected Store coverage encoding failure", impossible); + } + } + + private static PersistentStoreCoverage decodeCoverage(byte[] encoded) { + byte[] payload = checkedPayload(encoded, "serving Store coverage"); + try { + DataInputStream input = new DataInputStream(new ByteArrayInputStream(payload)); + String database = input.readUTF(); + long from = input.readLong(); + long through = input.readLong(); + byte[] headHash = new byte[32]; + byte[] sourceDigest = new byte[32]; + input.readFully(headHash); + input.readFully(sourceDigest); + String generationId = input.readUTF(); + String comparatorId = input.readUTF(); + if (from < 0 || through < from || generationId.isEmpty() || comparatorId.isEmpty() + || input.available() != 0) { + throw new ArchivePersistenceException("Invalid serving Store coverage"); + } + return new PersistentStoreCoverage(database, from, through, headHash, sourceDigest, + generationId, comparatorId); + } catch (IOException failure) { + throw new ArchivePersistenceException("Serving Store coverage is truncated", failure); + } + } + + private static void validateExactStoreCoverage(StateArchiveIndexDatabase.Reader database, + Descriptor descriptor) throws IOException { + for (String participant : descriptor.participants) { + PersistentStoreCoverage coverage = decodeCoverage(database.get( + storeCoverageKey(participant))); + if (!participant.equals(coverage.dbName) + || coverage.indexedFrom != descriptor.indexedFrom + || coverage.indexedThrough != descriptor.indexedThrough + || !Arrays.equals(coverage.headHash, descriptor.headHash) + || !Arrays.equals(coverage.sourceDigest, descriptor.sourceDigest) + || !coverage.generationId.equals(descriptor.generationId) + || !coverage.comparatorId.equals(comparatorId(participant))) { + throw new ArchivePersistenceException("Serving Store coverage identity mismatch"); + } + } + } + + private static byte[] withChecksum(byte[] payload) { + return ByteBuffer.allocate(payload.length + Integer.BYTES).put(payload) + .putInt(Hashing.crc32c().hashBytes(payload).asInt()).array(); + } + + private static byte[] checkedPayload(byte[] encoded, String name) { + if (encoded == null || encoded.length <= Integer.BYTES) { + throw new ArchivePersistenceException(name + " is missing or truncated"); + } + byte[] payload = Arrays.copyOf(encoded, encoded.length - Integer.BYTES); + int checksum = ByteBuffer.wrap(encoded, payload.length, Integer.BYTES).getInt(); + if (checksum != Hashing.crc32c().hashBytes(payload).asInt()) { + throw new ArchivePersistenceException(name + " checksum mismatch"); + } + return payload; + } + + private static void requireStrictEpochs(List epochs, long previous) { + for (long epoch : epochs) { + if (epoch < 0 || epoch <= previous) { + throw new ArchivePersistenceException("Serving epochs are not strictly increasing"); + } + previous = epoch; + } + } + + private static int pageCount(long count) { + return (int) ((count + EPOCHS_PER_PAGE - 1) / EPOCHS_PER_PAGE); + } + + private static long[] toArray(List epochs) { + long[] result = new long[epochs.size()]; + for (int i = 0; i < epochs.size(); i++) { + result[i] = epochs.get(i); + } + return result; + } + + private static List asList(long[] epochs) { + List result = new ArrayList<>(epochs.length); + for (long epoch : epochs) { + result.add(epoch); + } + return result; + } + + private static OptionalLong firstChange(long[] epochs, long target, long upperBound) { + int low = 0; + int high = epochs.length; + while (low < high) { + int middle = (low + high) >>> 1; + if (epochs[middle] <= target) { + low = middle + 1; + } else { + high = middle; + } + } + return low < epochs.length && epochs[low] <= upperBound + ? OptionalLong.of(epochs[low]) : OptionalLong.empty(); + } + + private static byte[] rollingDigest(byte[] previous, byte[] delta) { + MessageDigest digest = sha256(); + digest.update(previous); + digest.update(delta); + return digest.digest(); + } + + private static byte[] rollSourceDigest(byte[] seed, List steps) { + byte[] result = Arrays.copyOf(seed, seed.length); + for (byte[] step : steps) { + result = rollingDigest(result, step); + } + return result; + } + + static byte[] sourceDigestForRebuild(ServingIndexIncrementalPlan plan) { + Objects.requireNonNull(plan, "plan"); + return rollSourceDigest(plan.getSourceSeedDigest(), plan.getSourceStepDigests()); + } + + private static String comparatorId(String dbName) { + return "market_pair_price_to_order".equals(dbName) + ? "MARKET_PRICE_V1" : "UNSIGNED_RAW_V1"; + } + + private static Engine configuredEngine() { + return StateArchiveCheckpointServingIndex.configuredEngine(); + } + + private static void validateExactIdentity(String generationId, + ServingIndexIncrementalPlan plan, byte[] latestSourceIdentityDigest) { + if (generationId == null || generationId.isEmpty()) { + throw new IllegalArgumentException("generationId must not be empty"); + } + requireHash(latestSourceIdentityDigest, "latestSourceIdentityDigest"); + ArchiveParticipantDescriptor.current().requireExactParticipants( + plan.getParticipatingDatabases()); + } + + private static byte[] dataKey(String dbName, byte[] rawKey, long epoch) { + if (epoch < 0) { + throw new IllegalArgumentException("Serving index epoch must not be negative"); + } + byte[] prefix = dataPrefix(dbName, rawKey); + return ByteBuffer.allocate(prefix.length + Long.BYTES).put(prefix).putLong(epoch).array(); + } + + private static byte[] dataPrefix(String dbName, byte[] rawKey) { + byte[] database = dbName.getBytes(StandardCharsets.UTF_8); + return ByteBuffer.allocate(1 + Integer.BYTES + database.length + Integer.BYTES + rawKey.length) + .put(DATA_PREFIX).putInt(database.length).put(database).putInt(rawKey.length).put(rawKey) + .array(); + } + + private static byte[] rangeDataKey(String dbName, byte[] rawKey, long epoch) { + if (epoch < 0) { + throw new IllegalArgumentException("Serving index epoch must not be negative"); + } + byte[] databasePrefix = rangeDatabasePrefix(dbName); + byte[] encodedKey = encodeRangeRawKey(rawKey); + return ByteBuffer.allocate(databasePrefix.length + encodedKey.length + 2 + Long.BYTES) + .put(databasePrefix).put(encodedKey).put((byte) 0).put((byte) 0).putLong(epoch).array(); + } + + private static byte[] rangeDatabasePrefix(String dbName) { + byte[] name = dbName.getBytes(StandardCharsets.UTF_8); + return ByteBuffer.allocate(1 + Integer.BYTES + name.length) + .put(RANGE_DATA_PREFIX).putInt(name.length).put(name).array(); + } + + private static byte[] encodeRangeRawKey(byte[] rawKey) { + ByteArrayOutputStream encoded = new ByteArrayOutputStream(rawKey.length); + for (byte value : rawKey) { + encoded.write(value); + if (value == 0) { + encoded.write(0xff); + } + } + return encoded.toByteArray(); + } + + private static byte[] rangeAfterRawKey(byte[] databasePrefix, byte[] rawKey) { + byte[] encoded = encodeRangeRawKey(rawKey); + return ByteBuffer.allocate(databasePrefix.length + encoded.length + 2) + .put(databasePrefix).put(encoded).put((byte) 0).put((byte) 1).array(); + } + + private static RangeDataKey decodeRangeDataKey(byte[] key, byte[] databasePrefix) { + if (!startsWith(key, databasePrefix) || key.length < databasePrefix.length + 2 + Long.BYTES) { + return null; + } + ByteArrayOutputStream rawKey = new ByteArrayOutputStream(); + int cursor = databasePrefix.length; + int epochOffset = key.length - Long.BYTES; + while (cursor < epochOffset) { + byte value = key[cursor++]; + if (value != 0) { + rawKey.write(value); + } else if (cursor < epochOffset && key[cursor] == (byte) 0xff) { + rawKey.write(0); + cursor++; + } else if (cursor < epochOffset && key[cursor] == 0) { + cursor++; + if (cursor != epochOffset) { + return null; + } + return new RangeDataKey(rawKey.toByteArray(), + ByteBuffer.wrap(key, epochOffset, Long.BYTES).getLong()); + } else { + return null; + } + } + return null; + } + + private static byte[] concat(byte[] left, byte[] right) { + return ByteBuffer.allocate(left.length + right.length).put(left).put(right).array(); + } + + private static final class RangeDataKey { + private final byte[] rawKey; + private final long epoch; + + private RangeDataKey(byte[] rawKey, long epoch) { + this.rawKey = rawKey; + this.epoch = epoch; + } + } + + private static boolean startsWith(byte[] value, byte[] prefix) { + if (value.length < prefix.length) { + return false; + } + for (int i = 0; i < prefix.length; i++) { + if (value[i] != prefix[i]) { + return false; + } + } + return true; + } + + private static void validateNext(HistoryCommitMarker marker, long previousEpoch, + long previousBlock, byte[] previousHash, List participants) { + BlockSnapshotMeta meta = marker.getMeta(); + if (marker.getPreviousEpoch() != previousEpoch || meta.getEpoch() != previousEpoch + 1 + || meta.getBlockNumber() != previousBlock + 1 + || !Arrays.equals(meta.getParentHash(), previousHash) + || !participants.equals(marker.getDatabases())) { + throw new IllegalArgumentException("Serving index source commit prefix is inconsistent"); + } + } + + private static void validateMarker(HistoryCommitMarker marker, HistoryIndexRecord record, + List participants) { + if (record == null || !marker.getMeta().equals(record.getMeta()) + || !same(marker.getHistoryLocation(), record.getHistoryLocation())) { + throw new IllegalArgumentException( + "Commit marker does not match authoritative history index record"); + } + for (HistoryIndexRecord.KeyGroup group : record.getGroups()) { + if (Collections.binarySearch(participants, group.getDbName()) < 0) { + throw new IllegalArgumentException("History index contains an unknown database"); + } + } + } + + private static boolean same(HistoryLocation left, HistoryLocation right) { + return left.getSegmentId() == right.getSegmentId() + && left.getOffset() == right.getOffset() + && left.getRecordLength() == right.getRecordLength() + && left.getBodyChecksum() == right.getBodyChecksum() + && Arrays.equals(left.getBodyDigest(), right.getBodyDigest()); + } + + private static List sortedParticipants(List databases) { + List result = new ArrayList<>(Objects.requireNonNull(databases, "databases")); + Collections.sort(result); + if (result.isEmpty()) { + throw new IllegalArgumentException("Serving index participant set must not be empty"); + } + String previous = null; + for (String database : result) { + if (database == null || database.isEmpty() || database.equals(previous)) { + throw new IllegalArgumentException("Serving index participant set is invalid"); + } + previous = database; + } + return Collections.unmodifiableList(result); + } + + private static void updateSourceDigest(MessageDigest digest, HistoryCommitMarker marker) { + updateLong(digest, marker.getMeta().getEpoch()); + updateLong(digest, marker.getMeta().getBlockNumber()); + digest.update(marker.getMeta().getBlockHash()); + digest.update(marker.getMeta().getParentHash()); + updateLong(digest, marker.getIndexLocation().getOffset()); + updateLong(digest, marker.getIndexLocation().getRecordLength()); + digest.update(marker.getIndexLocation().getDigest()); + digest.update(marker.getHistoryLocation().getBodyDigest()); + } + + private static void updateParticipantDigest(MessageDigest digest, List databases) { + updateLong(digest, databases.size()); + for (String database : databases) { + byte[] encoded = database.getBytes(StandardCharsets.UTF_8); + updateLong(digest, encoded.length); + digest.update(encoded); + } + } + + private static void updateStringDigest(MessageDigest digest, String value) { + byte[] encoded = value.getBytes(StandardCharsets.UTF_8); + updateLong(digest, encoded.length); + digest.update(encoded); + } + + private static void updateLong(MessageDigest digest, long value) { + digest.update(ByteBuffer.allocate(Long.BYTES).putLong(value).array()); + } + + private static MessageDigest sha256() { + try { + return MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException impossible) { + throw new IllegalStateException("SHA-256 is unavailable", impossible); + } + } + + private static void requireHash(byte[] hash, String name) { + if (hash == null || hash.length != 32) { + throw new IllegalArgumentException(name + " must be exactly 32 bytes"); + } + } + + private static void persistDescriptor(Path directory, Descriptor descriptor) throws IOException { + byte[] encoded = encodeDescriptor(descriptor); + Path temporary = directory.resolve(MANIFEST_TEMP); + try (FileChannel channel = FileChannel.open(temporary, StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE)) { + ByteBuffer buffer = ByteBuffer.wrap(encoded); + while (buffer.hasRemaining()) { + channel.write(buffer); + } + channel.force(true); + } + try { + Files.move(temporary, directory.resolve(MANIFEST), StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException unsupported) { + throw new ArchivePersistenceException( + "Serving index filesystem does not support atomic manifests", unsupported); + } + } + + private static Descriptor loadDescriptor(Path directory) throws IOException { + Path manifest = directory.resolve(MANIFEST); + if (!Files.isRegularFile(manifest)) { + throw new ArchivePersistenceException("Serving index generation manifest is missing"); + } + try { + return decodeDescriptor(Files.readAllBytes(manifest)); + } catch (IllegalArgumentException invalid) { + throw new ArchivePersistenceException("Serving index generation manifest is corrupt", + invalid); + } + } + + private static byte[] encodeDescriptor(Descriptor descriptor) { + try { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + DataOutputStream output = new DataOutputStream(bytes); + output.writeInt(MAGIC); + output.writeShort(descriptor.formatVersion); + output.writeShort(0); + output.writeUTF(descriptor.scopeIdentity); + output.writeUTF(descriptor.generationId); + output.writeLong(descriptor.indexedFrom); + output.writeLong(descriptor.indexedThrough); + output.write(descriptor.headHash); + output.write(descriptor.sourceDigest); + output.write(descriptor.latestSourceIdentityDigest); + output.writeLong(descriptor.keyChanges); + output.writeInt(descriptor.participants.size()); + for (String participant : descriptor.participants) { + output.writeUTF(participant); + } + output.flush(); + byte[] payload = bytes.toByteArray(); + output.writeInt(Hashing.crc32c().hashBytes(payload).asInt()); + output.flush(); + return bytes.toByteArray(); + } catch (IOException impossible) { + throw new IllegalStateException("Unexpected serving manifest encoding failure", impossible); + } + } + + private static Descriptor decodeDescriptor(byte[] encoded) { + if (encoded == null || encoded.length < 96 || encoded.length > MAX_MANIFEST_SIZE) { + throw new IllegalArgumentException("Serving index manifest length is invalid"); + } + byte[] payload = Arrays.copyOf(encoded, encoded.length - Integer.BYTES); + int checksum = ByteBuffer.wrap(encoded, payload.length, Integer.BYTES).getInt(); + if (checksum != Hashing.crc32c().hashBytes(payload).asInt()) { + throw new IllegalArgumentException("Serving index manifest checksum mismatch"); + } + try { + DataInputStream input = new DataInputStream(new ByteArrayInputStream(encoded)); + if (input.readInt() != MAGIC) { + throw new IllegalArgumentException("Unsupported serving index manifest"); + } + short version = input.readShort(); + if ((version != EXACT_VERSION && version != VERSION && version != VERSION - 1) + || input.readShort() != 0) { + throw new IllegalArgumentException("Unsupported serving index manifest"); + } + String scopeIdentity = input.readUTF(); + String generationId = input.readUTF(); + long from = input.readLong(); + long through = input.readLong(); + byte[] headHash = new byte[32]; + byte[] sourceDigest = new byte[32]; + input.readFully(headHash); + input.readFully(sourceDigest); + byte[] latestSourceIdentityDigest = new byte[32]; + input.readFully(latestSourceIdentityDigest); + long keyChanges = input.readLong(); + int count = input.readInt(); + if (generationId.isEmpty() || from < 0 || through < from || keyChanges < 0 + || count <= 0 || count > ArchiveStoreScope.getStateDatabases().size() + 16) { + throw new IllegalArgumentException("Invalid serving index manifest fields"); + } + List participants = new ArrayList<>(count); + for (int i = 0; i < count; i++) { + participants.add(input.readUTF()); + } + if (input.available() != Integer.BYTES) { + throw new IllegalArgumentException("Serving index manifest payload mismatch"); + } + List sorted = sortedParticipants(participants); + if (!scopeIdentity.equals(ArchiveParticipantDescriptor.scopeIdentity(sorted))) { + throw new IllegalArgumentException("Serving index manifest scope identity mismatch"); + } + return new Descriptor(version, scopeIdentity, generationId, from, through, headHash, + sourceDigest, latestSourceIdentityDigest, sorted, keyChanges); + } catch (IOException invalid) { + throw new IllegalArgumentException("Serving index manifest is truncated", invalid); + } + } + + private static final class Descriptor { + private final short formatVersion; + private final String scopeIdentity; + private final String generationId; + private final long indexedFrom; + private final long indexedThrough; + private final byte[] headHash; + private final byte[] sourceDigest; + private final byte[] latestSourceIdentityDigest; + private final List participants; + private final long keyChanges; + + private Descriptor(short formatVersion, String scopeIdentity, String generationId, + long indexedFrom, + long indexedThrough, + byte[] headHash, byte[] sourceDigest, byte[] latestSourceIdentityDigest, + List participants, long keyChanges) { + this.formatVersion = formatVersion; + this.scopeIdentity = scopeIdentity; + this.generationId = generationId; + this.indexedFrom = indexedFrom; + this.indexedThrough = indexedThrough; + this.headHash = Arrays.copyOf(headHash, headHash.length); + this.sourceDigest = Arrays.copyOf(sourceDigest, sourceDigest.length); + this.latestSourceIdentityDigest = Arrays.copyOf(latestSourceIdentityDigest, + latestSourceIdentityDigest.length); + this.participants = participants; + this.keyChanges = keyChanges; + } + } + + @FunctionalInterface + interface ExactWriteFaultHook { + void beforeWrite() throws IOException; + } + + /** Durable completeness identity for one exact-27 Store partition. */ + public static final class PersistentStoreCoverage { + private final String dbName; + private final long indexedFrom; + private final long indexedThrough; + private final byte[] headHash; + private final byte[] sourceDigest; + private final String generationId; + private final String comparatorId; + + private PersistentStoreCoverage(String dbName, long indexedFrom, long indexedThrough, + byte[] headHash, byte[] sourceDigest, String generationId, String comparatorId) { + this.dbName = dbName; + this.indexedFrom = indexedFrom; + this.indexedThrough = indexedThrough; + this.headHash = Arrays.copyOf(headHash, headHash.length); + this.sourceDigest = Arrays.copyOf(sourceDigest, sourceDigest.length); + this.generationId = generationId; + this.comparatorId = comparatorId; + } + + public String getDbName() { + return dbName; + } + + public long getIndexedFrom() { + return indexedFrom; + } + + public long getIndexedThrough() { + return indexedThrough; + } + + public byte[] getHeadHash() { + return Arrays.copyOf(headHash, headHash.length); + } + + public byte[] getSourceDigest() { + return Arrays.copyOf(sourceDigest, sourceDigest.length); + } + + public String getGenerationId() { + return generationId; + } + + public String getComparatorId() { + return comparatorId; + } + } + + /** Read-only measured statistics for one immutable v5 generation. */ + public static final class GenerationStatistics { + private final String generationId; + private final long indexedFrom; + private final long indexedThrough; + private final Map stores; + private final long apparentBytes; + private final long allocatedBytes; + private final boolean allocatedBytesExact; + private final EngineStatistics engine; + + private GenerationStatistics(String generationId, long indexedFrom, long indexedThrough, + Map stores, long apparentBytes, long allocatedBytes, + boolean allocatedBytesExact, EngineStatistics engine) { + this.generationId = generationId; + this.indexedFrom = indexedFrom; + this.indexedThrough = indexedThrough; + this.stores = Collections.unmodifiableMap(new LinkedHashMap<>(stores)); + this.apparentBytes = apparentBytes; + this.allocatedBytes = allocatedBytes; + this.allocatedBytesExact = allocatedBytesExact; + this.engine = engine; + } + + public String getGenerationId() { + return generationId; + } + + public long getIndexedFrom() { + return indexedFrom; + } + + public long getIndexedThrough() { + return indexedThrough; + } + + public Map getStores() { + return stores; + } + + public long getApparentBytes() { + return apparentBytes; + } + + public long getAllocatedBytes() { + return allocatedBytes; + } + + public boolean isAllocatedBytesExact() { + return allocatedBytesExact; + } + + public EngineStatistics getEngine() { + return engine; + } + } + + /** RocksDB properties sampled from the pinned immutable generation. */ + public static final class EngineStatistics { + private final LongPropertyMeasurement estimatedLiveDataBytes; + private final LongPropertyMeasurement totalSstBytes; + private final LongPropertyMeasurement pendingCompactionBytes; + + private EngineStatistics(LongPropertyMeasurement estimatedLiveDataBytes, + LongPropertyMeasurement totalSstBytes, + LongPropertyMeasurement pendingCompactionBytes) { + this.estimatedLiveDataBytes = estimatedLiveDataBytes; + this.totalSstBytes = totalSstBytes; + this.pendingCompactionBytes = pendingCompactionBytes; + } + + public LongPropertyMeasurement getEstimatedLiveDataBytes() { + return estimatedLiveDataBytes; + } + + public LongPropertyMeasurement getTotalSstBytes() { + return totalSstBytes; + } + + public LongPropertyMeasurement getPendingCompactionBytes() { + return pendingCompactionBytes; + } + } + + /** One property value with an explicit unsupported/unavailable state. */ + public static final class LongPropertyMeasurement { + private final boolean available; + private final long value; + + private LongPropertyMeasurement(boolean available, long value) { + this.available = available; + this.value = value; + } + + private static LongPropertyMeasurement available(long value) { + return new LongPropertyMeasurement(true, value); + } + + private static LongPropertyMeasurement unavailable() { + return new LongPropertyMeasurement(false, 0); + } + + public boolean isAvailable() { + return available; + } + + public long getValue() { + if (!available) { + throw new IllegalStateException("RocksDB property is unavailable"); + } + return value; + } + } + + /** Logical RocksDB entry statistics for one exact Store partition. */ + public static final class StoreStatistics { + private final String dbName; + private final long keyMetadataCount; + private final long inlineKeyCount; + private final long pagedKeyCount; + private final long pageCount; + private final long changeEntryCount; + private final long logicalBytes; + + private StoreStatistics(String dbName, long keyMetadataCount, long inlineKeyCount, + long pagedKeyCount, long pageCount, long changeEntryCount, long logicalBytes) { + this.dbName = dbName; + this.keyMetadataCount = keyMetadataCount; + this.inlineKeyCount = inlineKeyCount; + this.pagedKeyCount = pagedKeyCount; + this.pageCount = pageCount; + this.changeEntryCount = changeEntryCount; + this.logicalBytes = logicalBytes; + } + + public String getDbName() { + return dbName; + } + + public long getKeyMetadataCount() { + return keyMetadataCount; + } + + public long getInlineKeyCount() { + return inlineKeyCount; + } + + public long getPagedKeyCount() { + return pagedKeyCount; + } + + public long getPageCount() { + return pageCount; + } + + public long getChangeEntryCount() { + return changeEntryCount; + } + + public long getLogicalBytes() { + return logicalBytes; + } + } + + private static final class FileSizeMeasurement { + private final long apparentBytes; + private final long allocatedBytes; + private final boolean allocatedExact; + + private FileSizeMeasurement(long apparentBytes, long allocatedBytes, + boolean allocatedExact) { + this.apparentBytes = apparentBytes; + this.allocatedBytes = allocatedBytes; + this.allocatedExact = allocatedExact; + } + } + + @FunctionalInterface + interface RocksPropertyReader { + OptionalLong read(String name) throws IOException; + } + + private static final class KeyMeta { + private final byte mode; + private final long count; + private final long firstEpoch; + private final long lastEpoch; + private final long[] inlineEpochs; + + private KeyMeta(byte mode, long count, long firstEpoch, long lastEpoch, + long[] inlineEpochs) { + this.mode = mode; + this.count = count; + this.firstEpoch = firstEpoch; + this.lastEpoch = lastEpoch; + this.inlineEpochs = inlineEpochs; + } + + private static KeyMeta inline(long[] epochs) { + return new KeyMeta(INLINE, epochs.length, epochs[0], epochs[epochs.length - 1], epochs); + } + + private static KeyMeta paged(long count, long firstEpoch, long lastEpoch) { + return new KeyMeta(PAGED, count, firstEpoch, lastEpoch, null); + } + } + + private static final class ExactKey { + private final String dbName; + private final byte[] rawKey; + + private ExactKey(String dbName, byte[] rawKey) { + this.dbName = dbName; + this.rawKey = Arrays.copyOf(rawKey, rawKey.length); + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof ExactKey)) { + return false; + } + ExactKey that = (ExactKey) other; + return dbName.equals(that.dbName) && Arrays.equals(rawKey, that.rawKey); + } + + @Override + public int hashCode() { + return 31 * dbName.hashCode() + Arrays.hashCode(rawKey); + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/PhysicalSnapshotPathStateCollector.java b/chainbase/src/main/java/org/tron/core/db2/archive/PhysicalSnapshotPathStateCollector.java new file mode 100644 index 00000000000..8990d5d40c4 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/PhysicalSnapshotPathStateCollector.java @@ -0,0 +1,35 @@ +package org.tron.core.db2.archive; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import org.tron.core.db2.stateroot.PathStateBlockTransition; +import org.tron.core.db2.stateroot.PathStateCanonicalizer.P66Phase; +import org.tron.core.db2.stateroot.PathStateMutation; +import org.tron.core.db2.stateroot.PathStateTransitionCollector; + +/** Consumes exact physical mutations after P66 materialization; no projection or scans. */ +public final class PhysicalSnapshotPathStateCollector implements PathStateTransitionCollector { + @Override + public PathStateBlockTransition collect(BlockChangeView view) { + boolean enabled = SnapshotOldValueCollector.resolveTargetAssetOptimization(view); + List mutations = new ArrayList<>(); + for (BlockChangeView.DatabaseChanges database : view.getDatabases()) { + for (BlockChangeView.Change change : database.getChanges()) { + byte[] key = change.getKey(); + byte[] oldValue = database.getPrevious(key); + byte[] value = change.getPostValue().isPresent() + ? change.getPostValue().getValue() : null; + if (!Arrays.equals(oldValue, value)) { + mutations.add((value == null ? PathStateMutation.delete(database.getDbName(), key) + : PathStateMutation.put(database.getDbName(), key, value)) + .withPreviousPhysicalValue(oldValue)); + } + } + } + BlockSnapshotMeta meta = view.getMeta(); + return new PathStateBlockTransition(meta.getBlockNumber(), meta.getBlockHash(), + meta.getParentHash(), meta.getTimestamp(), enabled ? P66Phase.P66_ON : P66Phase.P66_OFF, + mutations); + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/ServingIndexIncrementalPlan.java b/chainbase/src/main/java/org/tron/core/db2/archive/ServingIndexIncrementalPlan.java new file mode 100644 index 00000000000..0c472ff5ab7 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/ServingIndexIncrementalPlan.java @@ -0,0 +1,341 @@ +package org.tron.core.db2.archive; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Backend-neutral logical plan for advancing the exact-27 serving index over {@code (I,H]}. + * + *

The plan deliberately defines no persistent key, page, checksum, compression, or partition + * encoding. A durable backend may apply it only after the corresponding format decision is + * approved. Construction validates the complete suffix before exposing a target boundary, so a + * rejected suffix cannot partially advance serving coverage. + */ +public final class ServingIndexIncrementalPlan { + + private final long indexedFrom; + private final byte[] indexedFromHash; + private final long indexedThrough; + private final byte[] headHash; + private final byte[] deltaSourceDigest; + private final byte[] sourceSeedDigest; + private final List sourceStepDigests; + private final List participatingDatabases; + private final Map> changesByDatabase; + + private ServingIndexIncrementalPlan(long indexedFrom, byte[] indexedFromHash, + long indexedThrough, byte[] headHash, byte[] deltaSourceDigest, + byte[] sourceSeedDigest, List sourceStepDigests, + List participatingDatabases, + Map> changesByDatabase) { + this.indexedFrom = indexedFrom; + this.indexedFromHash = Arrays.copyOf(indexedFromHash, indexedFromHash.length); + this.indexedThrough = indexedThrough; + this.headHash = Arrays.copyOf(headHash, headHash.length); + this.deltaSourceDigest = Arrays.copyOf(deltaSourceDigest, deltaSourceDigest.length); + this.sourceSeedDigest = Arrays.copyOf(sourceSeedDigest, sourceSeedDigest.length); + List immutableSteps = new ArrayList<>(sourceStepDigests.size()); + sourceStepDigests.forEach(step -> immutableSteps.add(Arrays.copyOf(step, step.length))); + this.sourceStepDigests = Collections.unmodifiableList(immutableSteps); + this.participatingDatabases = participatingDatabases; + this.changesByDatabase = changesByDatabase; + } + + /** Validates and plans only the committed suffix after {@code indexedThrough}. */ + public static ServingIndexIncrementalPlan plan(long indexedThrough, byte[] headHash, + List participatingDatabases, List committedSuffix, + ServingKeyIndexGeneration.AuthoritativeIndexReader reader) throws IOException { + if (indexedThrough < 0) { + throw new IllegalArgumentException("indexedThrough must not be negative"); + } + requireHash(headHash, "headHash"); + List participants = exactParticipants(participatingDatabases); + Objects.requireNonNull(committedSuffix, "committedSuffix"); + Objects.requireNonNull(reader, "reader"); + + Map> changes = new LinkedHashMap<>(); + participants.forEach(database -> changes.put(database, new ArrayList<>())); + MessageDigest seedDigest = sha256(); + updateLong(seedDigest, indexedThrough); + seedDigest.update(headHash); + updateParticipants(seedDigest, participants); + byte[] sourceSeedDigest = seedDigest.digest(); + MessageDigest deltaDigest = sha256(); + deltaDigest.update(sourceSeedDigest); + List sourceSteps = new ArrayList<>(); + + long previousEpoch = indexedThrough; + long previousBlock = indexedThrough; + byte[] previousHash = Arrays.copyOf(headHash, headHash.length); + for (HistoryCommitMarker marker : committedSuffix) { + Objects.requireNonNull(marker, "committed marker"); + validateNext(marker, previousEpoch, previousBlock, previousHash, participants); + HistoryIndexRecord record = reader.read(marker.getIndexLocation()); + validateRecord(marker, record, participants); + collectChanges(changes, record); + MessageDigest stepDigest = sha256(); + updateSourceDigest(stepDigest, marker); + byte[] sourceStep = stepDigest.digest(); + sourceSteps.add(sourceStep); + deltaDigest.update(sourceStep); + previousEpoch = marker.getMeta().getEpoch(); + previousBlock = marker.getMeta().getBlockNumber(); + previousHash = marker.getMeta().getBlockHash(); + } + + Map> immutableChanges = new LinkedHashMap<>(); + changes.forEach((database, databaseChanges) -> immutableChanges.put(database, + Collections.unmodifiableList(new ArrayList<>(databaseChanges)))); + return new ServingIndexIncrementalPlan(indexedThrough, headHash, previousEpoch, previousHash, + deltaDigest.digest(), sourceSeedDigest, sourceSteps, participants, + Collections.unmodifiableMap(immutableChanges)); + } + + /** Plans an exact-27 increment directly from Common-committed captured reverse diffs. */ + static ServingIndexIncrementalPlan planCommittedDiffs(long indexedThrough, byte[] headHash, + List committedDiffs) { + if (indexedThrough < 0) { + throw new IllegalArgumentException("indexedThrough must not be negative"); + } + requireHash(headHash, "headHash"); + List participants = exactParticipants( + new ArrayList<>(ArchiveStoreScope.getStateDatabases())); + List diffs = new ArrayList<>(Objects.requireNonNull(committedDiffs, + "committedDiffs")); + Map> changes = new LinkedHashMap<>(); + participants.forEach(database -> changes.put(database, new ArrayList<>())); + MessageDigest seed = sha256(); + updateLong(seed, indexedThrough); + seed.update(headHash); + updateParticipants(seed, participants); + byte[] sourceSeed = seed.digest(); + MessageDigest delta = sha256(); + delta.update(sourceSeed); + List steps = new ArrayList<>(); + long previousBlock = indexedThrough; + byte[] previousHash = Arrays.copyOf(headHash, headHash.length); + BlockHistoryCodec sourceCodec = new BlockHistoryCodec(); + for (BlockReverseDiff diff : diffs) { + BlockSnapshotMeta meta = Objects.requireNonNull(diff, "committedDiff").getMeta(); + if (meta.getEpoch() != previousBlock + 1 || meta.getBlockNumber() != previousBlock + 1 + || !Arrays.equals(meta.getParentHash(), previousHash)) { + throw new IllegalArgumentException("Serving committed diff suffix is not contiguous"); + } + String previousDatabase = null; + for (BlockReverseDiff.DbGroup group : diff.getGroups()) { + List database = changes.get(group.getDbName()); + if (database == null || previousDatabase != null + && previousDatabase.compareTo(group.getDbName()) >= 0) { + throw new IllegalArgumentException("Serving committed diff Store coverage is invalid"); + } + byte[] previousKey = null; + for (BlockReverseDiff.Entry entry : group.getEntries()) { + byte[] key = entry.getKey(); + if (previousKey != null && BlockReverseDiff.compareUnsigned(previousKey, key) >= 0) { + throw new IllegalArgumentException("Serving committed diff keys are not unique"); + } + database.add(new KeyChange(key, meta.getEpoch())); + previousKey = key; + } + previousDatabase = group.getDbName(); + } + byte[] step = sha256().digest(sourceCodec.encode(diff)); + steps.add(step); + delta.update(step); + previousBlock = meta.getBlockNumber(); + previousHash = meta.getBlockHash(); + } + Map> immutable = new LinkedHashMap<>(); + changes.forEach((database, databaseChanges) -> immutable.put(database, + Collections.unmodifiableList(new ArrayList<>(databaseChanges)))); + return new ServingIndexIncrementalPlan(indexedThrough, headHash, previousBlock, + previousHash, delta.digest(), sourceSeed, steps, participants, + Collections.unmodifiableMap(immutable)); + } + + public long getIndexedFrom() { + return indexedFrom; + } + + public long getIndexedThrough() { + return indexedThrough; + } + + public byte[] getIndexedFromHash() { + return Arrays.copyOf(indexedFromHash, indexedFromHash.length); + } + + public byte[] getHeadHash() { + return Arrays.copyOf(headHash, headHash.length); + } + + /** Identity of this validated suffix only; it is not a frozen persistent rolling digest. */ + public byte[] getDeltaSourceDigest() { + return Arrays.copyOf(deltaSourceDigest, deltaSourceDigest.length); + } + + /** Stable seed for a rebuild beginning at this plan's I/hash/exact-27 scope. */ + public byte[] getSourceSeedDigest() { + return Arrays.copyOf(sourceSeedDigest, sourceSeedDigest.length); + } + + /** Per-commit source steps make the rolling identity independent of flush batch boundaries. */ + public List getSourceStepDigests() { + List copies = new ArrayList<>(sourceStepDigests.size()); + sourceStepDigests.forEach(step -> copies.add(Arrays.copyOf(step, step.length))); + return Collections.unmodifiableList(copies); + } + + public List getParticipatingDatabases() { + return participatingDatabases; + } + + /** Returns an entry for every exact-27 Store, including Stores with no changes in this suffix. */ + public Map> getChangesByDatabase() { + return changesByDatabase; + } + + public List getChanges(String dbName) { + List changes = changesByDatabase.get(Objects.requireNonNull(dbName, "dbName")); + if (changes == null) { + throw new IllegalArgumentException("Database is outside exact-27 serving scope: " + dbName); + } + return changes; + } + + private static void validateNext(HistoryCommitMarker marker, long previousEpoch, + long previousBlock, byte[] previousHash, List participants) { + BlockSnapshotMeta meta = marker.getMeta(); + if (marker.getPreviousEpoch() != previousEpoch || meta.getEpoch() != previousEpoch + 1 + || meta.getBlockNumber() != previousBlock + 1 + || !Arrays.equals(meta.getParentHash(), previousHash) + || !participants.equals(marker.getDatabases())) { + throw new IllegalArgumentException("Serving index suffix is not contiguous exact-27 history"); + } + } + + private static void validateRecord(HistoryCommitMarker marker, HistoryIndexRecord record, + List participants) { + if (record == null || !marker.getMeta().equals(record.getMeta()) + || !same(marker.getHistoryLocation(), record.getHistoryLocation())) { + throw new IllegalArgumentException( + "Serving index suffix marker does not match authoritative history"); + } + String previousDatabase = null; + for (HistoryIndexRecord.KeyGroup group : record.getGroups()) { + String database = group.getDbName(); + if (Collections.binarySearch(participants, database) < 0) { + throw new IllegalArgumentException("Serving index suffix contains an unknown Store"); + } + if (previousDatabase != null && previousDatabase.compareTo(database) >= 0) { + throw new IllegalArgumentException("Serving index suffix Store groups are not sorted"); + } + previousDatabase = database; + byte[] previousKey = null; + for (byte[] key : group.getKeys()) { + if (previousKey != null && BlockReverseDiff.compareUnsigned(previousKey, key) >= 0) { + throw new IllegalArgumentException("Serving index suffix keys are not unique and sorted"); + } + previousKey = key; + } + } + } + + private static void collectChanges(Map> changes, + HistoryIndexRecord record) { + long epoch = record.getMeta().getEpoch(); + for (HistoryIndexRecord.KeyGroup group : record.getGroups()) { + List databaseChanges = changes.get(group.getDbName()); + for (byte[] key : group.getKeys()) { + databaseChanges.add(new KeyChange(key, epoch)); + } + } + } + + private static List exactParticipants(List participatingDatabases) { + List participants = new ArrayList<>(Objects.requireNonNull(participatingDatabases, + "participatingDatabases")); + Collections.sort(participants); + List expected = new ArrayList<>(ArchiveStoreScope.getStateDatabases()); + Collections.sort(expected); + if (!participants.equals(expected)) { + throw new IllegalArgumentException("Serving index participant set must be exact-27"); + } + return Collections.unmodifiableList(participants); + } + + private static boolean same(HistoryLocation left, HistoryLocation right) { + return left.getSegmentId() == right.getSegmentId() + && left.getOffset() == right.getOffset() + && left.getRecordLength() == right.getRecordLength() + && left.getBodyChecksum() == right.getBodyChecksum() + && Arrays.equals(left.getBodyDigest(), right.getBodyDigest()); + } + + private static void updateSourceDigest(MessageDigest digest, HistoryCommitMarker marker) { + updateLong(digest, marker.getMeta().getEpoch()); + updateLong(digest, marker.getMeta().getBlockNumber()); + digest.update(marker.getMeta().getBlockHash()); + digest.update(marker.getMeta().getParentHash()); + updateLong(digest, marker.getIndexLocation().getOffset()); + updateLong(digest, marker.getIndexLocation().getRecordLength()); + digest.update(marker.getIndexLocation().getDigest()); + digest.update(marker.getHistoryLocation().getBodyDigest()); + } + + private static void updateParticipants(MessageDigest digest, List participants) { + updateLong(digest, participants.size()); + for (String participant : participants) { + byte[] encoded = participant.getBytes(StandardCharsets.UTF_8); + updateLong(digest, encoded.length); + digest.update(encoded); + } + } + + private static void updateLong(MessageDigest digest, long value) { + digest.update(ByteBuffer.allocate(Long.BYTES).putLong(value).array()); + } + + private static MessageDigest sha256() { + try { + return MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException impossible) { + throw new IllegalStateException("SHA-256 is unavailable", impossible); + } + } + + private static void requireHash(byte[] hash, String name) { + if (hash == null || hash.length != 32) { + throw new IllegalArgumentException(name + " must be exactly 32 bytes"); + } + } + + /** One exact physical key changed once at one committed epoch. */ + public static final class KeyChange { + private final byte[] rawKey; + private final long epoch; + + private KeyChange(byte[] rawKey, long epoch) { + this.rawKey = Arrays.copyOf(rawKey, rawKey.length); + this.epoch = epoch; + } + + public byte[] getRawKey() { + return Arrays.copyOf(rawKey, rawKey.length); + } + + public long getEpoch() { + return epoch; + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/ServingKeyIndex.java b/chainbase/src/main/java/org/tron/core/db2/archive/ServingKeyIndex.java new file mode 100644 index 00000000000..965ae022ccb --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/ServingKeyIndex.java @@ -0,0 +1,32 @@ +package org.tron.core.db2.archive; + +import java.io.Closeable; +import java.io.IOException; +import java.util.List; +import java.util.OptionalLong; + +/** One immutable serving generation for exact physical-key history lookup. */ +public interface ServingKeyIndex extends Closeable { + + String getGenerationId(); + + long getIndexedFrom(); + + long getIndexedThrough(); + + byte[] getHeadHash(); + + byte[] getAuthoritativePrefixDigest(); + + OptionalLong firstChangeAfter(String dbName, byte[] rawKey, long targetBlock, + long upperBound) throws IOException; + + List changesInRange(String dbName, + byte[] lowerInclusive, byte[] upperExclusive, long targetBlock, long upperBound, + int maxChangedKeys) throws IOException; + + @Override + default void close() throws IOException { + // Pure in-memory generations own no external resource. + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/ServingKeyIndexCatalog.java b/chainbase/src/main/java/org/tron/core/db2/archive/ServingKeyIndexCatalog.java new file mode 100644 index 00000000000..a1654663417 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/ServingKeyIndexCatalog.java @@ -0,0 +1,33 @@ +package org.tron.core.db2.archive; + +import java.util.Objects; +import java.util.concurrent.atomic.AtomicReference; + +/** Atomic publication point for immutable serving-key-index generations. */ +public final class ServingKeyIndexCatalog { + + private final AtomicReference current; + + public ServingKeyIndexCatalog(ServingKeyIndexGeneration initial) { + current = new AtomicReference<>(Objects.requireNonNull(initial, "initial")); + } + + /** Pins the current immutable generation by strong reference. */ + public ServingKeyIndexGeneration pin() { + return current.get(); + } + + /** Publishes only if the generation used as the build base is still current. */ + public boolean publish(ServingKeyIndexGeneration expected, + ServingKeyIndexGeneration replacement) { + Objects.requireNonNull(expected, "expected"); + Objects.requireNonNull(replacement, "replacement"); + if (replacement.getIndexedFrom() != expected.getIndexedFrom()) { + throw new IllegalArgumentException("Serving index replacement changes the coverage base"); + } + if (replacement.getIndexedThrough() < expected.getIndexedThrough()) { + throw new IllegalArgumentException("Serving index replacement regresses the watermark"); + } + return current.compareAndSet(expected, replacement); + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/ServingKeyIndexGeneration.java b/chainbase/src/main/java/org/tron/core/db2/archive/ServingKeyIndexGeneration.java new file mode 100644 index 00000000000..724062032cc --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/ServingKeyIndexGeneration.java @@ -0,0 +1,609 @@ +package org.tron.core.db2.archive; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.OptionalLong; + +/** + * Immutable backend-neutral prototype of one derived serving-key-index generation. + * + *

This class deliberately defines no persistent page encoding. A production LSM backend can + * preserve this exact-key, committed-prefix and coverage contract after H1 format approval. + */ +public final class ServingKeyIndexGeneration implements ServingKeyIndex { + + private final String generationId; + private final long indexedFrom; + private final long indexedThrough; + private final byte[] headHash; + private final byte[] authoritativePrefixDigest; + private final IndexLayout layout; + private final Map changes; + private final Map storeCoverage; + + private ServingKeyIndexGeneration(String generationId, long indexedFrom, long indexedThrough, + byte[] headHash, byte[] authoritativePrefixDigest, IndexLayout layout, + Map changes, Map storeCoverage) { + this.generationId = generationId; + this.indexedFrom = indexedFrom; + this.indexedThrough = indexedThrough; + this.headHash = Arrays.copyOf(headHash, headHash.length); + this.authoritativePrefixDigest = Arrays.copyOf(authoritativePrefixDigest, + authoritativePrefixDigest.length); + this.layout = layout; + this.changes = Collections.unmodifiableMap(new HashMap<>(changes)); + this.storeCoverage = Collections.unmodifiableMap(new HashMap<>(storeCoverage)); + } + + /** Rebuilds a complete immutable generation from commit-marker-proven index records. */ + public static ServingKeyIndexGeneration rebuild(String generationId, long baseEpoch, + byte[] baseHash, List committed, + AuthoritativeIndexReader reader) throws IOException { + return rebuild(generationId, baseEpoch, baseHash, committed, reader, + IndexLayout.prototypeDefaults()); + } + + /** Rebuilds using an explicit prototype layout without defining a persistent page ABI. */ + public static ServingKeyIndexGeneration rebuild(String generationId, long baseEpoch, + byte[] baseHash, List committed, + AuthoritativeIndexReader reader, IndexLayout layout) throws IOException { + return rebuild(generationId, baseEpoch, baseHash, committed, reader, null, layout); + } + + /** Rebuilds against an explicit descriptor participant set, including an empty prefix. */ + public static ServingKeyIndexGeneration rebuild(String generationId, long baseEpoch, + byte[] baseHash, List committed, + AuthoritativeIndexReader reader, List expectedParticipatingDatabases, + IndexLayout layout) throws IOException { + if (generationId == null || generationId.isEmpty()) { + throw new IllegalArgumentException("generationId must not be empty"); + } + if (baseEpoch < 0) { + throw new IllegalArgumentException("baseEpoch must not be negative"); + } + requireHash(baseHash, "baseHash"); + Objects.requireNonNull(committed, "committed"); + Objects.requireNonNull(reader, "reader"); + Objects.requireNonNull(layout, "layout"); + + Map> mutable = new HashMap<>(); + MessageDigest sourceDigest = sha256(); + updateLong(sourceDigest, baseEpoch); + sourceDigest.update(baseHash); + long previousEpoch = baseEpoch; + long previousBlock = baseEpoch; + byte[] previousHash = Arrays.copyOf(baseHash, baseHash.length); + List participatingDatabases = expectedParticipatingDatabases == null ? null + : sortedParticipants(expectedParticipatingDatabases); + if (participatingDatabases != null) { + updateStringDigest(sourceDigest, + ArchiveParticipantDescriptor.scopeIdentity(participatingDatabases)); + updateParticipantDigest(sourceDigest, participatingDatabases); + } + + for (HistoryCommitMarker marker : committed) { + Objects.requireNonNull(marker, "committed marker"); + BlockSnapshotMeta meta = marker.getMeta(); + if (marker.getPreviousEpoch() != previousEpoch + || meta.getEpoch() != previousEpoch + 1 + || meta.getBlockNumber() != previousBlock + 1 + || meta.getEpoch() != meta.getBlockNumber() + || !Arrays.equals(meta.getParentHash(), previousHash)) { + throw new IllegalArgumentException("Serving index source commit prefix is not contiguous"); + } + if (participatingDatabases == null) { + participatingDatabases = marker.getDatabases(); + validateParticipantSet(participatingDatabases); + updateStringDigest(sourceDigest, + ArchiveParticipantDescriptor.scopeIdentity(participatingDatabases)); + updateParticipantDigest(sourceDigest, participatingDatabases); + } else if (!participatingDatabases.equals(marker.getDatabases())) { + throw new IllegalArgumentException( + "Serving index source participant set changes inside one generation"); + } + + HistoryIndexRecord record = reader.read(marker.getIndexLocation()); + validateMarker(marker, record, participatingDatabases); + addRecord(mutable, record); + updateSourceDigest(sourceDigest, marker); + previousEpoch = meta.getEpoch(); + previousBlock = meta.getBlockNumber(); + previousHash = meta.getBlockHash(); + } + + Map immutable = new HashMap<>(); + mutable.forEach((key, blocks) -> { + immutable.put(key, KeyChangeIndex.from(blocks, layout)); + }); + byte[] prefixDigest = sourceDigest.digest(); + Map coverage = new HashMap<>(); + if (participatingDatabases != null) { + for (String database : participatingDatabases) { + coverage.put(database, new StoreCoverage(database, baseEpoch, previousEpoch, + previousHash, prefixDigest)); + } + } + return new ServingKeyIndexGeneration(generationId, baseEpoch, previousEpoch, previousHash, + prefixDigest, layout, immutable, coverage); + } + + /** Returns the first changed block in {@code (targetBlock, upperBound]}. */ + public OptionalLong firstChangeAfter(String dbName, byte[] rawKey, long targetBlock, + long upperBound) { + Objects.requireNonNull(dbName, "dbName"); + Objects.requireNonNull(rawKey, "rawKey"); + requireStoreCoverage(dbName, targetBlock, upperBound); + KeyChangeIndex index = changes.get(new KeyIdentity(dbName, rawKey)); + return index == null ? OptionalLong.empty() + : index.firstChangeAfter(targetBlock, upperBound); + } + + /** + * Returns sorted exact keys in one database which changed in {@code (targetBlock, upperBound]}. + * + *

The in-memory prototype scans all key metadata. A production ordered LSM must preserve the + * result and budget contract without materializing the entire database keyspace. + */ + public List changesInRange(String dbName, byte[] lowerInclusive, + byte[] upperExclusive, long targetBlock, long upperBound, int maxChangedKeys) { + Objects.requireNonNull(dbName, "dbName"); + Objects.requireNonNull(lowerInclusive, "lowerInclusive"); + if (maxChangedKeys <= 0) { + throw new IllegalArgumentException("maxChangedKeys must be positive"); + } + if (upperExclusive != null + && BlockReverseDiff.compareUnsigned(lowerInclusive, upperExclusive) > 0) { + throw new IllegalArgumentException("lowerInclusive must not exceed upperExclusive"); + } + requireStoreCoverage(dbName, targetBlock, upperBound); + List result = new ArrayList<>(); + for (Map.Entry entry : changes.entrySet()) { + KeyIdentity identity = entry.getKey(); + if (!identity.matchesDatabase(dbName) || !inRange(identity.rawKey, lowerInclusive, + upperExclusive)) { + continue; + } + OptionalLong first = entry.getValue().firstChangeAfter(targetBlock, upperBound); + if (!first.isPresent()) { + continue; + } + if (result.size() == maxChangedKeys) { + throw new ArchiveQueryLimitExceededException("changed-key budget exceeded"); + } + result.add(new ChangedKey(identity.rawKey, first.getAsLong())); + } + result.sort((left, right) -> BlockReverseDiff.compareUnsigned(left.key, right.key)); + return Collections.unmodifiableList(result); + } + + private void validateCoverage(long targetBlock, long upperBound) { + if (targetBlock < indexedFrom || upperBound > indexedThrough || targetBlock > upperBound) { + throw new IllegalArgumentException("Query range is outside serving index coverage"); + } + } + + private void requireStoreCoverage(String dbName, long targetBlock, long upperBound) { + validateCoverage(targetBlock, upperBound); + StoreCoverage coverage = storeCoverage.get(dbName); + if (coverage == null || !coverage.covers(targetBlock, upperBound)) { + throw new IllegalArgumentException("Database is outside serving index coverage: " + dbName); + } + } + + private static boolean inRange(byte[] key, byte[] lowerInclusive, byte[] upperExclusive) { + return BlockReverseDiff.compareUnsigned(key, lowerInclusive) >= 0 + && (upperExclusive == null + || BlockReverseDiff.compareUnsigned(key, upperExclusive) < 0); + } + + private static OptionalLong firstChange(long[] blocks, long targetBlock, long upperBound) { + int low = 0; + int high = blocks.length; + while (low < high) { + int middle = (low + high) >>> 1; + if (blocks[middle] <= targetBlock) { + low = middle + 1; + } else { + high = middle; + } + } + return low < blocks.length && blocks[low] <= upperBound + ? OptionalLong.of(blocks[low]) : OptionalLong.empty(); + } + + public String getGenerationId() { + return generationId; + } + + public long getIndexedFrom() { + return indexedFrom; + } + + public long getIndexedThrough() { + return indexedThrough; + } + + public byte[] getHeadHash() { + return Arrays.copyOf(headHash, headHash.length); + } + + public byte[] getAuthoritativePrefixDigest() { + return Arrays.copyOf(authoritativePrefixDigest, authoritativePrefixDigest.length); + } + + public IndexLayout getLayout() { + return layout; + } + + public Optional getStoreCoverage(String dbName) { + Objects.requireNonNull(dbName, "dbName"); + return Optional.ofNullable(storeCoverage.get(dbName)); + } + + public int getKeyMetadataCount() { + return changes.size(); + } + + public int getInlineKeyCount() { + return (int) changes.values().stream().filter(KeyChangeIndex::isInline).count(); + } + + public int getPagedKeyCount() { + return getKeyMetadataCount() - getInlineKeyCount(); + } + + public int getEpochPageCount() { + return changes.values().stream().mapToInt(KeyChangeIndex::getPageCount).sum(); + } + + public static final class ChangedKey { + private final byte[] key; + private final long firstChangeBlock; + + ChangedKey(byte[] key, long firstChangeBlock) { + this.key = Arrays.copyOf(key, key.length); + this.firstChangeBlock = firstChangeBlock; + } + + public byte[] getKey() { + return Arrays.copyOf(key, key.length); + } + + public long getFirstChangeBlock() { + return firstChangeBlock; + } + } + + private static void addRecord(Map> changes, + HistoryIndexRecord record) { + String previousDb = null; + for (HistoryIndexRecord.KeyGroup group : record.getGroups()) { + if (previousDb != null && previousDb.compareTo(group.getDbName()) >= 0) { + throw new IllegalArgumentException("Serving index database groups are not sorted"); + } + previousDb = group.getDbName(); + byte[] previousKey = null; + for (byte[] key : group.getKeys()) { + if (previousKey != null && BlockReverseDiff.compareUnsigned(previousKey, key) >= 0) { + throw new IllegalArgumentException("Serving index keys are not sorted"); + } + previousKey = key; + List blocks = changes.computeIfAbsent(new KeyIdentity(group.getDbName(), key), + ignored -> new ArrayList<>()); + long block = record.getMeta().getBlockNumber(); + if (!blocks.isEmpty() && blocks.get(blocks.size() - 1) >= block) { + throw new IllegalArgumentException("Serving index changes are not strictly increasing"); + } + blocks.add(block); + } + } + } + + private static void validateMarker(HistoryCommitMarker marker, HistoryIndexRecord record, + List participatingDatabases) { + if (record == null || !marker.getMeta().equals(record.getMeta()) + || !same(marker.getHistoryLocation(), record.getHistoryLocation())) { + throw new IllegalArgumentException( + "Commit marker does not match authoritative history index record"); + } + for (HistoryIndexRecord.KeyGroup group : record.getGroups()) { + if (Collections.binarySearch(participatingDatabases, group.getDbName()) < 0) { + throw new IllegalArgumentException( + "History index contains a database outside the participant set"); + } + } + } + + private static boolean same(HistoryLocation left, HistoryLocation right) { + return left.getSegmentId() == right.getSegmentId() + && left.getOffset() == right.getOffset() + && left.getRecordLength() == right.getRecordLength() + && left.getBodyChecksum() == right.getBodyChecksum() + && Arrays.equals(left.getBodyDigest(), right.getBodyDigest()); + } + + private static void updateSourceDigest(MessageDigest digest, HistoryCommitMarker marker) { + updateLong(digest, marker.getMeta().getEpoch()); + updateLong(digest, marker.getMeta().getBlockNumber()); + digest.update(marker.getMeta().getBlockHash()); + digest.update(marker.getMeta().getParentHash()); + updateLong(digest, marker.getIndexLocation().getOffset()); + updateLong(digest, marker.getIndexLocation().getRecordLength()); + digest.update(marker.getIndexLocation().getDigest()); + digest.update(marker.getHistoryLocation().getBodyDigest()); + } + + private static void updateParticipantDigest(MessageDigest digest, List databases) { + updateLong(digest, databases.size()); + for (String database : databases) { + byte[] encoded = database.getBytes(StandardCharsets.UTF_8); + updateLong(digest, encoded.length); + digest.update(encoded); + } + } + + private static void updateStringDigest(MessageDigest digest, String value) { + byte[] encoded = value.getBytes(StandardCharsets.UTF_8); + updateLong(digest, encoded.length); + digest.update(encoded); + } + + private static void validateParticipantSet(List databases) { + if (databases.isEmpty()) { + throw new IllegalArgumentException("Serving index participant set must not be empty"); + } + String previous = null; + for (String database : databases) { + if (database == null || database.isEmpty() + || (previous != null && previous.compareTo(database) >= 0)) { + throw new IllegalArgumentException( + "Serving index participant set must be non-empty, unique, and sorted"); + } + previous = database; + } + } + + private static List sortedParticipants(List databases) { + List result = new ArrayList<>(Objects.requireNonNull(databases, "databases")); + Collections.sort(result); + validateParticipantSet(result); + return Collections.unmodifiableList(result); + } + + private static void updateLong(MessageDigest digest, long value) { + digest.update(ByteBuffer.allocate(Long.BYTES).putLong(value).array()); + } + + private static MessageDigest sha256() { + try { + return MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 is unavailable", e); + } + } + + private static void requireHash(byte[] hash, String name) { + if (hash == null || hash.length != 32) { + throw new IllegalArgumentException(name + " must be exactly 32 bytes"); + } + } + + @FunctionalInterface + public interface AuthoritativeIndexReader { + HistoryIndexRecord read(HistoryIndexLocation location) throws IOException; + } + + /** Tunable in-memory prototype layout; this is not a persistent format contract. */ + public static final class IndexLayout { + private static final int DEFAULT_INLINE_EPOCH_LIMIT = 4; + private static final int DEFAULT_MAX_EPOCHS_PER_PAGE = 512; + + private final int inlineEpochLimit; + private final int maxEpochsPerPage; + + public IndexLayout(int inlineEpochLimit, int maxEpochsPerPage) { + if (inlineEpochLimit <= 0 || maxEpochsPerPage <= 0) { + throw new IllegalArgumentException("Serving index layout limits must be positive"); + } + this.inlineEpochLimit = inlineEpochLimit; + this.maxEpochsPerPage = maxEpochsPerPage; + } + + public static IndexLayout prototypeDefaults() { + return new IndexLayout(DEFAULT_INLINE_EPOCH_LIMIT, DEFAULT_MAX_EPOCHS_PER_PAGE); + } + + public int getInlineEpochLimit() { + return inlineEpochLimit; + } + + public int getMaxEpochsPerPage() { + return maxEpochsPerPage; + } + } + + /** Completeness identity for one Store in this immutable serving generation. */ + public static final class StoreCoverage { + private final String dbName; + private final long indexedFrom; + private final long indexedThrough; + private final byte[] headHash; + private final byte[] authoritativePrefixDigest; + + private StoreCoverage(String dbName, long indexedFrom, long indexedThrough, + byte[] headHash, byte[] authoritativePrefixDigest) { + this.dbName = dbName; + this.indexedFrom = indexedFrom; + this.indexedThrough = indexedThrough; + this.headHash = Arrays.copyOf(headHash, headHash.length); + this.authoritativePrefixDigest = Arrays.copyOf(authoritativePrefixDigest, + authoritativePrefixDigest.length); + } + + public String getDbName() { + return dbName; + } + + public long getIndexedFrom() { + return indexedFrom; + } + + public long getIndexedThrough() { + return indexedThrough; + } + + public byte[] getHeadHash() { + return Arrays.copyOf(headHash, headHash.length); + } + + public byte[] getAuthoritativePrefixDigest() { + return Arrays.copyOf(authoritativePrefixDigest, + authoritativePrefixDigest.length); + } + + private boolean covers(long targetBlock, long upperBound) { + return targetBlock >= indexedFrom && upperBound <= indexedThrough; + } + } + + private static final class KeyChangeIndex { + private final long firstChangedEpoch; + private final long lastChangedEpoch; + private final long[] inlineEpochs; + private final List pages; + + private KeyChangeIndex(long firstChangedEpoch, long lastChangedEpoch, long[] inlineEpochs, + List pages) { + this.firstChangedEpoch = firstChangedEpoch; + this.lastChangedEpoch = lastChangedEpoch; + this.inlineEpochs = inlineEpochs; + this.pages = pages; + } + + private static KeyChangeIndex from(List epochs, IndexLayout layout) { + if (epochs.isEmpty()) { + throw new IllegalArgumentException("Serving key change list must not be empty"); + } + long first = epochs.get(0); + long last = epochs.get(epochs.size() - 1); + if (epochs.size() <= layout.getInlineEpochLimit()) { + return new KeyChangeIndex(first, last, toArray(epochs), Collections.emptyList()); + } + List pages = new ArrayList<>(); + for (int start = 0; start < epochs.size(); start += layout.getMaxEpochsPerPage()) { + int end = Math.min(start + layout.getMaxEpochsPerPage(), epochs.size()); + pages.add(new EpochPage(toArray(epochs.subList(start, end)))); + } + return new KeyChangeIndex(first, last, null, + Collections.unmodifiableList(pages)); + } + + private OptionalLong firstChangeAfter(long targetBlock, long upperBound) { + if (lastChangedEpoch <= targetBlock || firstChangedEpoch > upperBound) { + return OptionalLong.empty(); + } + if (isInline()) { + return firstChange(inlineEpochs, targetBlock, upperBound); + } + int pageIndex = pageAtOrBefore(targetBlock); + OptionalLong changed = pages.get(pageIndex).firstChangeAfter(targetBlock, upperBound); + if (changed.isPresent() || pageIndex + 1 >= pages.size()) { + return changed; + } + return pages.get(pageIndex + 1).firstChangeAfter(targetBlock, upperBound); + } + + private int pageAtOrBefore(long targetBlock) { + int low = 0; + int high = pages.size(); + while (low < high) { + int middle = (low + high) >>> 1; + if (pages.get(middle).baseEpoch <= targetBlock) { + low = middle + 1; + } else { + high = middle; + } + } + return Math.max(0, low - 1); + } + + private boolean isInline() { + return inlineEpochs != null; + } + + private int getPageCount() { + return pages.size(); + } + + private static long[] toArray(List epochs) { + long[] result = new long[epochs.size()]; + for (int i = 0; i < epochs.size(); i++) { + result[i] = epochs.get(i); + } + return result; + } + } + + private static final class EpochPage { + private final long baseEpoch; + private final long maxEpoch; + private final long[] epochs; + + private EpochPage(long[] epochs) { + this.epochs = epochs; + this.baseEpoch = epochs[0]; + this.maxEpoch = epochs[epochs.length - 1]; + } + + private OptionalLong firstChangeAfter(long targetBlock, long upperBound) { + if (maxEpoch <= targetBlock || baseEpoch > upperBound) { + return OptionalLong.empty(); + } + return firstChange(epochs, targetBlock, upperBound); + } + } + + private static final class KeyIdentity { + private final byte[] dbName; + private final byte[] rawKey; + private final int hashCode; + + private KeyIdentity(String dbName, byte[] rawKey) { + this.dbName = dbName.getBytes(StandardCharsets.UTF_8); + this.rawKey = Arrays.copyOf(rawKey, rawKey.length); + this.hashCode = 31 * Arrays.hashCode(this.dbName) + Arrays.hashCode(this.rawKey); + } + + private boolean matchesDatabase(String candidate) { + return Arrays.equals(dbName, candidate.getBytes(StandardCharsets.UTF_8)); + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof KeyIdentity)) { + return false; + } + KeyIdentity that = (KeyIdentity) other; + return Arrays.equals(dbName, that.dbName) && Arrays.equals(rawKey, that.rawKey); + } + + @Override + public int hashCode() { + return hashCode; + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/SnapshotOldValueCollector.java b/chainbase/src/main/java/org/tron/core/db2/archive/SnapshotOldValueCollector.java new file mode 100644 index 00000000000..42141d4aebd --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/SnapshotOldValueCollector.java @@ -0,0 +1,122 @@ +package org.tron.core.db2.archive; + +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import java.util.function.BooleanSupplier; + +/** Scheme 2 collector: read old values from the completed block layer's previous view. */ +public final class SnapshotOldValueCollector implements OldValueCollector { + + private final AccountAssetArchiveProjector accountAssetProjector; + private final AccountAssetOldPhysicalAssetsSource oldPhysicalAssetsSource; + private final TargetAssetOptimizationResolver targetAssetOptimizationResolver; + + public SnapshotOldValueCollector() { + this(null, null, (TargetAssetOptimizationResolver) null); + } + + public SnapshotOldValueCollector(AccountAssetArchiveProjector accountAssetProjector, + AccountAssetOldPhysicalAssetsSource oldPhysicalAssetsSource, + BooleanSupplier optimizationEnabled) { + this(accountAssetProjector, oldPhysicalAssetsSource, constant(optimizationEnabled)); + } + + public SnapshotOldValueCollector(AccountAssetArchiveProjector accountAssetProjector, + AccountAssetOldPhysicalAssetsSource oldPhysicalAssetsSource, + TargetAssetOptimizationResolver targetAssetOptimizationResolver) { + this.accountAssetProjector = accountAssetProjector; + this.oldPhysicalAssetsSource = oldPhysicalAssetsSource; + this.targetAssetOptimizationResolver = targetAssetOptimizationResolver; + if (accountAssetProjector != null) { + Objects.requireNonNull(oldPhysicalAssetsSource, "oldPhysicalAssetsSource"); + Objects.requireNonNull(targetAssetOptimizationResolver, + "targetAssetOptimizationResolver"); + } + } + + @Override + public BlockReverseDiff collect(BlockChangeView view) { + List groups = new ArrayList<>(); + List accountAssetEntries = new ArrayList<>(); + boolean targetAssetOptimizationEnabled = accountAssetProjector != null + && targetAssetOptimizationResolver.isEnabled(view); + for (BlockChangeView.DatabaseChanges database : view.getDatabases()) { + List entries = new ArrayList<>(); + for (BlockChangeView.Change change : database.getChanges()) { + byte[] key = change.getKey(); + OldValue oldValue = OldValue.fromNullable(database.getPrevious(key)); + BlockChangeView.PostValue postValue = change.getPostValue(); + if (accountAssetProjector != null + && AccountAssetArchiveProjector.ACCOUNT_DB.equals(database.getDbName())) { + AccountAssetArchiveProjector.Projection projection = + accountAssetProjector.projectWithOldPhysicalAssetsSource( + key, oldValue.isPresent() ? oldValue.getValue() : null, postValue, + targetAssetOptimizationEnabled, oldPhysicalAssetsSource); + oldValue = projection.oldAccount; + postValue = projection.postAccount; + accountAssetEntries.addAll(projection.reverseAssets); + } + if (!sameLogicalValue(oldValue, postValue)) { + entries.add(new BlockReverseDiff.Entry(key, oldValue)); + } + } + if (!entries.isEmpty()) { + groups.add(new BlockReverseDiff.DbGroup(database.getDbName(), entries)); + } + } + if (!accountAssetEntries.isEmpty()) { + groups.add(new BlockReverseDiff.DbGroup( + AccountAssetArchiveProjector.ACCOUNT_ASSET_DB, accountAssetEntries)); + } + return new BlockReverseDiff(view.getMeta(), groups); + } + + /** Resolves proposal 66 from the same immutable target block view being projected. */ + public static boolean resolveTargetAssetOptimization(BlockChangeView view) { + Objects.requireNonNull(view, "view"); + byte[] propertyKey = HistoricalAccountAssetBalanceResolver.proposal66PhysicalKey(); + for (BlockChangeView.DatabaseChanges database : view.getDatabases()) { + if (!HistoricalAccountAssetBalanceResolver.PROPERTIES_DATABASE.equals( + database.getDbName())) { + continue; + } + byte[] targetValue = database.getPrevious(propertyKey); + for (BlockChangeView.Change change : database.getChanges()) { + if (Arrays.equals(propertyKey, change.getKey())) { + targetValue = change.getPostValue().isPresent() + ? change.getPostValue().getValue() : null; + } + } + if (targetValue == null || targetValue.length != Long.BYTES) { + throw new ArchivePersistenceException( + "Target proposal-66 property must be exactly eight bytes"); + } + long enabled = ByteBuffer.wrap(targetValue).getLong(); + if (enabled != 0L && enabled != 1L) { + throw new ArchivePersistenceException("Target proposal-66 property must be 0 or 1"); + } + return enabled == 1L; + } + throw new ArchivePersistenceException("Target proposal-66 properties Store is absent"); + } + + private boolean sameLogicalValue(OldValue oldValue, BlockChangeView.PostValue postValue) { + if (oldValue.isPresent() != postValue.isPresent()) { + return false; + } + return !oldValue.isPresent() || Arrays.equals(oldValue.getValue(), postValue.getValue()); + } + + @FunctionalInterface + public interface TargetAssetOptimizationResolver { + boolean isEnabled(BlockChangeView view); + } + + private static TargetAssetOptimizationResolver constant(BooleanSupplier supplier) { + BooleanSupplier checked = Objects.requireNonNull(supplier, "optimizationEnabled"); + return view -> checked.getAsBoolean(); + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/SnapshotPathStateTransitionCollector.java b/chainbase/src/main/java/org/tron/core/db2/archive/SnapshotPathStateTransitionCollector.java new file mode 100644 index 00000000000..0d29228b62a --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/SnapshotPathStateTransitionCollector.java @@ -0,0 +1,265 @@ +package org.tron.core.db2.archive; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import org.tron.core.db2.archive.P66AccountAssetCodec.AssetRow; +import org.tron.core.db2.common.WrappedByteArray; +import org.tron.core.db2.stateroot.PathStateBlockTransition; +import org.tron.core.db2.stateroot.PathStateCanonicalizer; +import org.tron.core.db2.stateroot.PathStateCanonicalizer.P66Phase; +import org.tron.core.db2.stateroot.PathStateMutation; +import org.tron.core.db2.stateroot.PathStateParticipantDescriptor; +import org.tron.core.db2.stateroot.PathStateTransitionCollector; + +/** Canonicalizes the shared SnapshotManager block differ for the current path-state root. */ +public final class SnapshotPathStateTransitionCollector + implements PathStateTransitionCollector { + + private final PathStateCanonicalizer canonicalizer = new PathStateCanonicalizer(); + private final AccountAssetArchiveProjector accountAssetProjector; + private final AccountAssetOldPhysicalAssetsSource oldPhysicalAssetsSource; + private final ActivationAccountSource activationAccountSource; + + public SnapshotPathStateTransitionCollector( + AccountAssetOldPhysicalAssetsSource oldPhysicalAssetsSource) { + this(oldPhysicalAssetsSource, consumer -> { + throw new IOException("path-state P66 activation Account source is unavailable"); + }); + } + + public SnapshotPathStateTransitionCollector( + AccountAssetOldPhysicalAssetsSource oldPhysicalAssetsSource, + ActivationAccountSource activationAccountSource) { + this.accountAssetProjector = new AccountAssetArchiveProjector(); + this.oldPhysicalAssetsSource = Objects.requireNonNull(oldPhysicalAssetsSource, + "oldPhysicalAssetsSource"); + this.activationAccountSource = Objects.requireNonNull(activationAccountSource, + "activationAccountSource"); + } + + @Override + public PathStateBlockTransition collect(BlockChangeView view) throws IOException { + BlockChangeView admitted = Objects.requireNonNull(view, "view"); + P66Phase phase = resolvePhase(admitted); + LinkedHashMap mutations = new LinkedHashMap<>(); + if (phase == P66Phase.P66_ACTIVATION) { + collectActivationAccounts(mutations); + } + for (BlockChangeView.DatabaseChanges database : admitted.getDatabases()) { + String dbName = database.getDbName(); + PathStateParticipantDescriptor.current().require(dbName); + if (phase == P66Phase.P66_ACTIVATION + && (AccountAssetArchiveProjector.ACCOUNT_DB.equals(dbName) + || AccountAssetArchiveProjector.ACCOUNT_ASSET_DB.equals(dbName))) { + continue; + } + for (BlockChangeView.Change change : database.getChanges()) { + if (AccountAssetArchiveProjector.ACCOUNT_DB.equals(dbName)) { + collectAccount(phase, database, change, mutations); + } else { + addPhysical(phase, dbName, change.getKey(), database.getPrevious(change.getKey()), + change.getPostValue(), mutations); + } + } + } + BlockSnapshotMeta meta = admitted.getMeta(); + return new PathStateBlockTransition(meta.getBlockNumber(), meta.getBlockHash(), + meta.getParentHash(), meta.getTimestamp(), phase, mutations.values()); + } + + private void collectActivationAccounts( + Map mutations) throws IOException { + activationAccountSource.scan((key, rawPost) -> { + BlockChangeView.PostValue postValue = BlockChangeView.PostValue.present(rawPost); + AccountAssetArchiveProjector.Projection projection = + accountAssetProjector.projectWithOldPhysicalAssetsSource( + key, null, postValue, true, oldPhysicalAssetsSource); + addCanonical(AccountAssetArchiveProjector.ACCOUNT_DB, key, projection.oldAccount, + projection.postAccount, P66Phase.P66_ACTIVATION, mutations); + Map reverseOldAssets = oldAssets(projection); + for (AssetRow asset : projection.changedAssetRows) { + OldValue oldValue = requireOldAsset(reverseOldAssets, asset.getPhysicalRawKey()); + addPhysical(P66Phase.P66_ACTIVATION, + AccountAssetArchiveProjector.ACCOUNT_ASSET_DB, + asset.getPhysicalRawKey(), nullable(oldValue), asset.getPostValue(), false, + mutations); + } + }); + } + + private void collectAccount(P66Phase phase, BlockChangeView.DatabaseChanges database, + BlockChangeView.Change change, Map mutations) { + byte[] key = change.getKey(); + byte[] rawOld = database.getPrevious(key); + AccountAssetArchiveProjector.Projection projection = + accountAssetProjector.projectWithOldPhysicalAssetsSource( + key, rawOld, change.getPostValue(), phase != P66Phase.P66_OFF, + oldPhysicalAssetsSource); + addCanonical(AccountAssetArchiveProjector.ACCOUNT_DB, key, projection.oldAccount, + projection.postAccount, phase, mutations); + Map reverseOldAssets = oldAssets(projection); + for (AssetRow asset : projection.changedAssetRows) { + OldValue oldValue = requireOldAsset(reverseOldAssets, asset.getPhysicalRawKey()); + addPhysical(phase, AccountAssetArchiveProjector.ACCOUNT_ASSET_DB, + asset.getPhysicalRawKey(), nullable(oldValue), asset.getPostValue(), false, mutations); + } + } + + private void addPhysical(P66Phase phase, String dbName, byte[] key, byte[] rawOld, + BlockChangeView.PostValue rawPost, Map mutations) { + addPhysical(phase, dbName, key, rawOld, rawPost, true, mutations); + } + + private void addPhysical(P66Phase phase, String dbName, byte[] key, byte[] rawOld, + BlockChangeView.PostValue rawPost, boolean physicalPreviousAuthoritative, + Map mutations) { + PathStateMutation oldMutation = rawOld == null ? null + : canonicalizer.put(phase, dbName, key, rawOld); + PathStateMutation postMutation = rawPost.isPresent() + ? canonicalizer.put(phase, dbName, key, rawPost.getValue()) + : canonicalizer.delete(phase, dbName, key); + if (oldMutation != null && same(oldMutation, postMutation)) { + return; + } + add(physicalPreviousAuthoritative + ? postMutation.withPreviousPhysicalValue( + oldMutation == null ? null : oldMutation.getPhysicalValue()) : postMutation, mutations); + } + + private void addCanonical(String dbName, byte[] key, OldValue oldValue, + BlockChangeView.PostValue postValue, P66Phase phase, + Map mutations) { + PathStateMutation oldMutation = oldValue.isPresent() + ? canonicalizer.put(phase, dbName, key, oldValue.getValue()) : null; + PathStateMutation postMutation = postValue.isPresent() + ? canonicalizer.put(phase, dbName, key, postValue.getValue()) + : canonicalizer.delete(phase, dbName, key); + if (oldMutation != null && same(oldMutation, postMutation)) { + return; + } + add(postMutation.withPreviousPhysicalValue( + oldMutation == null ? null : oldMutation.getPhysicalValue()), mutations); + } + + private static Map oldAssets( + AccountAssetArchiveProjector.Projection projection) { + Map result = new LinkedHashMap<>(); + for (org.tron.core.db2.archive.BlockReverseDiff.Entry entry : projection.reverseAssets) { + result.put(WrappedByteArray.copyOf(entry.getKey()), entry.getOldValue()); + } + return result; + } + + private static OldValue requireOldAsset(Map oldAssets, + byte[] physicalKey) { + OldValue oldValue = oldAssets.get(WrappedByteArray.copyOf(physicalKey)); + if (oldValue == null) { + throw new ArchivePersistenceException( + "Changed AccountAsset row has no matching pre-state value"); + } + return oldValue; + } + + private static byte[] nullable(OldValue oldValue) { + return oldValue.isPresent() ? oldValue.getValue() : null; + } + + private void add(PathStateMutation mutation, + Map mutations) { + MutationKey key = new MutationKey(mutation.getDbName(), mutation.getCanonicalKey()); + PathStateMutation previous = mutations.putIfAbsent(key, mutation); + if (previous != null && (!same(previous, mutation) + || previous.isPreviousValueKnown() != mutation.isPreviousValueKnown() + || previous.isPreviousValueKnown() + && !Arrays.equals(previous.getPreviousPhysicalValue(), + mutation.getPreviousPhysicalValue()))) { + throw new ArchivePersistenceException("Conflicting path-state block mutation"); + } + } + + private P66Phase resolvePhase(BlockChangeView view) throws IOException { + byte[] propertyKey = HistoricalAccountAssetBalanceResolver.proposal66PhysicalKey(); + for (BlockChangeView.DatabaseChanges database : view.getDatabases()) { + if (!HistoricalAccountAssetBalanceResolver.PROPERTIES_DATABASE.equals( + database.getDbName())) { + continue; + } + long previous = decodeP66(database.getPrevious(propertyKey), "previous"); + long target = previous; + for (BlockChangeView.Change change : database.getChanges()) { + if (Arrays.equals(propertyKey, change.getKey())) { + target = decodeP66(change.getPostValue().isPresent() + ? change.getPostValue().getValue() : null, "target"); + } + } + if (previous == 1L && target == 0L) { + throw new IOException("path-state P66 phase cannot move backwards"); + } + if (previous == 0L && target == 1L) { + return P66Phase.P66_ACTIVATION; + } + return target == 0L ? P66Phase.P66_OFF : P66Phase.P66_ON; + } + throw new IOException("path-state properties Store is absent from block differ"); + } + + private long decodeP66(byte[] value, String label) throws IOException { + if (value == null || value.length != Long.BYTES) { + throw new IOException("path-state " + label + " P66 property is invalid"); + } + long decoded = ByteBuffer.wrap(value).getLong(); + if (decoded != 0L && decoded != 1L) { + throw new IOException("path-state " + label + " P66 property is invalid"); + } + return decoded; + } + + private static boolean same(PathStateMutation left, PathStateMutation right) { + return left.isDelete() == right.isDelete() + && left.getDbName().equals(right.getDbName()) + && Arrays.equals(left.getCanonicalKey(), right.getCanonicalKey()) + && Arrays.equals(left.getCanonicalValue(), right.getCanonicalValue()); + } + + /** Scans the canonical post-state Account domain only for the one-time P66 transition. */ + @FunctionalInterface + public interface ActivationAccountSource { + + void scan(ActivationAccountConsumer consumer) throws IOException; + } + + @FunctionalInterface + public interface ActivationAccountConsumer { + + void accept(byte[] key, byte[] value) throws IOException; + } + + private static final class MutationKey { + + private final String dbName; + private final byte[] key; + + private MutationKey(String dbName, byte[] key) { + this.dbName = dbName; + this.key = Arrays.copyOf(key, key.length); + } + + @Override + public boolean equals(Object other) { + if (!(other instanceof MutationKey)) { + return false; + } + MutationKey that = (MutationKey) other; + return dbName.equals(that.dbName) && Arrays.equals(key, that.key); + } + + @Override + public int hashCode() { + return 31 * dbName.hashCode() + Arrays.hashCode(key); + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/StateArchiveAppendCheckpointMaterializerV3.java b/chainbase/src/main/java/org/tron/core/db2/archive/StateArchiveAppendCheckpointMaterializerV3.java new file mode 100644 index 00000000000..f6623148215 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/StateArchiveAppendCheckpointMaterializerV3.java @@ -0,0 +1,343 @@ +package org.tron.core.db2.archive; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import org.tron.core.db2.archive.StateArchiveFiveLaneBlockCodecV3.EncodedBundle; +import org.tron.core.db2.archive.StateArchiveFiveLaneRecoveryIntentV3.RecoveryPoint; +import org.tron.core.db2.archive.StateArchiveFiveLaneSegmentWriterV3.ArchiveDurabilityProof; +import org.tron.core.db2.core.CommonCheckpointCapture; +import org.tron.core.db2.core.CommonCheckpointMaterializer; +import org.tron.core.db2.core.CommonCheckpointPayload; +import org.tron.core.db2.core.CommonCheckpointTarget; +import org.tron.core.db2.stateroot.PathStateStoreManifest.Engine; + +/** Default-off Common participant backed by the five-lane append-file v3 authority. */ +public final class StateArchiveAppendCheckpointMaterializerV3 + implements CommonCheckpointMaterializer, StateArchiveCheckpointPlanner { + + private final Path directory; + private final byte[] commonFormatIdentity; + private final Engine bindingEngine; + private final short compressionId; + private final StateArchiveFiveLaneBlockCodecV3 codec = + new StateArchiveFiveLaneBlockCodecV3(); + private final StateArchiveFiveLaneSegmentWriterV3 writer; + private final StateArchiveServingWorkerV3 servingWorker; + private boolean closed; + + public StateArchiveAppendCheckpointMaterializerV3(Path directory, + byte[] commonFormatIdentity, Engine bindingEngine, byte[] baselineHistoryDigest, + short compressionId) throws IOException { + this(directory, commonFormatIdentity, bindingEngine, baselineHistoryDigest, + compressionId, StateArchiveFileFormatV3.SEGMENT_TARGET_BYTES); + } + + public StateArchiveAppendCheckpointMaterializerV3(Path directory, + byte[] commonFormatIdentity, Engine bindingEngine, byte[] baselineHistoryDigest, + short compressionId, long rotationTargetBytes) throws IOException { + this(directory, commonFormatIdentity, bindingEngine, baselineHistoryDigest, compressionId, + rotationTargetBytes, () -> { }); + } + + StateArchiveAppendCheckpointMaterializerV3(Path directory, + byte[] commonFormatIdentity, Engine bindingEngine, byte[] baselineHistoryDigest, + short compressionId, long rotationTargetBytes, Runnable beforeServingBuild) throws IOException { + this.directory = Objects.requireNonNull(directory, "directory"); + this.commonFormatIdentity = requireDigest(commonFormatIdentity, "Common format identity"); + this.bindingEngine = Objects.requireNonNull(bindingEngine, "bindingEngine"); + this.compressionId = compressionId; + this.writer = new StateArchiveFiveLaneSegmentWriterV3(directory, + baselineHistoryDigest, compressionId, rotationTargetBytes); + this.servingWorker = new StateArchiveServingWorkerV3( + () -> new StateArchiveServingIndexBuildCoordinatorV3(directory, bindingEngine, 1_000), + writer, beforeServingBuild); + } + + @Override + public Authority authority() { + return Authority.STATE_ARCHIVE; + } + + /** Builds the existing v2 transient-body binding without writing append-file bytes. */ + @Override + public synchronized StateArchiveHotBatchDescriptor planCheckpoint( + List diffs) throws IOException { + requireOpen(); + List admitted = admittedDiffs(diffs); + BlockSnapshotMeta first = admitted.get(0).getMeta(); + long lastBlock = admitted.get(admitted.size() - 1).getMeta().getBlockNumber(); + BlockSnapshotMeta head = writer.getAppendHead(); + if (head != null && (head.getBlockNumber() < first.getBlockNumber() - 1 + || head.getBlockNumber() > lastBlock)) { + throw new IOException("Append-file Archive checkpoint is not the current successor or retry"); + } + return StateArchiveHotStore.planCheckpointDescriptor(bindingEngine, + first.getBlockNumber() - 1, first.getParentHash(), + new byte[StateArchiveFileFormatV3.HASH_LENGTH], admitted); + } + + /** Appends, group-forces, rereads and persists SAP3 before the Common WAL is published. */ + @Override + public synchronized CommonCheckpointTarget prepare(CommonCheckpointCapture capture) + throws IOException { + requireOpen(); + CommonCheckpointCapture admitted = Objects.requireNonNull(capture, "capture"); + CommonCheckpointTarget target = requireTarget( + CommonCheckpointTarget.from(admitted.getPayload())); + Status status = inspect(target); + if (status != Status.NEEDS_MATERIALIZATION) { + return target; + } + List diffs = admittedDiffs(admitted.getArchiveDiffs()); + if (!admitted.getArchiveBinding().equals(planCheckpoint(diffs))) { + throw new IOException("Append-file Archive checkpoint binding differs"); + } + ArchiveDurabilityProof recoveredProof = writer.getLastDurabilityProof(); + if (recoveredProof != null && matches(recoveredProof, target)) { + ArchiveDurabilityProof forced = writer.sync(recoveredProof.getCheckpointSequence(), + recoveredProof.getTarget(), target.getPayloadDigest()); + StateArchiveFiveLaneDurabilityProofV3.publish(directory, forced); + return target; + } + byte[] previousHistory = writer.getResultHistoryDigest(); + int firstMissing = firstMissing(diffs, writer.getAppendHead()); + for (int index = firstMissing; index < diffs.size(); index++) { + BlockReverseDiff diff = diffs.get(index); + EncodedBundle bundle = codec.encode(diff, previousHistory, + writerCompressionId()); + writer.appendForCheckpoint(bundle, target.getLastBlock().getBlockNumber(), + target.getPayloadDigest()); + previousHistory = bundle.getResultHistoryDigest(); + } + BlockSnapshotMeta meta = target.getLastBlock(); + RecoveryPoint point = new RecoveryPoint(meta.getEpoch(), meta.getBlockNumber(), + meta.getTimestamp(), meta.getBlockHash(), meta.getParentHash(), + writer.getResultHistoryDigest()); + ArchiveDurabilityProof proof = writer.sync(target.getLastBlock().getBlockNumber(), + point, target.getPayloadDigest()); + StateArchiveFiveLaneDurabilityProofV3.publish(directory, proof); + if (inspect(target) != Status.MATERIALIZED) { + throw new IOException("Append-file Archive SAP3 materialization is not exact"); + } + return target; + } + + @Override + public synchronized Status inspect(CommonCheckpointTarget target) throws IOException { + requireOpen(); + CommonCheckpointTarget admitted = requireTarget(target); + Optional readable = + StateArchiveCheckpointMaterializer.loadReadableTargetIfPresent(directory); + if (readable.isPresent() && readable.get().equals(admitted)) { + requireExactProof(admitted); + return Status.PUBLISHED; + } + if (readable.isPresent()) { + requireParent(readable.get(), admitted); + } + if (!Files.isRegularFile(directory.resolve(StateArchiveFiveLaneDurabilityProofV3.FILE_NAME), + LinkOption.NOFOLLOW_LINKS)) { + return Status.NEEDS_MATERIALIZATION; + } + ArchiveDurabilityProof proof = StateArchiveFiveLaneDurabilityProofV3.loadAndVerify( + directory, writer); + if (matches(proof, admitted)) { + return Status.MATERIALIZED; + } + if (!readable.isPresent()) { + throw new IOException("Append-file Archive has an uncommitted different SAP3 target"); + } + return Status.NEEDS_MATERIALIZATION; + } + + /** Loads and revalidates the append-file target made readable by the Common publish barrier. */ + public synchronized Optional loadPublishedTargetIfPresent() + throws IOException { + requireOpen(); + Optional target = + StateArchiveCheckpointMaterializer.loadReadableTargetIfPresent(directory); + if (target.isPresent() && inspect(target.get()) != Status.PUBLISHED) { + throw new IOException("Append-file Archive readable target is not fully published"); + } + return target; + } + + @Override + public synchronized void materialize(CommonCheckpointPayload payload, + CommonCheckpointTarget target) throws IOException { + CommonCheckpointPayload admittedPayload = Objects.requireNonNull(payload, "payload"); + CommonCheckpointTarget admittedTarget = requireTarget(target); + if (admittedPayload.getVersion() != CommonCheckpointPayload.COORDINATION_FORMAT_VERSION + || !admittedTarget.equals(CommonCheckpointTarget.from(admittedPayload))) { + throw new IOException("Append-file Archive requires its exact coordination payload"); + } + if (inspect(admittedTarget) == Status.NEEDS_MATERIALIZATION) { + throw new IOException("Append-file Archive must be prepared before Common WAL publication"); + } + } + + @Override + public synchronized void publish(CommonCheckpointTarget target) throws IOException { + CommonCheckpointTarget admitted = requireTarget(target); + Status status = inspect(admitted); + if (status == Status.PUBLISHED) { + return; + } + if (status != Status.MATERIALIZED) { + throw new IOException("Append-file Archive target is not materialized"); + } + StateArchiveCheckpointMaterializer.publishReadableTarget(directory, admitted); + } + + @Override + public void afterCommit(CommonCheckpointTarget target) { + try { + servingWorker.offer(target); + } catch (IOException | RuntimeException failure) { + org.slf4j.LoggerFactory.getLogger("DB").error( + "Archive serving degraded after Common commit at {}", + target.getLastBlock().getBlockNumber(), failure); + } + } + + /** Explicit sync-lifecycle handoff; it never infers completion from peer/head timing. */ + public synchronized void completeServingInitialSync(CommonCheckpointTarget boundary) + throws IOException { + requireOpen(); + servingWorker.completeInitialSync(boundary); + } + + public synchronized StateArchiveServingIndexBuildCoordinatorV3.BuildProgress + servingIndexStatus() { + return servingWorker.status(); + } + + public IOException servingIndexFailure() { + return servingWorker.failure(); + } + + @Override + public synchronized void close() throws IOException { + if (!closed) { + closed = true; + IOException failure = null; + try { + servingWorker.close(); + } catch (IOException closeFailure) { + failure = closeFailure; + } + try { + writer.close(); + } catch (IOException closeFailure) { + if (failure == null) { + failure = closeFailure; + } else { + failure.addSuppressed(closeFailure); + } + } + if (failure != null) { + throw failure; + } + } + } + + private void requireExactProof(CommonCheckpointTarget target) throws IOException { + ArchiveDurabilityProof proof = StateArchiveFiveLaneDurabilityProofV3.loadAndVerify( + directory, writer); + if (!matches(proof, target)) { + throw new IOException("Append-file Archive proof differs from readable target"); + } + } + + private static boolean matches(ArchiveDurabilityProof proof, + CommonCheckpointTarget target) { + BlockSnapshotMeta meta = target.getLastBlock(); + RecoveryPoint point = proof.getTarget(); + return point.getEpoch() == meta.getEpoch() + && point.getBlockNumber() == meta.getBlockNumber() + && point.getTimestamp() == meta.getTimestamp() + && Arrays.equals(point.getBlockHash(), meta.getBlockHash()) + && Arrays.equals(point.getParentHash(), meta.getParentHash()) + && Arrays.equals(proof.getCommonTargetDigest(), target.getPayloadDigest()); + } + + private CommonCheckpointTarget requireTarget(CommonCheckpointTarget target) + throws IOException { + CommonCheckpointTarget admitted = Objects.requireNonNull(target, "target"); + if (!Arrays.equals(commonFormatIdentity, admitted.getFormatIdentity())) { + throw new IOException("Append-file Archive Common format identity differs"); + } + return admitted; + } + + private static void requireParent(CommonCheckpointTarget parent, + CommonCheckpointTarget target) throws IOException { + if (!Arrays.equals(parent.getFormatIdentity(), target.getFormatIdentity()) + || parent.getLastBlock().getBlockNumber() + 1 != target.getFirstBlock().getBlockNumber() + || !Arrays.equals(parent.getLastBlock().getBlockHash(), + target.getFirstBlock().getParentHash()) + || !Arrays.equals(parent.getStateRoot(), target.getParentStateRoot())) { + throw new IOException("Append-file Archive readable target is not the parent"); + } + } + + private static List admittedDiffs(List diffs) { + List admitted = new ArrayList<>(Objects.requireNonNull(diffs, "diffs")); + if (admitted.isEmpty() || admitted.contains(null)) { + throw new IllegalArgumentException("Append-file Archive checkpoint requires blocks"); + } + BlockSnapshotMeta previous = null; + for (BlockReverseDiff diff : admitted) { + BlockSnapshotMeta current = diff.getMeta(); + if (current.getEpoch() != current.getBlockNumber() + || previous != null && (current.getBlockNumber() != previous.getBlockNumber() + 1 + || !Arrays.equals(current.getParentHash(), previous.getBlockHash()))) { + throw new IllegalArgumentException("Append-file Archive block chain is not contiguous"); + } + previous = current; + } + return admitted; + } + + private static int firstMissing(List diffs, BlockSnapshotMeta head) + throws IOException { + if (head == null || head.getBlockNumber() < diffs.get(0).getMeta().getBlockNumber()) { + return 0; + } + for (int index = 0; index < diffs.size(); index++) { + BlockSnapshotMeta meta = diffs.get(index).getMeta(); + if (meta.getBlockNumber() == head.getBlockNumber()) { + if (!meta.equals(head)) { + throw new IOException("Append-file Archive retry prefix identity differs"); + } + return index + 1; + } + } + throw new IOException("Append-file Archive retry head is outside the checkpoint range"); + } + + private short writerCompressionId() { + return compressionId; + } + + private void requireOpen() throws IOException { + if (closed) { + throw new IOException("Append-file Archive materializer is closed"); + } + } + + private static byte[] requireDigest(byte[] value, String name) { + byte[] admitted = Arrays.copyOf(Objects.requireNonNull(value, name), value.length); + if (admitted.length != StateArchiveFileFormatV3.HASH_LENGTH) { + throw new IllegalArgumentException(name + " must contain exactly 32 bytes"); + } + return admitted; + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/StateArchiveBlockFrameCodecV3.java b/chainbase/src/main/java/org/tron/core/db2/archive/StateArchiveBlockFrameCodecV3.java new file mode 100644 index 00000000000..85e23a1d41f --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/StateArchiveBlockFrameCodecV3.java @@ -0,0 +1,796 @@ +package org.tron.core.db2.archive; + +import com.google.common.hash.Hashing; +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.zip.DataFormatException; +import java.util.zip.Deflater; +import java.util.zip.Inflater; +import org.tron.core.db2.archive.BlockReverseDiff.DbGroup; +import org.tron.core.db2.archive.BlockReverseDiff.Entry; + +/** Canonical codec for one default-off State Archive v3 mixed-stream block frame. */ +public final class StateArchiveBlockFrameCodecV3 { + + private static final long UNSIGNED_INT_MAX = 0xffff_ffffL; + private static final int TRAILER_CRC_AND_MAGIC_LENGTH = 8; + + /** + * Validates the exact capture boundary before collecting old values and encoding the frame. + */ + public EncodedBlock encode(BlockChangeView view, OldValueCollector collector, + byte[] previousHistoryDigest, short compressionId) { + Objects.requireNonNull(view, "view"); + Objects.requireNonNull(collector, "collector"); + List captured = new ArrayList<>(); + for (BlockChangeView.DatabaseChanges database : view.getDatabases()) { + captured.add(database.getDbName()); + } + StateArchiveFileFormatV3.requireExactCapture(captured); + BlockReverseDiff diff = Objects.requireNonNull(collector.collect(view), "collected diff"); + if (!view.getMeta().equals(diff.getMeta())) { + throw new IllegalArgumentException("Collected history block identity changed"); + } + return encode(diff, previousHistoryDigest, compressionId); + } + + /** Encodes an already collected reverse diff with exact-27 descriptor coverage. */ + EncodedBlock encode(BlockReverseDiff diff, byte[] previousHistoryDigest, + short compressionId) { + Objects.requireNonNull(diff, "diff"); + byte[] previousDigest = requireHash(previousHistoryDigest, "previousHistoryDigest"); + requireCompression(compressionId); + BlockSnapshotMeta meta = diff.getMeta(); + if (meta.getEpoch() != meta.getBlockNumber()) { + throw new IllegalArgumentException("v3 requires epoch to equal blockNumber"); + } + if (meta.getTimestamp() < 0) { + throw new IllegalArgumentException("v3 timestamp must not be negative"); + } + + CanonicalPayload canonical = encodePayload(diff.getGroups()); + byte[] payloadDigest = StateArchiveFileFormatV3.sha256( + StateArchiveFileFormatV3.PAYLOAD_DOMAIN, canonical.bytes); + byte[] descriptorDigest = StateArchiveFileFormatV3.storeDescriptorDigest(); + byte[] blockHistoryDigest = blockHistoryDigest(meta, descriptorDigest, canonical, + payloadDigest); + byte[] resultHistoryDigest = StateArchiveFileFormatV3.sha256( + StateArchiveFileFormatV3.ROLLING_DOMAIN, previousDigest, blockHistoryDigest); + byte[] storedPayload = compressionId == StateArchiveFileFormatV3.COMPRESSION_NONE + ? canonical.bytes : deflate(canonical.bytes); + long totalLength = checkedAdd(StateArchiveFileFormatV3.BLOCK_HEADER_LENGTH, + storedPayload.length, StateArchiveFileFormatV3.FRAME_TRAILER_LENGTH); + if (totalLength > StateArchiveFileFormatV3.MAX_BLOCK_FRAME_BYTES) { + throw new IllegalArgumentException("State Archive block frame exceeds 64 MiB"); + } + + ByteBuffer frame = ByteBuffer.allocate((int) totalLength); + putEnvelope(frame, totalLength, storedPayload.length); + frame.putLong(meta.getEpoch()); + frame.putLong(meta.getBlockNumber()); + frame.putLong(meta.getTimestamp()); + frame.put(meta.getBlockHash()); + frame.put(meta.getParentHash()); + frame.put(descriptorDigest); + frame.put(previousDigest); + frame.put(payloadDigest); + frame.put(blockHistoryDigest); + frame.put(resultHistoryDigest); + frame.putLong(StateArchiveFileFormatV3.EXACT_COVERAGE_BITMAP); + frame.putLong(canonical.changedBitmap); + frame.putInt(canonical.groupCount); + frame.putInt(0); + frame.putLong(canonical.entryCount); + frame.putLong(canonical.bytes.length); + frame.putShort(StateArchiveFileFormatV3.BODY_CODEC_ID); + frame.putShort(compressionId); + frame.putShort(StateArchiveFileFormatV3.KEY_ORDER_ID); + frame.putShort(StateArchiveFileFormatV3.DIGEST_ID); + frame.putShort(StateArchiveFileFormatV3.CHECKSUM_ID); + frame.putShort(StateArchiveFileFormatV3.IDENTITY_KIND); + frame.putInt(0); + if (frame.position() != StateArchiveFileFormatV3.BLOCK_HEADER_LENGTH) { + throw new IllegalStateException("Invalid State Archive block header length"); + } + frame.put(storedPayload); + byte[] encodedDigest = StateArchiveFileFormatV3.sha256( + StateArchiveFileFormatV3.ENCODED_BLOCK_DOMAIN, + Arrays.copyOf(frame.array(), frame.position())); + frame.put(encodedDigest); + frame.putLong(totalLength); + int crcLength = frame.position(); + frame.putInt(crc32c(frame.array(), 0, crcLength)); + frame.putInt(StateArchiveFileFormatV3.FRAME_TRAILER_MAGIC); + if (frame.hasRemaining()) { + throw new IllegalStateException("Invalid State Archive frame length"); + } + return new EncodedBlock(frame.array(), canonical.bytes, payloadDigest, + blockHistoryDigest, resultHistoryDigest, encodedDigest, diff); + } + + /** Decodes and fully verifies one complete v3 block frame. */ + public DecodedBlock decode(byte[] frameBytes) { + Objects.requireNonNull(frameBytes, "frameBytes"); + if (frameBytes.length < StateArchiveFileFormatV3.BLOCK_HEADER_LENGTH + + StateArchiveFileFormatV3.FRAME_TRAILER_LENGTH + || frameBytes.length > StateArchiveFileFormatV3.MAX_BLOCK_FRAME_BYTES) { + throw new IllegalArgumentException("State Archive block frame length is invalid"); + } + ByteBuffer frame = ByteBuffer.wrap(frameBytes); + requireInt(frame, StateArchiveFileFormatV3.FRAME_MAGIC, "frame magic"); + requireShort(frame, StateArchiveFileFormatV3.MAJOR_VERSION, "major version"); + requireShort(frame, StateArchiveFileFormatV3.MINOR_VERSION, "minor version"); + requireShort(frame, StateArchiveFileFormatV3.BLOCK_FRAME_TYPE, "frame type"); + requireShort(frame, (short) 0, "frame flags"); + requireInt(frame, StateArchiveFileFormatV3.BLOCK_HEADER_LENGTH, "header length"); + long totalLength = frame.getLong(); + long payloadLength = frame.getLong(); + if (totalLength != frameBytes.length || payloadLength < 0 + || checkedAdd(StateArchiveFileFormatV3.BLOCK_HEADER_LENGTH, payloadLength, + StateArchiveFileFormatV3.FRAME_TRAILER_LENGTH) != totalLength) { + throw new IllegalArgumentException("State Archive frame envelope length mismatch"); + } + + long epoch = requireNonNegative(frame.getLong(), "epoch"); + long blockNumber = requireNonNegative(frame.getLong(), "blockNumber"); + long timestamp = requireNonNegative(frame.getLong(), "timestamp"); + if (epoch != blockNumber) { + throw new IllegalArgumentException("v3 requires epoch to equal blockNumber"); + } + byte[] blockHash = getBytes(frame, StateArchiveFileFormatV3.HASH_LENGTH); + byte[] parentHash = getBytes(frame, StateArchiveFileFormatV3.HASH_LENGTH); + byte[] descriptorDigest = getBytes(frame, StateArchiveFileFormatV3.HASH_LENGTH); + requireArray(descriptorDigest, StateArchiveFileFormatV3.storeDescriptorDigest(), + "Store descriptor digest"); + byte[] previousHistoryDigest = getBytes(frame, StateArchiveFileFormatV3.HASH_LENGTH); + byte[] payloadDigest = getBytes(frame, StateArchiveFileFormatV3.HASH_LENGTH); + byte[] blockHistoryDigest = getBytes(frame, StateArchiveFileFormatV3.HASH_LENGTH); + byte[] resultHistoryDigest = getBytes(frame, StateArchiveFileFormatV3.HASH_LENGTH); + long coverageBitmap = frame.getLong(); + long changedBitmap = frame.getLong(); + int groupCount = requireNonNegative(frame.getInt(), "groupCount"); + requireInt(frame, 0, "block reserved field"); + long entryCount = requireNonNegative(frame.getLong(), "entryCount"); + long rawPayloadLength = requireNonNegative(frame.getLong(), "rawPayloadLength"); + requireShort(frame, StateArchiveFileFormatV3.BODY_CODEC_ID, "body codec"); + short compressionId = frame.getShort(); + requireCompression(compressionId); + requireShort(frame, StateArchiveFileFormatV3.KEY_ORDER_ID, "key order"); + requireShort(frame, StateArchiveFileFormatV3.DIGEST_ID, "digest algorithm"); + requireShort(frame, StateArchiveFileFormatV3.CHECKSUM_ID, "checksum algorithm"); + requireShort(frame, StateArchiveFileFormatV3.IDENTITY_KIND, "identity kind"); + requireInt(frame, 0, "block reserved tail"); + if (coverageBitmap != StateArchiveFileFormatV3.EXACT_COVERAGE_BITMAP + || (changedBitmap & ~coverageBitmap) != 0 + || groupCount != Long.bitCount(changedBitmap) + || rawPayloadLength > StateArchiveFileFormatV3.MAX_BLOCK_FRAME_BYTES) { + throw new IllegalArgumentException("State Archive block coverage is invalid"); + } + + byte[] storedPayload = getBytes(frame, checkedInt(payloadLength, "payloadLength")); + int trailerStart = frame.position(); + byte[] encodedFrameDigest = getBytes(frame, StateArchiveFileFormatV3.HASH_LENGTH); + if (frame.getLong() != totalLength) { + throw new IllegalArgumentException("State Archive repeated frame length mismatch"); + } + int expectedCrc = frame.getInt(); + requireInt(frame, StateArchiveFileFormatV3.FRAME_TRAILER_MAGIC, "trailer magic"); + int actualCrc = crc32c(frameBytes, 0, + frameBytes.length - TRAILER_CRC_AND_MAGIC_LENGTH); + if (expectedCrc != actualCrc) { + throw new IllegalArgumentException("State Archive frame checksum mismatch"); + } + byte[] actualEncodedDigest = StateArchiveFileFormatV3.sha256( + StateArchiveFileFormatV3.ENCODED_BLOCK_DOMAIN, + Arrays.copyOf(frameBytes, trailerStart)); + requireArray(encodedFrameDigest, actualEncodedDigest, "encoded frame digest"); + + byte[] rawPayload = compressionId == StateArchiveFileFormatV3.COMPRESSION_NONE + ? storedPayload : inflate(storedPayload, checkedInt(rawPayloadLength, + "rawPayloadLength")); + if (rawPayload.length != rawPayloadLength) { + throw new IllegalArgumentException("State Archive raw payload length mismatch"); + } + requireArray(payloadDigest, StateArchiveFileFormatV3.sha256( + StateArchiveFileFormatV3.PAYLOAD_DOMAIN, rawPayload), "payload digest"); + DecodedPayload decodedPayload = decodePayload(rawPayload, groupCount, entryCount, + changedBitmap); + BlockSnapshotMeta meta = new BlockSnapshotMeta(epoch, blockNumber, blockHash, parentHash, + timestamp); + CanonicalPayload identityPayload = new CanonicalPayload(rawPayload, groupCount, entryCount, + changedBitmap); + byte[] actualBlockDigest = blockHistoryDigest(meta, descriptorDigest, identityPayload, + payloadDigest); + requireArray(blockHistoryDigest, actualBlockDigest, "block history digest"); + requireArray(resultHistoryDigest, StateArchiveFileFormatV3.sha256( + StateArchiveFileFormatV3.ROLLING_DOMAIN, previousHistoryDigest, blockHistoryDigest), + "result history digest"); + BlockReverseDiff diff = new BlockReverseDiff(meta, decodedPayload.groups); + return new DecodedBlock(diff, rawPayload, previousHistoryDigest, payloadDigest, + blockHistoryDigest, resultHistoryDigest, encodedFrameDigest, compressionId); + } + + /** Returns the complete length described by a v3 common envelope. */ + public int frameLength(byte[] header) { + if (header == null || header.length < StateArchiveFileFormatV3.FRAME_ENVELOPE_LENGTH) { + throw new IllegalArgumentException("State Archive frame envelope is truncated"); + } + ByteBuffer buffer = ByteBuffer.wrap(header); + requireInt(buffer, StateArchiveFileFormatV3.FRAME_MAGIC, "frame magic"); + requireShort(buffer, StateArchiveFileFormatV3.MAJOR_VERSION, "major version"); + requireShort(buffer, StateArchiveFileFormatV3.MINOR_VERSION, "minor version"); + requireShort(buffer, StateArchiveFileFormatV3.BLOCK_FRAME_TYPE, "frame type"); + requireShort(buffer, (short) 0, "frame flags"); + requireInt(buffer, StateArchiveFileFormatV3.BLOCK_HEADER_LENGTH, "header length"); + long totalLength = buffer.getLong(); + long payloadLength = buffer.getLong(); + if (totalLength > StateArchiveFileFormatV3.MAX_BLOCK_FRAME_BYTES + || totalLength != checkedAdd(StateArchiveFileFormatV3.BLOCK_HEADER_LENGTH, + payloadLength, StateArchiveFileFormatV3.FRAME_TRAILER_LENGTH)) { + throw new IllegalArgumentException("State Archive frame envelope length mismatch"); + } + return checkedInt(totalLength, "totalLength"); + } + + private CanonicalPayload encodePayload(Collection inputGroups) { + List groups = new ArrayList<>(); + Set storeIds = new HashSet<>(); + for (DbGroup group : inputGroups) { + int storeId = StateArchiveFileFormatV3.storeId(group.getDbName()); + if (!storeIds.add(storeId)) { + throw new IllegalArgumentException("Duplicate archive Store ID: " + storeId); + } + if (group.getEntries().isEmpty()) { + throw new IllegalArgumentException("Changed archive Store section must not be empty"); + } + groups.add(new StoreGroup(storeId, group.getEntries())); + } + groups.sort(Comparator.comparingInt(group -> group.storeId)); + List sections = new ArrayList<>(); + long entryCount = 0; + long sectionBytes = 0; + long changedBitmap = 0; + for (StoreGroup group : groups) { + byte[] section = encodeSection(group); + sections.add(section); + entryCount = checkedAdd(entryCount, group.entries.size()); + sectionBytes = checkedAdd(sectionBytes, section.length); + changedBitmap |= 1L << (group.storeId - 1); + } + long payloadLength = checkedAdd(StateArchiveFileFormatV3.PAYLOAD_HEADER_LENGTH, + sectionBytes); + if (payloadLength > StateArchiveFileFormatV3.MAX_BLOCK_FRAME_BYTES) { + throw new IllegalArgumentException("State Archive canonical payload is too large"); + } + ByteBuffer payload = ByteBuffer.allocate((int) payloadLength); + payload.putInt(StateArchiveFileFormatV3.PAYLOAD_MAGIC); + payload.putShort((short) 1); + payload.putShort((short) 0); + payload.putInt(StateArchiveFileFormatV3.PAYLOAD_HEADER_LENGTH); + payload.putInt(groups.size()); + payload.putLong(entryCount); + payload.putLong(sectionBytes); + for (byte[] section : sections) { + payload.put(section); + } + return new CanonicalPayload(payload.array(), groups.size(), entryCount, changedBitmap); + } + + private byte[] encodeSection(StoreGroup group) { + int entryCount = group.entries.size(); + long offsetVectorLength = checkedMultiply(entryCount + 1L, Integer.BYTES); + ByteArrayOutputStream records = new ByteArrayOutputStream(); + ByteArrayOutputStream values = new ByteArrayOutputStream(); + ByteBuffer offsets = ByteBuffer.allocate(checkedInt(offsetVectorLength, + "offsetVectorLength")); + byte[] previousKey = null; + try (DataOutputStream recordOutput = new DataOutputStream(records)) { + for (Entry entry : group.entries) { + byte[] key = entry.getKey(); + if (previousKey != null && BlockReverseDiff.compareUnsigned(previousKey, key) >= 0) { + throw new IllegalArgumentException("Archive keys are not strictly sorted"); + } + previousKey = key; + offsets.putInt(records.size()); + recordOutput.writeInt(key.length); + OldValue oldValue = entry.getOldValue(); + recordOutput.writeByte(oldValue.isPresent() ? 1 : 0); + recordOutput.write(new byte[3]); + if (oldValue.isPresent()) { + byte[] value = oldValue.getValue(); + recordOutput.writeInt(values.size()); + recordOutput.writeInt(value.length); + values.write(value); + } else { + recordOutput.writeInt(-1); + recordOutput.writeInt(-1); + } + recordOutput.write(key); + } + recordOutput.flush(); + } catch (IOException impossible) { + throw new IllegalStateException("Unexpected in-memory section encoding failure", impossible); + } + offsets.putInt(records.size()); + byte[] offsetBytes = offsets.array(); + byte[] recordBytes = records.toByteArray(); + byte[] valueBytes = values.toByteArray(); + long sectionLength = checkedAdd(StateArchiveFileFormatV3.SECTION_HEADER_LENGTH, + offsetBytes.length, recordBytes.length, valueBytes.length); + byte[] sectionDigest = StateArchiveFileFormatV3.sha256( + StateArchiveFileFormatV3.SECTION_DOMAIN, + ByteBuffer.allocate(Short.BYTES + Integer.BYTES) + .putShort((short) group.storeId).putInt(entryCount).array(), + offsetBytes, recordBytes, valueBytes); + ByteBuffer section = ByteBuffer.allocate(checkedInt(sectionLength, "sectionLength")); + section.putShort((short) group.storeId); + section.putShort((short) 1); + section.putInt(StateArchiveFileFormatV3.SECTION_HEADER_LENGTH); + section.putInt(entryCount); + section.putInt(0); + section.putLong(offsetBytes.length); + section.putLong(recordBytes.length); + section.putLong(valueBytes.length); + section.putLong(sectionLength); + section.put(sectionDigest); + section.put(offsetBytes); + section.put(recordBytes); + section.put(valueBytes); + return section.array(); + } + + private DecodedPayload decodePayload(byte[] payloadBytes, int expectedGroupCount, + long expectedEntryCount, long expectedChangedBitmap) { + ByteBuffer payload = ByteBuffer.wrap(payloadBytes); + requireRemaining(payload, StateArchiveFileFormatV3.PAYLOAD_HEADER_LENGTH, + "payload header"); + requireInt(payload, StateArchiveFileFormatV3.PAYLOAD_MAGIC, "payload magic"); + requireShort(payload, (short) 1, "payload version"); + requireShort(payload, (short) 0, "payload flags"); + requireInt(payload, StateArchiveFileFormatV3.PAYLOAD_HEADER_LENGTH, + "payload header length"); + int groupCount = requireNonNegative(payload.getInt(), "payload section count"); + long entryCount = requireNonNegative(payload.getLong(), "payload entry count"); + long sectionBytes = requireNonNegative(payload.getLong(), "payload section bytes"); + if (groupCount != expectedGroupCount || entryCount != expectedEntryCount + || sectionBytes != payload.remaining()) { + throw new IllegalArgumentException("State Archive payload counts or length mismatch"); + } + List groups = new ArrayList<>(groupCount); + int previousStoreId = 0; + long actualEntryCount = 0; + long actualChangedBitmap = 0; + for (int index = 0; index < groupCount; index++) { + requireRemaining(payload, StateArchiveFileFormatV3.SECTION_HEADER_LENGTH, + "Store section header"); + int sectionStart = payload.position(); + int storeId = Short.toUnsignedInt(payload.getShort()); + if (storeId <= previousStoreId || storeId > StateArchiveFileFormatV3.STORE_COUNT) { + throw new IllegalArgumentException("State Archive Store sections are not ordered"); + } + previousStoreId = storeId; + requireShort(payload, (short) 1, "section version"); + requireInt(payload, StateArchiveFileFormatV3.SECTION_HEADER_LENGTH, + "section header length"); + int count = payload.getInt(); + if (count <= 0) { + throw new IllegalArgumentException("State Archive Store section is empty"); + } + requireInt(payload, 0, "section flags"); + long offsetVectorLength = payload.getLong(); + long indexRecordLength = payload.getLong(); + long valueDataLength = payload.getLong(); + long sectionLength = payload.getLong(); + byte[] sectionDigest = getBytes(payload, StateArchiveFileFormatV3.HASH_LENGTH); + long expectedOffsetLength = checkedMultiply(count + 1L, Integer.BYTES); + long expectedSectionLength = checkedAdd(StateArchiveFileFormatV3.SECTION_HEADER_LENGTH, + offsetVectorLength, indexRecordLength, valueDataLength); + if (offsetVectorLength != expectedOffsetLength || sectionLength != expectedSectionLength + || sectionLength > payloadBytes.length + || sectionLength > payloadBytes.length - sectionStart) { + throw new IllegalArgumentException("State Archive Store section length is invalid"); + } + byte[] offsetBytes = getBytes(payload, checkedInt(offsetVectorLength, + "offsetVectorLength")); + byte[] recordBytes = getBytes(payload, checkedInt(indexRecordLength, + "indexRecordLength")); + byte[] valueBytes = getBytes(payload, checkedInt(valueDataLength, + "valueDataLength")); + byte[] actualSectionDigest = StateArchiveFileFormatV3.sha256( + StateArchiveFileFormatV3.SECTION_DOMAIN, + ByteBuffer.allocate(Short.BYTES + Integer.BYTES) + .putShort((short) storeId).putInt(count).array(), + offsetBytes, recordBytes, valueBytes); + requireArray(sectionDigest, actualSectionDigest, "section digest"); + List entries = decodeEntries(count, offsetBytes, recordBytes, valueBytes); + groups.add(new DbGroup(StateArchiveFileFormatV3.dbName(storeId), entries)); + actualEntryCount = checkedAdd(actualEntryCount, count); + actualChangedBitmap |= 1L << (storeId - 1); + } + if (payload.hasRemaining() || actualEntryCount != entryCount + || actualChangedBitmap != expectedChangedBitmap) { + throw new IllegalArgumentException("State Archive payload coverage mismatch"); + } + return new DecodedPayload(groups); + } + + private List decodeEntries(int count, byte[] offsetBytes, byte[] recordBytes, + byte[] valueBytes) { + ByteBuffer offsets = ByteBuffer.wrap(offsetBytes); + int[] positions = new int[count + 1]; + for (int index = 0; index <= count; index++) { + long offset = Integer.toUnsignedLong(offsets.getInt()); + if (offset > recordBytes.length || (index == 0 && offset != 0) + || (index > 0 && offset <= positions[index - 1])) { + throw new IllegalArgumentException("State Archive record offsets are invalid"); + } + positions[index] = (int) offset; + } + if (positions[count] != recordBytes.length) { + throw new IllegalArgumentException("State Archive final record offset is invalid"); + } + List entries = new ArrayList<>(count); + byte[] previousKey = null; + int valueCursor = 0; + for (int index = 0; index < count; index++) { + int recordLength = positions[index + 1] - positions[index]; + if (recordLength < StateArchiveFileFormatV3.VARIABLE_INDEX_RECORD_BASE_LENGTH) { + throw new IllegalArgumentException("State Archive index record is truncated"); + } + ByteBuffer record = ByteBuffer.wrap(recordBytes, positions[index], recordLength).slice(); + long keyLength = Integer.toUnsignedLong(record.getInt()); + int tag = Byte.toUnsignedInt(record.get()); + requireZero(record, 3, "index record reserved bytes"); + long valueOffset = Integer.toUnsignedLong(record.getInt()); + long valueLength = Integer.toUnsignedLong(record.getInt()); + if (keyLength != record.remaining()) { + throw new IllegalArgumentException("State Archive key length is invalid"); + } + byte[] key = getBytes(record, checkedInt(keyLength, "keyLength")); + if (previousKey != null && BlockReverseDiff.compareUnsigned(previousKey, key) >= 0) { + throw new IllegalArgumentException("State Archive decoded keys are not ordered"); + } + previousKey = key; + OldValue oldValue; + if (tag == 0) { + if (valueOffset != UNSIGNED_INT_MAX || valueLength != UNSIGNED_INT_MAX) { + throw new IllegalArgumentException("State Archive ABSENT value locator is invalid"); + } + oldValue = OldValue.absent(); + } else if (tag == 1) { + if (valueOffset != valueCursor || valueLength > valueBytes.length - valueCursor) { + throw new IllegalArgumentException("State Archive PRESENT value locator is invalid"); + } + byte[] value = Arrays.copyOfRange(valueBytes, valueCursor, + valueCursor + (int) valueLength); + valueCursor += (int) valueLength; + oldValue = OldValue.present(value); + } else { + throw new IllegalArgumentException("Unknown State Archive old-value tag: " + tag); + } + entries.add(new Entry(key, oldValue)); + } + if (valueCursor != valueBytes.length) { + throw new IllegalArgumentException("State Archive value data has an unreferenced tail"); + } + return entries; + } + + private byte[] blockHistoryDigest(BlockSnapshotMeta meta, byte[] descriptorDigest, + CanonicalPayload payload, byte[] payloadDigest) { + int identityLength = 3 * Long.BYTES + 3 * StateArchiveFileFormatV3.HASH_LENGTH + + 2 * Long.BYTES + Integer.BYTES + 2 * Long.BYTES + + StateArchiveFileFormatV3.HASH_LENGTH; + ByteBuffer identity = ByteBuffer.allocate(identityLength); + identity.putLong(meta.getEpoch()); + identity.putLong(meta.getBlockNumber()); + identity.putLong(meta.getTimestamp()); + identity.put(meta.getBlockHash()); + identity.put(meta.getParentHash()); + identity.put(descriptorDigest); + identity.putLong(StateArchiveFileFormatV3.EXACT_COVERAGE_BITMAP); + identity.putLong(payload.changedBitmap); + identity.putInt(payload.groupCount); + identity.putLong(payload.entryCount); + identity.putLong(payload.bytes.length); + identity.put(payloadDigest); + return StateArchiveFileFormatV3.sha256(StateArchiveFileFormatV3.BLOCK_DOMAIN, + identity.array()); + } + + private void putEnvelope(ByteBuffer frame, long totalLength, long payloadLength) { + frame.putInt(StateArchiveFileFormatV3.FRAME_MAGIC); + frame.putShort(StateArchiveFileFormatV3.MAJOR_VERSION); + frame.putShort(StateArchiveFileFormatV3.MINOR_VERSION); + frame.putShort(StateArchiveFileFormatV3.BLOCK_FRAME_TYPE); + frame.putShort((short) 0); + frame.putInt(StateArchiveFileFormatV3.BLOCK_HEADER_LENGTH); + frame.putLong(totalLength); + frame.putLong(payloadLength); + } + + private byte[] deflate(byte[] input) { + Deflater deflater = new Deflater(1, true); + deflater.setStrategy(Deflater.DEFAULT_STRATEGY); + deflater.setInput(input); + deflater.finish(); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + byte[] buffer = new byte[8192]; + while (!deflater.finished()) { + int count = deflater.deflate(buffer); + if (count == 0 && deflater.needsInput()) { + throw new IllegalStateException("Unexpected raw DEFLATE termination"); + } + output.write(buffer, 0, count); + } + deflater.end(); + return output.toByteArray(); + } + + private byte[] inflate(byte[] input, int expectedLength) { + Inflater inflater = new Inflater(true); + inflater.setInput(input); + byte[] output = new byte[expectedLength]; + try { + int count = inflater.inflate(output); + if (count != expectedLength || !inflater.finished() || inflater.getRemaining() != 0) { + throw new IllegalArgumentException("State Archive compressed payload length mismatch"); + } + return output; + } catch (DataFormatException e) { + throw new IllegalArgumentException("Invalid State Archive compressed payload", e); + } finally { + inflater.end(); + } + } + + private static byte[] requireHash(byte[] value, String name) { + Objects.requireNonNull(value, name); + if (value.length != StateArchiveFileFormatV3.HASH_LENGTH) { + throw new IllegalArgumentException(name + " must be exactly 32 bytes"); + } + return Arrays.copyOf(value, value.length); + } + + private static void requireCompression(short compressionId) { + if (compressionId != StateArchiveFileFormatV3.COMPRESSION_NONE + && compressionId != StateArchiveFileFormatV3.COMPRESSION_RAW_DEFLATE_LEVEL_1) { + throw new IllegalArgumentException("Unsupported State Archive compression: " + + compressionId); + } + } + + private static long requireNonNegative(long value, String name) { + if (value < 0) { + throw new IllegalArgumentException("State Archive " + name + " is negative"); + } + return value; + } + + private static int requireNonNegative(int value, String name) { + if (value < 0) { + throw new IllegalArgumentException("State Archive " + name + " is negative"); + } + return value; + } + + private static void requireInt(ByteBuffer buffer, int expected, String name) { + requireRemaining(buffer, Integer.BYTES, name); + if (buffer.getInt() != expected) { + throw new IllegalArgumentException("Invalid State Archive " + name); + } + } + + private static void requireShort(ByteBuffer buffer, short expected, String name) { + requireRemaining(buffer, Short.BYTES, name); + if (buffer.getShort() != expected) { + throw new IllegalArgumentException("Invalid State Archive " + name); + } + } + + private static void requireZero(ByteBuffer buffer, int length, String name) { + requireRemaining(buffer, length, name); + for (int index = 0; index < length; index++) { + if (buffer.get() != 0) { + throw new IllegalArgumentException("Invalid State Archive " + name); + } + } + } + + private static void requireRemaining(ByteBuffer buffer, int length, String name) { + if (length < 0 || buffer.remaining() < length) { + throw new IllegalArgumentException("Truncated State Archive " + name); + } + } + + private static byte[] getBytes(ByteBuffer buffer, int length) { + requireRemaining(buffer, length, "byte field"); + byte[] bytes = new byte[length]; + buffer.get(bytes); + return bytes; + } + + private static void requireArray(byte[] actual, byte[] expected, String name) { + if (!Arrays.equals(actual, expected)) { + throw new IllegalArgumentException("State Archive " + name + " mismatch"); + } + } + + private static int crc32c(byte[] bytes, int offset, int length) { + return Hashing.crc32c().hashBytes(bytes, offset, length).asInt(); + } + + private static long checkedAdd(long... values) { + long result = 0; + for (long value : values) { + if (value < 0 || Long.MAX_VALUE - result < value) { + throw new IllegalArgumentException("State Archive length overflow"); + } + result += value; + } + return result; + } + + private static long checkedMultiply(long left, long right) { + if (left < 0 || right < 0 || (left != 0 && right > Long.MAX_VALUE / left)) { + throw new IllegalArgumentException("State Archive length overflow"); + } + return left * right; + } + + private static int checkedInt(long value, String name) { + if (value < 0 || value > Integer.MAX_VALUE) { + throw new IllegalArgumentException("State Archive " + name + " exceeds int range"); + } + return (int) value; + } + + private static final class StoreGroup { + private final int storeId; + private final List entries; + + private StoreGroup(int storeId, List entries) { + this.storeId = storeId; + this.entries = entries; + } + } + + private static final class CanonicalPayload { + private final byte[] bytes; + private final int groupCount; + private final long entryCount; + private final long changedBitmap; + + private CanonicalPayload(byte[] bytes, int groupCount, long entryCount, + long changedBitmap) { + this.bytes = bytes; + this.groupCount = groupCount; + this.entryCount = entryCount; + this.changedBitmap = changedBitmap; + } + } + + private static final class DecodedPayload { + private final List groups; + + private DecodedPayload(List groups) { + this.groups = groups; + } + } + + public static final class EncodedBlock { + private final byte[] frame; + private final byte[] canonicalPayload; + private final byte[] payloadDigest; + private final byte[] blockHistoryDigest; + private final byte[] resultHistoryDigest; + private final byte[] encodedFrameDigest; + private final BlockReverseDiff diff; + + private EncodedBlock(byte[] frame, byte[] canonicalPayload, byte[] payloadDigest, + byte[] blockHistoryDigest, byte[] resultHistoryDigest, byte[] encodedFrameDigest, + BlockReverseDiff diff) { + this.frame = frame; + this.canonicalPayload = canonicalPayload; + this.payloadDigest = payloadDigest; + this.blockHistoryDigest = blockHistoryDigest; + this.resultHistoryDigest = resultHistoryDigest; + this.encodedFrameDigest = encodedFrameDigest; + this.diff = diff; + } + + public byte[] getFrame() { + return Arrays.copyOf(frame, frame.length); + } + + public byte[] getCanonicalPayload() { + return Arrays.copyOf(canonicalPayload, canonicalPayload.length); + } + + public byte[] getPayloadDigest() { + return Arrays.copyOf(payloadDigest, payloadDigest.length); + } + + public byte[] getBlockHistoryDigest() { + return Arrays.copyOf(blockHistoryDigest, blockHistoryDigest.length); + } + + public byte[] getResultHistoryDigest() { + return Arrays.copyOf(resultHistoryDigest, resultHistoryDigest.length); + } + + public byte[] getEncodedFrameDigest() { + return Arrays.copyOf(encodedFrameDigest, encodedFrameDigest.length); + } + + public BlockReverseDiff getDiff() { + return diff; + } + } + + public static final class DecodedBlock { + private final BlockReverseDiff diff; + private final byte[] canonicalPayload; + private final byte[] previousHistoryDigest; + private final byte[] payloadDigest; + private final byte[] blockHistoryDigest; + private final byte[] resultHistoryDigest; + private final byte[] encodedFrameDigest; + private final short compressionId; + + private DecodedBlock(BlockReverseDiff diff, byte[] canonicalPayload, + byte[] previousHistoryDigest, byte[] payloadDigest, byte[] blockHistoryDigest, + byte[] resultHistoryDigest, byte[] encodedFrameDigest, short compressionId) { + this.diff = diff; + this.canonicalPayload = canonicalPayload; + this.previousHistoryDigest = previousHistoryDigest; + this.payloadDigest = payloadDigest; + this.blockHistoryDigest = blockHistoryDigest; + this.resultHistoryDigest = resultHistoryDigest; + this.encodedFrameDigest = encodedFrameDigest; + this.compressionId = compressionId; + } + + public BlockReverseDiff getDiff() { + return diff; + } + + public byte[] getCanonicalPayload() { + return Arrays.copyOf(canonicalPayload, canonicalPayload.length); + } + + public byte[] getPreviousHistoryDigest() { + return Arrays.copyOf(previousHistoryDigest, previousHistoryDigest.length); + } + + public byte[] getPayloadDigest() { + return Arrays.copyOf(payloadDigest, payloadDigest.length); + } + + public byte[] getBlockHistoryDigest() { + return Arrays.copyOf(blockHistoryDigest, blockHistoryDigest.length); + } + + public byte[] getResultHistoryDigest() { + return Arrays.copyOf(resultHistoryDigest, resultHistoryDigest.length); + } + + public byte[] getEncodedFrameDigest() { + return Arrays.copyOf(encodedFrameDigest, encodedFrameDigest.length); + } + + public short getCompressionId() { + return compressionId; + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/StateArchiveCheckpointMaterializer.java b/chainbase/src/main/java/org/tron/core/db2/archive/StateArchiveCheckpointMaterializer.java new file mode 100644 index 00000000000..c1a744ed7ce --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/StateArchiveCheckpointMaterializer.java @@ -0,0 +1,665 @@ +package org.tron.core.db2.archive; + +import com.google.common.hash.Hashing; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.EOFException; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import org.tron.core.db2.core.CommonCheckpointBaseline; +import org.tron.core.db2.core.CommonCheckpointMaterializedStore; +import org.tron.core.db2.core.CommonCheckpointMaterializer; +import org.tron.core.db2.core.CommonCheckpointPayload; +import org.tron.core.db2.core.CommonCheckpointTarget; +import org.tron.core.db2.stateroot.PathStateStoreManifest.Engine; + +/** Next-format State Archive participant for the common-checkpoint two-barrier protocol. */ +public final class StateArchiveCheckpointMaterializer implements CommonCheckpointMaterializer { + + static final String READABLE_FILE = "READABLE"; + static final String TARGET_DIRECTORY = "checkpoint-targets"; + static final String BLOCK_DIRECTORY = "blocks"; + static final String MATERIALIZED_FILE = "MATERIALIZED"; + + private static final int TARGET_MAGIC = 0x53414354; // SACT + private static final short TARGET_VERSION = 2; + private static final int BLOCK_MAGIC = 0x53414342; // SACB + private static final short BLOCK_VERSION = 2; + private static final int DIGEST_LENGTH = 32; + private static final int META_LENGTH = 3 * Long.BYTES + 2 * DIGEST_LENGTH; + private static final int TARGET_LENGTH = Integer.BYTES + 2 * Short.BYTES + + 4 * DIGEST_LENGTH + 2 * META_LENGTH + DIGEST_LENGTH; + private static final int BLOCK_FIXED_LENGTH = Integer.BYTES + 2 * Short.BYTES + + Integer.BYTES + DIGEST_LENGTH; + private static final long MAX_BLOCK_LENGTH = BlockHistoryCodec.DEFAULT_MAX_RECORD_LENGTH + + (long) BLOCK_FIXED_LENGTH; + + private final Path directory; + private final byte[] formatIdentity; + private final BlockHistoryCodec historyCodec = new BlockHistoryCodec(); + private final FaultHook faultHook; + private final CommonCheckpointBaseline baseline; + private final Engine engine; + private final CommonCheckpointMaterializedStore materializedStore; + private StateArchiveCheckpointServingIndex.Session checkpointServingIndex; + private CommonCheckpointTarget activeCheckpoint; + private boolean closed; + + public StateArchiveCheckpointMaterializer(Path directory, byte[] formatIdentity) { + this(directory, formatIdentity, null, StateArchiveCheckpointServingIndex.configuredEngine(), + null, (stage, blockIndex) -> { }); + } + + public StateArchiveCheckpointMaterializer(Path directory, byte[] formatIdentity, + CommonCheckpointBaseline baseline) { + this(directory, formatIdentity, baseline, + StateArchiveCheckpointServingIndex.configuredEngine(), null, (stage, blockIndex) -> { }); + } + + public StateArchiveCheckpointMaterializer(Path directory, byte[] formatIdentity, + CommonCheckpointBaseline baseline, Engine engine) { + this(directory, formatIdentity, baseline, engine, null, (stage, blockIndex) -> { }); + } + + public StateArchiveCheckpointMaterializer(Path directory, byte[] formatIdentity, + CommonCheckpointBaseline baseline, Engine engine, + CommonCheckpointMaterializedStore materializedStore) { + this(directory, formatIdentity, baseline, engine, materializedStore, + (stage, blockIndex) -> { }); + } + + StateArchiveCheckpointMaterializer(Path directory, byte[] formatIdentity, + FaultHook faultHook) { + this(directory, formatIdentity, null, StateArchiveCheckpointServingIndex.configuredEngine(), + null, faultHook); + } + + StateArchiveCheckpointMaterializer(Path directory, byte[] formatIdentity, Engine engine, + FaultHook faultHook) { + this(directory, formatIdentity, null, engine, null, faultHook); + } + + private StateArchiveCheckpointMaterializer(Path directory, byte[] formatIdentity, + CommonCheckpointBaseline baseline, Engine engine, + CommonCheckpointMaterializedStore materializedStore, FaultHook faultHook) { + this.directory = Objects.requireNonNull(directory, "directory"); + this.formatIdentity = digest(formatIdentity, "formatIdentity"); + this.faultHook = Objects.requireNonNull(faultHook, "faultHook"); + this.baseline = baseline; + this.engine = Objects.requireNonNull(engine, "engine"); + this.materializedStore = materializedStore; + } + + @Override + public Authority authority() { + return Authority.STATE_ARCHIVE; + } + + @Override + public synchronized void beginCheckpoint(CommonCheckpointTarget target) throws IOException { + CommonCheckpointTarget admitted = requireTarget(target); + requireOpen(); + if (activeCheckpoint != null) { + throw new IOException("State Archive checkpoint serving session is already open"); + } + if (checkpointServingIndex == null) { + checkpointServingIndex = StateArchiveCheckpointServingIndex.session(directory, engine); + } + activeCheckpoint = admitted; + } + + @Override + public synchronized void endCheckpoint(CommonCheckpointTarget target) throws IOException { + CommonCheckpointTarget admitted = requireTarget(target); + if (activeCheckpoint == null) { + return; + } + if (!activeCheckpoint.equals(admitted)) { + throw new IOException("State Archive checkpoint serving session target differs"); + } + activeCheckpoint = null; + } + + /** Releases the serving-index writer retained across checkpoint targets. */ + @Override + public synchronized void close() throws IOException { + if (closed) { + return; + } + closed = true; + activeCheckpoint = null; + if (checkpointServingIndex != null) { + try { + checkpointServingIndex.close(); + } finally { + checkpointServingIndex = null; + } + } + } + + @Override + public synchronized Status inspect(CommonCheckpointTarget target) throws IOException { + requireOpen(); + CommonCheckpointTarget admitted = requireTarget(target); + byte[] expected = encodeTarget(admitted); + Path readable = directory.resolve(READABLE_FILE); + if (Files.exists(readable, LinkOption.NOFOLLOW_LINKS)) { + TargetMarker current = loadTarget(readable); + if (Arrays.equals(current.encoded, expected)) { + requireMaterialized(admitted, expected); + requireServingIndex(admitted); + return Status.PUBLISHED; + } + requireParent(current, admitted); + } else if (baseline != null) { + baseline.requireParent(admitted, "State Archive"); + } + if (!isMaterialized(admitted, expected)) { + return Status.NEEDS_MATERIALIZATION; + } + requireServingIndex(admitted); + return Status.MATERIALIZED; + } + + /** Loads and fully validates the target currently published by Archive READABLE. */ + public static CommonCheckpointTarget loadPublishedTarget(Path directory, + byte[] expectedFormatIdentity) throws IOException { + return loadPublishedTarget(directory, expectedFormatIdentity, + StateArchiveCheckpointServingIndex.configuredEngine()); + } + + public static CommonCheckpointTarget loadPublishedTarget(Path directory, + byte[] expectedFormatIdentity, Engine engine) throws IOException { + return loadPublishedTarget(directory, expectedFormatIdentity, engine, null); + } + + public static CommonCheckpointTarget loadPublishedTarget(Path directory, + byte[] expectedFormatIdentity, Engine engine, + CommonCheckpointMaterializedStore materializedStore) throws IOException { + StateArchiveCheckpointMaterializer materializer = + new StateArchiveCheckpointMaterializer(directory, expectedFormatIdentity, null, engine, + materializedStore); + Path readable = directory.resolve(READABLE_FILE); + if (!Files.exists(readable, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("State Archive READABLE target is missing"); + } + CommonCheckpointTarget target = loadTarget(readable).target; + materializer.requireTarget(target); + if (materializer.inspect(target) != Status.PUBLISHED) { + throw new IOException("State Archive READABLE target is not fully published"); + } + return target; + } + + /** Returns the published target when present, validating its complete serving boundary once. */ + public static Optional loadPublishedTargetIfPresent(Path directory, + byte[] expectedFormatIdentity, Engine engine) throws IOException { + return loadPublishedTargetIfPresent(directory, expectedFormatIdentity, engine, null); + } + + public static Optional loadPublishedTargetIfPresent(Path directory, + byte[] expectedFormatIdentity, Engine engine, + CommonCheckpointMaterializedStore materializedStore) throws IOException { + if (!Files.exists(directory.resolve(READABLE_FILE), LinkOption.NOFOLLOW_LINKS)) { + return Optional.empty(); + } + return Optional.of(loadPublishedTarget(directory, expectedFormatIdentity, engine, + materializedStore)); + } + + /** Resolves the configured Archive index engine at runtime construction boundaries. */ + public static Engine configuredEngine() { + return StateArchiveCheckpointServingIndex.configuredEngine(); + } + + @Override + public synchronized void materialize(CommonCheckpointPayload payload, + CommonCheckpointTarget target) throws IOException { + requireOpen(); + CommonCheckpointPayload admittedPayload = Objects.requireNonNull(payload, "payload"); + CommonCheckpointTarget admittedTarget = requireTarget(target); + if (!admittedTarget.equals(CommonCheckpointTarget.from(admittedPayload))) { + throw new IOException("State Archive checkpoint payload and target differ"); + } + Status status = inspect(admittedTarget); + if (status != Status.NEEDS_MATERIALIZATION) { + return; + } + + Path blocks = blocksPath(admittedTarget); + createDirectory(blocks); + Set expectedNames = new HashSet<>(); + for (int index = 0; index < admittedPayload.getBlocks().size(); index++) { + CommonCheckpointPayload.BlockPayload block = admittedPayload.getBlocks().get(index); + String name = blockFileName(index, block.getMeta()); + expectedNames.add(name); + byte[] encoded = encodeBlock(block); + publishImmutable(blocks.resolve(name), encoded); + faultHook.after(Stage.AFTER_BLOCK_FILE, index); + } + requireExactBlockSet(blocks, expectedNames); + if (checkpointServingIndex == null) { + StateArchiveCheckpointServingIndex.apply(directory, admittedPayload, admittedTarget, engine); + } else { + checkpointServingIndex.apply(admittedPayload, admittedTarget); + } + faultHook.after(Stage.AFTER_SERVING_INDEX_BATCH, -1); + recordMaterialized(admittedTarget, encodeTarget(admittedTarget)); + faultHook.after(Stage.AFTER_MATERIALIZED_TARGET, -1); + } + + @Override + public synchronized void publish(CommonCheckpointTarget target) throws IOException { + requireOpen(); + CommonCheckpointTarget admitted = requireTarget(target); + Status status = inspect(admitted); + if (status == Status.PUBLISHED) { + return; + } + if (status != Status.MATERIALIZED) { + throw new IOException("State Archive checkpoint target is not fully materialized"); + } + replace(directory.resolve(READABLE_FILE), encodeTarget(admitted)); + faultHook.after(Stage.AFTER_READABLE, -1); + } + + BlockReverseDiff loadBlock(CommonCheckpointTarget target, int index) throws IOException { + CommonCheckpointTarget admitted = requireTarget(target); + Path blocks = blocksPath(admitted); + String prefix = String.format("%08d-", index); + Path found = null; + try (DirectoryStream paths = Files.newDirectoryStream(blocks, prefix + "*.diff")) { + for (Path path : paths) { + if (found != null) { + throw new IOException("State Archive checkpoint block index is ambiguous"); + } + found = path; + } + } + if (found == null) { + throw new IOException("State Archive checkpoint block is missing"); + } + return loadCheckpointBlock(found); + } + + private CommonCheckpointTarget requireTarget(CommonCheckpointTarget target) throws IOException { + CommonCheckpointTarget admitted = Objects.requireNonNull(target, "target"); + if (!Arrays.equals(formatIdentity, admitted.getFormatIdentity())) { + throw new IOException("State Archive checkpoint format identity differs"); + } + return admitted; + } + + private void requireOpen() throws IOException { + if (closed) { + throw new IOException("State Archive checkpoint materializer is closed"); + } + } + + private void requireParent(TargetMarker current, CommonCheckpointTarget target) + throws IOException { + BlockSnapshotMeta first = target.getFirstBlock(); + CommonCheckpointTarget published = current.target; + if (!Arrays.equals(published.getFormatIdentity(), target.getFormatIdentity()) + || published.getLastBlock().getEpoch() + 1 != first.getEpoch() + || published.getLastBlock().getBlockNumber() + 1 != first.getBlockNumber() + || !Arrays.equals(published.getLastBlock().getBlockHash(), first.getParentHash()) + || !Arrays.equals(published.getStateRoot(), target.getParentStateRoot())) { + throw new IOException("State Archive READABLE is not the checkpoint parent target"); + } + } + + private void requireServingIndex(CommonCheckpointTarget target) throws IOException { + StateArchiveCheckpointServingIndex.Status status = checkpointServingIndex == null + ? StateArchiveCheckpointServingIndex.inspect(directory, target, engine) + : checkpointServingIndex.inspect(target); + if (status != StateArchiveCheckpointServingIndex.Status.EXACT) { + throw new IOException("State Archive checkpoint serving index target differs"); + } + } + + private Path targetPath(CommonCheckpointTarget target) { + return directory.resolve(TARGET_DIRECTORY).resolve(hex(target.getPayloadDigest())); + } + + private Path blocksPath(CommonCheckpointTarget target) { + return targetPath(target).resolve(BLOCK_DIRECTORY); + } + + private Path materializedPath(CommonCheckpointTarget target) { + return targetPath(target).resolve(MATERIALIZED_FILE); + } + + private boolean isMaterialized(CommonCheckpointTarget target, byte[] expected) + throws IOException { + if (materializedStore != null && materializedStore.exists(Authority.STATE_ARCHIVE)) { + return materializedStore.matches(Authority.STATE_ARCHIVE, expected); + } + Path legacy = materializedPath(target); + if (!Files.exists(legacy, LinkOption.NOFOLLOW_LINKS)) { + return false; + } + requireExact(legacy, expected); + return true; + } + + private void requireMaterialized(CommonCheckpointTarget target, byte[] expected) + throws IOException { + if (!isMaterialized(target, expected)) { + throw new IOException("State Archive materialized checkpoint target is missing"); + } + } + + private void recordMaterialized(CommonCheckpointTarget target, byte[] encoded) + throws IOException { + if (materializedStore == null) { + publishImmutable(materializedPath(target), encoded); + } else { + materializedStore.replace(Authority.STATE_ARCHIVE, encoded); + } + } + + private byte[] encodeBlock(CommonCheckpointPayload.BlockPayload block) { + try { + byte[] history = historyCodec.encode(block.getArchiveDiff()); + ByteArrayOutputStream bytes = new ByteArrayOutputStream(BLOCK_FIXED_LENGTH + history.length); + DataOutputStream output = new DataOutputStream(bytes); + output.writeInt(BLOCK_MAGIC); + output.writeShort(BLOCK_VERSION); + output.writeShort(0); + output.writeInt(history.length); + output.write(history); + output.flush(); + byte[] body = bytes.toByteArray(); + output.write(Hashing.sha256().hashBytes(body).asBytes()); + output.flush(); + return bytes.toByteArray(); + } catch (IOException impossible) { + throw new IllegalStateException("in-memory State Archive block encoding failed", impossible); + } + } + + static BlockReverseDiff loadCheckpointBlock(Path path) throws IOException { + return decodeBlock(readBounded(path, MAX_BLOCK_LENGTH)); + } + + private static BlockReverseDiff decodeBlock(byte[] encoded) throws IOException { + if (encoded.length < BLOCK_FIXED_LENGTH) { + throw new IOException("State Archive checkpoint block is truncated"); + } + int bodyLength = encoded.length - DIGEST_LENGTH; + byte[] body = Arrays.copyOf(encoded, bodyLength); + byte[] checksum = Arrays.copyOfRange(encoded, bodyLength, encoded.length); + if (!Arrays.equals(checksum, Hashing.sha256().hashBytes(body).asBytes())) { + throw new IOException("State Archive checkpoint block checksum differs"); + } + try (DataInputStream input = new DataInputStream(new ByteArrayInputStream(body))) { + if (input.readInt() != BLOCK_MAGIC || input.readShort() != BLOCK_VERSION + || input.readShort() != 0) { + throw new IOException("State Archive checkpoint block format is unsupported"); + } + int historyLength = input.readInt(); + if (historyLength <= 0 || historyLength != input.available()) { + throw new IOException("State Archive checkpoint history length is invalid"); + } + byte[] history = new byte[historyLength]; + input.readFully(history); + BlockReverseDiff decoded; + try { + decoded = new BlockHistoryCodec().decode(history); + } catch (IllegalArgumentException invalid) { + throw new IOException("State Archive checkpoint history is corrupt", invalid); + } + return decoded; + } catch (EOFException truncated) { + throw new IOException("State Archive checkpoint block is truncated", truncated); + } + } + + static String blockFileName(int index, BlockSnapshotMeta meta) { + return String.format("%08d-%020d-%s.diff", index, meta.getEpoch(), hex(meta.getBlockHash())); + } + + private static byte[] encodeTarget(CommonCheckpointTarget target) { + try { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(TARGET_LENGTH); + DataOutputStream output = new DataOutputStream(bytes); + output.writeInt(TARGET_MAGIC); + output.writeShort(TARGET_VERSION); + output.writeShort(0); + output.write(target.getFormatIdentity()); + output.write(target.getPayloadDigest()); + writeMeta(output, target.getFirstBlock()); + writeMeta(output, target.getLastBlock()); + output.write(target.getParentStateRoot()); + output.write(target.getStateRoot()); + output.flush(); + byte[] body = bytes.toByteArray(); + output.write(Hashing.sha256().hashBytes(body).asBytes()); + output.flush(); + return bytes.toByteArray(); + } catch (IOException impossible) { + throw new IllegalStateException("in-memory State Archive target encoding failed", impossible); + } + } + + private static TargetMarker loadTarget(Path path) throws IOException { + byte[] encoded = readBounded(path, TARGET_LENGTH); + if (encoded.length != TARGET_LENGTH) { + throw new IOException("State Archive checkpoint target length is invalid"); + } + int bodyLength = encoded.length - DIGEST_LENGTH; + byte[] body = Arrays.copyOf(encoded, bodyLength); + byte[] checksum = Arrays.copyOfRange(encoded, bodyLength, encoded.length); + if (!Arrays.equals(checksum, Hashing.sha256().hashBytes(body).asBytes())) { + throw new IOException("State Archive checkpoint target checksum differs"); + } + try (DataInputStream input = new DataInputStream(new ByteArrayInputStream(body))) { + if (input.readInt() != TARGET_MAGIC || input.readShort() != TARGET_VERSION + || input.readShort() != 0) { + throw new IOException("State Archive checkpoint target format is unsupported"); + } + byte[] formatIdentity = readDigest(input); + byte[] payloadDigest = readDigest(input); + BlockSnapshotMeta first = readMeta(input); + BlockSnapshotMeta last = readMeta(input); + byte[] parentStateRoot = readDigest(input); + byte[] stateRoot = readDigest(input); + CommonCheckpointTarget target; + try { + target = CommonCheckpointTarget.restore(formatIdentity, payloadDigest, first, last, + parentStateRoot, stateRoot); + } catch (IllegalArgumentException invalid) { + throw new IOException("State Archive checkpoint target identity is invalid", invalid); + } + return new TargetMarker(encoded, target); + } catch (EOFException truncated) { + throw new IOException("State Archive checkpoint target is truncated", truncated); + } + } + + static Optional loadReadableTargetIfPresent(Path directory) + throws IOException { + Path readable = Objects.requireNonNull(directory, "directory").resolve(READABLE_FILE); + return Files.exists(readable, LinkOption.NOFOLLOW_LINKS) + ? Optional.of(loadTarget(readable).target) : Optional.empty(); + } + + static void publishReadableTarget(Path directory, CommonCheckpointTarget target) + throws IOException { + replace(Objects.requireNonNull(directory, "directory").resolve(READABLE_FILE), + encodeTarget(Objects.requireNonNull(target, "target"))); + } + + private static void requireExact(Path path, byte[] expected) throws IOException { + if (!Arrays.equals(loadTarget(path).encoded, expected)) { + throw new IOException("State Archive checkpoint target identity differs"); + } + } + + private static void requireExactBlockSet(Path directory, Set expected) + throws IOException { + Set actual = new HashSet<>(); + try (DirectoryStream paths = Files.newDirectoryStream(directory)) { + for (Path path : paths) { + if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS) + || !actual.add(path.getFileName().toString())) { + throw new IOException("State Archive checkpoint block directory is ambiguous"); + } + } + } + if (!actual.equals(expected)) { + throw new IOException("State Archive checkpoint block set differs"); + } + } + + private static void createDirectory(Path path) throws IOException { + Files.createDirectories(path); + if (!Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("State Archive checkpoint path is not a directory"); + } + } + + private static void publishImmutable(Path path, byte[] bytes) throws IOException { + createDirectory(path.getParent()); + if (Files.exists(path, LinkOption.NOFOLLOW_LINKS)) { + if (!Arrays.equals(readBounded(path, bytes.length), bytes)) { + throw new IOException("State Archive immutable checkpoint file differs"); + } + return; + } + Path temporary = path.resolveSibling(path.getFileName() + ".tmp-" + UUID.randomUUID()); + try { + writeForced(temporary, bytes); + try { + Files.move(temporary, path, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException unsupported) { + throw new IOException("State Archive filesystem lacks atomic immutable publication", + unsupported); + } catch (java.nio.file.FileAlreadyExistsException raced) { + if (!Arrays.equals(readBounded(path, bytes.length), bytes)) { + throw new IOException("State Archive immutable checkpoint publication raced", raced); + } + } + HistorySegmentStore.syncDirectory(path.getParent()); + } finally { + Files.deleteIfExists(temporary); + } + } + + private static void replace(Path path, byte[] bytes) throws IOException { + createDirectory(path.getParent()); + Path temporary = path.resolveSibling(path.getFileName() + ".tmp-" + UUID.randomUUID()); + try { + writeForced(temporary, bytes); + try { + Files.move(temporary, path, StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException unsupported) { + throw new IOException("State Archive filesystem lacks atomic READABLE replacement", + unsupported); + } + HistorySegmentStore.syncDirectory(path.getParent()); + } finally { + Files.deleteIfExists(temporary); + } + } + + private static void writeForced(Path path, byte[] bytes) throws IOException { + try (FileChannel channel = FileChannel.open(path, StandardOpenOption.CREATE_NEW, + StandardOpenOption.WRITE)) { + ByteBuffer buffer = ByteBuffer.wrap(bytes); + while (buffer.hasRemaining()) { + channel.write(buffer); + } + channel.force(true); + } + } + + private static byte[] readBounded(Path path, long maximum) throws IOException { + if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("State Archive checkpoint path is not a regular file"); + } + long size = Files.size(path); + if (size <= 0 || size > maximum) { + throw new IOException("State Archive checkpoint file length is invalid"); + } + return Files.readAllBytes(path); + } + + private static byte[] readDigest(DataInputStream input) throws IOException { + byte[] value = new byte[DIGEST_LENGTH]; + input.readFully(value); + return value; + } + + private static void writeMeta(DataOutputStream output, BlockSnapshotMeta meta) + throws IOException { + output.writeLong(meta.getEpoch()); + output.writeLong(meta.getBlockNumber()); + output.write(meta.getBlockHash()); + output.write(meta.getParentHash()); + output.writeLong(meta.getTimestamp()); + } + + private static BlockSnapshotMeta readMeta(DataInputStream input) throws IOException { + return new BlockSnapshotMeta(input.readLong(), input.readLong(), readDigest(input), + readDigest(input), input.readLong()); + } + + private static byte[] digest(byte[] value, String name) { + byte[] copy = Arrays.copyOf(Objects.requireNonNull(value, name), value.length); + if (copy.length != DIGEST_LENGTH) { + throw new IllegalArgumentException(name + " must contain exactly 32 bytes"); + } + return copy; + } + + static String hex(byte[] value) { + StringBuilder encoded = new StringBuilder(value.length * 2); + for (byte current : value) { + encoded.append(Character.forDigit(current >>> 4 & 0xf, 16)); + encoded.append(Character.forDigit(current & 0xf, 16)); + } + return encoded.toString(); + } + + enum Stage { + AFTER_BLOCK_FILE, + AFTER_SERVING_INDEX_BATCH, + AFTER_MATERIALIZED_TARGET, + AFTER_READABLE + } + + @FunctionalInterface + interface FaultHook { + void after(Stage stage, int blockIndex) throws IOException; + } + + private static final class TargetMarker { + + private final byte[] encoded; + private final CommonCheckpointTarget target; + + private TargetMarker(byte[] encoded, CommonCheckpointTarget target) { + this.encoded = encoded; + this.target = target; + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/StateArchiveCheckpointPlanner.java b/chainbase/src/main/java/org/tron/core/db2/archive/StateArchiveCheckpointPlanner.java new file mode 100644 index 00000000000..c6c1f89eaed --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/StateArchiveCheckpointPlanner.java @@ -0,0 +1,16 @@ +package org.tron.core.db2.archive; + +import java.io.IOException; +import java.util.List; +import org.tron.core.db2.core.CommonCheckpointCapture; +import org.tron.core.db2.core.CommonCheckpointMaterializer; +import org.tron.core.db2.core.CommonCheckpointTarget; + +/** Computes the transient Archive binding embedded in a Common checkpoint v2 payload. */ +public interface StateArchiveCheckpointPlanner extends CommonCheckpointMaterializer { + + StateArchiveHotBatchDescriptor planCheckpoint(List diffs) + throws IOException; + + CommonCheckpointTarget prepare(CommonCheckpointCapture capture) throws IOException; +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/StateArchiveCheckpointReadAdapter.java b/chainbase/src/main/java/org/tron/core/db2/archive/StateArchiveCheckpointReadAdapter.java new file mode 100644 index 00000000000..bd9ec5a7dd7 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/StateArchiveCheckpointReadAdapter.java @@ -0,0 +1,107 @@ +package org.tron.core.db2.archive; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.Objects; +import java.util.Optional; +import java.util.OptionalLong; +import org.tron.core.db2.core.CommonCheckpointMaterializer.Status; +import org.tron.core.db2.core.CommonCheckpointTarget; +import org.tron.core.db2.stateroot.PathStateStoreManifest.Engine; + +/** Request-owned exact-key reader for one published next-format checkpoint target. */ +public final class StateArchiveCheckpointReadAdapter implements AutoCloseable { + + private final CommonCheckpointTarget target; + private final StateArchiveCheckpointServingIndex.Reader reader; + private boolean closed; + + private StateArchiveCheckpointReadAdapter(CommonCheckpointTarget target, + StateArchiveCheckpointServingIndex.Reader reader) { + this.target = target; + this.reader = reader; + } + + /** Opens only an exact target whose Archive READABLE and serving-index markers are published. */ + public static StateArchiveCheckpointReadAdapter open(Path archiveDirectory, + CommonCheckpointTarget target) throws IOException { + return open(archiveDirectory, target, StateArchiveCheckpointServingIndex.configuredEngine()); + } + + public static StateArchiveCheckpointReadAdapter open(Path archiveDirectory, + CommonCheckpointTarget target, Engine engine) throws IOException { + Path directory = Objects.requireNonNull(archiveDirectory, "archiveDirectory"); + CommonCheckpointTarget admitted = Objects.requireNonNull(target, "target"); + StateArchiveCheckpointMaterializer materializer = + new StateArchiveCheckpointMaterializer(directory, admitted.getFormatIdentity(), null, + engine); + if (materializer.inspect(admitted) != Status.PUBLISHED) { + throw new IOException("State Archive checkpoint target is not published for reading"); + } + return new StateArchiveCheckpointReadAdapter(admitted, + StateArchiveCheckpointServingIndex.openReader(directory, admitted, engine)); + } + + /** Opens a target already validated and pinned by the common-checkpoint runtime. */ + public static StateArchiveCheckpointReadAdapter openTrusted(Path archiveDirectory, + CommonCheckpointTarget target, Engine engine) throws IOException { + Path directory = Objects.requireNonNull(archiveDirectory, "archiveDirectory"); + CommonCheckpointTarget admitted = Objects.requireNonNull(target, "target"); + return new StateArchiveCheckpointReadAdapter(admitted, + StateArchiveCheckpointServingIndex.openTrustedReader(directory, admitted, engine)); + } + + /** Reconstructs the published target from disk before opening the exact-point reader. */ + public static StateArchiveCheckpointReadAdapter open(Path archiveDirectory, + byte[] expectedFormatIdentity) throws IOException { + return open(archiveDirectory, expectedFormatIdentity, + StateArchiveCheckpointServingIndex.configuredEngine()); + } + + public static StateArchiveCheckpointReadAdapter open(Path archiveDirectory, + byte[] expectedFormatIdentity, Engine engine) throws IOException { + Path directory = Objects.requireNonNull(archiveDirectory, "archiveDirectory"); + return open(directory, StateArchiveCheckpointMaterializer.loadPublishedTarget(directory, + expectedFormatIdentity, engine), engine); + } + + /** + * Returns the old value from the first change in {@code (targetBlock, publishedHead]}, or empty + * when the caller must use its pinned latest-state value. + */ + public synchronized Optional findOldValueAfter(String dbName, byte[] rawKey, + long targetBlock) throws IOException { + ensureOpen(); + OptionalLong first = reader.firstChangeAfter(dbName, rawKey, targetBlock, + target.getLastBlock().getBlockNumber()); + return first.isPresent() + ? Optional.of(reader.readOldValue(dbName, rawKey, first.getAsLong())) + : Optional.empty(); + } + + public long getIndexedFrom() { + return reader.getIndexedFrom(); + } + + public long getIndexedThrough() { + return reader.getIndexedThrough(); + } + + public byte[] getHeadHash() { + return reader.getHeadHash(); + } + + @Override + public synchronized void close() { + if (!closed) { + closed = true; + reader.close(); + } + } + + private void ensureOpen() { + if (closed) { + throw new IllegalStateException("State Archive checkpoint read adapter is closed"); + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/StateArchiveCheckpointReadSnapshot.java b/chainbase/src/main/java/org/tron/core/db2/archive/StateArchiveCheckpointReadSnapshot.java new file mode 100644 index 00000000000..90b6e9569d7 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/StateArchiveCheckpointReadSnapshot.java @@ -0,0 +1,207 @@ +package org.tron.core.db2.archive; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Objects; +import java.util.Optional; +import org.tron.core.db2.archive.ArchiveReadSnapshot.PinnedLatestState; +import org.tron.core.db2.core.CommonCheckpointTarget; +import org.tron.core.db2.core.CommonCheckpointRuntimeOwner; +import org.tron.core.db2.stateroot.PathStateStoreManifest.Engine; + +/** Request-owned, point-only view over one published next-format checkpoint head. */ +public final class StateArchiveCheckpointReadSnapshot implements ArchivePointSnapshot { + + private final long targetBlock; + private final long pinnedBlock; + private final byte[] pinnedHash; + private final CommonCheckpointRuntimeOwner.ReadLease lease; + private final StateArchiveCheckpointReadAdapter archive; + private final PinnedLatestState latest; + private boolean closed; + + private StateArchiveCheckpointReadSnapshot(long targetBlock, + CommonCheckpointRuntimeOwner.ReadLease lease, + StateArchiveCheckpointReadAdapter archive, PinnedLatestState latest) { + this.targetBlock = targetBlock; + this.pinnedBlock = archive.getIndexedThrough(); + this.pinnedHash = archive.getHeadHash(); + this.lease = lease; + this.archive = archive; + this.latest = latest; + validateIdentity(); + } + + /** Pins the publication gate, Archive index, and latest engine head as one request unit. */ + public static StateArchiveCheckpointReadSnapshot pin(long targetBlock, + CommonCheckpointRuntimeOwner owner, Path archiveDirectory, byte[] expectedFormatIdentity, + PinnedLatestStateFactory latestFactory) throws IOException { + CommonCheckpointRuntimeOwner admittedOwner = Objects.requireNonNull(owner, "owner"); + CommonCheckpointRuntimeOwner.ReadLease lease = admittedOwner.acquireReadLease(); + StateArchiveCheckpointReadAdapter archive = null; + PinnedLatestState latest = null; + try { + archive = StateArchiveCheckpointReadAdapter.open(archiveDirectory, + expectedFormatIdentity); + if (targetBlock < archive.getIndexedFrom() || targetBlock > archive.getIndexedThrough()) { + throw new IllegalArgumentException("checkpoint target block is outside indexed coverage"); + } + latest = Objects.requireNonNull(latestFactory, "latestFactory").pin( + archive.getIndexedThrough(), archive.getHeadHash()); + return new StateArchiveCheckpointReadSnapshot(targetBlock, lease, archive, + Objects.requireNonNull(latest, "pinned latest state")); + } catch (IOException | RuntimeException failure) { + closeAfterFailedPin(lease, archive, latest, failure); + throw failure; + } + } + + /** Pins a target already validated and bound by its owning common-checkpoint runtime. */ + public static StateArchiveCheckpointReadSnapshot pin(long targetBlock, + CommonCheckpointRuntimeOwner owner, Path archiveDirectory, + CommonCheckpointTarget publishedTarget, Engine engine, + PinnedLatestStateFactory latestFactory) throws IOException { + CommonCheckpointRuntimeOwner admittedOwner = Objects.requireNonNull(owner, "owner"); + CommonCheckpointRuntimeOwner.ReadLease lease = admittedOwner.acquireReadLease(); + StateArchiveCheckpointReadAdapter archive = null; + PinnedLatestState latest = null; + try { + archive = StateArchiveCheckpointReadAdapter.openTrusted(archiveDirectory, + publishedTarget, engine); + if (targetBlock < archive.getIndexedFrom() || targetBlock > archive.getIndexedThrough()) { + throw new IllegalArgumentException("checkpoint target block is outside indexed coverage"); + } + latest = Objects.requireNonNull(latestFactory, "latestFactory").pin( + archive.getIndexedThrough(), archive.getHeadHash()); + return new StateArchiveCheckpointReadSnapshot(targetBlock, lease, archive, + Objects.requireNonNull(latest, "pinned latest state")); + } catch (IOException | RuntimeException failure) { + closeAfterFailedPin(lease, archive, latest, failure); + throw failure; + } + } + + /** Returns the first reverse-diff old value, or the same-request pinned latest value. */ + public synchronized OldValue get(String dbName, byte[] physicalRawKey) throws IOException { + ensureOpen(); + Optional historical = archive.findOldValueAfter(dbName, physicalRawKey, + targetBlock); + OldValue value = historical.isPresent() + ? historical.get() : latest.get(dbName, physicalRawKey); + if (value == null) { + throw new IllegalStateException("Pinned latest state returned null"); + } + return value; + } + + public long getTargetBlock() { + return targetBlock; + } + + public long getPinnedBlock() { + return pinnedBlock; + } + + public byte[] getPinnedHash() { + return Arrays.copyOf(pinnedHash, pinnedHash.length); + } + + /** Revalidates the request-owned history and latest head identity. */ + public synchronized void requirePinnedIdentity() { + ensureOpen(); + validateIdentity(); + } + + @Override + public synchronized void close() throws IOException { + if (closed) { + return; + } + closed = true; + IOException failure = null; + try { + latest.close(); + } catch (IOException e) { + failure = e; + } + try { + archive.close(); + } catch (RuntimeException e) { + if (failure == null) { + failure = new IOException("Failed to close checkpoint Archive reader", e); + } else { + failure.addSuppressed(e); + } + } finally { + lease.close(); + } + if (failure != null) { + throw failure; + } + } + + private void validateIdentity() { + if (pinnedBlock != archive.getIndexedThrough() + || !Arrays.equals(pinnedHash, archive.getHeadHash()) + || latest.getBlockNumber() != pinnedBlock + || !Arrays.equals(pinnedHash, latest.getBlockHash())) { + throw new IllegalArgumentException("checkpoint Archive read snapshot identity mismatch"); + } + } + + private void ensureOpen() { + if (closed) { + throw new IllegalStateException("checkpoint Archive read snapshot is closed"); + } + } + + private static void closeAfterFailedPin(CommonCheckpointRuntimeOwner.ReadLease lease, + StateArchiveCheckpointReadAdapter archive, PinnedLatestState latest, + Exception failure) { + IOException closeFailure = closeResources(lease, archive, latest); + if (closeFailure != null) { + failure.addSuppressed(closeFailure); + } + } + + private static IOException closeResources(CommonCheckpointRuntimeOwner.ReadLease lease, + StateArchiveCheckpointReadAdapter archive, PinnedLatestState latest) { + IOException failure = null; + if (latest != null) { + try { + latest.close(); + } catch (IOException e) { + failure = e; + } + } + if (archive != null) { + try { + archive.close(); + } catch (RuntimeException e) { + failure = append(failure, new IOException( + "Failed to close checkpoint Archive reader", e)); + } + } + try { + lease.close(); + } catch (RuntimeException e) { + failure = append(failure, new IOException( + "Failed to release common checkpoint read lease", e)); + } + return failure; + } + + private static IOException append(IOException failure, IOException addition) { + if (failure == null) { + return addition; + } + failure.addSuppressed(addition); + return failure; + } + + @FunctionalInterface + public interface PinnedLatestStateFactory { + PinnedLatestState pin(long blockNumber, byte[] blockHash) throws IOException; + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/StateArchiveCheckpointServingIndex.java b/chainbase/src/main/java/org/tron/core/db2/archive/StateArchiveCheckpointServingIndex.java new file mode 100644 index 00000000000..c1ac1ad1716 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/StateArchiveCheckpointServingIndex.java @@ -0,0 +1,535 @@ +package org.tron.core.db2.archive; + +import com.google.common.hash.Hashing; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.EOFException; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.OptionalLong; +import org.tron.common.parameter.CommonParameter; +import org.tron.core.db2.archive.BlockReverseDiff.DbGroup; +import org.tron.core.db2.archive.BlockReverseDiff.Entry; +import org.tron.core.db2.core.CommonCheckpointPayload; +import org.tron.core.db2.core.CommonCheckpointTarget; +import org.tron.core.db2.stateroot.PathStateStoreManifest.Engine; + +/** Persistent exact-key locator for next-format per-block checkpoint history files. */ +final class StateArchiveCheckpointServingIndex { + + static final String DIRECTORY = "checkpoint-serving-index"; + private static final String DATABASE = "keys"; + private static final byte[] MARKER_KEY = new byte[]{0}; + private static final byte CHANGE_PREFIX = 1; + private static final byte BLOCK_PREFIX = 2; + private static final int MARKER_MAGIC = 0x53414349; // SACI + private static final short MARKER_VERSION = 1; + private static final int DIGEST_LENGTH = 32; + private static final int MARKER_LENGTH = Integer.BYTES + 2 * Short.BYTES + + 5 * DIGEST_LENGTH + 3 * Long.BYTES + DIGEST_LENGTH; + private static final int LOCATION_LENGTH = DIGEST_LENGTH + Integer.BYTES + Long.BYTES + + DIGEST_LENGTH; + + private StateArchiveCheckpointServingIndex() { + } + + static Status inspect(Path archiveDirectory, CommonCheckpointTarget target) + throws IOException { + return inspect(archiveDirectory, target, configuredEngine()); + } + + static Status inspect(Path archiveDirectory, CommonCheckpointTarget target, Engine engine) + throws IOException { + Path databasePath = databasePath(archiveDirectory); + if (!Files.exists(databasePath, LinkOption.NOFOLLOW_LINKS)) { + return Status.ABSENT; + } + StateArchiveIndexEngineManifest.require(archiveDirectory.resolve(DIRECTORY), engine); + try (StateArchiveIndexDatabase.Reader database = + StateArchiveIndexDatabase.openReader(databasePath, engine)) { + return inspect(database.get(MARKER_KEY), target); + } + } + + static void apply(Path archiveDirectory, CommonCheckpointPayload payload, + CommonCheckpointTarget target) throws IOException { + apply(archiveDirectory, payload, target, configuredEngine()); + } + + static void apply(Path archiveDirectory, CommonCheckpointPayload payload, + CommonCheckpointTarget target, Engine engine) throws IOException { + try (Session session = session(archiveDirectory, engine)) { + session.apply(payload, target); + } + } + + static Session session(Path archiveDirectory, Engine engine) { + return new Session(archiveDirectory, engine); + } + + static int openReferenceCount(Path archiveDirectory, Engine engine) { + return StateArchiveIndexDatabase.openReferenceCount(databasePath(archiveDirectory), engine); + } + + private static Status inspect(byte[] encoded, CommonCheckpointTarget target) + throws IOException { + if (encoded == null) { + throw new IOException("State Archive checkpoint serving marker is missing"); + } + Marker marker = decodeMarker(encoded); + if (Arrays.equals(encoded, encodeMarker(target, marker.baseBlockNumber, + marker.baseBlockHash))) { + return Status.EXACT; + } + requireParent(marker, target); + return Status.PARENT; + } + + static Reader openReader(Path archiveDirectory, CommonCheckpointTarget target) + throws IOException { + return openReader(archiveDirectory, target, configuredEngine()); + } + + static Reader openReader(Path archiveDirectory, CommonCheckpointTarget target, Engine engine) + throws IOException { + return new Reader(archiveDirectory, target, engine, true); + } + + static Reader openTrustedReader(Path archiveDirectory, CommonCheckpointTarget target, + Engine engine) throws IOException { + return new Reader(archiveDirectory, target, engine, false); + } + + static Engine configuredEngine() { + org.tron.core.config.args.Storage storage = CommonParameter.getInstance().getStorage(); + if (storage == null) { + return Engine.ROCKSDB; + } + String configured = storage.getStateArchiveServingIndexEngine(); + if (configured == null) { + configured = storage.getDbEngine(); + } + if (configured == null) { + return Engine.ROCKSDB; + } + return Engine.valueOf(configured.trim().toUpperCase(Locale.ROOT)); + } + + private static void requireParent(Marker marker, CommonCheckpointTarget target) + throws IOException { + BlockSnapshotMeta first = target.getFirstBlock(); + if (!Arrays.equals(marker.formatIdentity, target.getFormatIdentity()) + || marker.lastEpoch + 1 != first.getEpoch() + || marker.lastBlockNumber + 1 != first.getBlockNumber() + || !Arrays.equals(marker.lastBlockHash, first.getParentHash()) + || !Arrays.equals(marker.stateRoot, target.getParentStateRoot())) { + throw new IOException("State Archive checkpoint serving index is not the target parent"); + } + } + + private static byte[] changeKey(String dbName, byte[] rawKey, long blockNumber) { + int storeId = ArchiveParticipantDescriptor.current().getStoreId(dbName); + byte[] key = Objects.requireNonNull(rawKey, "rawKey"); + return ByteBuffer.allocate(1 + Short.BYTES + Integer.BYTES + key.length + Long.BYTES) + .put(CHANGE_PREFIX).putShort((short) storeId).putInt(key.length).put(key) + .putLong(blockNumber).array(); + } + + private static byte[] changePrefix(String dbName, byte[] rawKey) { + int storeId = ArchiveParticipantDescriptor.current().getStoreId(dbName); + byte[] key = Objects.requireNonNull(rawKey, "rawKey"); + return ByteBuffer.allocate(1 + Short.BYTES + Integer.BYTES + key.length) + .put(CHANGE_PREFIX).putShort((short) storeId).putInt(key.length).put(key).array(); + } + + private static byte[] blockKey(long blockNumber) { + return ByteBuffer.allocate(1 + Long.BYTES).put(BLOCK_PREFIX).putLong(blockNumber).array(); + } + + private static byte[] encodeLocation(byte[] targetDigest, int index, BlockSnapshotMeta meta) { + return ByteBuffer.allocate(LOCATION_LENGTH).put(targetDigest).putInt(index) + .putLong(meta.getEpoch()).put(meta.getBlockHash()).array(); + } + + private static Location decodeLocation(byte[] encoded) throws IOException { + if (encoded == null || encoded.length != LOCATION_LENGTH) { + throw new IOException("State Archive checkpoint block location is missing or corrupt"); + } + ByteBuffer input = ByteBuffer.wrap(encoded); + byte[] targetDigest = new byte[DIGEST_LENGTH]; + input.get(targetDigest); + int index = input.getInt(); + long epoch = input.getLong(); + byte[] blockHash = new byte[DIGEST_LENGTH]; + input.get(blockHash); + if (index < 0 || epoch < 0) { + throw new IOException("State Archive checkpoint block location is invalid"); + } + return new Location(targetDigest, index, epoch, blockHash); + } + + private static byte[] encodeMarker(CommonCheckpointTarget target, long baseBlockNumber, + byte[] baseBlockHash) { + if (baseBlockNumber < 0) { + throw new IllegalArgumentException("checkpoint serving base block must not be negative"); + } + try { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(MARKER_LENGTH); + DataOutputStream output = new DataOutputStream(bytes); + output.writeInt(MARKER_MAGIC); + output.writeShort(MARKER_VERSION); + output.writeShort(0); + output.write(target.getFormatIdentity()); + output.write(target.getPayloadDigest()); + output.writeLong(baseBlockNumber); + output.write(baseBlockHash); + output.writeLong(target.getLastBlock().getEpoch()); + output.writeLong(target.getLastBlock().getBlockNumber()); + output.write(target.getLastBlock().getBlockHash()); + output.write(target.getStateRoot()); + output.flush(); + byte[] body = bytes.toByteArray(); + output.write(Hashing.sha256().hashBytes(body).asBytes()); + output.flush(); + return bytes.toByteArray(); + } catch (IOException impossible) { + throw new IllegalStateException("in-memory checkpoint serving marker encoding failed", + impossible); + } + } + + private static Marker decodeMarker(byte[] encoded) throws IOException { + if (encoded == null || encoded.length != MARKER_LENGTH) { + throw new IOException("State Archive checkpoint serving marker length is invalid"); + } + int bodyLength = encoded.length - DIGEST_LENGTH; + byte[] body = Arrays.copyOf(encoded, bodyLength); + if (!Arrays.equals(Arrays.copyOfRange(encoded, bodyLength, encoded.length), + Hashing.sha256().hashBytes(body).asBytes())) { + throw new IOException("State Archive checkpoint serving marker checksum differs"); + } + try (DataInputStream input = new DataInputStream(new ByteArrayInputStream(body))) { + if (input.readInt() != MARKER_MAGIC || input.readShort() != MARKER_VERSION + || input.readShort() != 0) { + throw new IOException("State Archive checkpoint serving marker format is unsupported"); + } + byte[] formatIdentity = readDigest(input); + byte[] payloadDigest = readDigest(input); + long baseBlockNumber = input.readLong(); + byte[] baseBlockHash = readDigest(input); + long lastEpoch = input.readLong(); + long lastBlockNumber = input.readLong(); + byte[] lastBlockHash = readDigest(input); + byte[] stateRoot = readDigest(input); + if (baseBlockNumber < 0 || lastEpoch < 0 || lastBlockNumber <= baseBlockNumber) { + throw new IOException("State Archive checkpoint serving marker range is invalid"); + } + return new Marker(formatIdentity, payloadDigest, baseBlockNumber, baseBlockHash, lastEpoch, + lastBlockNumber, lastBlockHash, stateRoot); + } catch (EOFException truncated) { + throw new IOException("State Archive checkpoint serving marker is truncated", truncated); + } + } + + private static Path databasePath(Path archiveDirectory) { + return archiveDirectory.resolve(DIRECTORY).resolve(DATABASE); + } + + private static void requireStateDatabase(String dbName) throws IOException { + if (!ArchiveStoreScope.isStateDatabase(dbName)) { + throw new IOException("State Archive checkpoint contains a non-state Store: " + dbName); + } + } + + private static byte[] readDigest(DataInputStream input) throws IOException { + byte[] value = new byte[DIGEST_LENGTH]; + input.readFully(value); + return value; + } + + private static boolean startsWith(byte[] value, byte[] prefix) { + if (value.length < prefix.length) { + return false; + } + for (int index = 0; index < prefix.length; index++) { + if (value[index] != prefix[index]) { + return false; + } + } + return true; + } + + enum Status { + ABSENT, + PARENT, + EXACT + } + + static final class Session implements AutoCloseable { + + private final Path archiveDirectory; + private final Engine engine; + private StateArchiveIndexDatabase.Writer database; + + private Session(Path archiveDirectory, Engine engine) { + this.archiveDirectory = Objects.requireNonNull(archiveDirectory, "archiveDirectory"); + this.engine = Objects.requireNonNull(engine, "engine"); + } + + Status inspect(CommonCheckpointTarget target) throws IOException { + Path databasePath = databasePath(archiveDirectory); + if (database == null && !Files.exists(databasePath, LinkOption.NOFOLLOW_LINKS)) { + return Status.ABSENT; + } + return StateArchiveCheckpointServingIndex.inspect( + openExisting().get(MARKER_KEY), target); + } + + void apply(CommonCheckpointPayload payload, CommonCheckpointTarget target) + throws IOException { + Path indexDirectory = archiveDirectory.resolve(DIRECTORY); + Path databasePath = databasePath(archiveDirectory); + boolean databaseExisted = Files.exists(databasePath, LinkOption.NOFOLLOW_LINKS); + Files.createDirectories(indexDirectory); + if (!Files.isDirectory(indexDirectory, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("State Archive checkpoint serving path is not a directory"); + } + StateArchiveIndexEngineManifest.openOrCreate(indexDirectory, engine); + StateArchiveIndexDatabase.Writer writer = open(); + byte[] existing = writer.get(MARKER_KEY); + long baseBlockNumber = target.getFirstBlock().getBlockNumber() - 1; + byte[] baseBlockHash = target.getFirstBlock().getParentHash(); + if (existing != null) { + Marker parent = decodeMarker(existing); + if (Arrays.equals(existing, + encodeMarker(target, parent.baseBlockNumber, parent.baseBlockHash))) { + return; + } + requireParent(parent, target); + baseBlockNumber = parent.baseBlockNumber; + baseBlockHash = parent.baseBlockHash; + } else if (databaseExisted) { + throw new IOException("State Archive checkpoint serving marker is missing"); + } + List mutations = new ArrayList<>(); + for (int index = 0; index < payload.getBlocks().size(); index++) { + CommonCheckpointPayload.BlockPayload block = payload.getBlocks().get(index); + long blockNumber = block.getMeta().getBlockNumber(); + for (DbGroup group : block.getArchiveDiff().getGroups()) { + requireStateDatabase(group.getDbName()); + for (Entry entry : group.getEntries()) { + mutations.add(StateArchiveIndexDatabase.put( + changeKey(group.getDbName(), entry.getKey(), blockNumber), new byte[]{1})); + } + } + mutations.add(StateArchiveIndexDatabase.put(blockKey(blockNumber), + encodeLocation(target.getPayloadDigest(), index, block.getMeta()))); + } + mutations.add(StateArchiveIndexDatabase.put(MARKER_KEY, + encodeMarker(target, baseBlockNumber, baseBlockHash))); + writer.write(mutations); + HistorySegmentStore.syncDirectory(indexDirectory); + } + + private StateArchiveIndexDatabase.Writer openExisting() throws IOException { + StateArchiveIndexEngineManifest.require(archiveDirectory.resolve(DIRECTORY), engine); + return open(); + } + + private StateArchiveIndexDatabase.Writer open() throws IOException { + if (database == null) { + database = StateArchiveIndexDatabase.openWriter(databasePath(archiveDirectory), engine); + } + return database; + } + + @Override + public void close() throws IOException { + if (database != null) { + try { + database.close(); + } finally { + database = null; + } + } + } + } + + static final class Reader implements AutoCloseable { + + private final Path archiveDirectory; + private final StateArchiveIndexDatabase.Reader database; + private final Marker marker; + private boolean closed; + + private Reader(Path archiveDirectory, CommonCheckpointTarget target, Engine engine, + boolean validateEngine) throws IOException { + this.archiveDirectory = Objects.requireNonNull(archiveDirectory, "archiveDirectory"); + Objects.requireNonNull(target, "target"); + if (validateEngine) { + StateArchiveIndexEngineManifest.require(archiveDirectory.resolve(DIRECTORY), engine); + } + StateArchiveIndexDatabase.Reader opened; + try { + opened = StateArchiveIndexDatabase.openReader(databasePath(archiveDirectory), engine); + } catch (IOException | RuntimeException failure) { + throw new IOException("Failed to open State Archive checkpoint serving reader", failure); + } + this.database = opened; + Marker loaded; + try { + byte[] encoded = opened.get(MARKER_KEY); + loaded = decodeMarker(encoded); + if (!Arrays.equals(encoded, encodeMarker(target, loaded.baseBlockNumber, + loaded.baseBlockHash))) { + throw new IOException("State Archive checkpoint reader target differs"); + } + } catch (IOException | RuntimeException failure) { + opened.close(); + if (failure instanceof IOException) { + throw (IOException) failure; + } + throw new IOException("Failed to validate State Archive checkpoint serving reader", + failure); + } + this.marker = loaded; + } + + OptionalLong firstChangeAfter(String dbName, byte[] rawKey, long targetBlock, + long upperBound) throws IOException { + ensureOpen(); + requireStateDatabase(dbName); + if (targetBlock < marker.baseBlockNumber || upperBound > marker.lastBlockNumber + || targetBlock > upperBound) { + throw new IllegalArgumentException("checkpoint serving query is outside coverage"); + } + if (targetBlock == Long.MAX_VALUE) { + return OptionalLong.empty(); + } + byte[] prefix = changePrefix(dbName, rawKey); + byte[] seek = ByteBuffer.allocate(prefix.length + Long.BYTES).put(prefix) + .putLong(targetBlock + 1).array(); + StateArchiveIndexDatabase.KeyValue foundEntry = database.seek(seek); + if (foundEntry == null) { + return OptionalLong.empty(); + } + byte[] found = foundEntry.getKey(); + if (found.length != prefix.length + Long.BYTES || !startsWith(found, prefix)) { + return OptionalLong.empty(); + } + long blockNumber = ByteBuffer.wrap(found, prefix.length, Long.BYTES).getLong(); + return blockNumber <= upperBound ? OptionalLong.of(blockNumber) : OptionalLong.empty(); + } + + OldValue readOldValue(String dbName, byte[] rawKey, long blockNumber) throws IOException { + ensureOpen(); + requireStateDatabase(dbName); + if (blockNumber <= marker.baseBlockNumber || blockNumber > marker.lastBlockNumber) { + throw new IllegalArgumentException("checkpoint history block is outside coverage"); + } + Location location = decodeLocation(database.get(blockKey(blockNumber))); + String fileName = StateArchiveCheckpointMaterializer.blockFileName(location.index, + new BlockSnapshotMeta(location.epoch, blockNumber, location.blockHash, + new byte[DIGEST_LENGTH], 0)); + Path path = archiveDirectory.resolve(StateArchiveCheckpointMaterializer.TARGET_DIRECTORY) + .resolve(StateArchiveCheckpointMaterializer.hex(location.targetDigest)) + .resolve(StateArchiveCheckpointMaterializer.BLOCK_DIRECTORY).resolve(fileName); + BlockReverseDiff diff = StateArchiveCheckpointMaterializer.loadCheckpointBlock(path); + if (diff.getMeta().getBlockNumber() != blockNumber + || diff.getMeta().getEpoch() != location.epoch + || !Arrays.equals(diff.getMeta().getBlockHash(), location.blockHash)) { + throw new IOException("State Archive checkpoint block location identity differs"); + } + for (DbGroup group : diff.getGroups()) { + if (group.getDbName().equals(dbName)) { + for (Entry entry : group.getEntries()) { + if (Arrays.equals(entry.getKey(), rawKey)) { + return entry.getOldValue(); + } + } + } + } + throw new IOException("State Archive checkpoint index references a missing key"); + } + + long getIndexedFrom() { + return marker.baseBlockNumber; + } + + long getIndexedThrough() { + return marker.lastBlockNumber; + } + + byte[] getHeadHash() { + return Arrays.copyOf(marker.lastBlockHash, marker.lastBlockHash.length); + } + + @Override + public void close() { + if (!closed) { + closed = true; + try { + database.close(); + } catch (IOException failure) { + throw new IllegalStateException("Failed to close Archive serving reader", failure); + } + } + } + + private void ensureOpen() { + if (closed) { + throw new IllegalStateException("State Archive checkpoint serving reader is closed"); + } + } + } + + private static final class Marker { + + private final byte[] formatIdentity; + private final byte[] payloadDigest; + private final long baseBlockNumber; + private final byte[] baseBlockHash; + private final long lastEpoch; + private final long lastBlockNumber; + private final byte[] lastBlockHash; + private final byte[] stateRoot; + + private Marker(byte[] formatIdentity, byte[] payloadDigest, long baseBlockNumber, + byte[] baseBlockHash, long lastEpoch, long lastBlockNumber, byte[] lastBlockHash, + byte[] stateRoot) { + this.formatIdentity = formatIdentity; + this.payloadDigest = payloadDigest; + this.baseBlockNumber = baseBlockNumber; + this.baseBlockHash = baseBlockHash; + this.lastEpoch = lastEpoch; + this.lastBlockNumber = lastBlockNumber; + this.lastBlockHash = lastBlockHash; + this.stateRoot = stateRoot; + } + } + + private static final class Location { + + private final byte[] targetDigest; + private final int index; + private final long epoch; + private final byte[] blockHash; + + private Location(byte[] targetDigest, int index, long epoch, byte[] blockHash) { + this.targetDigest = targetDigest; + this.index = index; + this.epoch = epoch; + this.blockHash = blockHash; + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/StateArchiveFileFormatV3.java b/chainbase/src/main/java/org/tron/core/db2/archive/StateArchiveFileFormatV3.java new file mode 100644 index 00000000000..3d2e066227b --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/StateArchiveFileFormatV3.java @@ -0,0 +1,468 @@ +package org.tron.core.db2.archive; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; +import java.util.Collection; + +/** Single source of truth for the default-off State Archive append-file v3 format. */ +public final class StateArchiveFileFormatV3 { + + public static final String FORMAT_ID = "archive-state/mixed-frame-segment/v3"; + public static final String FIVE_LANE_FORMAT_ID = + "archive-state/five-lane-frame-segment/v3"; + + public static final int FRAME_MAGIC = 0x53414633; + public static final int FRAME_TRAILER_MAGIC = 0x33464153; + public static final int PAYLOAD_MAGIC = 0x53425031; + public static final int PART_MAGIC = 0x53415033; + public static final int SEGMENT_MAGIC = 0x53415333; + public static final int BLOCK_INDEX_MAGIC = 0x53424933; + public static final int RECOVERY_INTENT_MAGIC = 0x53524933; + public static final int RECOVERY_INTENT_TRAILER_MAGIC = 0x33495253; + public static final int SEGMENT_MANIFEST_MAGIC = 0x53414d33; + public static final int SEGMENT_MANIFEST_TRAILER_MAGIC = 0x334d4153; + + public static final short MAJOR_VERSION = 3; + public static final short MINOR_VERSION = 0; + public static final short BLOCK_FRAME_TYPE = 1; + public static final short DURABLE_MARKER_FRAME_TYPE = 2; + public static final short PART_SEAL_FRAME_TYPE = 3; + + public static final short BODY_CODEC_ID = 1; + public static final short LANE_VARIABLE_BODY_CODEC_ID = 2; + public static final short DEDICATED_FIXED_WIDTH_BODY_CODEC_ID = 3; + public static final short COMPRESSION_NONE = 0; + public static final short COMPRESSION_RAW_DEFLATE_LEVEL_1 = 1; + public static final short KEY_ORDER_ID = 1; + public static final short DIGEST_ID = 1; + public static final short CHECKSUM_ID = 1; + public static final short IDENTITY_KIND = 1; + public static final short LANE_KIND = 1; + public static final short LANE_ID = 0; + public static final short SIZE_ROLLED_AFTER_TARGET_ROTATION_ID = 1; + public static final short SEGMENT_LAYOUT_ID = 2; + public static final short SEGMENT_OVERSHOOT_POLICY_ID = 1; + public static final short RECOVERY_INTENT_ACTION_SCHEMA_ID = 1; + public static final short RECOVERY_POINT_SCHEMA_ID = 1; + + public static final int FORMAT_DESCRIPTOR_LENGTH = 96; + public static final int FRAME_ENVELOPE_LENGTH = 32; + public static final int BLOCK_HEADER_LENGTH = 336; + public static final int MARKER_HEADER_LENGTH = 288; + public static final int SEAL_HEADER_LENGTH = 320; + public static final int FRAME_TRAILER_LENGTH = 48; + public static final int PART_HEADER_LENGTH = 512; + public static final int BLOCK_INDEX_HEADER_LENGTH = 128; + public static final int BLOCK_INDEX_ENTRY_LENGTH = 32; + public static final int MANIFEST_HEADER_LENGTH = 256; + public static final int MANIFEST_PART_RECORD_LENGTH = 160; + public static final int MANIFEST_TRAILER_LENGTH = 48; + public static final int MANIFEST_TOTAL_LENGTH = MANIFEST_HEADER_LENGTH + + MANIFEST_TRAILER_LENGTH; + public static final int SEGMENT_LAYOUT_DESCRIPTOR_LENGTH = 64; + public static final int SEGMENT_MAP_ENTRY_LENGTH = 192; + public static final int RECOVERY_INTENT_LAYOUT_DESCRIPTOR_LENGTH = 32; + public static final int RECOVERY_INTENT_HEADER_LENGTH = 768; + public static final int RECOVERY_INTENT_LANE_RECORD_LENGTH = 160; + public static final int RECOVERY_INTENT_TRAILER_LENGTH = 48; + public static final int RECOVERY_INTENT_TOTAL_LENGTH = 1_616; + public static final int PAYLOAD_HEADER_LENGTH = 32; + public static final int SECTION_HEADER_LENGTH = 80; + public static final int VARIABLE_INDEX_RECORD_BASE_LENGTH = 16; + + // Legacy U0 descriptor fields. They are not five-lane rotation rules. + static final int U0_SEGMENT_BLOCK_SPAN = 16_384; + static final long U0_PART_MAX_BYTES = 2_000_000_000L; + public static final int MAX_BLOCK_FRAME_BYTES = 67_108_864; + public static final long SEGMENT_TARGET_BYTES = 2_000_000_000L; + public static final int SHARD_MAX_SEGMENTS = 1_024; + public static final long EXACT_COVERAGE_BITMAP = 0x0000000007ffffffL; + public static final long MIXED_LANE_COVERAGE_BITMAP = 0x0000000007dfefe7L; + private static final int[] FIVE_LANE_ID_VALUES = {0, 4, 5, 13, 22}; + public static final int STORE_COUNT = 27; + public static final int HASH_LENGTH = 32; + + static final byte[] FORMAT_DOMAIN = ascii("TRON-STATE-ARCHIVE-FORMAT-V3\0"); + static final byte[] STORE_DESCRIPTOR_DOMAIN = + ascii("TRON-STATE-ARCHIVE-STORE-DESCRIPTOR-V3\0"); + static final byte[] FIVE_LANE_DESCRIPTOR_DOMAIN = + ascii("TRON-STATE-ARCHIVE-FIVE-LANE-DESCRIPTOR-V3\0"); + static final byte[] SECTION_DOMAIN = ascii("TRON-STATE-ARCHIVE-SECTION-V3\0"); + static final byte[] PAYLOAD_DOMAIN = ascii("TRON-STATE-ARCHIVE-PAYLOAD-V3\0"); + static final byte[] BLOCK_DOMAIN = ascii("TRON-STATE-ARCHIVE-BLOCK-V3\0"); + static final byte[] ROLLING_DOMAIN = ascii("TRON-STATE-ARCHIVE-ROLLING-V3\0"); + static final byte[] ENCODED_BLOCK_DOMAIN = + ascii("TRON-STATE-ARCHIVE-ENCODED-BLOCK-V3\0"); + static final byte[] FIXED_SECTION_DOMAIN = + ascii("TRON-STATE-ARCHIVE-FIXED-SECTION-V3\0"); + static final byte[] LANE_ITEM_DOMAIN = + ascii("TRON-STATE-ARCHIVE-LANE-ITEM-V3\0"); + static final byte[] BUNDLE_BLOCK_DOMAIN = + ascii("TRON-STATE-ARCHIVE-BUNDLE-BLOCK-V3\0"); + static final byte[] PART_HEADER_DOMAIN = + ascii("TRON-STATE-ARCHIVE-PART-HEADER-V3\0"); + static final byte[] SEGMENT_LAYOUT_DOMAIN = + ascii("TRON-STATE-ARCHIVE-SEGMENT-LAYOUT-V3\0"); + static final byte[] COMPOSITE_FORMAT_DOMAIN = + ascii("TRON-STATE-ARCHIVE-COMPOSITE-FORMAT-V3\0"); + static final byte[] SEGMENT_HEADER_DOMAIN = + ascii("TRON-STATE-ARCHIVE-SEGMENT-HEADER-V3\0"); + static final byte[] SEGMENT_CONTENT_DOMAIN = + ascii("TRON-STATE-ARCHIVE-SEGMENT-CONTENT-V3\0"); + static final byte[] ENCODED_SEGMENT_SEAL_DOMAIN = + ascii("TRON-STATE-ARCHIVE-ENCODED-SEGMENT-SEAL-V3\0"); + static final byte[] ENCODED_MARKER_DOMAIN = + ascii("TRON-STATE-ARCHIVE-ENCODED-MARKER-V3\0"); + static final byte[] SEGMENT_CHAIN_DOMAIN = + ascii("TRON-STATE-ARCHIVE-SEGMENT-CHAIN-V3\0"); + static final byte[] LANE_SEGMENT_BASELINE_DOMAIN = + ascii("TRON-STATE-ARCHIVE-LANE-SEGMENT-BASELINE-V3\0"); + static final byte[] BLOCK_INDEX_HEADER_DOMAIN = + ascii("TRON-STATE-ARCHIVE-BLOCK-INDEX-HEADER-V3\0"); + static final byte[] RECOVERY_INTENT_LAYOUT_DOMAIN = + ascii("TRON-STATE-ARCHIVE-RECOVERY-INTENT-LAYOUT-V3\0"); + static final byte[] RECOVERY_INTENT_RECORDS_DOMAIN = + ascii("TRON-STATE-ARCHIVE-RECOVERY-INTENT-RECORDS-V3\0"); + static final byte[] RECOVERY_INTENT_HEADER_DOMAIN = + ascii("TRON-STATE-ARCHIVE-RECOVERY-INTENT-HEADER-V3\0"); + static final byte[] RECOVERY_INTENT_DOMAIN = + ascii("TRON-STATE-ARCHIVE-RECOVERY-INTENT-V3\0"); + static final byte[] RECOVERY_DATA_PREFIX_DOMAIN = + ascii("TRON-STATE-ARCHIVE-RECOVERY-DATA-PREFIX-V3\0"); + static final byte[] RECOVERY_INDEX_FILE_DOMAIN = + ascii("TRON-STATE-ARCHIVE-RECOVERY-INDEX-FILE-V3\0"); + + private static final byte[] FORMAT_DESCRIPTOR = buildFormatDescriptor(); + private static final byte[] FORMAT_DIGEST = sha256(FORMAT_DOMAIN, FORMAT_DESCRIPTOR); + private static final byte[] STORE_DESCRIPTOR_DIGEST = buildStoreDescriptorDigest(); + private static final byte[] FIVE_LANE_DESCRIPTOR_DIGEST = + buildFiveLaneDescriptorDigest(); + private static final byte[] SEGMENT_LAYOUT_DESCRIPTOR = buildSegmentLayoutDescriptor(); + private static final byte[] SEGMENT_LAYOUT_DIGEST = sha256(SEGMENT_LAYOUT_DOMAIN, + SEGMENT_LAYOUT_DESCRIPTOR); + private static final byte[] RECOVERY_INTENT_LAYOUT_DESCRIPTOR = + buildRecoveryIntentLayoutDescriptor(); + private static final byte[] RECOVERY_INTENT_LAYOUT_DIGEST = sha256( + RECOVERY_INTENT_LAYOUT_DOMAIN, RECOVERY_INTENT_LAYOUT_DESCRIPTOR); + private static final byte[] COMPOSITE_FORMAT_DIGEST = sha256(COMPOSITE_FORMAT_DOMAIN, + FORMAT_DIGEST, SEGMENT_LAYOUT_DIGEST, RECOVERY_INTENT_LAYOUT_DIGEST); + + private StateArchiveFileFormatV3() { + } + + public static byte[] formatDescriptor() { + return Arrays.copyOf(FORMAT_DESCRIPTOR, FORMAT_DESCRIPTOR.length); + } + + public static byte[] formatDigest() { + return Arrays.copyOf(FORMAT_DIGEST, FORMAT_DIGEST.length); + } + + public static byte[] storeDescriptorDigest() { + return Arrays.copyOf(STORE_DESCRIPTOR_DIGEST, STORE_DESCRIPTOR_DIGEST.length); + } + + public static byte[] fiveLaneDescriptorDigest() { + return Arrays.copyOf(FIVE_LANE_DESCRIPTOR_DIGEST, + FIVE_LANE_DESCRIPTOR_DIGEST.length); + } + + public static byte[] segmentLayoutDescriptor() { + return Arrays.copyOf(SEGMENT_LAYOUT_DESCRIPTOR, SEGMENT_LAYOUT_DESCRIPTOR.length); + } + + public static byte[] segmentLayoutDigest() { + return Arrays.copyOf(SEGMENT_LAYOUT_DIGEST, SEGMENT_LAYOUT_DIGEST.length); + } + + public static byte[] recoveryIntentLayoutDescriptor() { + return Arrays.copyOf(RECOVERY_INTENT_LAYOUT_DESCRIPTOR, + RECOVERY_INTENT_LAYOUT_DESCRIPTOR.length); + } + + public static byte[] recoveryIntentLayoutDigest() { + return Arrays.copyOf(RECOVERY_INTENT_LAYOUT_DIGEST, + RECOVERY_INTENT_LAYOUT_DIGEST.length); + } + + public static byte[] compositeFormatDigest() { + return Arrays.copyOf(COMPOSITE_FORMAT_DIGEST, COMPOSITE_FORMAT_DIGEST.length); + } + + static int[] fiveLaneIds() { + return Arrays.copyOf(FIVE_LANE_ID_VALUES, FIVE_LANE_ID_VALUES.length); + } + + static int laneId(int storeId) { + requireStoreId(storeId); + switch (storeId) { + case 4: + case 5: + case 13: + case 22: + return storeId; + default: + return 0; + } + } + + static long laneCoverage(int laneId) { + if (laneId == 0) { + return MIXED_LANE_COVERAGE_BITMAP; + } + requireDedicatedLane(laneId); + return 1L << (laneId - 1); + } + + static short laneKind(int laneId) { + if (laneId == 0) { + return 1; + } + requireDedicatedLane(laneId); + return 2; + } + + static short laneBodyCodec(int laneId) { + if (laneId == 4 || laneId == 22) { + return DEDICATED_FIXED_WIDTH_BODY_CODEC_ID; + } + if (laneId == 0 || laneId == 5 || laneId == 13) { + return LANE_VARIABLE_BODY_CODEC_ID; + } + throw new IllegalArgumentException("Unknown State Archive lane ID: " + laneId); + } + + static int fixedKeyWidth(int laneId) { + if (laneId == 4) { + return 21; + } + if (laneId == 22) { + return 32; + } + return 0; + } + + static int storeId(String dbName) { + int storeId = ArchiveParticipantDescriptor.current().getStoreId(dbName); + if (storeId < 1 || storeId > STORE_COUNT) { + throw new IllegalArgumentException("Inactive archive Store: " + dbName); + } + return storeId; + } + + static String dbName(int storeId) { + String dbName = ArchiveParticipantDescriptor.current().getActiveDatabases().stream() + .filter(candidate -> ArchiveParticipantDescriptor.current().getStoreId(candidate) + == storeId) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Unknown archive Store ID: " + storeId)); + return dbName; + } + + static void requireExactCapture(Collection dbNames) { + ArchiveParticipantDescriptor.current().requireExactParticipants(dbNames); + } + + static byte[] sha256(byte[]... values) { + MessageDigest digest; + try { + digest = MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException impossible) { + throw new IllegalStateException("SHA-256 is unavailable", impossible); + } + for (byte[] value : values) { + digest.update(value); + } + return digest.digest(); + } + + private static byte[] buildFormatDescriptor() { + ByteBuffer buffer = ByteBuffer.allocate(FORMAT_DESCRIPTOR_LENGTH); + buffer.putInt(0x464d5433); + buffer.putShort(MAJOR_VERSION); + buffer.putShort(MINOR_VERSION); + buffer.putInt(FORMAT_DESCRIPTOR_LENGTH); + buffer.putShort((short) BLOCK_HEADER_LENGTH); + buffer.putShort((short) MARKER_HEADER_LENGTH); + buffer.putShort((short) SEAL_HEADER_LENGTH); + buffer.putShort((short) FRAME_TRAILER_LENGTH); + buffer.putShort((short) PART_HEADER_LENGTH); + buffer.putShort((short) BLOCK_INDEX_HEADER_LENGTH); + buffer.putShort((short) BLOCK_INDEX_ENTRY_LENGTH); + buffer.putShort((short) MANIFEST_HEADER_LENGTH); + buffer.putShort((short) MANIFEST_PART_RECORD_LENGTH); + buffer.putShort((short) MANIFEST_TRAILER_LENGTH); + buffer.putShort((short) PAYLOAD_HEADER_LENGTH); + buffer.putShort((short) SECTION_HEADER_LENGTH); + buffer.putShort((short) VARIABLE_INDEX_RECORD_BASE_LENGTH); + buffer.putShort(LANE_KIND); + buffer.putShort(LANE_ID); + buffer.putShort(BODY_CODEC_ID); + buffer.putShort((short) 0x0003); + buffer.putShort(KEY_ORDER_ID); + buffer.putShort(DIGEST_ID); + buffer.putShort(CHECKSUM_ID); + buffer.putShort(IDENTITY_KIND); + buffer.putInt(U0_SEGMENT_BLOCK_SPAN); + buffer.putLong(U0_PART_MAX_BYTES); + buffer.putLong(MAX_BLOCK_FRAME_BYTES); + buffer.putLong(EXACT_COVERAGE_BITMAP); + buffer.put(new byte[14]); + if (buffer.hasRemaining()) { + throw new IllegalStateException("Invalid State Archive format descriptor length"); + } + return buffer.array(); + } + + private static byte[] buildStoreDescriptorDigest() { + ArchiveParticipantDescriptor descriptor = ArchiveParticipantDescriptor.current(); + int encodedLength = STORE_DESCRIPTOR_DOMAIN.length + HASH_LENGTH + Short.BYTES; + for (int storeId = 1; storeId <= STORE_COUNT; storeId++) { + encodedLength += 2 + 1 + 1 + 2 + + descriptorName(descriptor, storeId).getBytes(StandardCharsets.UTF_8).length; + } + ByteBuffer buffer = ByteBuffer.allocate(encodedLength); + buffer.put(STORE_DESCRIPTOR_DOMAIN); + buffer.put(FORMAT_DIGEST); + buffer.putShort((short) STORE_COUNT); + for (int storeId = 1; storeId <= STORE_COUNT; storeId++) { + byte[] name = descriptorName(descriptor, storeId).getBytes(StandardCharsets.UTF_8); + if (name.length > 0xffff) { + throw new IllegalStateException("Archive Store name is too long"); + } + buffer.putShort((short) storeId); + buffer.put((byte) 1); + buffer.put((byte) 0); + buffer.putShort((short) name.length); + buffer.put(name); + } + return sha256(buffer.array()); + } + + private static byte[] buildSegmentLayoutDescriptor() { + ByteBuffer buffer = ByteBuffer.allocate(SEGMENT_LAYOUT_DESCRIPTOR_LENGTH); + buffer.putInt(0x534c4433); + buffer.putShort(MAJOR_VERSION); + buffer.putShort(MINOR_VERSION); + buffer.putInt(SEGMENT_LAYOUT_DESCRIPTOR_LENGTH); + buffer.putShort(SEGMENT_LAYOUT_ID); + buffer.putShort((short) PART_HEADER_LENGTH); + buffer.putShort((short) SEAL_HEADER_LENGTH); + buffer.putShort((short) BLOCK_INDEX_HEADER_LENGTH); + buffer.putShort((short) BLOCK_INDEX_ENTRY_LENGTH); + buffer.putShort((short) MANIFEST_HEADER_LENGTH); + buffer.putShort((short) MANIFEST_TRAILER_LENGTH); + buffer.putShort((short) SEGMENT_MAP_ENTRY_LENGTH); + buffer.putLong(SEGMENT_TARGET_BYTES); + buffer.putLong(MAX_BLOCK_FRAME_BYTES); + buffer.putInt(SHARD_MAX_SEGMENTS); + buffer.putShort(SIZE_ROLLED_AFTER_TARGET_ROTATION_ID); + buffer.putShort(SEGMENT_OVERSHOOT_POLICY_ID); + buffer.put(new byte[12]); + if (buffer.hasRemaining()) { + throw new IllegalStateException("Invalid State Archive segment layout length"); + } + return buffer.array(); + } + + private static byte[] buildRecoveryIntentLayoutDescriptor() { + ByteBuffer buffer = ByteBuffer.allocate(RECOVERY_INTENT_LAYOUT_DESCRIPTOR_LENGTH); + buffer.putInt(0x52494433); + buffer.putShort(MAJOR_VERSION); + buffer.putShort(MINOR_VERSION); + buffer.putInt(RECOVERY_INTENT_LAYOUT_DESCRIPTOR_LENGTH); + buffer.putShort((short) RECOVERY_INTENT_HEADER_LENGTH); + buffer.putShort((short) RECOVERY_INTENT_LANE_RECORD_LENGTH); + buffer.putShort((short) FIVE_LANE_ID_VALUES.length); + buffer.putShort((short) RECOVERY_INTENT_TRAILER_LENGTH); + buffer.putInt(RECOVERY_INTENT_TOTAL_LENGTH); + buffer.putShort(RECOVERY_INTENT_ACTION_SCHEMA_ID); + buffer.putShort(DIGEST_ID); + buffer.putShort(CHECKSUM_ID); + buffer.putShort(RECOVERY_POINT_SCHEMA_ID); + if (buffer.hasRemaining()) { + throw new IllegalStateException("Invalid State Archive recovery intent layout length"); + } + return buffer.array(); + } + + private static byte[] buildFiveLaneDescriptorDigest() { + ArchiveParticipantDescriptor descriptor = ArchiveParticipantDescriptor.current(); + int encodedLength = FIVE_LANE_DESCRIPTOR_DOMAIN.length + HASH_LENGTH + + 3 * Short.BYTES + FIVE_LANE_ID_VALUES.length * 24 + Short.BYTES; + for (int storeId = 1; storeId <= STORE_COUNT; storeId++) { + encodedLength += 14 + + descriptorName(descriptor, storeId).getBytes(StandardCharsets.UTF_8).length; + } + ByteBuffer buffer = ByteBuffer.allocate(encodedLength); + buffer.put(FIVE_LANE_DESCRIPTOR_DOMAIN); + buffer.put(FORMAT_DIGEST); + buffer.putShort((short) 1); + buffer.putShort(SIZE_ROLLED_AFTER_TARGET_ROTATION_ID); + buffer.putShort((short) FIVE_LANE_ID_VALUES.length); + long union = 0; + for (int laneId : FIVE_LANE_ID_VALUES) { + long coverage = laneCoverage(laneId); + if ((union & coverage) != 0) { + throw new IllegalStateException("Overlapping State Archive lane coverage"); + } + union |= coverage; + buffer.putShort((short) laneId); + buffer.putShort(laneKind(laneId)); + buffer.putShort(laneBodyCodec(laneId)); + buffer.putShort((short) 0); + buffer.putLong(coverage); + buffer.putInt(fixedKeyWidth(laneId)); + buffer.putInt(0); + } + if (union != EXACT_COVERAGE_BITMAP) { + throw new IllegalStateException("Incomplete State Archive lane coverage"); + } + buffer.putShort((short) STORE_COUNT); + for (int storeId = 1; storeId <= STORE_COUNT; storeId++) { + int laneId = laneId(storeId); + int keyWidth = fixedKeyWidth(laneId); + byte[] name = descriptorName(descriptor, storeId).getBytes(StandardCharsets.UTF_8); + buffer.putShort((short) storeId); + buffer.put((byte) 1); + buffer.put((byte) 0); + buffer.putShort((short) laneId); + buffer.putShort((short) (keyWidth == 0 ? 1 : 2)); + buffer.putInt(keyWidth); + buffer.putShort((short) name.length); + buffer.put(name); + } + if (buffer.hasRemaining()) { + throw new IllegalStateException("Invalid five-lane descriptor length"); + } + return sha256(buffer.array()); + } + + private static void requireStoreId(int storeId) { + if (storeId < 1 || storeId > STORE_COUNT) { + throw new IllegalArgumentException("Unknown archive Store ID: " + storeId); + } + } + + private static void requireDedicatedLane(int laneId) { + if (laneId != 4 && laneId != 5 && laneId != 13 && laneId != 22) { + throw new IllegalArgumentException("Unknown State Archive lane ID: " + laneId); + } + } + + private static String descriptorName(ArchiveParticipantDescriptor descriptor, int storeId) { + for (String dbName : descriptor.getActiveDatabases()) { + if (descriptor.getStoreId(dbName) == storeId) { + return dbName; + } + } + throw new IllegalStateException("Missing active archive Store ID: " + storeId); + } + + static byte[] ascii(String value) { + return value.getBytes(StandardCharsets.US_ASCII); + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/StateArchiveFiveLaneBlockCodecV3.java b/chainbase/src/main/java/org/tron/core/db2/archive/StateArchiveFiveLaneBlockCodecV3.java new file mode 100644 index 00000000000..53961dc6751 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/StateArchiveFiveLaneBlockCodecV3.java @@ -0,0 +1,1128 @@ +package org.tron.core.db2.archive; + +import com.google.common.hash.Hashing; +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.zip.DataFormatException; +import java.util.zip.Deflater; +import java.util.zip.Inflater; +import org.tron.core.db2.archive.BlockReverseDiff.DbGroup; +import org.tron.core.db2.archive.BlockReverseDiff.Entry; + +/** Canonical codec for one complete State Archive v3 five-lane block bundle. */ +public final class StateArchiveFiveLaneBlockCodecV3 { + + private static final long UNSIGNED_INT_MAX = 0xffff_ffffL; + private static final int FIXED_SECTION_HEADER_LENGTH = 96; + private static final int TRAILER_CRC_AND_MAGIC_LENGTH = 8; + + /** Validates one exact capture before emitting any lane frame. */ + public EncodedBundle encode(BlockChangeView view, OldValueCollector collector, + byte[] previousHistoryDigest, short compressionId) { + Objects.requireNonNull(view, "view"); + Objects.requireNonNull(collector, "collector"); + List captured = new ArrayList<>(); + for (BlockChangeView.DatabaseChanges database : view.getDatabases()) { + captured.add(database.getDbName()); + } + StateArchiveFileFormatV3.requireExactCapture(captured); + BlockReverseDiff diff = Objects.requireNonNull(collector.collect(view), "collected diff"); + if (!view.getMeta().equals(diff.getMeta())) { + throw new IllegalArgumentException("Collected history block identity changed"); + } + return encode(diff, previousHistoryDigest, compressionId); + } + + /** Encodes an already collected reverse diff into exactly five lane frames. */ + EncodedBundle encode(BlockReverseDiff diff, byte[] previousHistoryDigest, + short compressionId) { + Objects.requireNonNull(diff, "diff"); + byte[] previousDigest = requireHash(previousHistoryDigest, "previousHistoryDigest"); + requireCompression(compressionId); + BlockSnapshotMeta meta = requireMeta(diff.getMeta()); + List groups = admitAndCanonicalize(diff.getGroups()); + + int[] laneIds = StateArchiveFileFormatV3.fiveLaneIds(); + List payloads = new ArrayList<>(laneIds.length); + for (int laneId : laneIds) { + payloads.add(encodeLanePayload(laneId, groups)); + } + byte[] descriptorDigest = StateArchiveFileFormatV3.fiveLaneDescriptorDigest(); + byte[] blockHistoryDigest = bundleBlockHistoryDigest(meta, descriptorDigest, payloads); + byte[] resultHistoryDigest = StateArchiveFileFormatV3.sha256( + StateArchiveFileFormatV3.ROLLING_DOMAIN, previousDigest, blockHistoryDigest); + + List lanes = new ArrayList<>(payloads.size()); + for (LanePayload payload : payloads) { + lanes.add(encodeLaneFrame(meta, previousDigest, descriptorDigest, + blockHistoryDigest, resultHistoryDigest, payload, compressionId)); + } + return new EncodedBundle(lanes, blockHistoryDigest, resultHistoryDigest, diff); + } + + /** Decodes five frames and only returns after verifying the complete bundle. */ + public DecodedBundle decode(List frameBytes) { + Objects.requireNonNull(frameBytes, "frameBytes"); + int[] laneIds = StateArchiveFileFormatV3.fiveLaneIds(); + if (frameBytes.size() != laneIds.length) { + throw new IllegalArgumentException("State Archive bundle must contain exactly five frames"); + } + Map byLane = new HashMap<>(); + for (byte[] frame : frameBytes) { + DecodedLane lane = decodeLane(frame); + if (byLane.put(lane.laneId, lane) != null) { + throw new IllegalArgumentException("Duplicate State Archive lane: " + lane.laneId); + } + } + List ordered = new ArrayList<>(frameBytes.size()); + for (int laneId : laneIds) { + DecodedLane lane = byLane.get(laneId); + if (lane == null) { + throw new IllegalArgumentException("Missing State Archive lane: " + laneId); + } + ordered.add(lane); + } + DecodedLane first = ordered.get(0); + long coverage = 0; + List payloads = new ArrayList<>(ordered.size()); + List groups = new ArrayList<>(); + for (DecodedLane lane : ordered) { + requireMetaEquals(first, lane); + if ((coverage & lane.coverageBitmap) != 0) { + throw new IllegalArgumentException("Overlapping State Archive lane coverage"); + } + coverage |= lane.coverageBitmap; + payloads.add(lane.payload); + groups.addAll(lane.groups); + } + if (coverage != StateArchiveFileFormatV3.EXACT_COVERAGE_BITMAP) { + throw new IllegalArgumentException("Incomplete State Archive bundle coverage"); + } + byte[] actualBlockDigest = bundleBlockHistoryDigest(first.meta, + StateArchiveFileFormatV3.fiveLaneDescriptorDigest(), payloads); + requireArray(first.blockHistoryDigest, actualBlockDigest, "bundle block digest"); + byte[] actualResultDigest = StateArchiveFileFormatV3.sha256( + StateArchiveFileFormatV3.ROLLING_DOMAIN, first.previousHistoryDigest, + actualBlockDigest); + requireArray(first.resultHistoryDigest, actualResultDigest, "bundle result digest"); + groups.sort(Comparator.comparingInt(group -> + StateArchiveFileFormatV3.storeId(group.getDbName()))); + BlockReverseDiff diff = new BlockReverseDiff(first.meta, groups); + return new DecodedBundle(diff, ordered, actualBlockDigest, actualResultDigest); + } + + private List admitAndCanonicalize(Collection inputGroups) { + List groups = new ArrayList<>(); + Set storeIds = new HashSet<>(); + for (DbGroup group : inputGroups) { + int storeId = StateArchiveFileFormatV3.storeId(group.getDbName()); + if (!storeIds.add(storeId)) { + throw new IllegalArgumentException("Duplicate archive Store ID: " + storeId); + } + if (group.getEntries().isEmpty()) { + throw new IllegalArgumentException("Changed archive Store section must not be empty"); + } + int laneId = StateArchiveFileFormatV3.laneId(storeId); + int keyWidth = StateArchiveFileFormatV3.fixedKeyWidth(laneId); + byte[] previousKey = null; + for (Entry entry : group.getEntries()) { + byte[] key = entry.getKey(); + if (keyWidth != 0 && key.length != keyWidth) { + throw new IllegalArgumentException("Invalid fixed-width key for Store " + storeId); + } + if (previousKey != null && BlockReverseDiff.compareUnsigned(previousKey, key) >= 0) { + throw new IllegalArgumentException("Archive keys are not strictly sorted"); + } + previousKey = key; + } + groups.add(new StoreGroup(storeId, laneId, group.getEntries())); + } + groups.sort(Comparator.comparingInt(group -> group.storeId)); + return groups; + } + + private LanePayload encodeLanePayload(int laneId, List allGroups) { + List groups = new ArrayList<>(); + for (StoreGroup group : allGroups) { + if (group.laneId == laneId) { + groups.add(group); + } + } + short bodyCodec = StateArchiveFileFormatV3.laneBodyCodec(laneId); + if (bodyCodec == StateArchiveFileFormatV3.DEDICATED_FIXED_WIDTH_BODY_CODEC_ID + && groups.size() > 1) { + throw new IllegalArgumentException("Fixed-width lane has multiple Store sections"); + } + List sections = new ArrayList<>(groups.size()); + long entryCount = 0; + long sectionBytes = 0; + long changedBitmap = 0; + for (StoreGroup group : groups) { + byte[] section = bodyCodec == StateArchiveFileFormatV3.LANE_VARIABLE_BODY_CODEC_ID + ? encodeVariableSection(group) : encodeFixedSection(group); + sections.add(section); + entryCount = checkedAdd(entryCount, group.entries.size()); + sectionBytes = checkedAdd(sectionBytes, section.length); + changedBitmap |= 1L << (group.storeId - 1); + } + long payloadLength = checkedAdd(StateArchiveFileFormatV3.PAYLOAD_HEADER_LENGTH, + sectionBytes); + if (payloadLength > StateArchiveFileFormatV3.MAX_BLOCK_FRAME_BYTES) { + throw new IllegalArgumentException("State Archive lane payload is too large"); + } + ByteBuffer payload = ByteBuffer.allocate(checkedInt(payloadLength, "payloadLength")); + payload.putInt(StateArchiveFileFormatV3.PAYLOAD_MAGIC); + payload.putShort((short) 1); + payload.putShort((short) 0); + payload.putInt(StateArchiveFileFormatV3.PAYLOAD_HEADER_LENGTH); + payload.putInt(groups.size()); + payload.putLong(entryCount); + payload.putLong(sectionBytes); + for (byte[] section : sections) { + payload.put(section); + } + byte[] bytes = payload.array(); + byte[] payloadDigest = StateArchiveFileFormatV3.sha256( + StateArchiveFileFormatV3.PAYLOAD_DOMAIN, bytes); + return new LanePayload(laneId, StateArchiveFileFormatV3.laneKind(laneId), + bodyCodec, StateArchiveFileFormatV3.laneCoverage(laneId), changedBitmap, + groups.size(), entryCount, bytes, payloadDigest); + } + + private byte[] encodeVariableSection(StoreGroup group) { + int entryCount = group.entries.size(); + ByteBuffer offsets = ByteBuffer.allocate(checkedInt( + checkedMultiply(entryCount + 1L, Integer.BYTES), "offsetVectorLength")); + ByteArrayOutputStream records = new ByteArrayOutputStream(); + ByteArrayOutputStream values = new ByteArrayOutputStream(); + try (DataOutputStream output = new DataOutputStream(records)) { + for (Entry entry : group.entries) { + offsets.putInt(records.size()); + byte[] key = entry.getKey(); + output.writeInt(key.length); + writeValueLocator(output, values, entry.getOldValue()); + output.write(key); + } + output.flush(); + } catch (IOException impossible) { + throw new IllegalStateException("Unexpected variable section encoding failure", impossible); + } + offsets.putInt(records.size()); + byte[] offsetBytes = offsets.array(); + byte[] recordBytes = records.toByteArray(); + byte[] valueBytes = values.toByteArray(); + long sectionLength = checkedAdd(StateArchiveFileFormatV3.SECTION_HEADER_LENGTH, + offsetBytes.length, recordBytes.length, valueBytes.length); + byte[] digest = StateArchiveFileFormatV3.sha256( + StateArchiveFileFormatV3.SECTION_DOMAIN, + ByteBuffer.allocate(Short.BYTES + Integer.BYTES) + .putShort((short) group.storeId).putInt(entryCount).array(), + offsetBytes, recordBytes, valueBytes); + ByteBuffer section = ByteBuffer.allocate(checkedInt(sectionLength, "sectionLength")); + section.putShort((short) group.storeId); + section.putShort((short) 1); + section.putInt(StateArchiveFileFormatV3.SECTION_HEADER_LENGTH); + section.putInt(entryCount); + section.putInt(0); + section.putLong(offsetBytes.length); + section.putLong(recordBytes.length); + section.putLong(valueBytes.length); + section.putLong(sectionLength); + section.put(digest); + section.put(offsetBytes); + section.put(recordBytes); + section.put(valueBytes); + return section.array(); + } + + private byte[] encodeFixedSection(StoreGroup group) { + int keyWidth = StateArchiveFileFormatV3.fixedKeyWidth(group.laneId); + int count = group.entries.size(); + byte[] keys = new byte[checkedInt(checkedMultiply(count, keyWidth), "keyDataLength")]; + byte[] presence = new byte[(count + 7) / 8]; + ByteBuffer ends = ByteBuffer.allocate(checkedInt( + checkedMultiply(count + 1L, Integer.BYTES), "valueEndVectorLength")); + ByteArrayOutputStream values = new ByteArrayOutputStream(); + ends.putInt(0); + for (int index = 0; index < count; index++) { + Entry entry = group.entries.get(index); + byte[] key = entry.getKey(); + System.arraycopy(key, 0, keys, index * keyWidth, keyWidth); + if (entry.getOldValue().isPresent()) { + presence[index / 8] |= (byte) (1 << (index % 8)); + byte[] value = entry.getOldValue().getValue(); + values.write(value, 0, value.length); + } + ends.putInt(values.size()); + } + byte[] endBytes = ends.array(); + byte[] valueBytes = values.toByteArray(); + long sectionLength = checkedAdd(FIXED_SECTION_HEADER_LENGTH, keys.length, + presence.length, endBytes.length, valueBytes.length); + byte[] digest = StateArchiveFileFormatV3.sha256( + StateArchiveFileFormatV3.FIXED_SECTION_DOMAIN, + ByteBuffer.allocate(Short.BYTES + 2 * Integer.BYTES) + .putShort((short) group.storeId).putInt(count).putInt(keyWidth).array(), + keys, presence, endBytes, valueBytes); + ByteBuffer section = ByteBuffer.allocate(checkedInt(sectionLength, "sectionLength")); + section.putShort((short) group.storeId); + section.putShort((short) 2); + section.putInt(FIXED_SECTION_HEADER_LENGTH); + section.putInt(count); + section.putInt(0); + section.putInt(keyWidth); + section.putInt(0); + section.putLong(keys.length); + section.putLong(presence.length); + section.putLong(endBytes.length); + section.putLong(valueBytes.length); + section.putLong(sectionLength); + section.put(digest); + section.put(keys); + section.put(presence); + section.put(endBytes); + section.put(valueBytes); + return section.array(); + } + + private void writeValueLocator(DataOutputStream output, ByteArrayOutputStream values, + OldValue oldValue) throws IOException { + output.writeByte(oldValue.isPresent() ? 1 : 0); + output.write(new byte[3]); + if (oldValue.isPresent()) { + byte[] value = oldValue.getValue(); + output.writeInt(values.size()); + output.writeInt(value.length); + values.write(value); + } else { + output.writeInt(-1); + output.writeInt(-1); + } + } + + private EncodedLane encodeLaneFrame(BlockSnapshotMeta meta, byte[] previousDigest, + byte[] descriptorDigest, byte[] blockHistoryDigest, byte[] resultHistoryDigest, + LanePayload payload, short compressionId) { + byte[] storedPayload = compressionId == StateArchiveFileFormatV3.COMPRESSION_NONE + ? payload.bytes : deflate(payload.bytes); + long totalLength = checkedAdd(StateArchiveFileFormatV3.BLOCK_HEADER_LENGTH, + storedPayload.length, StateArchiveFileFormatV3.FRAME_TRAILER_LENGTH); + if (totalLength > StateArchiveFileFormatV3.MAX_BLOCK_FRAME_BYTES) { + throw new IllegalArgumentException("State Archive lane frame exceeds 64 MiB"); + } + ByteBuffer frame = ByteBuffer.allocate((int) totalLength); + putEnvelope(frame, totalLength, storedPayload.length); + frame.putLong(meta.getEpoch()); + frame.putLong(meta.getBlockNumber()); + frame.putLong(meta.getTimestamp()); + frame.put(meta.getBlockHash()); + frame.put(meta.getParentHash()); + frame.put(descriptorDigest); + frame.put(previousDigest); + frame.put(payload.payloadDigest); + frame.put(blockHistoryDigest); + frame.put(resultHistoryDigest); + frame.putLong(payload.coverageBitmap); + frame.putLong(payload.changedBitmap); + frame.putInt(payload.groupCount); + frame.putInt(0); + frame.putLong(payload.entryCount); + frame.putLong(payload.bytes.length); + frame.putShort(payload.bodyCodec); + frame.putShort(compressionId); + frame.putShort(StateArchiveFileFormatV3.KEY_ORDER_ID); + frame.putShort(StateArchiveFileFormatV3.DIGEST_ID); + frame.putShort(StateArchiveFileFormatV3.CHECKSUM_ID); + frame.putShort(StateArchiveFileFormatV3.IDENTITY_KIND); + frame.putInt(0); + if (frame.position() != StateArchiveFileFormatV3.BLOCK_HEADER_LENGTH) { + throw new IllegalStateException("Invalid State Archive block header length"); + } + frame.put(storedPayload); + byte[] encodedDigest = StateArchiveFileFormatV3.sha256( + StateArchiveFileFormatV3.ENCODED_BLOCK_DOMAIN, + Arrays.copyOf(frame.array(), frame.position())); + frame.put(encodedDigest); + frame.putLong(totalLength); + int crcLength = frame.position(); + frame.putInt(crc32c(frame.array(), 0, crcLength)); + frame.putInt(StateArchiveFileFormatV3.FRAME_TRAILER_MAGIC); + return new EncodedLane(payload.laneId, payload.bodyCodec, payload.coverageBitmap, + payload.bytes, payload.payloadDigest, encodedDigest, frame.array()); + } + + private DecodedLane decodeLane(byte[] frameBytes) { + Objects.requireNonNull(frameBytes, "frameBytes"); + if (frameBytes.length < StateArchiveFileFormatV3.BLOCK_HEADER_LENGTH + + StateArchiveFileFormatV3.FRAME_TRAILER_LENGTH + || frameBytes.length > StateArchiveFileFormatV3.MAX_BLOCK_FRAME_BYTES) { + throw new IllegalArgumentException("State Archive lane frame length is invalid"); + } + ByteBuffer frame = ByteBuffer.wrap(frameBytes); + requireInt(frame, StateArchiveFileFormatV3.FRAME_MAGIC, "frame magic"); + requireShort(frame, StateArchiveFileFormatV3.MAJOR_VERSION, "major version"); + requireShort(frame, StateArchiveFileFormatV3.MINOR_VERSION, "minor version"); + requireShort(frame, StateArchiveFileFormatV3.BLOCK_FRAME_TYPE, "frame type"); + requireShort(frame, (short) 0, "frame flags"); + requireInt(frame, StateArchiveFileFormatV3.BLOCK_HEADER_LENGTH, "header length"); + long totalLength = frame.getLong(); + long payloadLength = frame.getLong(); + if (totalLength != frameBytes.length || payloadLength < 0 + || checkedAdd(StateArchiveFileFormatV3.BLOCK_HEADER_LENGTH, payloadLength, + StateArchiveFileFormatV3.FRAME_TRAILER_LENGTH) != totalLength) { + throw new IllegalArgumentException("State Archive lane envelope length mismatch"); + } + long epoch = requireNonNegative(frame.getLong(), "epoch"); + long blockNumber = requireNonNegative(frame.getLong(), "blockNumber"); + long timestamp = requireNonNegative(frame.getLong(), "timestamp"); + if (epoch != blockNumber) { + throw new IllegalArgumentException("v3 requires epoch to equal blockNumber"); + } + byte[] blockHash = getBytes(frame, StateArchiveFileFormatV3.HASH_LENGTH); + byte[] parentHash = getBytes(frame, StateArchiveFileFormatV3.HASH_LENGTH); + byte[] descriptorDigest = getBytes(frame, StateArchiveFileFormatV3.HASH_LENGTH); + requireArray(descriptorDigest, StateArchiveFileFormatV3.fiveLaneDescriptorDigest(), + "five-lane descriptor digest"); + byte[] previousHistoryDigest = getBytes(frame, StateArchiveFileFormatV3.HASH_LENGTH); + byte[] payloadDigest = getBytes(frame, StateArchiveFileFormatV3.HASH_LENGTH); + byte[] blockHistoryDigest = getBytes(frame, StateArchiveFileFormatV3.HASH_LENGTH); + byte[] resultHistoryDigest = getBytes(frame, StateArchiveFileFormatV3.HASH_LENGTH); + long coverageBitmap = frame.getLong(); + int laneId = laneIdForCoverage(coverageBitmap); + long changedBitmap = frame.getLong(); + int groupCount = requireNonNegative(frame.getInt(), "groupCount"); + requireInt(frame, 0, "block reserved field"); + long entryCount = requireNonNegative(frame.getLong(), "entryCount"); + long rawPayloadLength = requireNonNegative(frame.getLong(), "rawPayloadLength"); + short bodyCodec = frame.getShort(); + requireShortValue(bodyCodec, StateArchiveFileFormatV3.laneBodyCodec(laneId), + "body codec"); + short compressionId = frame.getShort(); + requireCompression(compressionId); + requireShort(frame, StateArchiveFileFormatV3.KEY_ORDER_ID, "key order"); + requireShort(frame, StateArchiveFileFormatV3.DIGEST_ID, "digest algorithm"); + requireShort(frame, StateArchiveFileFormatV3.CHECKSUM_ID, "checksum algorithm"); + requireShort(frame, StateArchiveFileFormatV3.IDENTITY_KIND, "identity kind"); + requireInt(frame, 0, "block reserved tail"); + if ((changedBitmap & ~coverageBitmap) != 0 + || groupCount != Long.bitCount(changedBitmap) + || rawPayloadLength > StateArchiveFileFormatV3.MAX_BLOCK_FRAME_BYTES) { + throw new IllegalArgumentException("State Archive lane coverage is invalid"); + } + byte[] storedPayload = getBytes(frame, checkedInt(payloadLength, "payloadLength")); + int trailerStart = frame.position(); + byte[] encodedFrameDigest = getBytes(frame, StateArchiveFileFormatV3.HASH_LENGTH); + if (frame.getLong() != totalLength) { + throw new IllegalArgumentException("State Archive repeated frame length mismatch"); + } + int expectedCrc = frame.getInt(); + requireInt(frame, StateArchiveFileFormatV3.FRAME_TRAILER_MAGIC, "trailer magic"); + if (expectedCrc != crc32c(frameBytes, 0, + frameBytes.length - TRAILER_CRC_AND_MAGIC_LENGTH)) { + throw new IllegalArgumentException("State Archive lane frame checksum mismatch"); + } + byte[] actualEncodedDigest = StateArchiveFileFormatV3.sha256( + StateArchiveFileFormatV3.ENCODED_BLOCK_DOMAIN, + Arrays.copyOf(frameBytes, trailerStart)); + requireArray(encodedFrameDigest, actualEncodedDigest, "encoded frame digest"); + byte[] rawPayload = compressionId == StateArchiveFileFormatV3.COMPRESSION_NONE + ? storedPayload : inflate(storedPayload, + checkedInt(rawPayloadLength, "rawPayloadLength")); + if (rawPayload.length != rawPayloadLength) { + throw new IllegalArgumentException("State Archive raw payload length mismatch"); + } + requireArray(payloadDigest, StateArchiveFileFormatV3.sha256( + StateArchiveFileFormatV3.PAYLOAD_DOMAIN, rawPayload), "payload digest"); + DecodedPayload decoded = decodePayload(laneId, bodyCodec, rawPayload, + groupCount, entryCount, changedBitmap); + LanePayload payload = new LanePayload(laneId, + StateArchiveFileFormatV3.laneKind(laneId), bodyCodec, coverageBitmap, + changedBitmap, groupCount, entryCount, rawPayload, payloadDigest); + return new DecodedLane(laneId, new BlockSnapshotMeta(epoch, blockNumber, + blockHash, parentHash, timestamp), previousHistoryDigest, blockHistoryDigest, + resultHistoryDigest, payload, decoded.groups, compressionId, + encodedFrameDigest); + } + + private DecodedPayload decodePayload(int laneId, short bodyCodec, byte[] payloadBytes, + int expectedGroupCount, long expectedEntryCount, long expectedChangedBitmap) { + ByteBuffer payload = ByteBuffer.wrap(payloadBytes); + requireRemaining(payload, StateArchiveFileFormatV3.PAYLOAD_HEADER_LENGTH, + "payload header"); + requireInt(payload, StateArchiveFileFormatV3.PAYLOAD_MAGIC, "payload magic"); + requireShort(payload, (short) 1, "payload version"); + requireShort(payload, (short) 0, "payload flags"); + requireInt(payload, StateArchiveFileFormatV3.PAYLOAD_HEADER_LENGTH, + "payload header length"); + int groupCount = requireNonNegative(payload.getInt(), "payload section count"); + long entryCount = requireNonNegative(payload.getLong(), "payload entry count"); + long sectionBytes = requireNonNegative(payload.getLong(), "payload section bytes"); + if (groupCount != expectedGroupCount || entryCount != expectedEntryCount + || sectionBytes != payload.remaining()) { + throw new IllegalArgumentException("State Archive payload counts or length mismatch"); + } + if (bodyCodec == StateArchiveFileFormatV3.DEDICATED_FIXED_WIDTH_BODY_CODEC_ID + && groupCount > 1) { + throw new IllegalArgumentException("Fixed-width lane has multiple sections"); + } + List groups = new ArrayList<>(groupCount); + int previousStoreId = 0; + long actualEntryCount = 0; + long actualChangedBitmap = 0; + for (int index = 0; index < groupCount; index++) { + int sectionStart = payload.position(); + requireRemaining(payload, Short.BYTES, "Store ID"); + int storeId = Short.toUnsignedInt(payload.getShort()); + if (storeId <= previousStoreId || StateArchiveFileFormatV3.laneId(storeId) != laneId) { + throw new IllegalArgumentException("State Archive Store section lane is invalid"); + } + previousStoreId = storeId; + List entries = bodyCodec == StateArchiveFileFormatV3.LANE_VARIABLE_BODY_CODEC_ID + ? decodeVariableSection(payloadBytes, payload, sectionStart, storeId) + : decodeFixedSection(payloadBytes, payload, sectionStart, storeId, laneId); + groups.add(new DbGroup(StateArchiveFileFormatV3.dbName(storeId), entries)); + actualEntryCount = checkedAdd(actualEntryCount, entries.size()); + actualChangedBitmap |= 1L << (storeId - 1); + } + if (payload.hasRemaining() || actualEntryCount != entryCount + || actualChangedBitmap != expectedChangedBitmap) { + throw new IllegalArgumentException("State Archive lane payload coverage mismatch"); + } + return new DecodedPayload(groups); + } + + private List decodeVariableSection(byte[] payloadBytes, ByteBuffer payload, + int sectionStart, int storeId) { + requireRemaining(payload, StateArchiveFileFormatV3.SECTION_HEADER_LENGTH - Short.BYTES, + "variable Store section header"); + requireShort(payload, (short) 1, "section version"); + requireInt(payload, StateArchiveFileFormatV3.SECTION_HEADER_LENGTH, + "section header length"); + int count = requirePositive(payload.getInt(), "section entry count"); + requireInt(payload, 0, "section flags"); + long offsetLength = payload.getLong(); + long recordLength = payload.getLong(); + long valueLength = payload.getLong(); + long sectionLength = payload.getLong(); + byte[] sectionDigest = getBytes(payload, StateArchiveFileFormatV3.HASH_LENGTH); + long expectedOffsetLength = checkedMultiply(count + 1L, Integer.BYTES); + requireSectionLength(payloadBytes, sectionStart, sectionLength, + StateArchiveFileFormatV3.SECTION_HEADER_LENGTH, offsetLength, recordLength, + valueLength); + if (offsetLength != expectedOffsetLength) { + throw new IllegalArgumentException("State Archive record offset length is invalid"); + } + byte[] offsets = getBytes(payload, checkedInt(offsetLength, "offsetLength")); + byte[] records = getBytes(payload, checkedInt(recordLength, "recordLength")); + byte[] values = getBytes(payload, checkedInt(valueLength, "valueLength")); + byte[] actualDigest = StateArchiveFileFormatV3.sha256( + StateArchiveFileFormatV3.SECTION_DOMAIN, + ByteBuffer.allocate(Short.BYTES + Integer.BYTES) + .putShort((short) storeId).putInt(count).array(), offsets, records, values); + requireArray(sectionDigest, actualDigest, "section digest"); + return decodeVariableEntries(count, offsets, records, values); + } + + private List decodeFixedSection(byte[] payloadBytes, ByteBuffer payload, + int sectionStart, int storeId, int laneId) { + requireRemaining(payload, FIXED_SECTION_HEADER_LENGTH - Short.BYTES, + "fixed Store section header"); + requireShort(payload, (short) 2, "fixed section version"); + requireInt(payload, FIXED_SECTION_HEADER_LENGTH, "fixed section header length"); + int count = requirePositive(payload.getInt(), "fixed section entry count"); + requireInt(payload, 0, "fixed section flags"); + int keyWidth = payload.getInt(); + requireInt(payload, 0, "fixed section reserved field"); + long keyLength = payload.getLong(); + long presenceLength = payload.getLong(); + long endsLength = payload.getLong(); + long valueLength = payload.getLong(); + long sectionLength = payload.getLong(); + byte[] sectionDigest = getBytes(payload, StateArchiveFileFormatV3.HASH_LENGTH); + int expectedKeyWidth = StateArchiveFileFormatV3.fixedKeyWidth(laneId); + if (storeId != laneId || keyWidth != expectedKeyWidth + || keyLength != checkedMultiply(count, keyWidth) + || presenceLength != (count + 7L) / 8 + || endsLength != checkedMultiply(count + 1L, Integer.BYTES)) { + throw new IllegalArgumentException("State Archive fixed section shape is invalid"); + } + requireSectionLength(payloadBytes, sectionStart, sectionLength, + FIXED_SECTION_HEADER_LENGTH, keyLength, presenceLength, endsLength, valueLength); + byte[] keys = getBytes(payload, checkedInt(keyLength, "keyLength")); + byte[] presence = getBytes(payload, checkedInt(presenceLength, "presenceLength")); + byte[] ends = getBytes(payload, checkedInt(endsLength, "endsLength")); + byte[] values = getBytes(payload, checkedInt(valueLength, "valueLength")); + byte[] actualDigest = StateArchiveFileFormatV3.sha256( + StateArchiveFileFormatV3.FIXED_SECTION_DOMAIN, + ByteBuffer.allocate(Short.BYTES + 2 * Integer.BYTES) + .putShort((short) storeId).putInt(count).putInt(keyWidth).array(), + keys, presence, ends, values); + requireArray(sectionDigest, actualDigest, "fixed section digest"); + return decodeFixedEntries(count, keyWidth, keys, presence, ends, values); + } + + private List decodeVariableEntries(int count, byte[] offsetBytes, + byte[] recordBytes, byte[] valueBytes) { + ByteBuffer offsets = ByteBuffer.wrap(offsetBytes); + int[] positions = new int[count + 1]; + for (int index = 0; index <= count; index++) { + long offset = Integer.toUnsignedLong(offsets.getInt()); + if (offset > recordBytes.length || (index == 0 && offset != 0) + || (index > 0 && offset <= positions[index - 1])) { + throw new IllegalArgumentException("State Archive record offsets are invalid"); + } + positions[index] = (int) offset; + } + if (positions[count] != recordBytes.length) { + throw new IllegalArgumentException("State Archive final record offset is invalid"); + } + List entries = new ArrayList<>(count); + byte[] previousKey = null; + int valueCursor = 0; + for (int index = 0; index < count; index++) { + int recordLength = positions[index + 1] - positions[index]; + if (recordLength < StateArchiveFileFormatV3.VARIABLE_INDEX_RECORD_BASE_LENGTH) { + throw new IllegalArgumentException("State Archive index record is truncated"); + } + ByteBuffer record = ByteBuffer.wrap(recordBytes, positions[index], recordLength).slice(); + long keyLength = Integer.toUnsignedLong(record.getInt()); + int tag = Byte.toUnsignedInt(record.get()); + requireZero(record, 3, "index record reserved bytes"); + long valueOffset = Integer.toUnsignedLong(record.getInt()); + long valueLength = Integer.toUnsignedLong(record.getInt()); + if (keyLength != record.remaining()) { + throw new IllegalArgumentException("State Archive key length is invalid"); + } + byte[] key = getBytes(record, checkedInt(keyLength, "keyLength")); + requireOrdered(previousKey, key); + previousKey = key; + ValueRead valueRead = readValue(tag, valueOffset, valueLength, + valueCursor, valueBytes); + valueCursor = valueRead.nextCursor; + entries.add(new Entry(key, valueRead.oldValue)); + } + if (valueCursor != valueBytes.length) { + throw new IllegalArgumentException("State Archive value data has an unreferenced tail"); + } + return entries; + } + + private List decodeFixedEntries(int count, int keyWidth, byte[] keys, + byte[] presence, byte[] endBytes, byte[] values) { + int usedBits = count % 8; + if (usedBits != 0 && (Byte.toUnsignedInt(presence[presence.length - 1]) + & ~((1 << usedBits) - 1)) != 0) { + throw new IllegalArgumentException("State Archive presence bitmap has non-zero tail bits"); + } + ByteBuffer ends = ByteBuffer.wrap(endBytes); + long first = Integer.toUnsignedLong(ends.getInt()); + if (first != 0) { + throw new IllegalArgumentException("State Archive first value end is invalid"); + } + List entries = new ArrayList<>(count); + byte[] previousKey = null; + int valueCursor = 0; + for (int index = 0; index < count; index++) { + long next = Integer.toUnsignedLong(ends.getInt()); + if (next < valueCursor || next > values.length) { + throw new IllegalArgumentException("State Archive value ends are invalid"); + } + byte[] key = Arrays.copyOfRange(keys, index * keyWidth, (index + 1) * keyWidth); + requireOrdered(previousKey, key); + previousKey = key; + boolean present = (presence[index / 8] & (1 << (index % 8))) != 0; + if (!present && next != valueCursor) { + throw new IllegalArgumentException("State Archive absent fixed value has bytes"); + } + OldValue oldValue = present + ? OldValue.present(Arrays.copyOfRange(values, valueCursor, (int) next)) + : OldValue.absent(); + valueCursor = (int) next; + entries.add(new Entry(key, oldValue)); + } + if (valueCursor != values.length) { + throw new IllegalArgumentException("State Archive fixed value data has a tail"); + } + return entries; + } + + private ValueRead readValue(int tag, long valueOffset, long valueLength, + int valueCursor, byte[] values) { + if (tag == 0) { + if (valueOffset != UNSIGNED_INT_MAX || valueLength != UNSIGNED_INT_MAX) { + throw new IllegalArgumentException("State Archive ABSENT value locator is invalid"); + } + return new ValueRead(OldValue.absent(), valueCursor); + } + if (tag != 1 || valueOffset != valueCursor + || valueLength > values.length - valueCursor) { + throw new IllegalArgumentException("State Archive PRESENT value locator is invalid"); + } + int next = valueCursor + (int) valueLength; + return new ValueRead(OldValue.present(Arrays.copyOfRange(values, valueCursor, next)), next); + } + + private byte[] bundleBlockHistoryDigest(BlockSnapshotMeta meta, + byte[] descriptorDigest, List payloads) { + ByteBuffer identity = ByteBuffer.allocate(3 * Long.BYTES + + 3 * StateArchiveFileFormatV3.HASH_LENGTH + Short.BYTES + + payloads.size() * StateArchiveFileFormatV3.HASH_LENGTH); + identity.putLong(meta.getEpoch()); + identity.putLong(meta.getBlockNumber()); + identity.putLong(meta.getTimestamp()); + identity.put(meta.getBlockHash()); + identity.put(meta.getParentHash()); + identity.put(descriptorDigest); + identity.putShort((short) payloads.size()); + int previousLane = -1; + for (LanePayload payload : payloads) { + if (payload.laneId <= previousLane) { + throw new IllegalArgumentException("State Archive lane items are not ordered"); + } + previousLane = payload.laneId; + identity.put(laneItemDigest(payload)); + } + return StateArchiveFileFormatV3.sha256( + StateArchiveFileFormatV3.BUNDLE_BLOCK_DOMAIN, identity.array()); + } + + private byte[] laneItemDigest(LanePayload payload) { + ByteBuffer identity = ByteBuffer.allocate(3 * Short.BYTES + 2 * Long.BYTES + + Integer.BYTES + 2 * Long.BYTES + StateArchiveFileFormatV3.HASH_LENGTH); + identity.putShort(payload.laneKind); + identity.putShort((short) payload.laneId); + identity.putShort(payload.bodyCodec); + identity.putLong(payload.coverageBitmap); + identity.putLong(payload.changedBitmap); + identity.putInt(payload.groupCount); + identity.putLong(payload.entryCount); + identity.putLong(payload.bytes.length); + identity.put(payload.payloadDigest); + return StateArchiveFileFormatV3.sha256( + StateArchiveFileFormatV3.LANE_ITEM_DOMAIN, identity.array()); + } + + private void requireMetaEquals(DecodedLane expected, DecodedLane actual) { + if (!expected.meta.equals(actual.meta) + || !Arrays.equals(expected.previousHistoryDigest, actual.previousHistoryDigest) + || !Arrays.equals(expected.blockHistoryDigest, actual.blockHistoryDigest) + || !Arrays.equals(expected.resultHistoryDigest, actual.resultHistoryDigest)) { + throw new IllegalArgumentException("State Archive lane bundle identity mismatch"); + } + } + + private int laneIdForCoverage(long coverage) { + for (int laneId : StateArchiveFileFormatV3.fiveLaneIds()) { + if (coverage == StateArchiveFileFormatV3.laneCoverage(laneId)) { + return laneId; + } + } + throw new IllegalArgumentException("Unknown State Archive lane coverage"); + } + + private BlockSnapshotMeta requireMeta(BlockSnapshotMeta meta) { + if (meta.getEpoch() != meta.getBlockNumber()) { + throw new IllegalArgumentException("v3 requires epoch to equal blockNumber"); + } + if (meta.getTimestamp() < 0) { + throw new IllegalArgumentException("v3 timestamp must not be negative"); + } + return meta; + } + + private void putEnvelope(ByteBuffer frame, long totalLength, long payloadLength) { + frame.putInt(StateArchiveFileFormatV3.FRAME_MAGIC); + frame.putShort(StateArchiveFileFormatV3.MAJOR_VERSION); + frame.putShort(StateArchiveFileFormatV3.MINOR_VERSION); + frame.putShort(StateArchiveFileFormatV3.BLOCK_FRAME_TYPE); + frame.putShort((short) 0); + frame.putInt(StateArchiveFileFormatV3.BLOCK_HEADER_LENGTH); + frame.putLong(totalLength); + frame.putLong(payloadLength); + } + + private byte[] deflate(byte[] input) { + Deflater deflater = new Deflater(1, true); + deflater.setStrategy(Deflater.DEFAULT_STRATEGY); + deflater.setInput(input); + deflater.finish(); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + byte[] buffer = new byte[8192]; + while (!deflater.finished()) { + int count = deflater.deflate(buffer); + if (count == 0 && deflater.needsInput()) { + throw new IllegalStateException("Unexpected raw DEFLATE termination"); + } + output.write(buffer, 0, count); + } + deflater.end(); + return output.toByteArray(); + } + + private byte[] inflate(byte[] input, int expectedLength) { + Inflater inflater = new Inflater(true); + inflater.setInput(input); + byte[] output = new byte[expectedLength]; + try { + int count = inflater.inflate(output); + if (count != expectedLength || !inflater.finished() || inflater.getRemaining() != 0) { + throw new IllegalArgumentException("State Archive compressed payload length mismatch"); + } + return output; + } catch (DataFormatException e) { + throw new IllegalArgumentException("Invalid State Archive compressed payload", e); + } finally { + inflater.end(); + } + } + + private void requireSectionLength(byte[] payload, int start, long sectionLength, + long... components) { + if (sectionLength != checkedAdd(components) || sectionLength > payload.length - start) { + throw new IllegalArgumentException("State Archive Store section length is invalid"); + } + } + + private static void requireOrdered(byte[] previous, byte[] key) { + if (previous != null && BlockReverseDiff.compareUnsigned(previous, key) >= 0) { + throw new IllegalArgumentException("State Archive decoded keys are not ordered"); + } + } + + private static byte[] requireHash(byte[] value, String name) { + Objects.requireNonNull(value, name); + if (value.length != StateArchiveFileFormatV3.HASH_LENGTH) { + throw new IllegalArgumentException(name + " must be exactly 32 bytes"); + } + return Arrays.copyOf(value, value.length); + } + + private static void requireCompression(short compressionId) { + if (compressionId != StateArchiveFileFormatV3.COMPRESSION_NONE + && compressionId != StateArchiveFileFormatV3.COMPRESSION_RAW_DEFLATE_LEVEL_1) { + throw new IllegalArgumentException("Unsupported State Archive compression: " + + compressionId); + } + } + + private static long requireNonNegative(long value, String name) { + if (value < 0) { + throw new IllegalArgumentException("State Archive " + name + " is negative"); + } + return value; + } + + private static int requireNonNegative(int value, String name) { + if (value < 0) { + throw new IllegalArgumentException("State Archive " + name + " is negative"); + } + return value; + } + + private static int requirePositive(int value, String name) { + if (value <= 0) { + throw new IllegalArgumentException("State Archive " + name + " is not positive"); + } + return value; + } + + private static void requireInt(ByteBuffer buffer, int expected, String name) { + requireRemaining(buffer, Integer.BYTES, name); + if (buffer.getInt() != expected) { + throw new IllegalArgumentException("Invalid State Archive " + name); + } + } + + private static void requireShort(ByteBuffer buffer, short expected, String name) { + requireRemaining(buffer, Short.BYTES, name); + requireShortValue(buffer.getShort(), expected, name); + } + + private static void requireShortValue(short actual, short expected, String name) { + if (actual != expected) { + throw new IllegalArgumentException("Invalid State Archive " + name); + } + } + + private static void requireZero(ByteBuffer buffer, int length, String name) { + requireRemaining(buffer, length, name); + for (int index = 0; index < length; index++) { + if (buffer.get() != 0) { + throw new IllegalArgumentException("Invalid State Archive " + name); + } + } + } + + private static void requireRemaining(ByteBuffer buffer, int length, String name) { + if (length < 0 || buffer.remaining() < length) { + throw new IllegalArgumentException("Truncated State Archive " + name); + } + } + + private static byte[] getBytes(ByteBuffer buffer, int length) { + requireRemaining(buffer, length, "byte field"); + byte[] bytes = new byte[length]; + buffer.get(bytes); + return bytes; + } + + private static void requireArray(byte[] actual, byte[] expected, String name) { + if (!Arrays.equals(actual, expected)) { + throw new IllegalArgumentException("State Archive " + name + " mismatch"); + } + } + + private static int crc32c(byte[] bytes, int offset, int length) { + return Hashing.crc32c().hashBytes(bytes, offset, length).asInt(); + } + + private static long checkedAdd(long... values) { + long result = 0; + for (long value : values) { + if (value < 0 || Long.MAX_VALUE - result < value) { + throw new IllegalArgumentException("State Archive length overflow"); + } + result += value; + } + return result; + } + + private static long checkedMultiply(long left, long right) { + if (left < 0 || right < 0 || (left != 0 && right > Long.MAX_VALUE / left)) { + throw new IllegalArgumentException("State Archive length overflow"); + } + return left * right; + } + + private static int checkedInt(long value, String name) { + if (value < 0 || value > Integer.MAX_VALUE) { + throw new IllegalArgumentException("State Archive " + name + " exceeds int range"); + } + return (int) value; + } + + private static final class StoreGroup { + private final int storeId; + private final int laneId; + private final List entries; + + private StoreGroup(int storeId, int laneId, List entries) { + this.storeId = storeId; + this.laneId = laneId; + this.entries = entries; + } + } + + private static final class LanePayload { + private final int laneId; + private final short laneKind; + private final short bodyCodec; + private final long coverageBitmap; + private final long changedBitmap; + private final int groupCount; + private final long entryCount; + private final byte[] bytes; + private final byte[] payloadDigest; + + private LanePayload(int laneId, short laneKind, short bodyCodec, + long coverageBitmap, long changedBitmap, int groupCount, long entryCount, + byte[] bytes, byte[] payloadDigest) { + this.laneId = laneId; + this.laneKind = laneKind; + this.bodyCodec = bodyCodec; + this.coverageBitmap = coverageBitmap; + this.changedBitmap = changedBitmap; + this.groupCount = groupCount; + this.entryCount = entryCount; + this.bytes = bytes; + this.payloadDigest = payloadDigest; + } + } + + private static final class DecodedPayload { + private final List groups; + + private DecodedPayload(List groups) { + this.groups = groups; + } + } + + private static final class ValueRead { + private final OldValue oldValue; + private final int nextCursor; + + private ValueRead(OldValue oldValue, int nextCursor) { + this.oldValue = oldValue; + this.nextCursor = nextCursor; + } + } + + public static final class EncodedBundle { + private final List lanes; + private final byte[] blockHistoryDigest; + private final byte[] resultHistoryDigest; + private final BlockReverseDiff diff; + + private EncodedBundle(List lanes, byte[] blockHistoryDigest, + byte[] resultHistoryDigest, BlockReverseDiff diff) { + this.lanes = java.util.Collections.unmodifiableList(new ArrayList<>(lanes)); + this.blockHistoryDigest = blockHistoryDigest; + this.resultHistoryDigest = resultHistoryDigest; + this.diff = diff; + } + + public List getLanes() { + return lanes; + } + + public byte[] getBlockHistoryDigest() { + return Arrays.copyOf(blockHistoryDigest, blockHistoryDigest.length); + } + + public byte[] getResultHistoryDigest() { + return Arrays.copyOf(resultHistoryDigest, resultHistoryDigest.length); + } + + public BlockReverseDiff getDiff() { + return diff; + } + } + + public static final class EncodedLane { + private final int laneId; + private final short bodyCodec; + private final long coverageBitmap; + private final byte[] canonicalPayload; + private final byte[] payloadDigest; + private final byte[] encodedFrameDigest; + private final byte[] frame; + + private EncodedLane(int laneId, short bodyCodec, long coverageBitmap, + byte[] canonicalPayload, byte[] payloadDigest, byte[] encodedFrameDigest, + byte[] frame) { + this.laneId = laneId; + this.bodyCodec = bodyCodec; + this.coverageBitmap = coverageBitmap; + this.canonicalPayload = canonicalPayload; + this.payloadDigest = payloadDigest; + this.encodedFrameDigest = encodedFrameDigest; + this.frame = frame; + } + + public int getLaneId() { + return laneId; + } + + public short getBodyCodec() { + return bodyCodec; + } + + public long getCoverageBitmap() { + return coverageBitmap; + } + + public byte[] getCanonicalPayload() { + return Arrays.copyOf(canonicalPayload, canonicalPayload.length); + } + + public byte[] getPayloadDigest() { + return Arrays.copyOf(payloadDigest, payloadDigest.length); + } + + public byte[] getEncodedFrameDigest() { + return Arrays.copyOf(encodedFrameDigest, encodedFrameDigest.length); + } + + public byte[] getFrame() { + return Arrays.copyOf(frame, frame.length); + } + } + + public static final class DecodedBundle { + private final BlockReverseDiff diff; + private final List lanes; + private final byte[] blockHistoryDigest; + private final byte[] resultHistoryDigest; + + private DecodedBundle(BlockReverseDiff diff, List lanes, + byte[] blockHistoryDigest, byte[] resultHistoryDigest) { + this.diff = diff; + this.lanes = java.util.Collections.unmodifiableList(new ArrayList<>(lanes)); + this.blockHistoryDigest = blockHistoryDigest; + this.resultHistoryDigest = resultHistoryDigest; + } + + public BlockReverseDiff getDiff() { + return diff; + } + + public List getLanes() { + return lanes; + } + + public byte[] getBlockHistoryDigest() { + return Arrays.copyOf(blockHistoryDigest, blockHistoryDigest.length); + } + + public byte[] getResultHistoryDigest() { + return Arrays.copyOf(resultHistoryDigest, resultHistoryDigest.length); + } + } + + public static final class DecodedLane { + private final int laneId; + private final BlockSnapshotMeta meta; + private final byte[] previousHistoryDigest; + private final byte[] blockHistoryDigest; + private final byte[] resultHistoryDigest; + private final LanePayload payload; + private final List groups; + private final short compressionId; + private final byte[] encodedFrameDigest; + private final long coverageBitmap; + + private DecodedLane(int laneId, BlockSnapshotMeta meta, + byte[] previousHistoryDigest, byte[] blockHistoryDigest, + byte[] resultHistoryDigest, LanePayload payload, List groups, + short compressionId, byte[] encodedFrameDigest) { + this.laneId = laneId; + this.meta = meta; + this.previousHistoryDigest = previousHistoryDigest; + this.blockHistoryDigest = blockHistoryDigest; + this.resultHistoryDigest = resultHistoryDigest; + this.payload = payload; + this.groups = java.util.Collections.unmodifiableList(new ArrayList<>(groups)); + this.compressionId = compressionId; + this.encodedFrameDigest = encodedFrameDigest; + this.coverageBitmap = payload.coverageBitmap; + } + + public int getLaneId() { + return laneId; + } + + public List getGroups() { + return groups; + } + + public short getCompressionId() { + return compressionId; + } + + public byte[] getEncodedFrameDigest() { + return Arrays.copyOf(encodedFrameDigest, encodedFrameDigest.length); + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/StateArchiveFiveLaneDurabilityProofV3.java b/chainbase/src/main/java/org/tron/core/db2/archive/StateArchiveFiveLaneDurabilityProofV3.java new file mode 100644 index 00000000000..e3a1244afe6 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/StateArchiveFiveLaneDurabilityProofV3.java @@ -0,0 +1,250 @@ +package org.tron.core.db2.archive; + +import com.google.common.hash.Hashing; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import org.tron.core.db2.archive.StateArchiveFiveLaneRecoveryIntentV3.RecoveryPoint; +import org.tron.core.db2.archive.StateArchiveFiveLaneSegmentWriterV3.ArchiveDurabilityProof; +import org.tron.core.db2.archive.StateArchiveFiveLaneSegmentWriterV3.FileTailProof; + +/** Byte-exact Common authority record for one append-file durability proof. */ +public final class StateArchiveFiveLaneDurabilityProofV3 { + + public static final String FILE_NAME = "append-file-proof.current"; + public static final String TEMP_FILE_NAME = "append-file-proof.current.tmp"; + public static final int MAGIC = 0x53415033; // SAP3 + public static final int TRAILER_MAGIC = 0x33504153; // 3PAS + public static final int HEADER_LENGTH = 352; + public static final int TAIL_LENGTH = 72; + public static final int TRAILER_LENGTH = 48; + + private static final byte[] HEADER_DOMAIN = StateArchiveFileFormatV3.ascii( + "TRON-STATE-ARCHIVE-PROOF-HEADER-V3\0"); + private static final byte[] TAILS_DOMAIN = StateArchiveFileFormatV3.ascii( + "TRON-STATE-ARCHIVE-PROOF-TAILS-V3\0"); + private static final byte[] PROOF_DOMAIN = StateArchiveFileFormatV3.ascii( + "TRON-STATE-ARCHIVE-PROOF-V3\0"); + private static final int HEADER_DIGEST_OFFSET = 316; + private static final int HEADER_CRC_OFFSET = 348; + + private StateArchiveFiveLaneDurabilityProofV3() { + } + + public static byte[] encode(ArchiveDurabilityProof proof) { + ArchiveDurabilityProof admitted = Objects.requireNonNull(proof, "proof"); + List tails = admitted.getFileTails(); + byte[] tailBytes = encodeTails(tails); + int totalLength = Math.addExact(HEADER_LENGTH + TRAILER_LENGTH, tailBytes.length); + ByteBuffer bytes = ByteBuffer.allocate(totalLength); + bytes.putInt(MAGIC); + bytes.putShort(StateArchiveFileFormatV3.MAJOR_VERSION); + bytes.putShort(StateArchiveFileFormatV3.MINOR_VERSION); + bytes.putInt(HEADER_LENGTH); + bytes.putShort((short) TAIL_LENGTH); + bytes.putShort((short) tails.size()); + bytes.putLong(totalLength); + bytes.putInt(0); + bytes.putInt(0); + bytes.put(admitted.getFormatIdentity()); + bytes.put(admitted.getDescriptorDigest()); + bytes.putLong(admitted.getCheckpointSequence()); + putPoint(bytes, admitted.getTarget()); + bytes.put(admitted.getCommonTargetDigest()); + bytes.put(StateArchiveFileFormatV3.sha256(TAILS_DOMAIN, tailBytes)); + bytes.put(new byte[28]); + if (bytes.position() != HEADER_DIGEST_OFFSET) { + throw new IllegalStateException("Invalid State Archive proof header layout"); + } + bytes.put(StateArchiveFileFormatV3.sha256(HEADER_DOMAIN, + Arrays.copyOf(bytes.array(), HEADER_DIGEST_OFFSET))); + bytes.putInt(crc32c(bytes.array(), 0, HEADER_CRC_OFFSET)); + bytes.put(tailBytes); + int proofDigestOffset = bytes.position(); + bytes.put(StateArchiveFileFormatV3.sha256(PROOF_DOMAIN, + Arrays.copyOf(bytes.array(), proofDigestOffset))); + bytes.putLong(totalLength); + bytes.putInt(crc32c(bytes.array(), 0, totalLength - 8)); + bytes.putInt(TRAILER_MAGIC); + return bytes.array(); + } + + public static ArchiveDurabilityProof decode(byte[] encoded) { + Objects.requireNonNull(encoded, "encoded"); + if (encoded.length < HEADER_LENGTH + TRAILER_LENGTH) { + throw new IllegalArgumentException("State Archive proof is truncated"); + } + ByteBuffer bytes = ByteBuffer.wrap(encoded); + require(bytes.getInt() == MAGIC, "proof magic"); + require(bytes.getShort() == StateArchiveFileFormatV3.MAJOR_VERSION, "proof major version"); + require(bytes.getShort() == StateArchiveFileFormatV3.MINOR_VERSION, "proof minor version"); + require(bytes.getInt() == HEADER_LENGTH, "proof header length"); + require(Short.toUnsignedInt(bytes.getShort()) == TAIL_LENGTH, "proof tail length"); + int tailCount = Short.toUnsignedInt(bytes.getShort()); + int expectedLength = Math.addExact(HEADER_LENGTH + TRAILER_LENGTH, + Math.multiplyExact(tailCount, TAIL_LENGTH)); + require(bytes.getLong() == expectedLength && encoded.length == expectedLength, + "proof total length"); + require(bytes.getInt() == 0 && bytes.getInt() == 0, "proof flags or reserved field"); + requireArray(read(bytes, 32), StateArchiveFileFormatV3.compositeFormatDigest(), + "proof composite format"); + requireArray(read(bytes, 32), StateArchiveFileFormatV3.fiveLaneDescriptorDigest(), + "proof placement descriptor"); + long checkpointSequence = bytes.getLong(); + RecoveryPoint target = readPoint(bytes); + byte[] commonTargetDigest = read(bytes, 32); + byte[] tailsDigest = read(bytes, 32); + requireZero(bytes, 28, "proof reserved bytes"); + requireArray(read(bytes, 32), StateArchiveFileFormatV3.sha256(HEADER_DOMAIN, + Arrays.copyOf(encoded, HEADER_DIGEST_OFFSET)), "proof header digest"); + require(bytes.getInt() == crc32c(encoded, 0, HEADER_CRC_OFFSET), + "proof header checksum"); + byte[] tailBytes = read(bytes, tailCount * TAIL_LENGTH); + requireArray(tailsDigest, StateArchiveFileFormatV3.sha256(TAILS_DOMAIN, tailBytes), + "proof tails digest"); + List tails = decodeTails(tailBytes); + int proofDigestOffset = HEADER_LENGTH + tailBytes.length; + requireArray(read(bytes, 32), StateArchiveFileFormatV3.sha256(PROOF_DOMAIN, + Arrays.copyOf(encoded, proofDigestOffset)), "proof digest"); + require(bytes.getLong() == expectedLength, "proof repeated length"); + require(bytes.getInt() == crc32c(encoded, 0, encoded.length - 8), + "proof checksum"); + require(bytes.getInt() == TRAILER_MAGIC, "proof trailer magic"); + return new ArchiveDurabilityProof(checkpointSequence, target, commonTargetDigest, tails); + } + + public static void publish(Path archiveRoot, ArchiveDurabilityProof proof) throws IOException { + Path root = Objects.requireNonNull(archiveRoot, "archiveRoot"); + Files.createDirectories(root); + byte[] encoded = encode(proof); + Path temporary = root.resolve(TEMP_FILE_NAME); + Path target = root.resolve(FILE_NAME); + try (FileChannel channel = FileChannel.open(temporary, StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE)) { + ByteBuffer buffer = ByteBuffer.wrap(encoded); + while (buffer.hasRemaining()) { + channel.write(buffer); + } + channel.force(true); + } + try { + Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException unsupported) { + throw new IOException("State Archive proof requires atomic publication", unsupported); + } + HistorySegmentStore.syncDirectory(root); + decode(Files.readAllBytes(target)); + } + + public static ArchiveDurabilityProof loadAndVerify(Path archiveRoot, + StateArchiveFiveLaneSegmentWriterV3 writer) throws IOException { + ArchiveDurabilityProof proof = decode(Files.readAllBytes( + Objects.requireNonNull(archiveRoot, "archiveRoot").resolve(FILE_NAME))); + Objects.requireNonNull(writer, "writer").verifyDurabilityProof(proof); + return proof; + } + + /** Proves the persisted Archive tail is the exact History authority referenced by Common W. */ + public static ArchiveDurabilityProof loadAndVerify(Path archiveRoot, + StateArchiveFiveLaneSegmentWriterV3 writer, RecoveryPoint commonCommitted, + byte[] commonTargetDigest) throws IOException { + ArchiveDurabilityProof proof = decode(Files.readAllBytes( + Objects.requireNonNull(archiveRoot, "archiveRoot").resolve(FILE_NAME))); + RecoveryPoint common = Objects.requireNonNull(commonCommitted, "commonCommitted"); + if (!samePoint(proof.getTarget(), common) + || !Arrays.equals(proof.getCommonTargetDigest(), commonTargetDigest)) { + throw new IllegalArgumentException("State Archive proof differs from Common target"); + } + Objects.requireNonNull(writer, "writer").verifyDurabilityProof(proof); + return proof; + } + + private static byte[] encodeTails(List tails) { + ByteBuffer bytes = ByteBuffer.allocate(Math.multiplyExact(tails.size(), TAIL_LENGTH)); + for (FileTailProof tail : tails) { + bytes.putShort((short) tail.getLaneId()); + bytes.putShort((short) 0); + bytes.putInt(0); + bytes.putLong(tail.getSegmentSeq()); + bytes.putLong(tail.getMarkerOffset()); + bytes.putLong(tail.getMarkerLength()); + bytes.putLong(tail.getMarkerEndOffset()); + bytes.put(tail.getMarkerDigest()); + } + return bytes.array(); + } + + private static List decodeTails(byte[] encoded) { + ByteBuffer bytes = ByteBuffer.wrap(encoded); + List tails = new ArrayList<>(); + while (bytes.hasRemaining()) { + int laneId = Short.toUnsignedInt(bytes.getShort()); + require(bytes.getShort() == 0 && bytes.getInt() == 0, "proof tail reserved field"); + long segmentSeq = bytes.getLong(); + long markerOffset = bytes.getLong(); + long markerLength = bytes.getLong(); + long markerEndOffset = bytes.getLong(); + require(markerLength <= Integer.MAX_VALUE, "proof marker length"); + tails.add(new FileTailProof(laneId, segmentSeq, markerOffset, (int) markerLength, + markerEndOffset, read(bytes, 32))); + } + return tails; + } + + private static void putPoint(ByteBuffer bytes, RecoveryPoint point) { + bytes.putLong(point.getEpoch()); + bytes.putLong(point.getBlockNumber()); + bytes.putLong(point.getTimestamp()); + bytes.put(point.getBlockHash()); + bytes.put(point.getParentHash()); + bytes.put(point.getResultHistoryDigest()); + } + + private static RecoveryPoint readPoint(ByteBuffer bytes) { + return new RecoveryPoint(bytes.getLong(), bytes.getLong(), bytes.getLong(), + read(bytes, 32), read(bytes, 32), read(bytes, 32)); + } + + private static boolean samePoint(RecoveryPoint left, RecoveryPoint right) { + return left.getEpoch() == right.getEpoch() + && left.getBlockNumber() == right.getBlockNumber() + && left.getTimestamp() == right.getTimestamp() + && Arrays.equals(left.getBlockHash(), right.getBlockHash()) + && Arrays.equals(left.getParentHash(), right.getParentHash()) + && Arrays.equals(left.getResultHistoryDigest(), right.getResultHistoryDigest()); + } + + private static byte[] read(ByteBuffer bytes, int length) { + byte[] result = new byte[length]; + bytes.get(result); + return result; + } + + private static void requireZero(ByteBuffer bytes, int length, String field) { + requireArray(read(bytes, length), new byte[length], field); + } + + private static void requireArray(byte[] actual, byte[] expected, String field) { + require(Arrays.equals(actual, expected), field); + } + + private static void require(boolean condition, String field) { + if (!condition) { + throw new IllegalArgumentException("State Archive " + field + " mismatch"); + } + } + + private static int crc32c(byte[] bytes, int offset, int length) { + return Hashing.crc32c().hashBytes(bytes, offset, length).asInt(); + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/StateArchiveFiveLaneRecoveryIntentV3.java b/chainbase/src/main/java/org/tron/core/db2/archive/StateArchiveFiveLaneRecoveryIntentV3.java new file mode 100644 index 00000000000..4b75d782178 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/StateArchiveFiveLaneRecoveryIntentV3.java @@ -0,0 +1,571 @@ +package org.tron.core.db2.archive; + +import com.google.common.hash.Hashing; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** Byte-exact crash-recovery plan for five-lane open-tail repair. */ +public final class StateArchiveFiveLaneRecoveryIntentV3 { + + public static final String FILE_NAME = "five-lane-recovery.intent"; + public static final String TEMP_FILE_NAME = "five-lane-recovery.intent.tmp"; + public static final int DATA_TRUNCATE = 1; + public static final int DELETE_PAIR = 1 << 1; + public static final int INDEX_REPLACE = 1 << 2; + public static final int ORIGINAL_INDEX_MISSING = 1 << 3; + public static final int SOURCE_PAIR_MISSING = 1 << 4; + public static final long NO_TARGET_SEGMENT = -1L; + + private static final int KNOWN_FLAGS = DATA_TRUNCATE | DELETE_PAIR + | INDEX_REPLACE | ORIGINAL_INDEX_MISSING | SOURCE_PAIR_MISSING; + private static final int COMMON_POINT_PRESENT = 1; + private static final int TARGET_POINT_PRESENT = 1 << 1; + private static final int KNOWN_HEADER_FLAGS = COMMON_POINT_PRESENT | TARGET_POINT_PRESENT; + private static final int HEADER_DIGEST_OFFSET = 732; + private static final int HEADER_CRC_OFFSET = 764; + private static final int INTENT_DIGEST_OFFSET = 1_568; + private static final int INTENT_CRC_OFFSET = 1_608; + + private StateArchiveFiveLaneRecoveryIntentV3() { + } + + public static byte[] dataPrefixDigest(byte[] exactTargetPrefix) { + return StateArchiveFileFormatV3.sha256( + StateArchiveFileFormatV3.RECOVERY_DATA_PREFIX_DOMAIN, + Objects.requireNonNull(exactTargetPrefix, "exactTargetPrefix")); + } + + public static byte[] indexFileDigest(byte[] exactTargetIndex) { + return StateArchiveFileFormatV3.sha256( + StateArchiveFileFormatV3.RECOVERY_INDEX_FILE_DOMAIN, + Objects.requireNonNull(exactTargetIndex, "exactTargetIndex")); + } + + public static byte[] encode(Intent intent) { + Objects.requireNonNull(intent, "intent"); + intent.validate(); + byte[] records = encodeRecords(intent.lanes); + byte[] recordsDigest = StateArchiveFileFormatV3.sha256( + StateArchiveFileFormatV3.RECOVERY_INTENT_RECORDS_DOMAIN, records); + ByteBuffer bytes = ByteBuffer.allocate( + StateArchiveFileFormatV3.RECOVERY_INTENT_TOTAL_LENGTH); + bytes.putInt(StateArchiveFileFormatV3.RECOVERY_INTENT_MAGIC); + bytes.putShort(StateArchiveFileFormatV3.MAJOR_VERSION); + bytes.putShort(StateArchiveFileFormatV3.MINOR_VERSION); + bytes.putInt(StateArchiveFileFormatV3.RECOVERY_INTENT_HEADER_LENGTH); + bytes.putShort((short) StateArchiveFileFormatV3.RECOVERY_INTENT_LANE_RECORD_LENGTH); + bytes.putShort((short) StateArchiveFileFormatV3.fiveLaneIds().length); + bytes.putLong(StateArchiveFileFormatV3.RECOVERY_INTENT_TOTAL_LENGTH); + bytes.putShort(StateArchiveFileFormatV3.RECOVERY_INTENT_ACTION_SCHEMA_ID); + int flags = (intent.commonCommitted == null ? 0 : COMMON_POINT_PRESENT) + | (intent.target == null ? 0 : TARGET_POINT_PRESENT); + bytes.putShort((short) flags); + bytes.putInt(0); + bytes.put(StateArchiveFileFormatV3.compositeFormatDigest()); + bytes.put(StateArchiveFileFormatV3.fiveLaneDescriptorDigest()); + bytes.put(intent.baselineHistoryDigest); + putPoint(bytes, intent.authorizedCeiling); + putPointOrZero(bytes, intent.commonCommitted); + putPointOrZero(bytes, intent.target); + bytes.put(recordsDigest); + bytes.put(new byte[212]); + if (bytes.position() != HEADER_DIGEST_OFFSET) { + throw new IllegalStateException("Invalid State Archive recovery intent header layout"); + } + bytes.put(StateArchiveFileFormatV3.sha256( + StateArchiveFileFormatV3.RECOVERY_INTENT_HEADER_DOMAIN, + Arrays.copyOf(bytes.array(), HEADER_DIGEST_OFFSET))); + bytes.putInt(crc32c(bytes.array(), 0, HEADER_CRC_OFFSET)); + bytes.put(records); + if (bytes.position() != INTENT_DIGEST_OFFSET) { + throw new IllegalStateException("Invalid State Archive recovery intent record layout"); + } + bytes.put(StateArchiveFileFormatV3.sha256( + StateArchiveFileFormatV3.RECOVERY_INTENT_DOMAIN, + Arrays.copyOf(bytes.array(), INTENT_DIGEST_OFFSET))); + bytes.putLong(StateArchiveFileFormatV3.RECOVERY_INTENT_TOTAL_LENGTH); + bytes.putInt(crc32c(bytes.array(), 0, INTENT_CRC_OFFSET)); + bytes.putInt(StateArchiveFileFormatV3.RECOVERY_INTENT_TRAILER_MAGIC); + if (bytes.hasRemaining()) { + throw new IllegalStateException("Invalid State Archive recovery intent length"); + } + return bytes.array(); + } + + public static Intent decode(byte[] encoded) { + requireLength(encoded, StateArchiveFileFormatV3.RECOVERY_INTENT_TOTAL_LENGTH, + "recovery intent"); + ByteBuffer bytes = ByteBuffer.wrap(encoded); + requireInt(bytes, StateArchiveFileFormatV3.RECOVERY_INTENT_MAGIC, "intent magic"); + requireShort(bytes, StateArchiveFileFormatV3.MAJOR_VERSION, "intent major version"); + requireShort(bytes, StateArchiveFileFormatV3.MINOR_VERSION, "intent minor version"); + requireInt(bytes, StateArchiveFileFormatV3.RECOVERY_INTENT_HEADER_LENGTH, + "intent header length"); + requireShort(bytes, (short) StateArchiveFileFormatV3.RECOVERY_INTENT_LANE_RECORD_LENGTH, + "intent record length"); + requireShort(bytes, (short) StateArchiveFileFormatV3.fiveLaneIds().length, + "intent lane count"); + requireLong(bytes, StateArchiveFileFormatV3.RECOVERY_INTENT_TOTAL_LENGTH, + "intent total length"); + requireShort(bytes, StateArchiveFileFormatV3.RECOVERY_INTENT_ACTION_SCHEMA_ID, + "intent action schema"); + int flags = Short.toUnsignedInt(bytes.getShort()); + if ((flags & ~KNOWN_HEADER_FLAGS) != 0 + || (flags & COMMON_POINT_PRESENT) != 0 && (flags & TARGET_POINT_PRESENT) == 0) { + throw new IllegalArgumentException("State Archive recovery intent flags mismatch"); + } + requireInt(bytes, 0, "intent reserved field"); + requireArray(getBytes(bytes, 32), StateArchiveFileFormatV3.compositeFormatDigest(), + "intent composite format"); + requireArray(getBytes(bytes, 32), StateArchiveFileFormatV3.fiveLaneDescriptorDigest(), + "intent placement descriptor"); + byte[] baselineHistoryDigest = getBytes(bytes, 32); + RecoveryPoint authorized = readPoint(bytes); + RecoveryPoint common = readPointOrZero(bytes, (flags & COMMON_POINT_PRESENT) != 0); + RecoveryPoint target = readPointOrZero(bytes, (flags & TARGET_POINT_PRESENT) != 0); + byte[] recordsDigest = getBytes(bytes, 32); + requireZero(bytes, 212, "intent header reserved bytes"); + byte[] headerDigest = getBytes(bytes, 32); + requireArray(headerDigest, StateArchiveFileFormatV3.sha256( + StateArchiveFileFormatV3.RECOVERY_INTENT_HEADER_DOMAIN, + Arrays.copyOf(encoded, HEADER_DIGEST_OFFSET)), "intent header digest"); + if (bytes.getInt() != crc32c(encoded, 0, HEADER_CRC_OFFSET)) { + throw new IllegalArgumentException("State Archive recovery intent header checksum mismatch"); + } + byte[] recordBytes = getBytes(bytes, StateArchiveFileFormatV3.fiveLaneIds().length + * StateArchiveFileFormatV3.RECOVERY_INTENT_LANE_RECORD_LENGTH); + requireArray(recordsDigest, StateArchiveFileFormatV3.sha256( + StateArchiveFileFormatV3.RECOVERY_INTENT_RECORDS_DOMAIN, recordBytes), + "intent records digest"); + List lanes = decodeRecords(recordBytes); + byte[] intentDigest = getBytes(bytes, 32); + requireArray(intentDigest, StateArchiveFileFormatV3.sha256( + StateArchiveFileFormatV3.RECOVERY_INTENT_DOMAIN, + Arrays.copyOf(encoded, INTENT_DIGEST_OFFSET)), "intent digest"); + requireLong(bytes, StateArchiveFileFormatV3.RECOVERY_INTENT_TOTAL_LENGTH, + "repeated intent total length"); + if (bytes.getInt() != crc32c(encoded, 0, INTENT_CRC_OFFSET)) { + throw new IllegalArgumentException("State Archive recovery intent checksum mismatch"); + } + requireInt(bytes, StateArchiveFileFormatV3.RECOVERY_INTENT_TRAILER_MAGIC, + "intent trailer magic"); + return new Intent(baselineHistoryDigest, authorized, common, target, lanes, + headerDigest, recordsDigest, intentDigest); + } + + private static byte[] encodeRecords(List lanes) { + ByteBuffer bytes = ByteBuffer.allocate(lanes.size() + * StateArchiveFileFormatV3.RECOVERY_INTENT_LANE_RECORD_LENGTH); + for (LaneTarget lane : lanes) { + bytes.putShort((short) lane.laneId); + bytes.putShort((short) lane.actionFlags); + bytes.putInt(0); + bytes.putLong(lane.sourceSegmentSeq); + bytes.putLong(lane.originalDataEnd); + bytes.putLong(lane.originalIndexEnd); + bytes.putLong(lane.targetSegmentSeq); + bytes.putLong(lane.targetDataEnd); + bytes.putLong(lane.targetIndexEnd); + bytes.put(lane.sourceSegmentHeaderDigest); + bytes.put(lane.targetDataPrefixDigest); + bytes.put(lane.targetIndexFileDigest); + bytes.putLong(0); + } + return bytes.array(); + } + + private static List decodeRecords(byte[] encoded) { + ByteBuffer bytes = ByteBuffer.wrap(encoded); + List lanes = new ArrayList<>(); + while (bytes.hasRemaining()) { + int laneId = Short.toUnsignedInt(bytes.getShort()); + int actionFlags = Short.toUnsignedInt(bytes.getShort()); + requireInt(bytes, 0, "lane intent reserved field"); + LaneTarget lane = new LaneTarget(laneId, actionFlags, + bytes.getLong(), bytes.getLong(), bytes.getLong(), bytes.getLong(), + bytes.getLong(), bytes.getLong(), getBytes(bytes, 32), getBytes(bytes, 32), + getBytes(bytes, 32)); + requireLong(bytes, 0, "lane intent reserved tail"); + lanes.add(lane); + } + return lanes; + } + + private static void putPoint(ByteBuffer bytes, RecoveryPoint point) { + bytes.putLong(point.epoch); + bytes.putLong(point.blockNumber); + bytes.putLong(point.timestamp); + bytes.put(point.blockHash); + bytes.put(point.parentHash); + bytes.put(point.resultHistoryDigest); + } + + private static void putPointOrZero(ByteBuffer bytes, RecoveryPoint point) { + if (point == null) { + bytes.put(new byte[120]); + } else { + putPoint(bytes, point); + } + } + + private static RecoveryPoint readPoint(ByteBuffer bytes) { + return new RecoveryPoint(bytes.getLong(), bytes.getLong(), bytes.getLong(), + getBytes(bytes, 32), getBytes(bytes, 32), getBytes(bytes, 32)); + } + + private static RecoveryPoint readPointOrZero(ByteBuffer bytes, boolean present) { + if (present) { + return readPoint(bytes); + } + requireZero(bytes, 120, "absent recovery point"); + return null; + } + + private static void requireSameIfEqual(RecoveryPoint first, RecoveryPoint second) { + if (first.blockNumber == second.blockNumber + && (first.epoch != second.epoch || first.timestamp != second.timestamp + || !Arrays.equals(first.blockHash, second.blockHash) + || !Arrays.equals(first.parentHash, second.parentHash) + || !Arrays.equals(first.resultHistoryDigest, second.resultHistoryDigest))) { + throw new IllegalArgumentException("State Archive recovery point identity mismatch"); + } + } + + private static byte[] requireHash(byte[] value, String name) { + requireLength(value, StateArchiveFileFormatV3.HASH_LENGTH, name); + return Arrays.copyOf(value, value.length); + } + + private static void requireLength(byte[] value, int expected, String name) { + if (value == null || value.length != expected) { + throw new IllegalArgumentException("Invalid State Archive " + name + " length"); + } + } + + private static byte[] getBytes(ByteBuffer bytes, int length) { + byte[] result = new byte[length]; + bytes.get(result); + return result; + } + + private static void requireZero(ByteBuffer bytes, int length, String name) { + for (int index = 0; index < length; index++) { + if (bytes.get() != 0) { + throw new IllegalArgumentException("Non-zero State Archive " + name); + } + } + } + + private static void requireInt(ByteBuffer bytes, int expected, String name) { + if (bytes.getInt() != expected) { + throw new IllegalArgumentException("State Archive " + name + " mismatch"); + } + } + + private static void requireShort(ByteBuffer bytes, short expected, String name) { + if (bytes.getShort() != expected) { + throw new IllegalArgumentException("State Archive " + name + " mismatch"); + } + } + + private static void requireLong(ByteBuffer bytes, long expected, String name) { + if (bytes.getLong() != expected) { + throw new IllegalArgumentException("State Archive " + name + " mismatch"); + } + } + + private static void requireArray(byte[] actual, byte[] expected, String name) { + if (!Arrays.equals(actual, expected)) { + throw new IllegalArgumentException("State Archive " + name + " mismatch"); + } + } + + private static boolean isZero(byte[] value) { + for (byte element : value) { + if (element != 0) { + return false; + } + } + return true; + } + + private static int crc32c(byte[] bytes, int offset, int length) { + return Hashing.crc32c().hashBytes(bytes, offset, length).asInt(); + } + + public static final class RecoveryPoint { + private final long epoch; + private final long blockNumber; + private final long timestamp; + private final byte[] blockHash; + private final byte[] parentHash; + private final byte[] resultHistoryDigest; + + public RecoveryPoint(long epoch, long blockNumber, long timestamp, + byte[] blockHash, byte[] parentHash, byte[] resultHistoryDigest) { + if (epoch < 0 || blockNumber < 0 || timestamp < 0 || epoch != blockNumber) { + throw new IllegalArgumentException("Invalid State Archive recovery block identity"); + } + this.epoch = epoch; + this.blockNumber = blockNumber; + this.timestamp = timestamp; + this.blockHash = requireHash(blockHash, "recovery block hash"); + this.parentHash = requireHash(parentHash, "recovery parent hash"); + this.resultHistoryDigest = requireHash(resultHistoryDigest, + "recovery history digest"); + } + + public long getEpoch() { + return epoch; + } + + public long getBlockNumber() { + return blockNumber; + } + + public long getTimestamp() { + return timestamp; + } + + public byte[] getBlockHash() { + return Arrays.copyOf(blockHash, blockHash.length); + } + + public byte[] getParentHash() { + return Arrays.copyOf(parentHash, parentHash.length); + } + + public byte[] getResultHistoryDigest() { + return Arrays.copyOf(resultHistoryDigest, resultHistoryDigest.length); + } + } + + public static final class LaneTarget { + private final int laneId; + private final int actionFlags; + private final long sourceSegmentSeq; + private final long originalDataEnd; + private final long originalIndexEnd; + private final long targetSegmentSeq; + private final long targetDataEnd; + private final long targetIndexEnd; + private final byte[] sourceSegmentHeaderDigest; + private final byte[] targetDataPrefixDigest; + private final byte[] targetIndexFileDigest; + + public LaneTarget(int laneId, int actionFlags, long sourceSegmentSeq, + long originalDataEnd, long originalIndexEnd, long targetSegmentSeq, + long targetDataEnd, long targetIndexEnd, byte[] sourceSegmentHeaderDigest, + byte[] targetDataPrefixDigest, byte[] targetIndexFileDigest) { + this.laneId = laneId; + this.actionFlags = actionFlags; + this.sourceSegmentSeq = sourceSegmentSeq; + this.originalDataEnd = originalDataEnd; + this.originalIndexEnd = originalIndexEnd; + this.targetSegmentSeq = targetSegmentSeq; + this.targetDataEnd = targetDataEnd; + this.targetIndexEnd = targetIndexEnd; + this.sourceSegmentHeaderDigest = requireHash(sourceSegmentHeaderDigest, + "intent segment header digest"); + this.targetDataPrefixDigest = requireHash(targetDataPrefixDigest, + "intent target data digest"); + this.targetIndexFileDigest = requireHash(targetIndexFileDigest, + "intent target index digest"); + validate(); + } + + private void validate() { + StateArchiveFileFormatV3.laneKind(laneId); + if ((actionFlags & ~KNOWN_FLAGS) != 0) { + throw new IllegalArgumentException("Invalid State Archive lane recovery flags"); + } + if ((actionFlags & SOURCE_PAIR_MISSING) != 0) { + if (actionFlags != SOURCE_PAIR_MISSING || sourceSegmentSeq != NO_TARGET_SEGMENT + || originalDataEnd != 0 || originalIndexEnd != 0 + || targetSegmentSeq != NO_TARGET_SEGMENT || targetDataEnd != 0 + || targetIndexEnd != 0 || !isZero(sourceSegmentHeaderDigest) + || !isZero(targetDataPrefixDigest) || !isZero(targetIndexFileDigest)) { + throw new IllegalArgumentException("Invalid State Archive missing lane source"); + } + return; + } + if (sourceSegmentSeq < 0 + || originalDataEnd < StateArchiveFileFormatV3.PART_HEADER_LENGTH + || originalIndexEnd < 0 + || ((actionFlags & ORIGINAL_INDEX_MISSING) != 0) != (originalIndexEnd == 0)) { + throw new IllegalArgumentException("Invalid State Archive lane recovery source"); + } + if ((actionFlags & DELETE_PAIR) != 0) { + if ((actionFlags & (DATA_TRUNCATE | INDEX_REPLACE)) != 0 + || targetSegmentSeq != NO_TARGET_SEGMENT || targetDataEnd != 0 + || targetIndexEnd != 0 || !isZero(targetDataPrefixDigest) + || !isZero(targetIndexFileDigest)) { + throw new IllegalArgumentException("Invalid State Archive lane delete target"); + } + return; + } + if (targetSegmentSeq != sourceSegmentSeq + || targetDataEnd < StateArchiveFileFormatV3.PART_HEADER_LENGTH + || targetDataEnd > originalDataEnd + || ((actionFlags & DATA_TRUNCATE) != 0) != (targetDataEnd < originalDataEnd) + || targetIndexEnd < StateArchiveFileFormatV3.BLOCK_INDEX_HEADER_LENGTH + || (targetIndexEnd - StateArchiveFileFormatV3.BLOCK_INDEX_HEADER_LENGTH) + % StateArchiveFileFormatV3.BLOCK_INDEX_ENTRY_LENGTH != 0 + || ((originalIndexEnd == 0 || originalIndexEnd != targetIndexEnd) + && (actionFlags & INDEX_REPLACE) == 0)) { + throw new IllegalArgumentException("Invalid State Archive lane recovery target"); + } + } + + public int getLaneId() { + return laneId; + } + + public int getActionFlags() { + return actionFlags; + } + + public long getSourceSegmentSeq() { + return sourceSegmentSeq; + } + + public long getOriginalDataEnd() { + return originalDataEnd; + } + + public long getOriginalIndexEnd() { + return originalIndexEnd; + } + + public long getTargetSegmentSeq() { + return targetSegmentSeq; + } + + public long getTargetDataEnd() { + return targetDataEnd; + } + + public long getTargetIndexEnd() { + return targetIndexEnd; + } + + public byte[] getSourceSegmentHeaderDigest() { + return Arrays.copyOf(sourceSegmentHeaderDigest, sourceSegmentHeaderDigest.length); + } + + public byte[] getTargetDataPrefixDigest() { + return Arrays.copyOf(targetDataPrefixDigest, targetDataPrefixDigest.length); + } + + public byte[] getTargetIndexFileDigest() { + return Arrays.copyOf(targetIndexFileDigest, targetIndexFileDigest.length); + } + } + + public static final class Intent { + private final byte[] baselineHistoryDigest; + private final RecoveryPoint authorizedCeiling; + private final RecoveryPoint commonCommitted; + private final RecoveryPoint target; + private final List lanes; + private final byte[] headerDigest; + private final byte[] recordsDigest; + private final byte[] intentDigest; + + public Intent(byte[] baselineHistoryDigest, RecoveryPoint authorizedCeiling, + RecoveryPoint commonCommitted, RecoveryPoint target, List lanes) { + this(baselineHistoryDigest, authorizedCeiling, commonCommitted, target, lanes, + null, null, null); + } + + private Intent(byte[] baselineHistoryDigest, RecoveryPoint authorizedCeiling, + RecoveryPoint commonCommitted, RecoveryPoint target, List lanes, + byte[] headerDigest, byte[] recordsDigest, byte[] intentDigest) { + this.baselineHistoryDigest = requireHash(baselineHistoryDigest, + "intent baseline history digest"); + this.authorizedCeiling = Objects.requireNonNull(authorizedCeiling, + "authorizedCeiling"); + this.commonCommitted = commonCommitted; + this.target = target; + this.lanes = Collections.unmodifiableList(new ArrayList<>( + Objects.requireNonNull(lanes, "lanes"))); + this.headerDigest = headerDigest == null ? null + : requireHash(headerDigest, "intent header digest"); + this.recordsDigest = recordsDigest == null ? null + : requireHash(recordsDigest, "intent records digest"); + this.intentDigest = intentDigest == null ? null + : requireHash(intentDigest, "intent digest"); + validate(); + } + + private void validate() { + if (commonCommitted != null && target == null) { + throw new IllegalArgumentException("Invalid State Archive recovery point order"); + } + if (target != null && target.blockNumber > authorizedCeiling.blockNumber) { + throw new IllegalArgumentException("Invalid State Archive recovery point order"); + } + if (commonCommitted != null && commonCommitted.blockNumber > target.blockNumber) { + throw new IllegalArgumentException("Invalid State Archive recovery point order"); + } + if (commonCommitted != null) { + requireSameIfEqual(commonCommitted, target); + requireSameIfEqual(commonCommitted, authorizedCeiling); + } + if (target != null) { + requireSameIfEqual(target, authorizedCeiling); + } + int[] laneIds = StateArchiveFileFormatV3.fiveLaneIds(); + if (lanes.size() != laneIds.length) { + throw new IllegalArgumentException("Incomplete State Archive recovery lane plan"); + } + boolean mutation = false; + for (int index = 0; index < laneIds.length; index++) { + LaneTarget lane = Objects.requireNonNull(lanes.get(index), "lane"); + if (lane.laneId != laneIds[index]) { + throw new IllegalArgumentException("Non-canonical State Archive recovery lane plan"); + } + mutation |= (lane.actionFlags & (DATA_TRUNCATE | DELETE_PAIR | INDEX_REPLACE)) != 0; + } + if (!mutation) { + throw new IllegalArgumentException("State Archive recovery intent has no mutation"); + } + } + + public byte[] getBaselineHistoryDigest() { + return Arrays.copyOf(baselineHistoryDigest, baselineHistoryDigest.length); + } + + public RecoveryPoint getAuthorizedCeiling() { + return authorizedCeiling; + } + + public RecoveryPoint getCommonCommitted() { + return commonCommitted; + } + + public RecoveryPoint getTarget() { + return target; + } + + public List getLanes() { + return lanes; + } + + public byte[] getHeaderDigest() { + return headerDigest == null ? null : Arrays.copyOf(headerDigest, headerDigest.length); + } + + public byte[] getRecordsDigest() { + return recordsDigest == null ? null : Arrays.copyOf(recordsDigest, recordsDigest.length); + } + + public byte[] getIntentDigest() { + return intentDigest == null ? null : Arrays.copyOf(intentDigest, intentDigest.length); + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/StateArchiveFiveLaneSegmentWriterV3.java b/chainbase/src/main/java/org/tron/core/db2/archive/StateArchiveFiveLaneSegmentWriterV3.java new file mode 100644 index 00000000000..418bdcf254a --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/StateArchiveFiveLaneSegmentWriterV3.java @@ -0,0 +1,2469 @@ +package org.tron.core.db2.archive; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.NavigableMap; +import java.util.Objects; +import java.util.TreeMap; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.tron.core.db2.archive.StateArchiveFiveLaneBlockCodecV3.DecodedBundle; +import org.tron.core.db2.archive.StateArchiveFiveLaneBlockCodecV3.EncodedBundle; +import org.tron.core.db2.archive.StateArchiveFiveLaneBlockCodecV3.EncodedLane; +import org.tron.core.db2.archive.StateArchiveFiveLaneRecoveryIntentV3.Intent; +import org.tron.core.db2.archive.StateArchiveFiveLaneRecoveryIntentV3.LaneTarget; +import org.tron.core.db2.archive.StateArchiveFiveLaneRecoveryIntentV3.RecoveryPoint; +import org.tron.core.db2.archive.StateArchiveSegmentFormatV3.BlockIndexEntry; +import org.tron.core.db2.archive.StateArchiveSegmentFormatV3.BlockIndexHeader; +import org.tron.core.db2.archive.StateArchiveSegmentFormatV3.CurrentSegment; +import org.tron.core.db2.archive.StateArchiveSegmentFormatV3.DurableMarker; +import org.tron.core.db2.archive.StateArchiveSegmentFormatV3.SealedSegment; +import org.tron.core.db2.archive.StateArchiveSegmentFormatV3.SegmentHeader; +import org.tron.core.db2.archive.StateArchiveSegmentFormatV3.SegmentManifest; +import org.tron.core.db2.archive.StateArchiveSegmentFormatV3.SegmentSeal; + +/** Default-off five-lane append writer for State Archive v3 segment data and block indexes. */ +public final class StateArchiveFiveLaneSegmentWriterV3 implements AutoCloseable { + + private static final int APPEND_BUFFER_BYTES = 2 * 1024 * 1024; + private static final int BLOCK_NUMBER_OFFSET = 40; + private static final int PREVIOUS_HISTORY_DIGEST_OFFSET = 152; + private static final int RESULT_HISTORY_DIGEST_OFFSET = 248; + private static final int ENTRY_COUNT_OFFSET = 304; + private static final int COVERAGE_BITMAP_OFFSET = 280; + private static final int RAW_PAYLOAD_LENGTH_OFFSET = 312; + private static final int COMPRESSION_ID_OFFSET = 322; + private static final int ENCODED_DIGEST_FROM_END = 48; + + private final Path segmentRoot; + private final Path archiveRoot; + private final byte[] baselineHistoryDigest; + private final short compressionId; + private final long rotationTargetBytes; + private final StateArchiveFiveLaneBlockCodecV3 codec = + new StateArchiveFiveLaneBlockCodecV3(); + private final Map lanes = new HashMap<>(); + private final List sealedSegments = new ArrayList<>(); + private final StateArchiveHistoryCatalogV3 catalog; + private boolean structuralChanged; + private BlockSnapshotMeta appendHead; + private byte[] resultHistoryDigest; + private boolean failed; + private RecoveryRequest activeRecoveryRequest; + private Intent activeRecoveryIntent; + private RecoveryFaultHook recoveryFaultHook = RecoveryFaultHook.NONE; + private ArchiveDurabilityProof lastDurabilityProof; + private boolean lastDurabilityProofRecovered; + private boolean rotationWithUnsyncedData; + private final List pendingRotationTails = new ArrayList<>(); + private long activeCheckpointSequence = -1; + private byte[] activeCommonTargetDigest; + private Map> servingSegments; + private long servingReadFrames; + private long servingReadBytes; + + public StateArchiveFiveLaneSegmentWriterV3(Path archiveRoot, + byte[] baselineHistoryDigest, short compressionId) throws IOException { + this(archiveRoot, baselineHistoryDigest, compressionId, + StateArchiveFileFormatV3.SEGMENT_TARGET_BYTES, null, RecoveryFaultHook.NONE); + } + + StateArchiveFiveLaneSegmentWriterV3(Path archiveRoot, byte[] baselineHistoryDigest, + short compressionId, long rotationTargetBytes) throws IOException { + this(archiveRoot, baselineHistoryDigest, compressionId, rotationTargetBytes, null, + RecoveryFaultHook.NONE); + } + + /** Repairs open tails under complete caller and Common identities. */ + public static StateArchiveFiveLaneSegmentWriterV3 recover(Path archiveRoot, + byte[] baselineHistoryDigest, short compressionId, + RecoveryPoint authorizedCeiling, RecoveryPoint commonCommitted) throws IOException { + return new StateArchiveFiveLaneSegmentWriterV3(archiveRoot, baselineHistoryDigest, + compressionId, StateArchiveFileFormatV3.SEGMENT_TARGET_BYTES, + new RecoveryRequest(authorizedCeiling, commonCommitted), RecoveryFaultHook.NONE); + } + + /** Prototype-only numeric boundary retained for component tests, never a Common proof. */ + static StateArchiveFiveLaneSegmentWriterV3 recover(Path archiveRoot, + byte[] baselineHistoryDigest, short compressionId, long authorizedHead) + throws IOException { + if (authorizedHead < 0) { + throw new IllegalArgumentException("Invalid State Archive recovery boundary"); + } + return new StateArchiveFiveLaneSegmentWriterV3(archiveRoot, baselineHistoryDigest, + compressionId, StateArchiveFileFormatV3.SEGMENT_TARGET_BYTES, + RecoveryRequest.prototype(authorizedHead), RecoveryFaultHook.NONE); + } + + static StateArchiveFiveLaneSegmentWriterV3 recover(Path archiveRoot, + byte[] baselineHistoryDigest, short compressionId, long rotationTargetBytes, + long authorizedHead) throws IOException { + if (authorizedHead < 0) { + throw new IllegalArgumentException("Invalid State Archive recovery boundary"); + } + return new StateArchiveFiveLaneSegmentWriterV3(archiveRoot, baselineHistoryDigest, + compressionId, rotationTargetBytes, RecoveryRequest.prototype(authorizedHead), + RecoveryFaultHook.NONE); + } + + static StateArchiveFiveLaneSegmentWriterV3 recover(Path archiveRoot, + byte[] baselineHistoryDigest, short compressionId, long rotationTargetBytes, + RecoveryPoint authorizedCeiling, RecoveryPoint commonCommitted, + RecoveryFaultHook faultHook) throws IOException { + return new StateArchiveFiveLaneSegmentWriterV3(archiveRoot, baselineHistoryDigest, + compressionId, rotationTargetBytes, + new RecoveryRequest(authorizedCeiling, commonCommitted), faultHook); + } + + private StateArchiveFiveLaneSegmentWriterV3(Path archiveRoot, + byte[] baselineHistoryDigest, short compressionId, long rotationTargetBytes, + RecoveryRequest recoveryRequest, RecoveryFaultHook faultHook) throws IOException { + Objects.requireNonNull(archiveRoot, "archiveRoot"); + this.baselineHistoryDigest = requireHash(baselineHistoryDigest, + "baseline history digest"); + requireCompression(compressionId); + if (rotationTargetBytes <= StateArchiveFileFormatV3.PART_HEADER_LENGTH) { + throw new IllegalArgumentException("Invalid State Archive rotation target"); + } + this.compressionId = compressionId; + this.rotationTargetBytes = rotationTargetBytes; + this.archiveRoot = archiveRoot; + this.segmentRoot = archiveRoot.resolve("segments"); + Files.createDirectories(segmentRoot); + this.catalog = StateArchiveHistoryCatalogV3.openOrEmpty(archiveRoot); + requireNoLegacyIntent(); + Intent existingIntent = loadIntent(); + if (existingIntent != null) { + if (!Arrays.equals(existingIntent.getBaselineHistoryDigest(), this.baselineHistoryDigest)) { + throw new IllegalArgumentException("State Archive recovery intent baseline mismatch"); + } + recoverWithIntent(existingIntent, faultHook); + } else if (recoveryRequest != null) { + planAndRecover(recoveryRequest, faultHook); + } else { + discardUnpublishedTemporaryIntent(); + reopen(null); + } + } + + private void planAndRecover(RecoveryRequest request, RecoveryFaultHook faultHook) + throws IOException { + discardUnpublishedTemporaryIntent(); + activeRecoveryRequest = request; + recoveryFaultHook = Objects.requireNonNull(faultHook, "faultHook"); + reopen(request.authorizedBlock()); + } + + private void recoverWithIntent(Intent intent, RecoveryFaultHook faultHook) + throws IOException { + activeRecoveryIntent = StateArchiveFiveLaneRecoveryIntentV3.decode( + StateArchiveFiveLaneRecoveryIntentV3.encode(intent)); + recoveryFaultHook = Objects.requireNonNull(faultHook, "faultHook"); + Long target = intent.getTarget() == null ? 0L : intent.getTarget().getBlockNumber(); + reopen(target); + } + + private void requireNoLegacyIntent() { + if (Files.exists(archiveRoot.resolve("truncation.intent"))) { + throw new IllegalArgumentException("Incompatible legacy State Archive truncation intent"); + } + } + + private Intent loadIntent() throws IOException { + Path path = archiveRoot.resolve(StateArchiveFiveLaneRecoveryIntentV3.FILE_NAME); + if (!Files.exists(path)) { + return null; + } + return StateArchiveFiveLaneRecoveryIntentV3.decode(Files.readAllBytes(path)); + } + + private void persistIntent(Intent intent) throws IOException { + byte[] encoded = StateArchiveFiveLaneRecoveryIntentV3.encode(intent); + Path temporary = archiveRoot.resolve(StateArchiveFiveLaneRecoveryIntentV3.TEMP_FILE_NAME); + Path target = archiveRoot.resolve(StateArchiveFiveLaneRecoveryIntentV3.FILE_NAME); + try (FileChannel channel = FileChannel.open(temporary, StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE)) { + writeFully(channel, ByteBuffer.wrap(encoded)); + channel.force(true); + } + recoveryFaultHook.after(RecoveryStage.TEMPORARY_FORCED, -1); + try { + Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException unsupported) { + throw new IOException("State Archive recovery intent requires atomic move", unsupported); + } + syncDirectory(archiveRoot); + recoveryFaultHook.after(RecoveryStage.INTENT_PUBLISHED, -1); + } + + private void clearIntent() throws IOException { + Files.deleteIfExists(archiveRoot.resolve(StateArchiveFiveLaneRecoveryIntentV3.FILE_NAME)); + syncDirectory(archiveRoot); + recoveryFaultHook.after(RecoveryStage.INTENT_DELETED, -1); + } + + private void discardUnpublishedTemporaryIntent() throws IOException { + if (Files.deleteIfExists( + archiveRoot.resolve(StateArchiveFiveLaneRecoveryIntentV3.TEMP_FILE_NAME))) { + syncDirectory(archiveRoot); + } + } + + /** Appends all five frames; the in-memory append head advances only after every lane succeeds. */ + public synchronized void append(EncodedBundle bundle) throws IOException { + if (activeCheckpointSequence >= 0) { + throw new IllegalStateException("State Archive checkpoint append is active"); + } + append(bundle, -1, null, SyncFaultHook.NONE); + } + + /** Appends one bundle under the Common identity needed to prove any intervening rotation. */ + public synchronized void appendForCheckpoint(EncodedBundle bundle, long checkpointSequence, + byte[] commonTargetDigest) throws IOException { + appendForCheckpoint(bundle, checkpointSequence, commonTargetDigest, SyncFaultHook.NONE); + } + + synchronized void appendForCheckpoint(EncodedBundle bundle, long checkpointSequence, + byte[] commonTargetDigest, SyncFaultHook faultHook) throws IOException { + if (checkpointSequence < 0) { + throw new IllegalArgumentException("Invalid State Archive checkpoint sequence"); + } + byte[] admittedDigest = requireHash(commonTargetDigest, "Common target digest"); + if (activeCheckpointSequence >= 0 + && (activeCheckpointSequence != checkpointSequence + || !Arrays.equals(activeCommonTargetDigest, admittedDigest))) { + throw new IllegalArgumentException("State Archive checkpoint append identity mismatch"); + } + append(bundle, checkpointSequence, admittedDigest, + Objects.requireNonNull(faultHook, "faultHook")); + } + + private void append(EncodedBundle bundle, long checkpointSequence, + byte[] commonTargetDigest, SyncFaultHook faultHook) throws IOException { + requireUsable(); + Objects.requireNonNull(bundle, "bundle"); + List frames = bundle.getLanes().stream().map(EncodedLane::getFrame) + .collect(Collectors.toList()); + DecodedBundle decoded = codec.decode(frames); + BlockSnapshotMeta meta = decoded.getDiff().getMeta(); + validateNext(meta, frames.get(0)); + if (checkpointSequence >= 0 && activeCheckpointSequence < 0) { + activeCheckpointSequence = checkpointSequence; + activeCommonTargetDigest = Arrays.copyOf(commonTargetDigest, commonTargetDigest.length); + } + try { + for (EncodedLane lane : bundle.getLanes()) { + if (ByteBuffer.wrap(lane.getFrame()).getShort(COMPRESSION_ID_OFFSET) + != compressionId) { + throw new IllegalArgumentException("State Archive writer compression mismatch"); + } + LaneState state = lanes.get(lane.getLaneId()); + if (state != null && StateArchiveSegmentFormatV3.shouldRotate( + state.blockFrameCount, state.dataEndOffset, rotationTargetBytes)) { + // A fully marked segment belongs to the preceding checkpoint. Seal it without + // adding its old marker to the new checkpoint's durability proof. + if (checkpointSequence >= 0 + && state.markedBlockFrameCount < state.blockFrameCount) { + addPendingTail(markRotation(state, checkpointSequence, + commonTargetDigest, faultHook)); + } + seal(state); + state = null; + } + if (state == null) { + state = openNewSegment(lane.getLaneId(), meta.getBlockNumber(), + previousHistoryDigest(lane.getFrame())); + } + appendLaneFrame(state, meta, lane); + } + appendHead = meta; + resultHistoryDigest = decoded.getResultHistoryDigest(); + if (structuralChanged || !catalog.isPublished()) { + publishCatalog(); + } + } catch (IOException | RuntimeException failure) { + failed = true; + throw failure; + } + } + + public synchronized BlockSnapshotMeta getAppendHead() { + return appendHead; + } + + public synchronized long getHistoryStartBlock() { + long first = Long.MAX_VALUE; + for (CurrentSegment segment : getCurrentSegments()) { + first = Math.min(first, segment.getFirstBlock()); + } + for (SealedSegment segment : sealedSegments) { + first = Math.min(first, segment.getFirstBlock()); + } + return first == Long.MAX_VALUE ? -1 : first; + } + + public synchronized byte[] getResultHistoryDigest() { + return resultHistoryDigest == null ? Arrays.copyOf(baselineHistoryDigest, + baselineHistoryDigest.length) : Arrays.copyOf(resultHistoryDigest, + resultHistoryDigest.length); + } + + public synchronized List getCurrentSegments() { + return lanes.values().stream().sorted(Comparator.comparingInt(state -> state.laneId)) + .map(LaneState::currentMap).collect(Collectors.toList()); + } + + public synchronized List getSealedSegments() { + return Collections.unmodifiableList(new ArrayList<>(sealedSegments)); + } + + /** Replays complete five-lane bundles from Catalog-selected authority for serving repair. */ + public synchronized List readCommittedDiffs(long fromExclusive, long through) + throws IOException { + return readCommittedDiffs(fromExclusive, through, Long.MAX_VALUE); + } + + synchronized List readCommittedDiffs(long fromExclusive, long through, + long maxEncodedBytes) throws IOException { + requireUsable(); + if (fromExclusive < 0 || through < fromExclusive || appendHead == null + || through > appendHead.getBlockNumber()) { + throw new IllegalArgumentException("Invalid State Archive committed read range"); + } + servingReadFrames = 0; + servingReadBytes = 0; + if (fromExclusive == through) { + return Collections.emptyList(); + } + int blockCount = Math.toIntExact(through - fromExclusive); + int[] laneIds = StateArchiveFileFormatV3.fiveLaneIds(); + byte[][][] bundles = new byte[laneIds.length][blockCount][]; + if (servingSegments == null) { + servingSegments = new HashMap<>(); + for (int laneId : StateArchiveFileFormatV3.fiveLaneIds()) { + servingSegments.put(laneId, new TreeMap<>()); + } + for (SealedSegment segment : sealedSegments) { + servingSegments.get(segment.getLaneId()).put(segment.getFirstBlock(), segment); + } + } + long first = fromExclusive + 1; + for (int laneOrdinal = 0; laneOrdinal < laneIds.length; laneOrdinal++) { + int laneId = laneIds[laneOrdinal]; + NavigableMap segments = servingSegments.get(laneId); + Map.Entry selected = segments.floorEntry(first); + if (selected == null) { + selected = segments.ceilingEntry(first); + } + while (selected != null && selected.getKey() <= through) { + SealedSegment segment = selected.getValue(); + if (segment.getLastBlock() >= first) { + readIndexedRange(laneId, segment.getSegmentSeq(), segment.getFirstBlock(), + Math.max(first, segment.getFirstBlock()), + Math.min(through, segment.getLastBlock()), segment.getSegmentHeaderDigest(), + first, bundles[laneOrdinal], maxEncodedBytes); + } + selected = segments.higherEntry(selected.getKey()); + } + LaneState current = lanes.get(laneId); + if (current != null && current.firstBlock <= through && current.lastBlock >= first) { + readIndexedRange(laneId, current.segmentSeq, current.firstBlock, + Math.max(first, current.firstBlock), Math.min(through, current.lastBlock), + current.headerDigest, first, bundles[laneOrdinal], maxEncodedBytes); + } + } + List result = new ArrayList<>(blockCount); + for (int block = 0; block < blockCount; block++) { + List ordered = new ArrayList<>(laneIds.length); + for (int lane = 0; lane < laneIds.length; lane++) { + byte[] frame = bundles[lane][block]; + if (frame == null) { + throw new IOException("Incomplete State Archive serving source bundle"); + } + ordered.add(frame); + bundles[lane][block] = null; + } + result.add(codec.decode(ordered).getDiff()); + } + return Collections.unmodifiableList(result); + } + + synchronized long getServingReadFrames() { + return servingReadFrames; + } + + static final class ServingReadBudgetException extends IOException { + ServingReadBudgetException() { + super("Serving source encoded-byte budget exceeded"); + } + } + + synchronized long getServingReadBytes() { + return servingReadBytes; + } + + private void readIndexedRange(int laneId, long sequence, long segmentFirst, + long first, long last, byte[] headerDigest, + long rangeFirst, byte[][] laneFrames, long maxEncodedBytes) throws IOException { + try (FileChannel data = FileChannel.open(dataPath(laneId, sequence), + StandardOpenOption.READ); + FileChannel index = FileChannel.open(indexPath(laneId, sequence), + StandardOpenOption.READ)) { + BlockIndexHeader header = StateArchiveSegmentFormatV3.decodeBlockIndexHeader( + readExact(index, 0, StateArchiveFileFormatV3.BLOCK_INDEX_HEADER_LENGTH)); + if (header.getLaneId() != laneId || header.getSegmentSeq() != sequence + || !Arrays.equals(headerDigest, header.getDataSegmentHeaderDigest())) { + throw new IOException("State Archive serving block index identity mismatch"); + } + long dataBytes = data.size(); + int entryBytes = StateArchiveFileFormatV3.BLOCK_INDEX_ENTRY_LENGTH; + byte[] entries = null; + for (long block = first; block <= last; block++) { + int entryInBatch = (int) ((block - first) % 256); + if (entryInBatch == 0) { + long offset = Math.addExact(StateArchiveFileFormatV3.BLOCK_INDEX_HEADER_LENGTH, + Math.multiplyExact(block - segmentFirst, entryBytes)); + int count = (int) Math.min(256, last - block + 1); + entries = readExact(index, offset, count * entryBytes); + } + BlockIndexEntry entry = StateArchiveSegmentFormatV3.decodeBlockIndexEntry( + Arrays.copyOfRange(entries, entryInBatch * entryBytes, + (entryInBatch + 1) * entryBytes)); + if (entry.getBlockNumber() != block + || entry.getFrameOffset() > dataBytes - entry.getFrameLength()) { + throw new IOException("State Archive serving block index range mismatch"); + } + if (entry.getFrameLength() > maxEncodedBytes - servingReadBytes) { + throw new ServingReadBudgetException(); + } + byte[] frame = readExact(data, entry.getFrameOffset(), entry.getFrameLength()); + servingReadFrames++; + servingReadBytes += frame.length; + if (blockNumber(frame) != block || laneIdFromFrame(frame) != laneId + || ByteBuffer.wrap(frame).getLong(frame.length - ENCODED_DIGEST_FROM_END) + != entry.getEncodedFrameDigestPrefix()) { + throw new IOException("State Archive serving block index frame mismatch"); + } + int slot = Math.toIntExact(block - rangeFirst); + if (laneFrames[slot] != null) { + throw new IOException("Duplicate State Archive serving source lane frame"); + } + laneFrames[slot] = frame; + } + } + } + + private static int laneIdFromFrame(byte[] frame) throws IOException { + long coverage = ByteBuffer.wrap(frame).getLong(COVERAGE_BITMAP_OFFSET); + for (int laneId : StateArchiveFileFormatV3.fiveLaneIds()) { + if (coverage == StateArchiveFileFormatV3.laneCoverage(laneId)) { + return laneId; + } + } + throw new IOException("State Archive serving source lane coverage mismatch"); + } + + public synchronized ArchiveDurabilityProof getLastDurabilityProof() { + return lastDurabilityProof; + } + + /** Forces one complete five-lane marker barrier and returns proof only after reread. */ + public synchronized ArchiveDurabilityProof sync(long checkpointSequence, + RecoveryPoint target, byte[] commonTargetDigest) throws IOException { + return sync(checkpointSequence, target, commonTargetDigest, SyncFaultHook.NONE); + } + + synchronized ArchiveDurabilityProof sync(long checkpointSequence, + RecoveryPoint target, byte[] commonTargetDigest, SyncFaultHook faultHook) + throws IOException { + requireUsable(); + RecoveryPoint admittedTarget = Objects.requireNonNull(target, "target"); + byte[] admittedCommonDigest = requireHash(commonTargetDigest, "Common target digest"); + SyncFaultHook admittedFaultHook = Objects.requireNonNull(faultHook, "faultHook"); + if (appendHead == null) { + throw new IllegalStateException("State Archive has no bundle to sync"); + } + requireSamePoint(recoveryPoint(appendHead, resultHistoryDigest), admittedTarget, + "durability target"); + if (rotationWithUnsyncedData) { + throw new IllegalStateException( + "State Archive sync cannot prove a segment rotated before its marker"); + } + List ordered = lanes.values().stream() + .sorted(Comparator.comparingInt(state -> state.laneId)) + .collect(Collectors.toList()); + if (ordered.size() != StateArchiveFileFormatV3.fiveLaneIds().length) { + throw new IllegalStateException("State Archive sync requires five current lanes"); + } + boolean hasUnsynced = !pendingRotationTails.isEmpty() || ordered.stream() + .anyMatch(state -> state.blockFrameCount > state.markedBlockFrameCount); + if (!hasUnsynced) { + if (lastDurabilityProof == null + || !Arrays.equals(lastDurabilityProof.getCommonTargetDigest(), + admittedCommonDigest)) { + throw new IllegalStateException("State Archive sync has no new bundle range"); + } + requireSamePoint(lastDurabilityProof.getTarget(), admittedTarget, + "reused durability target"); + if (lastDurabilityProofRecovered) { + reforceRecoveredProof(lastDurabilityProof, admittedFaultHook); + lastDurabilityProofRecovered = false; + } + return lastDurabilityProof; + } + if (checkpointSequence < 0 || lastDurabilityProof != null + && checkpointSequence <= lastDurabilityProof.getCheckpointSequence() + || ordered.stream().anyMatch(state -> checkpointSequence < state.lastCheckpointSequence)) { + throw new IllegalArgumentException("Invalid State Archive checkpoint sequence"); + } + if (activeCheckpointSequence >= 0 + && (activeCheckpointSequence != checkpointSequence + || !Arrays.equals(activeCommonTargetDigest, admittedCommonDigest))) { + throw new IllegalArgumentException("State Archive checkpoint barrier identity mismatch"); + } + List markers = new ArrayList<>(); + try { + for (LaneState state : ordered) { + long blockCount = state.blockFrameCount - state.markedBlockFrameCount; + if (blockCount == 0 && state.lastCheckpointSequence == checkpointSequence + && state.lastBlock == admittedTarget.getBlockNumber() + && Arrays.equals(state.lastCommonTargetDigest, admittedCommonDigest)) { + addPendingTail(new FileTailProof(state.laneId, state.segmentSeq, + state.lastMarkerOffset, StateArchiveFileFormatV3.MARKER_HEADER_LENGTH + + StateArchiveFileFormatV3.FRAME_TRAILER_LENGTH, + state.lastMarkerEndOffset, state.previousMarkerDigest)); + continue; + } + if (blockCount <= 0 || state.lastBlock != admittedTarget.getBlockNumber()) { + throw new IllegalStateException("State Archive sync lane range mismatch"); + } + long firstBlock = state.lastBlock - blockCount + 1; + long encodedBytes = state.encodedBlockFrameBytes - state.markedEncodedBytes; + long markerOffset = state.dataEndOffset; + long markerEnd = markerOffset + StateArchiveFileFormatV3.MARKER_HEADER_LENGTH + + StateArchiveFileFormatV3.FRAME_TRAILER_LENGTH; + DurableMarker marker = new DurableMarker(checkpointSequence, firstBlock, + admittedTarget.getBlockNumber(), firstBlock, admittedTarget.getBlockNumber(), + admittedTarget.getBlockHash(), admittedTarget.getResultHistoryDigest(), + state.lastMarkerEndOffset, markerEnd, blockCount, + state.logicalPayloadBytes - state.markedLogicalBytes, encodedBytes, + state.previousMarkerDigest, admittedCommonDigest, state.laneId, + state.segmentSeq); + byte[] encoded = StateArchiveSegmentFormatV3.encodeDurableMarker(marker); + state.data.position(markerOffset); + writeFully(state.data, ByteBuffer.wrap(encoded)); + state.contentDigest.update(encoded); + state.dataEndOffset = markerEnd; + markers.add(new MarkerWrite(state, markerOffset, encoded, + StateArchiveSegmentFormatV3.decodeDurableMarker(encoded))); + admittedFaultHook.after(SyncStage.MARKER_WRITTEN, state.laneId); + } + for (LaneState state : ordered) { + state.data.force(false); + admittedFaultHook.after(SyncStage.DATA_FORCED, state.laneId); + } + List tails = new ArrayList<>(pendingRotationTails); + for (MarkerWrite write : markers) { + byte[] reread = readExact(write.state.data, write.offset, write.encoded.length); + DurableMarker decoded = StateArchiveSegmentFormatV3.decodeDurableMarker(reread); + if (!Arrays.equals(reread, write.encoded) + || write.state.data.size() != decoded.getMarkerEndOffset()) { + throw new IllegalArgumentException("State Archive durable marker reread mismatch"); + } + tails.add(new FileTailProof(decoded.getLaneId(), decoded.getSegmentSeq(), + write.offset, reread.length, decoded.getMarkerEndOffset(), + decoded.getEncodedFrameDigest())); + admittedFaultHook.after(SyncStage.MARKER_VERIFIED, write.state.laneId); + } + tails.sort(Comparator.comparingInt(FileTailProof::getLaneId) + .thenComparingLong(FileTailProof::getSegmentSeq)); + ArchiveDurabilityProof proof = new ArchiveDurabilityProof(checkpointSequence, + admittedTarget, admittedCommonDigest, tails); + verifyDurabilityProof(proof); + for (MarkerWrite write : markers) { + LaneState state = write.state; + state.markedBlockFrameCount = state.blockFrameCount; + state.markedLogicalBytes = state.logicalPayloadBytes; + state.markedEncodedBytes = state.encodedBlockFrameBytes; + state.lastMarkerEndOffset = write.marker.getMarkerEndOffset(); + state.previousMarkerDigest = write.marker.getEncodedFrameDigest(); + state.lastCheckpointSequence = checkpointSequence; + state.lastCommonTargetDigest = admittedCommonDigest; + } + lastDurabilityProof = proof; + pendingRotationTails.clear(); + activeCheckpointSequence = -1; + activeCommonTargetDigest = null; + admittedFaultHook.after(SyncStage.PROOF_READY, -1); + return proof; + } catch (IOException | RuntimeException failure) { + failed = true; + throw failure; + } + } + + /** Reopens proof bytes by exact lane/segment/offset; ordinary readable bytes are insufficient. */ + public synchronized void verifyDurabilityProof(ArchiveDurabilityProof proof) + throws IOException { + ArchiveDurabilityProof admitted = Objects.requireNonNull(proof, "proof"); + int activeLane = -1; + long previousBlock = -1; + DurableMarker lastMarker = null; + for (FileTailProof tail : admitted.getFileTails()) { + if (tail.getLaneId() != activeLane) { + requireFinalProofMarker(lastMarker, admitted); + activeLane = tail.getLaneId(); + previousBlock = -1; + } + Path path = dataPath(tail.getLaneId(), tail.getSegmentSeq()); + try (FileChannel channel = FileChannel.open(path, StandardOpenOption.READ)) { + byte[] encoded = readExact(channel, tail.getMarkerOffset(), tail.getMarkerLength()); + DurableMarker marker = StateArchiveSegmentFormatV3.decodeDurableMarker(encoded); + if (channel.size() < tail.getMarkerEndOffset() + || marker.getCheckpointSequence() != admitted.getCheckpointSequence() + || marker.getLaneId() != tail.getLaneId() + || marker.getSegmentSeq() != tail.getSegmentSeq() + || marker.getMarkerEndOffset() != tail.getMarkerEndOffset() + || marker.getLastBlock() <= previousBlock + || marker.getLastBlock() > admitted.getTarget().getBlockNumber() + || !Arrays.equals(marker.getCommonTargetDigest(), + admitted.getCommonTargetDigest()) + || !Arrays.equals(marker.getEncodedFrameDigest(), tail.getMarkerDigest())) { + throw new IllegalArgumentException("State Archive durability proof mismatch"); + } + previousBlock = marker.getLastBlock(); + lastMarker = marker; + } + } + requireFinalProofMarker(lastMarker, admitted); + } + + private static void requireFinalProofMarker(DurableMarker marker, + ArchiveDurabilityProof proof) { + if (marker != null && (marker.getLastBlock() != proof.getTarget().getBlockNumber() + || !Arrays.equals(marker.getLastBlockHash(), proof.getTarget().getBlockHash()) + || !Arrays.equals(marker.getResultHistoryDigest(), + proof.getTarget().getResultHistoryDigest()))) { + throw new IllegalArgumentException("State Archive durability proof final tail mismatch"); + } + } + + private void validateNext(BlockSnapshotMeta meta, byte[] firstFrame) { + byte[] previousDigest = previousHistoryDigest(firstFrame); + if (appendHead == null) { + if (!Arrays.equals(previousDigest, baselineHistoryDigest)) { + throw new IllegalArgumentException("State Archive first bundle history mismatch"); + } + return; + } + if (meta.getBlockNumber() != appendHead.getBlockNumber() + 1 + || meta.getEpoch() != appendHead.getEpoch() + 1 + || !Arrays.equals(meta.getParentHash(), appendHead.getBlockHash()) + || !Arrays.equals(previousDigest, resultHistoryDigest)) { + throw new IllegalArgumentException("State Archive bundle is not expected-next"); + } + } + + private LaneState openNewSegment(int laneId, long firstBlock, + byte[] previousHistory) throws IOException { + LaneState prior = lanes.get(laneId); + long sequence = prior == null ? nextSequence(laneId) : prior.segmentSeq + 1; + byte[] previousSegment = sequence == 0 + ? StateArchiveSegmentFormatV3.laneBaselineDigest(laneId) + : previousChainDigest(laneId, sequence - 1); + SegmentHeader header = new SegmentHeader(laneId, sequence, firstBlock, + previousSegment, previousHistory, compressionId); + byte[] headerBytes = StateArchiveSegmentFormatV3.encodeHeader(header); + SegmentHeader decodedHeader = StateArchiveSegmentFormatV3.decodeHeader(headerBytes); + Path dataPath = dataPath(laneId, sequence); + Path indexPath = indexPath(laneId, sequence); + Files.createDirectories(dataPath.getParent()); + FileChannel data = FileChannel.open(dataPath, StandardOpenOption.CREATE_NEW, + StandardOpenOption.READ, StandardOpenOption.WRITE); + FileChannel index = null; + try { + writeFully(data, ByteBuffer.wrap(headerBytes)); + data.force(false); + BlockIndexHeader indexHeader = new BlockIndexHeader(laneId, sequence, + decodedHeader.getHeaderDigest()); + index = FileChannel.open(indexPath, StandardOpenOption.CREATE_NEW, + StandardOpenOption.READ, StandardOpenOption.WRITE); + writeFully(index, ByteBuffer.wrap( + StateArchiveSegmentFormatV3.encodeBlockIndexHeader(indexHeader))); + index.force(false); + syncDirectory(dataPath.getParent()); + LaneState state = new LaneState(laneId, sequence, firstBlock, + decodedHeader.getHeaderDigest(), previousHistory, data, index, + newContentDigest(headerBytes)); + lanes.put(laneId, state); + structuralChanged = true; + return state; + } catch (IOException | RuntimeException failure) { + data.close(); + if (index != null) { + index.close(); + } + throw failure; + } + } + + private void appendLaneFrame(LaneState state, BlockSnapshotMeta meta, EncodedLane lane) + throws IOException { + byte[] frame = lane.getFrame(); + if (frame.length > StateArchiveFileFormatV3.MAX_BLOCK_FRAME_BYTES) { + throw new IllegalArgumentException("State Archive block frame exceeds 64 MiB"); + } + long offset = state.dataEndOffset; + state.data.position(offset); + writeBuffered(state.data, frame, state.appendBuffer); + state.contentDigest.update(frame); + byte[] indexEntry = StateArchiveSegmentFormatV3.encodeBlockIndexEntry( + new BlockIndexEntry(meta.getBlockNumber(), offset, frame.length, + ByteBuffer.wrap(lane.getEncodedFrameDigest()).getLong())); + state.index.position(state.index.size()); + writeFully(state.index, ByteBuffer.wrap(indexEntry)); + state.record(meta, frame, lane.getEncodedFrameDigest()); + } + + private FileTailProof markRotation(LaneState state, long checkpointSequence, + byte[] commonTargetDigest, SyncFaultHook faultHook) throws IOException { + if (state.lastMeta == null) { + throw new IllegalStateException("State Archive rotation has no unmarked bundle range"); + } + if (state.markedBlockFrameCount == state.blockFrameCount) { + if (state.lastCheckpointSequence != checkpointSequence + || !Arrays.equals(state.lastCommonTargetDigest, commonTargetDigest)) { + throw new IllegalStateException("State Archive rotation marker identity differs"); + } + return new FileTailProof(state.laneId, state.segmentSeq, state.lastMarkerOffset, + StateArchiveFileFormatV3.MARKER_HEADER_LENGTH + + StateArchiveFileFormatV3.FRAME_TRAILER_LENGTH, + state.lastMarkerEndOffset, state.previousMarkerDigest); + } + long blockCount = state.blockFrameCount - state.markedBlockFrameCount; + long firstBlock = state.lastBlock - blockCount + 1; + long markerOffset = state.dataEndOffset; + long markerEnd = markerOffset + StateArchiveFileFormatV3.MARKER_HEADER_LENGTH + + StateArchiveFileFormatV3.FRAME_TRAILER_LENGTH; + DurableMarker marker = new DurableMarker(checkpointSequence, firstBlock, + state.lastBlock, firstBlock, state.lastBlock, state.lastMeta.getBlockHash(), + state.endHistoryDigest, state.lastMarkerEndOffset, markerEnd, blockCount, + state.logicalPayloadBytes - state.markedLogicalBytes, + state.encodedBlockFrameBytes - state.markedEncodedBytes, + state.previousMarkerDigest, commonTargetDigest, state.laneId, state.segmentSeq); + byte[] encoded = StateArchiveSegmentFormatV3.encodeDurableMarker(marker); + state.data.position(markerOffset); + writeFully(state.data, ByteBuffer.wrap(encoded)); + state.contentDigest.update(encoded); + state.dataEndOffset = markerEnd; + faultHook.after(SyncStage.MARKER_WRITTEN, state.laneId); + state.data.force(false); + faultHook.after(SyncStage.DATA_FORCED, state.laneId); + byte[] reread = readExact(state.data, markerOffset, encoded.length); + DurableMarker decoded = StateArchiveSegmentFormatV3.decodeDurableMarker(reread); + if (!Arrays.equals(reread, encoded) || state.data.size() != decoded.getMarkerEndOffset()) { + throw new IllegalArgumentException("State Archive rotation marker reread mismatch"); + } + faultHook.after(SyncStage.MARKER_VERIFIED, state.laneId); + state.markedBlockFrameCount = state.blockFrameCount; + state.markedLogicalBytes = state.logicalPayloadBytes; + state.markedEncodedBytes = state.encodedBlockFrameBytes; + state.lastMarkerEndOffset = decoded.getMarkerEndOffset(); + state.lastMarkerOffset = markerOffset; + state.previousMarkerDigest = decoded.getEncodedFrameDigest(); + state.lastCheckpointSequence = checkpointSequence; + state.lastCommonTargetDigest = commonTargetDigest; + return new FileTailProof(state.laneId, state.segmentSeq, markerOffset, + reread.length, decoded.getMarkerEndOffset(), decoded.getEncodedFrameDigest()); + } + + private void addPendingTail(FileTailProof tail) { + boolean exists = pendingRotationTails.stream().anyMatch(candidate -> + candidate.getLaneId() == tail.getLaneId() + && candidate.getSegmentSeq() == tail.getSegmentSeq()); + if (!exists) { + pendingRotationTails.add(tail); + } + } + + private void seal(LaneState state) throws IOException { + if (state.markedBlockFrameCount != state.blockFrameCount) { + rotationWithUnsyncedData = true; + } + byte[] contentDigest = state.contentDigest.digest(); + int sealLength = StateArchiveFileFormatV3.SEAL_HEADER_LENGTH + + StateArchiveFileFormatV3.FRAME_TRAILER_LENGTH; + SegmentSeal seal = new SegmentSeal(state.laneId, state.segmentSeq, + state.firstBlock, state.lastBlock, state.blockFrameCount, state.entryCount, + state.logicalPayloadBytes, state.encodedBlockFrameBytes, state.dataEndOffset, + state.dataEndOffset + sealLength, state.firstFrameDigest, + state.lastFrameDigest, state.startHistoryDigest, state.endHistoryDigest, + contentDigest); + byte[] encodedSeal = StateArchiveSegmentFormatV3.encodeSeal(seal); + SegmentSeal decodedSeal = StateArchiveSegmentFormatV3.decodeSeal(encodedSeal); + state.data.position(state.dataEndOffset); + writeFully(state.data, ByteBuffer.wrap(encodedSeal)); + state.data.force(false); + state.index.force(false); + if (state.index.size() != StateArchiveFileFormatV3.BLOCK_INDEX_HEADER_LENGTH + + state.blockFrameCount * StateArchiveFileFormatV3.BLOCK_INDEX_ENTRY_LENGTH) { + throw new IllegalStateException("State Archive sealed block index length mismatch"); + } + byte[] previousSegmentDigest = state.segmentSeq == 0 + ? StateArchiveSegmentFormatV3.laneBaselineDigest(state.laneId) + : previousChainDigest(state.laneId, state.segmentSeq - 1); + SegmentManifest manifest = new SegmentManifest(state.laneId, state.segmentSeq, + state.firstBlock, state.lastBlock, state.blockFrameCount, state.entryCount, + state.logicalPayloadBytes, state.encodedBlockFrameBytes, + state.dataEndOffset + sealLength, state.index.size(), previousSegmentDigest, + state.endHistoryDigest); + byte[] encodedManifest = StateArchiveSegmentFormatV3.encodeManifest(manifest); + SegmentManifest decodedManifest = StateArchiveSegmentFormatV3.decodeManifest(encodedManifest); + publishManifest(state.laneId, state.segmentSeq, encodedManifest); + sealedSegments.add(new SealedSegment(state.laneId, state.segmentSeq, + state.firstBlock, state.lastBlock, state.blockFrameCount, + state.dataEndOffset + sealLength, state.index.size(), state.headerDigest, + contentDigest, decodedSeal.getEncodedFrameDigest(), decodedManifest.getManifestDigest())); + state.close(); + lanes.remove(state.laneId); + structuralChanged = true; + } + + private void reopen(Long recoveryBoundary) throws IOException { + servingSegments = null; + List dataFiles = listDataFiles(recoveryBoundary != null || activeRecoveryIntent != null); + if (dataFiles.isEmpty()) { + resultHistoryDigest = Arrays.copyOf(baselineHistoryDigest, + baselineHistoryDigest.length); + if (activeRecoveryIntent != null) { + if (activeRecoveryIntent.getTarget() != null) { + throw new IllegalArgumentException("State Archive recovery target is unavailable"); + } + try { + verifyRecoveredIntent(activeRecoveryIntent); + recoveryFaultHook.after(RecoveryStage.TARGET_VERIFIED, -1); + clearIntent(); + activeRecoveryIntent = null; + } catch (IOException | RuntimeException failure) { + closeAfterFailure(failure); + throw failure; + } + } else if (activeRecoveryRequest != null && !activeRecoveryRequest.prototype + && activeRecoveryRequest.commonCommitted != null) { + throw new IllegalArgumentException("State Archive cannot verify Common committed"); + } + return; + } + Map> bundles = new java.util.TreeMap<>(); + Map expectedSequence = new HashMap<>(); + Map expectedPreviousChain = new HashMap<>(); + List scannedSegments = new ArrayList<>(); + boolean recovering = recoveryBoundary != null; + for (Path path : dataFiles) { + ParsedName name = parseName(path); + long expected = expectedSequence.getOrDefault(name.laneId, 0L); + if (name.segmentSeq != expected) { + throw new IllegalArgumentException("Non-contiguous State Archive segment sequence"); + } + expectedSequence.put(name.laneId, expected + 1); + ScannedSegment scanned = scanSegment(path, name, bundles, recovering); + scannedSegments.add(scanned); + byte[] expectedPrevious = name.segmentSeq == 0 + ? StateArchiveSegmentFormatV3.laneBaselineDigest(name.laneId) + : expectedPreviousChain.get(name.laneId); + if (!Arrays.equals(scanned.header.getPreviousSegmentDigest(), expectedPrevious)) { + throw new IllegalArgumentException("State Archive previous segment chain mismatch"); + } + if (!recovering && scanned.seal == null) { + if (lanes.containsKey(name.laneId)) { + throw new IllegalArgumentException("Multiple open State Archive lane segments"); + } + lanes.put(name.laneId, scanned.openState()); + } else if (scanned.seal != null) { + expectedPreviousChain.put(name.laneId, scanned.chainDigest()); + sealedSegments.add(scanned.sealedMap()); + } + } + long commonHead = rebuildBundleHead(bundles, recoveryBoundary); + if (appendHead != null) { + for (LaneState state : lanes.values()) { + if (state.lastBlock == appendHead.getBlockNumber()) { + state.lastMeta = appendHead; + } + } + } + if (!recovering) { + rebuildLastDurabilityProof(scannedSegments, bundles); + validateCatalogSelection(); + } + if (recovering) { + Intent intent = activeRecoveryIntent; + if (intent == null && activeRecoveryRequest != null + && !activeRecoveryRequest.prototype) { + RecoveryPoint targetPoint = appendHead == null ? null + : recoveryPoint(appendHead, resultHistoryDigest); + verifyRecoveryPoint(activeRecoveryRequest.authorizedCeiling, bundles, + "authorized ceiling", false); + if (activeRecoveryRequest.commonCommitted != null) { + verifyRecoveryPoint(activeRecoveryRequest.commonCommitted, bundles, + "Common committed", true); + } + List laneTargets = buildLaneTargets(scannedSegments, commonHead); + if (laneTargets.stream().noneMatch(StateArchiveFiveLaneSegmentWriterV3::mutates)) { + intent = null; + } else { + intent = new Intent(baselineHistoryDigest, + activeRecoveryRequest.authorizedCeiling, + activeRecoveryRequest.commonCommitted, targetPoint, + laneTargets); + try { + persistIntent(intent); + } catch (IOException | RuntimeException failure) { + closeScannedAfterFailure(scannedSegments, failure); + throw failure; + } + intent = loadIntent(); + activeRecoveryIntent = intent; + } + } + if (intent != null) { + long intendedHead = intent.getTarget() == null ? -1 + : intent.getTarget().getBlockNumber(); + if (commonHead != intendedHead) { + closeScanned(scannedSegments); + throw new IllegalArgumentException("State Archive recovery target is unavailable"); + } + verifyIntentSources(intent, scannedSegments); + } + for (ScannedSegment scanned : scannedSegments) { + if (scanned.seal != null && scanned.lastBlock > commonHead) { + closeScanned(scannedSegments); + throw new IllegalArgumentException( + "Authorized State Archive recovery boundary crosses a sealed segment"); + } + } + try { + for (ScannedSegment scanned : scannedSegments) { + if (scanned.seal == null) { + scanned.repairTo(commonHead, recoveryFaultHook); + } + } + } catch (IOException | RuntimeException failure) { + closeScannedAfterFailure(scannedSegments, failure); + throw failure; + } + closeScanned(scannedSegments); + lanes.clear(); + sealedSegments.clear(); + appendHead = null; + resultHistoryDigest = Arrays.copyOf(baselineHistoryDigest, + baselineHistoryDigest.length); + reopen(null); + if (intent != null) { + try { + verifyRecoveredIntent(intent); + recoveryFaultHook.after(RecoveryStage.TARGET_VERIFIED, -1); + structuralChanged = true; + publishCatalog(); + clearIntent(); + activeRecoveryIntent = null; + } catch (IOException | RuntimeException failure) { + closeAfterFailure(failure); + throw failure; + } + } + } + } + + private void rebuildLastDurabilityProof(List scannedSegments, + Map> bundles) { + if (appendHead == null) { + return; + } + long latestSequence = scannedSegments.stream() + .filter(scanned -> scanned.lastMarker != null) + .mapToLong(scanned -> scanned.lastMarker.marker.getCheckpointSequence()) + .max().orElse(-1); + if (latestSequence < 0) { + return; + } + List latest = scannedSegments.stream() + .filter(scanned -> scanned.lastMarker != null + && scanned.lastMarker.marker.getCheckpointSequence() == latestSequence) + .map(scanned -> scanned.lastMarker) + .sorted(Comparator.comparingInt((ScannedMarker marker) -> marker.marker.getLaneId()) + .thenComparingLong(marker -> marker.marker.getSegmentSeq())) + .collect(Collectors.toList()); + DurableMarker identity = latest.get(0).marker; + List tails = new ArrayList<>(); + for (ScannedMarker candidate : latest) { + DurableMarker marker = candidate.marker; + if (marker.getCheckpointSequence() != identity.getCheckpointSequence() + || !Arrays.equals(marker.getCommonTargetDigest(), + identity.getCommonTargetDigest())) { + return; + } + tails.add(new FileTailProof(marker.getLaneId(), marker.getSegmentSeq(), + candidate.offset, StateArchiveFileFormatV3.MARKER_HEADER_LENGTH + + StateArchiveFileFormatV3.FRAME_TRAILER_LENGTH, + marker.getMarkerEndOffset(), marker.getEncodedFrameDigest())); + } + ArchiveDurabilityProof rebuilt; + try { + DurableMarker finalIdentity = null; + int laneCount = 0; + for (int laneId : StateArchiveFileFormatV3.fiveLaneIds()) { + DurableMarker laneFinal = null; + for (ScannedMarker candidate : latest) { + if (candidate.marker.getLaneId() == laneId) { + laneFinal = candidate.marker; + } + } + if (laneFinal == null) { + throw new IllegalArgumentException("Incomplete State Archive marker group"); + } + laneCount++; + if (finalIdentity == null) { + finalIdentity = laneFinal; + } else if (laneFinal.getLastBlock() != finalIdentity.getLastBlock() + || !Arrays.equals(laneFinal.getLastBlockHash(), finalIdentity.getLastBlockHash()) + || !Arrays.equals(laneFinal.getResultHistoryDigest(), + finalIdentity.getResultHistoryDigest())) { + throw new IllegalArgumentException("Incomplete State Archive marker identity"); + } + } + if (laneCount != StateArchiveFileFormatV3.fiveLaneIds().length) { + throw new IllegalArgumentException("Incomplete State Archive marker lanes"); + } + RecoveryPoint target = recoveryPointAt(bundles, finalIdentity.getLastBlock()); + if (target == null + || !Arrays.equals(finalIdentity.getLastBlockHash(), target.getBlockHash()) + || !Arrays.equals(finalIdentity.getResultHistoryDigest(), + target.getResultHistoryDigest())) { + throw new IllegalArgumentException("Incomplete State Archive marker target"); + } + rebuilt = new ArchiveDurabilityProof(identity.getCheckpointSequence(), + target, identity.getCommonTargetDigest(), tails); + } catch (IllegalArgumentException incomplete) { + pendingRotationTails.addAll(tails); + activeCheckpointSequence = identity.getCheckpointSequence(); + activeCommonTargetDigest = identity.getCommonTargetDigest(); + return; + } + lastDurabilityProof = rebuilt; + lastDurabilityProofRecovered = true; + } + + private void reforceRecoveredProof(ArchiveDurabilityProof proof, + SyncFaultHook faultHook) throws IOException { + int previousLane = -1; + long previousSegment = -1; + for (FileTailProof tail : proof.getFileTails()) { + if (tail.getLaneId() == previousLane && tail.getSegmentSeq() == previousSegment) { + continue; + } + LaneState current = lanes.get(tail.getLaneId()); + if (current != null && current.segmentSeq == tail.getSegmentSeq()) { + current.data.force(false); + } else { + try (FileChannel channel = FileChannel.open( + dataPath(tail.getLaneId(), tail.getSegmentSeq()), StandardOpenOption.WRITE)) { + channel.force(false); + } + } + faultHook.after(SyncStage.DATA_FORCED, tail.getLaneId()); + previousLane = tail.getLaneId(); + previousSegment = tail.getSegmentSeq(); + } + verifyDurabilityProof(proof); + } + + private RecoveryPoint recoveryPointAt(Map> bundles, + long blockNumber) { + Map laneFrames = bundles.get(blockNumber); + if (laneFrames == null || laneFrames.size() != StateArchiveFileFormatV3.fiveLaneIds().length) { + return null; + } + List frames = new ArrayList<>(); + for (int laneId : StateArchiveFileFormatV3.fiveLaneIds()) { + frames.add(laneFrames.get(laneId)); + } + DecodedBundle decoded = codec.decode(frames); + return recoveryPoint(decoded.getDiff().getMeta(), decoded.getResultHistoryDigest()); + } + + private List buildLaneTargets(List scannedSegments, + long commonHead) throws IOException { + List targets = new ArrayList<>(); + for (int laneId : StateArchiveFileFormatV3.fiveLaneIds()) { + ScannedSegment open = null; + for (ScannedSegment scanned : scannedSegments) { + if (scanned.header.getLaneId() == laneId && scanned.seal == null) { + open = scanned; + } + } + targets.add(open == null ? missingLaneTarget(laneId) : open.laneTarget(commonHead)); + } + return targets; + } + + private static LaneTarget missingLaneTarget(int laneId) { + byte[] zero = new byte[StateArchiveFileFormatV3.HASH_LENGTH]; + return new LaneTarget(laneId, StateArchiveFiveLaneRecoveryIntentV3.SOURCE_PAIR_MISSING, + StateArchiveFiveLaneRecoveryIntentV3.NO_TARGET_SEGMENT, 0, 0, + StateArchiveFiveLaneRecoveryIntentV3.NO_TARGET_SEGMENT, 0, 0, + zero, zero, zero); + } + + private static boolean mutates(LaneTarget target) { + return (target.getActionFlags() & (StateArchiveFiveLaneRecoveryIntentV3.DATA_TRUNCATE + | StateArchiveFiveLaneRecoveryIntentV3.DELETE_PAIR + | StateArchiveFiveLaneRecoveryIntentV3.INDEX_REPLACE)) != 0; + } + + private void verifyIntentSources(Intent intent, List scannedSegments) + throws IOException { + for (LaneTarget target : intent.getLanes()) { + ScannedSegment source = null; + for (ScannedSegment scanned : scannedSegments) { + if (scanned.seal == null && scanned.header.getLaneId() == target.getLaneId() + && scanned.header.getSegmentSeq() == target.getSourceSegmentSeq()) { + source = scanned; + } + } + if ((target.getActionFlags() + & StateArchiveFiveLaneRecoveryIntentV3.SOURCE_PAIR_MISSING) != 0) { + if (source != null || hasLaneIndexFile(target.getLaneId())) { + throw new IllegalArgumentException("State Archive missing-source lane appeared"); + } + continue; + } + if (source == null) { + if ((target.getActionFlags() & StateArchiveFiveLaneRecoveryIntentV3.DELETE_PAIR) != 0 + && !Files.exists(dataPath(target.getLaneId(), target.getSourceSegmentSeq())) + && !Files.exists(indexPath(target.getLaneId(), target.getSourceSegmentSeq()))) { + continue; + } + throw new IllegalArgumentException("State Archive recovery source is missing"); + } + long dataSize = source.data.size(); + long indexSize = source.index == null ? 0 : source.index.size(); + boolean deleting = (target.getActionFlags() + & StateArchiveFiveLaneRecoveryIntentV3.DELETE_PAIR) != 0; + boolean indexSizeAllowed = indexSize == target.getOriginalIndexEnd() + || (target.getActionFlags() & StateArchiveFiveLaneRecoveryIntentV3.INDEX_REPLACE) != 0 + && indexSize == target.getTargetIndexEnd() + || deleting && indexSize == 0; + boolean targetDataMatches = deleting + || Arrays.equals(digestFilePrefix(StateArchiveFileFormatV3.RECOVERY_DATA_PREFIX_DOMAIN, + source.data, target.getTargetDataEnd()), target.getTargetDataPrefixDigest()); + boolean targetIndexMatches = deleting || indexSize != target.getTargetIndexEnd() + || source.index != null && Arrays.equals(digestFilePrefix( + StateArchiveFileFormatV3.RECOVERY_INDEX_FILE_DOMAIN, source.index, + target.getTargetIndexEnd()), target.getTargetIndexFileDigest()); + if (!Arrays.equals(source.header.getHeaderDigest(), + target.getSourceSegmentHeaderDigest()) + || dataSize < target.getTargetDataEnd() + || dataSize > target.getOriginalDataEnd() + || !indexSizeAllowed || !targetDataMatches || !targetIndexMatches) { + throw new IllegalArgumentException("State Archive recovery source drifted"); + } + } + } + + private void verifyRecoveryPoint(RecoveryPoint expected, + Map> bundles, String name, boolean required) { + Map laneFrames = bundles.get(expected.getBlockNumber()); + if (laneFrames == null || laneFrames.size() != StateArchiveFileFormatV3.fiveLaneIds().length) { + if (required) { + throw new IllegalArgumentException("State Archive cannot verify " + name); + } + return; + } + List frames = new ArrayList<>(); + for (int laneId : StateArchiveFileFormatV3.fiveLaneIds()) { + frames.add(laneFrames.get(laneId)); + } + DecodedBundle decoded = codec.decode(frames); + RecoveryPoint actual = recoveryPoint(decoded.getDiff().getMeta(), + decoded.getResultHistoryDigest()); + requireSamePoint(actual, expected, name); + } + + private void verifyRecoveredIntent(Intent intent) { + RecoveryPoint target = intent.getTarget(); + if (target == null) { + if (appendHead != null) { + throw new IllegalArgumentException("State Archive baseline recovery retained blocks"); + } + } else { + if (appendHead == null) { + throw new IllegalArgumentException("State Archive recovery target is missing"); + } + requireSamePoint(recoveryPoint(appendHead, resultHistoryDigest), target, + "recovered target"); + } + RecoveryPoint common = intent.getCommonCommitted(); + if (common != null && (appendHead == null + || appendHead.getBlockNumber() < common.getBlockNumber())) { + throw new IllegalArgumentException("State Archive recovery fell below Common"); + } + for (LaneTarget lane : intent.getLanes()) { + verifyLaneTarget(lane); + } + } + + private void verifyLaneTarget(LaneTarget target) { + if ((target.getActionFlags() & StateArchiveFiveLaneRecoveryIntentV3.SOURCE_PAIR_MISSING) + != 0) { + try { + if (hasLaneIndexFile(target.getLaneId())) { + throw new IllegalArgumentException( + "State Archive missing-source recovery index appeared"); + } + } catch (IOException failure) { + throw new IllegalArgumentException( + "Cannot verify State Archive missing-source recovery lane", failure); + } + return; + } + Path data = dataPath(target.getLaneId(), target.getSourceSegmentSeq()); + Path index = indexPath(target.getLaneId(), target.getSourceSegmentSeq()); + if ((target.getActionFlags() & StateArchiveFiveLaneRecoveryIntentV3.DELETE_PAIR) != 0) { + if (Files.exists(data) || Files.exists(index)) { + throw new IllegalArgumentException("State Archive deleted recovery pair remains"); + } + return; + } + try { + try (FileChannel dataChannel = FileChannel.open(data, StandardOpenOption.READ); + FileChannel indexChannel = FileChannel.open(index, StandardOpenOption.READ)) { + if (dataChannel.size() != target.getTargetDataEnd() + || indexChannel.size() != target.getTargetIndexEnd() + || !Arrays.equals(digestFilePrefix( + StateArchiveFileFormatV3.RECOVERY_DATA_PREFIX_DOMAIN, dataChannel, + target.getTargetDataEnd()), target.getTargetDataPrefixDigest()) + || !Arrays.equals(digestFilePrefix( + StateArchiveFileFormatV3.RECOVERY_INDEX_FILE_DOMAIN, indexChannel, + target.getTargetIndexEnd()), target.getTargetIndexFileDigest())) { + throw new IllegalArgumentException("State Archive recovered lane target mismatch"); + } + } + } catch (IOException failure) { + throw new IllegalArgumentException("Cannot verify State Archive recovered lane", failure); + } + } + + private static RecoveryPoint recoveryPoint(BlockSnapshotMeta meta, byte[] historyDigest) { + return new RecoveryPoint(meta.getEpoch(), meta.getBlockNumber(), meta.getTimestamp(), + meta.getBlockHash(), meta.getParentHash(), historyDigest); + } + + private static void requireSamePoint(RecoveryPoint actual, RecoveryPoint expected, + String name) { + if (actual.getEpoch() != expected.getEpoch() + || actual.getBlockNumber() != expected.getBlockNumber() + || actual.getTimestamp() != expected.getTimestamp() + || !Arrays.equals(actual.getBlockHash(), expected.getBlockHash()) + || !Arrays.equals(actual.getParentHash(), expected.getParentHash()) + || !Arrays.equals(actual.getResultHistoryDigest(), expected.getResultHistoryDigest())) { + throw new IllegalArgumentException("State Archive " + name + " mismatch"); + } + } + + private ScannedSegment scanSegment(Path path, ParsedName name, + Map> bundles, boolean recovering) throws IOException { + FileChannel data = FileChannel.open(path, StandardOpenOption.READ, + StandardOpenOption.WRITE); + Path indexPath = indexPath(name.laneId, name.segmentSeq); + FileChannel index = Files.exists(indexPath) ? FileChannel.open(indexPath, + StandardOpenOption.READ, StandardOpenOption.WRITE) : null; + try { + byte[] headerBytes = readExact(data, 0, StateArchiveFileFormatV3.PART_HEADER_LENGTH); + SegmentHeader header = StateArchiveSegmentFormatV3.decodeHeader(headerBytes); + if (header.getLaneId() != name.laneId || header.getSegmentSeq() != name.segmentSeq + || header.getCompressionId() != compressionId) { + throw new IllegalArgumentException("State Archive segment filename/header mismatch"); + } + if (index != null && index.size() >= StateArchiveFileFormatV3.BLOCK_INDEX_HEADER_LENGTH) { + try { + BlockIndexHeader indexHeader = StateArchiveSegmentFormatV3.decodeBlockIndexHeader( + readExact(index, 0, StateArchiveFileFormatV3.BLOCK_INDEX_HEADER_LENGTH)); + if (indexHeader.getLaneId() != name.laneId + || indexHeader.getSegmentSeq() != name.segmentSeq + || !Arrays.equals(indexHeader.getDataSegmentHeaderDigest(), + header.getHeaderDigest())) { + throw new IllegalArgumentException("State Archive block index/header mismatch"); + } + } catch (IllegalArgumentException invalidIndexHeader) { + if (!recovering) { + throw invalidIndexHeader; + } + } + } else if (!recovering) { + throw new IllegalArgumentException("Truncated State Archive block index header"); + } + ScannedSegment scanned = new ScannedSegment(path, data, index, indexPath, header, + headerBytes, isCatalogSelectedCurrent(name)); + long offset = StateArchiveFileFormatV3.PART_HEADER_LENGTH; + while (offset < data.size()) { + if (data.size() - offset < StateArchiveFileFormatV3.FRAME_ENVELOPE_LENGTH) { + if (recovering) { + scanned.tailDamaged = true; + break; + } + throw new IllegalArgumentException("Truncated State Archive frame envelope"); + } + byte[] envelope = readExact(data, offset, + StateArchiveFileFormatV3.FRAME_ENVELOPE_LENGTH); + ByteBuffer fields = ByteBuffer.wrap(envelope); + if (fields.getInt(0) != StateArchiveFileFormatV3.FRAME_MAGIC) { + if (recovering) { + scanned.tailDamaged = true; + break; + } + throw new IllegalArgumentException("State Archive frame magic mismatch"); + } + short frameType = fields.getShort(8); + long totalLength = fields.getLong(16); + if (totalLength <= 0 || totalLength > Integer.MAX_VALUE + || totalLength > data.size() - offset) { + if (recovering) { + scanned.tailDamaged = true; + break; + } + throw new IllegalArgumentException("Truncated State Archive frame"); + } + byte[] frame = readExact(data, offset, (int) totalLength); + if (frameType == StateArchiveFileFormatV3.BLOCK_FRAME_TYPE) { + scanned.addBlock(offset, frame); + byte[] duplicate = bundles.computeIfAbsent(blockNumber(frame), + ignored -> new HashMap<>()).put(name.laneId, frame); + if (duplicate != null) { + throw new IllegalArgumentException("Duplicate State Archive lane block frame"); + } + } else if (frameType == StateArchiveFileFormatV3.DURABLE_MARKER_FRAME_TYPE) { + try { + scanned.addMarker(offset, + StateArchiveSegmentFormatV3.decodeDurableMarker(frame), frame); + } catch (IllegalArgumentException invalidMarker) { + if (!recovering) { + throw invalidMarker; + } + scanned.tailDamaged = true; + break; + } + } else if (frameType == StateArchiveFileFormatV3.PART_SEAL_FRAME_TYPE) { + if (offset + totalLength != data.size()) { + throw new IllegalArgumentException("State Archive seal has trailing bytes"); + } + try { + scanned.setSeal(StateArchiveSegmentFormatV3.decodeSeal(frame)); + } catch (IllegalArgumentException invalidSeal) { + if (!recovering) { + throw invalidSeal; + } + scanned.tailDamaged = true; + break; + } + } else { + if (recovering) { + scanned.tailDamaged = true; + break; + } + throw new IllegalArgumentException("Unknown State Archive frame type"); + } + offset += totalLength; + } + scanned.finish(recovering); + return scanned; + } catch (IOException | RuntimeException failure) { + data.close(); + if (index != null) { + index.close(); + } + throw failure; + } + } + + private long rebuildBundleHead(Map> bundles, + Long recoveryBoundary) { + long commonHead = -1; + for (Map.Entry> entry : bundles.entrySet()) { + if (recoveryBoundary != null && entry.getKey() > recoveryBoundary) { + break; + } + List frames = new ArrayList<>(); + for (int laneId : StateArchiveFileFormatV3.fiveLaneIds()) { + byte[] frame = entry.getValue().get(laneId); + if (frame == null) { + if (recoveryBoundary != null) { + return commonHead; + } + throw new IllegalArgumentException("Incomplete State Archive five-lane bundle"); + } + frames.add(frame); + } + DecodedBundle decoded; + try { + decoded = codec.decode(frames); + validateNext(decoded.getDiff().getMeta(), frames.get(0)); + } catch (IllegalArgumentException invalidBundle) { + if (recoveryBoundary != null) { + return commonHead; + } + throw invalidBundle; + } + appendHead = decoded.getDiff().getMeta(); + resultHistoryDigest = decoded.getResultHistoryDigest(); + commonHead = appendHead.getBlockNumber(); + } + return commonHead; + } + + private static void closeScanned(List scannedSegments) + throws IOException { + IOException failure = null; + for (ScannedSegment scanned : scannedSegments) { + try { + scanned.close(); + } catch (IOException closeFailure) { + if (failure == null) { + failure = closeFailure; + } else { + failure.addSuppressed(closeFailure); + } + } + } + if (failure != null) { + throw failure; + } + } + + private static void closeScannedAfterFailure(List scannedSegments, + Throwable failure) { + try { + closeScanned(scannedSegments); + } catch (IOException closeFailure) { + failure.addSuppressed(closeFailure); + } + } + + private void closeAfterFailure(Throwable failure) { + try { + close(); + } catch (IOException closeFailure) { + failure.addSuppressed(closeFailure); + } + } + + private long nextSequence(int laneId) { + long next = 0; + for (SealedSegment segment : sealedSegments) { + if (segment.getLaneId() == laneId) { + next = Math.max(next, segment.getSegmentSeq() + 1); + } + } + return next; + } + + private byte[] previousChainDigest(int laneId, long sequence) { + for (SealedSegment segment : sealedSegments) { + if (segment.getLaneId() == laneId && segment.getSegmentSeq() == sequence) { + return StateArchiveSegmentFormatV3.segmentChainDigest(laneId, sequence, + segment.getSegmentHeaderDigest(), segment.getSegmentContentDigest(), + segment.getSealFrameDigest()); + } + } + throw new IllegalStateException("Missing previous State Archive sealed segment"); + } + + private List listDataFiles(boolean recovering) throws IOException { + if (catalog.isPublished()) { + List selected = new ArrayList<>(); + for (SealedSegment segment : catalog.selected().getSealed()) { + selected.add(dataPath(segment.getLaneId(), segment.getSegmentSeq())); + } + for (CurrentSegment segment : catalog.selected().getCurrent()) { + selected.add(dataPath(segment.getLaneId(), segment.getSegmentSeq())); + Path successor = dataPath(segment.getLaneId(), segment.getSegmentSeq() + 1); + if (Files.isRegularFile(successor)) { + selected.add(successor); + } + } + for (Path path : selected) { + if (!Files.isRegularFile(path)) { + if (!recovering) { + throw new IOException("State Archive Catalog selected segment is missing"); + } + } + } + selected.removeIf(path -> !Files.isRegularFile(path)); + selected.sort(Comparator.comparing((Path path) -> parseName(path).laneId) + .thenComparingLong(path -> parseName(path).segmentSeq)); + return selected; + } + try (Stream paths = Files.walk(segmentRoot)) { + List discovered = paths.filter(Files::isRegularFile) + .filter(path -> path.getFileName().toString().endsWith(".dat")) + .sorted(Comparator.comparing((Path path) -> parseName(path).laneId) + .thenComparingLong(path -> parseName(path).segmentSeq)) + .collect(Collectors.toList()); + if (!discovered.isEmpty()) { + throw new IOException("State Archive Catalog CURRENT is missing"); + } + return discovered; + } + } + + private void publishCatalog() throws IOException { + servingSegments = null; + catalog.publish(rotationTargetBytes, getCurrentSegments(), getSealedSegments()); + structuralChanged = false; + } + + private boolean isCatalogSelectedCurrent(ParsedName name) { + return catalog.isPublished() && catalog.selected().getCurrent().stream().anyMatch(segment -> + segment.getLaneId() == name.laneId && segment.getSegmentSeq() == name.segmentSeq); + } + + private void validateCatalogSelection() throws IOException { + if (!catalog.isPublished()) { + if (!lanes.isEmpty() || !sealedSegments.isEmpty()) { + throw new IOException("State Archive Catalog selection is missing"); + } + return; + } + List expectedCurrent = catalog.selected().getCurrent(); + List actualCurrent = getCurrentSegments(); + List expectedSealed = catalog.selected().getSealed(); + Map> actualSealed = new HashMap<>(); + for (SealedSegment segment : sealedSegments) { + if (actualSealed.computeIfAbsent(segment.getLaneId(), ignored -> new HashMap<>()) + .put(segment.getSegmentSeq(), segment) != null) { + throw new IOException("State Archive duplicate sealed segment identity"); + } + } + for (SealedSegment expected : expectedSealed) { + SealedSegment actual = actualSealed.getOrDefault(expected.getLaneId(), + Collections.emptyMap()).get(expected.getSegmentSeq()); + if (actual == null || !Arrays.equals(StateArchiveSegmentFormatV3.encodeSealedMapRecord( + expected), StateArchiveSegmentFormatV3.encodeSealedMapRecord(actual))) { + throw new IOException("State Archive Catalog sealed segment identity mismatch"); + } + } + for (CurrentSegment expected : expectedCurrent) { + CurrentSegment same = actualCurrent.stream().filter(actual -> + actual.getLaneId() == expected.getLaneId() + && actual.getSegmentSeq() == expected.getSegmentSeq()).findFirst().orElse(null); + if (same != null) { + if (same.getFirstBlock() != expected.getFirstBlock() + || !Arrays.equals(same.getHeaderDigest(), expected.getHeaderDigest())) { + throw new IOException("State Archive Catalog current segment identity mismatch"); + } + continue; + } + SealedSegment promoted = sealedSegments.stream().filter(actual -> + actual.getLaneId() == expected.getLaneId() + && actual.getSegmentSeq() == expected.getSegmentSeq()).findFirst().orElse(null); + if (promoted == null + || !Arrays.equals(promoted.getSegmentHeaderDigest(), expected.getHeaderDigest())) { + throw new IOException("State Archive Catalog selected current segment disappeared"); + } + CurrentSegment successor = actualCurrent.stream().filter(actual -> + actual.getLaneId() == expected.getLaneId() + && actual.getSegmentSeq() == expected.getSegmentSeq() + 1) + .findFirst().orElse(null); + if (successor != null && successor.getFirstBlock() != promoted.getLastBlock() + 1) { + throw new IOException("State Archive Catalog rotation successor is discontinuous"); + } + } + if (actualCurrent.size() == StateArchiveFileFormatV3.fiveLaneIds().length + && sealedSegments.size() > expectedSealed.size()) { + structuralChanged = true; + publishCatalog(); + } + } + + private boolean hasLaneIndexFile(int laneId) throws IOException { + String prefix = String.format("lane-%04d-seg-", laneId); + try (Stream paths = Files.walk(segmentRoot)) { + return paths.filter(Files::isRegularFile) + .map(path -> path.getFileName().toString()) + .anyMatch(name -> name.startsWith(prefix) && name.endsWith(".bidx")); + } + } + + private Path dataPath(int laneId, long sequence) { + return segmentRoot.resolve(String.format("shard-%06d", + sequence / StateArchiveFileFormatV3.SHARD_MAX_SEGMENTS)) + .resolve(String.format("lane-%04d-seg-%020d.dat", laneId, sequence)); + } + + private Path indexPath(int laneId, long sequence) { + String name = dataPath(laneId, sequence).getFileName().toString(); + return dataPath(laneId, sequence).resolveSibling( + name.substring(0, name.length() - 4) + ".bidx"); + } + + private Path manifestPath(int laneId, long sequence) { + String name = dataPath(laneId, sequence).getFileName().toString(); + return dataPath(laneId, sequence).resolveSibling( + name.substring(0, name.length() - 4) + ".manifest"); + } + + private void publishManifest(int laneId, long sequence, byte[] encoded) throws IOException { + Path target = manifestPath(laneId, sequence); + Path temporary = target.resolveSibling(target.getFileName() + ".tmp"); + try (FileChannel channel = FileChannel.open(temporary, StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE)) { + writeFully(channel, ByteBuffer.wrap(encoded)); + channel.force(true); + } + try { + Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException unsupported) { + throw new IOException("State Archive manifest requires atomic publication", unsupported); + } + syncDirectory(target.getParent()); + } + + private static ParsedName parseName(Path path) { + String name = path.getFileName().toString(); + if (!name.matches("lane-[0-9]{4}-seg-[0-9]{20}\\.dat")) { + throw new IllegalArgumentException("Invalid State Archive segment filename: " + name); + } + try { + return new ParsedName(Integer.parseInt(name.substring(5, 9)), + Long.parseLong(name.substring(14, 34))); + } catch (NumberFormatException failure) { + throw new IllegalArgumentException("Invalid State Archive segment filename: " + name, + failure); + } + } + + private static byte[] previousHistoryDigest(byte[] frame) { + return Arrays.copyOfRange(frame, PREVIOUS_HISTORY_DIGEST_OFFSET, + PREVIOUS_HISTORY_DIGEST_OFFSET + StateArchiveFileFormatV3.HASH_LENGTH); + } + + private static long blockNumber(byte[] frame) { + return ByteBuffer.wrap(frame).getLong(BLOCK_NUMBER_OFFSET); + } + + private static byte[] encodedFrameDigest(byte[] frame) { + return Arrays.copyOfRange(frame, frame.length - ENCODED_DIGEST_FROM_END, + frame.length - ENCODED_DIGEST_FROM_END + StateArchiveFileFormatV3.HASH_LENGTH); + } + + private static MessageDigest newContentDigest(byte[] header) { + MessageDigest digest = sha256(); + digest.update(StateArchiveFileFormatV3.SEGMENT_CONTENT_DOMAIN); + digest.update(header); + return digest; + } + + private static MessageDigest sha256() { + try { + return MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException impossible) { + throw new IllegalStateException("SHA-256 is unavailable", impossible); + } + } + + private static byte[] readExact(FileChannel channel, long offset, int length) + throws IOException { + ByteBuffer bytes = ByteBuffer.allocate(length); + channel.position(offset); + while (bytes.hasRemaining()) { + if (channel.read(bytes) < 0) { + throw new IOException("Unexpected end of State Archive file"); + } + } + return bytes.array(); + } + + private static byte[] digestFilePrefix(byte[] domain, FileChannel channel, long length) + throws IOException { + if (length < 0 || length > channel.size()) { + throw new IllegalArgumentException("Invalid State Archive digest prefix length"); + } + MessageDigest digest = sha256(); + digest.update(domain); + ByteBuffer bytes = ByteBuffer.allocateDirect(APPEND_BUFFER_BYTES); + channel.position(0); + long remaining = length; + while (remaining > 0) { + bytes.clear(); + bytes.limit((int) Math.min(bytes.capacity(), remaining)); + int read = channel.read(bytes); + if (read < 0) { + throw new IOException("Unexpected end of State Archive digest prefix"); + } + bytes.flip(); + digest.update(bytes); + remaining -= read; + } + return digest.digest(); + } + + private static void writeFully(FileChannel channel, ByteBuffer bytes) throws IOException { + while (bytes.hasRemaining()) { + channel.write(bytes); + } + } + + private static void writeBuffered(FileChannel channel, byte[] bytes, ByteBuffer staging) + throws IOException { + int offset = 0; + while (offset < bytes.length) { + staging.clear(); + int length = Math.min(staging.remaining(), bytes.length - offset); + staging.put(bytes, offset, length); + staging.flip(); + writeFully(channel, staging); + offset += length; + } + } + + private static void syncDirectory(Path directory) throws IOException { + try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) { + channel.force(true); + } + } + + private static void publishRecoveredManifest(Path target, byte[] encoded) throws IOException { + Path temporary = target.resolveSibling(target.getFileName() + ".tmp"); + try (FileChannel channel = FileChannel.open(temporary, StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE)) { + writeFully(channel, ByteBuffer.wrap(encoded)); + channel.force(true); + } + try { + Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException unsupported) { + throw new IOException("State Archive recovered manifest requires atomic move", unsupported); + } + syncDirectory(target.getParent()); + } + + private static byte[] requireHash(byte[] value, String name) { + if (value == null || value.length != StateArchiveFileFormatV3.HASH_LENGTH) { + throw new IllegalArgumentException("Invalid State Archive " + name + " length"); + } + return Arrays.copyOf(value, value.length); + } + + private static void requireCompression(short value) { + if (value != StateArchiveFileFormatV3.COMPRESSION_NONE + && value != StateArchiveFileFormatV3.COMPRESSION_RAW_DEFLATE_LEVEL_1) { + throw new IllegalArgumentException("Unknown State Archive compression ID"); + } + } + + private void requireUsable() { + if (failed) { + throw new IllegalStateException("State Archive writer requires reopen after append failure"); + } + } + + @Override + public synchronized void close() throws IOException { + IOException failure = null; + for (LaneState state : lanes.values()) { + try { + state.close(); + } catch (IOException closeFailure) { + if (failure == null) { + failure = closeFailure; + } else { + failure.addSuppressed(closeFailure); + } + } + } + lanes.clear(); + if (failure != null) { + throw failure; + } + } + + private static final class ParsedName { + private final int laneId; + private final long segmentSeq; + + private ParsedName(int laneId, long segmentSeq) { + StateArchiveFileFormatV3.laneKind(laneId); + this.laneId = laneId; + this.segmentSeq = segmentSeq; + } + } + + private static final class RecoveryRequest { + private final RecoveryPoint authorizedCeiling; + private final RecoveryPoint commonCommitted; + private final long prototypeBoundary; + private final boolean prototype; + + private RecoveryRequest(RecoveryPoint authorizedCeiling, + RecoveryPoint commonCommitted) { + this.authorizedCeiling = Objects.requireNonNull(authorizedCeiling, + "authorizedCeiling"); + this.commonCommitted = commonCommitted; + this.prototypeBoundary = authorizedCeiling.getBlockNumber(); + this.prototype = false; + } + + private RecoveryRequest(long prototypeBoundary) { + this.authorizedCeiling = null; + this.commonCommitted = null; + this.prototypeBoundary = prototypeBoundary; + this.prototype = true; + } + + private static RecoveryRequest prototype(long boundary) { + return new RecoveryRequest(boundary); + } + + private long authorizedBlock() { + return prototypeBoundary; + } + } + + enum RecoveryStage { + TEMPORARY_FORCED, + INTENT_PUBLISHED, + LANE_DATA_APPLIED, + LANE_INDEX_APPLIED, + TARGET_VERIFIED, + INTENT_DELETED + } + + @FunctionalInterface + interface RecoveryFaultHook { + RecoveryFaultHook NONE = (stage, laneId) -> { }; + + void after(RecoveryStage stage, int laneId) throws IOException; + } + + enum SyncStage { + MARKER_WRITTEN, + DATA_FORCED, + MARKER_VERIFIED, + PROOF_READY + } + + @FunctionalInterface + interface SyncFaultHook { + SyncFaultHook NONE = (stage, laneId) -> { }; + + void after(SyncStage stage, int laneId) throws IOException; + } + + private static final class MarkerWrite { + private final LaneState state; + private final long offset; + private final byte[] encoded; + private final DurableMarker marker; + + private MarkerWrite(LaneState state, long offset, byte[] encoded, + DurableMarker marker) { + this.state = state; + this.offset = offset; + this.encoded = encoded; + this.marker = marker; + } + } + + private static final class ScannedMarker { + private final long offset; + private final DurableMarker marker; + + private ScannedMarker(long offset, DurableMarker marker) { + this.offset = offset; + this.marker = marker; + } + } + + public static final class FileTailProof { + private final int laneId; + private final long segmentSeq; + private final long markerOffset; + private final int markerLength; + private final long markerEndOffset; + private final byte[] markerDigest; + + FileTailProof(int laneId, long segmentSeq, long markerOffset, + int markerLength, long markerEndOffset, byte[] markerDigest) { + StateArchiveFileFormatV3.laneKind(laneId); + if (segmentSeq < 0 || markerOffset < StateArchiveFileFormatV3.PART_HEADER_LENGTH + || markerLength != StateArchiveFileFormatV3.MARKER_HEADER_LENGTH + + StateArchiveFileFormatV3.FRAME_TRAILER_LENGTH + || markerEndOffset != markerOffset + markerLength) { + throw new IllegalArgumentException("Invalid State Archive file tail proof"); + } + this.laneId = laneId; + this.segmentSeq = segmentSeq; + this.markerOffset = markerOffset; + this.markerLength = markerLength; + this.markerEndOffset = markerEndOffset; + this.markerDigest = requireHash(markerDigest, "marker digest"); + } + + public int getLaneId() { + return laneId; + } + + public long getSegmentSeq() { + return segmentSeq; + } + + public long getMarkerOffset() { + return markerOffset; + } + + public int getMarkerLength() { + return markerLength; + } + + public long getMarkerEndOffset() { + return markerEndOffset; + } + + public byte[] getMarkerDigest() { + return Arrays.copyOf(markerDigest, markerDigest.length); + } + } + + public static final class ArchiveDurabilityProof { + private final long checkpointSequence; + private final RecoveryPoint target; + private final byte[] commonTargetDigest; + private final List fileTails; + private final byte[] formatIdentity; + private final byte[] descriptorDigest; + + ArchiveDurabilityProof(long checkpointSequence, RecoveryPoint target, + byte[] commonTargetDigest, List fileTails) { + if (checkpointSequence < 0) { + throw new IllegalArgumentException("Invalid State Archive proof sequence"); + } + this.checkpointSequence = checkpointSequence; + this.target = Objects.requireNonNull(target, "target"); + this.commonTargetDigest = requireHash(commonTargetDigest, "proof Common target digest"); + this.fileTails = Collections.unmodifiableList(new ArrayList<>(fileTails)); + this.formatIdentity = StateArchiveFileFormatV3.compositeFormatDigest(); + this.descriptorDigest = StateArchiveFileFormatV3.fiveLaneDescriptorDigest(); + int[] laneIds = StateArchiveFileFormatV3.fiveLaneIds(); + if (this.fileTails.size() < laneIds.length) { + throw new IllegalArgumentException("Incomplete State Archive durability proof"); + } + int laneIndex = 0; + long previousSegment = -1; + for (FileTailProof tail : this.fileTails) { + if (tail.getLaneId() != laneIds[laneIndex]) { + if (laneIndex + 1 >= laneIds.length + || tail.getLaneId() != laneIds[++laneIndex]) { + throw new IllegalArgumentException("Non-canonical State Archive durability proof"); + } + previousSegment = -1; + } + if (tail.getSegmentSeq() <= previousSegment) { + throw new IllegalArgumentException("Non-canonical State Archive durability proof"); + } + previousSegment = tail.getSegmentSeq(); + } + if (laneIndex != laneIds.length - 1) { + throw new IllegalArgumentException("Incomplete State Archive durability proof"); + } + } + + public long getCheckpointSequence() { + return checkpointSequence; + } + + public RecoveryPoint getTarget() { + return target; + } + + public byte[] getCommonTargetDigest() { + return Arrays.copyOf(commonTargetDigest, commonTargetDigest.length); + } + + public List getFileTails() { + return fileTails; + } + + public byte[] getFormatIdentity() { + return Arrays.copyOf(formatIdentity, formatIdentity.length); + } + + public byte[] getDescriptorDigest() { + return Arrays.copyOf(descriptorDigest, descriptorDigest.length); + } + } + + private static final class LaneState { + private final int laneId; + private final long segmentSeq; + private final long firstBlock; + private final byte[] headerDigest; + private final byte[] startHistoryDigest; + private final FileChannel data; + private final FileChannel index; + private final MessageDigest contentDigest; + private final ByteBuffer appendBuffer = ByteBuffer.allocateDirect(APPEND_BUFFER_BYTES); + private long lastBlock; + private long blockFrameCount; + private long entryCount; + private long logicalPayloadBytes; + private long encodedBlockFrameBytes; + private long dataEndOffset = StateArchiveFileFormatV3.PART_HEADER_LENGTH; + private byte[] firstFrameDigest; + private byte[] lastFrameDigest; + private byte[] endHistoryDigest; + private BlockSnapshotMeta lastMeta; + private long markedBlockFrameCount; + private long markedLogicalBytes; + private long markedEncodedBytes; + private long lastMarkerEndOffset = StateArchiveFileFormatV3.PART_HEADER_LENGTH; + private long lastMarkerOffset = -1; + private byte[] previousMarkerDigest = new byte[StateArchiveFileFormatV3.HASH_LENGTH]; + private byte[] lastCommonTargetDigest; + private long lastCheckpointSequence = -1; + + private LaneState(int laneId, long segmentSeq, long firstBlock, byte[] headerDigest, + byte[] startHistoryDigest, FileChannel data, FileChannel index, + MessageDigest contentDigest) { + this.laneId = laneId; + this.segmentSeq = segmentSeq; + this.firstBlock = firstBlock; + this.lastBlock = firstBlock - 1; + this.headerDigest = headerDigest; + this.startHistoryDigest = startHistoryDigest; + this.data = data; + this.index = index; + this.contentDigest = contentDigest; + } + + private void record(BlockSnapshotMeta meta, byte[] frame, byte[] digest) { + long blockNumber = meta.getBlockNumber(); + if (blockFrameCount > 0 && blockNumber != lastBlock + 1) { + throw new IllegalArgumentException("Non-contiguous State Archive lane block"); + } + if (firstFrameDigest == null) { + firstFrameDigest = Arrays.copyOf(digest, digest.length); + } + lastFrameDigest = Arrays.copyOf(digest, digest.length); + lastBlock = blockNumber; + blockFrameCount++; + entryCount += ByteBuffer.wrap(frame).getLong(ENTRY_COUNT_OFFSET); + logicalPayloadBytes += ByteBuffer.wrap(frame).getLong(RAW_PAYLOAD_LENGTH_OFFSET); + encodedBlockFrameBytes += frame.length; + dataEndOffset += frame.length; + endHistoryDigest = Arrays.copyOfRange(frame, RESULT_HISTORY_DIGEST_OFFSET, + RESULT_HISTORY_DIGEST_OFFSET + StateArchiveFileFormatV3.HASH_LENGTH); + lastMeta = meta; + } + + private CurrentSegment currentMap() { + return new CurrentSegment(laneId, segmentSeq, firstBlock, lastBlock, + dataEndOffset, blockFrameCount, headerDigest); + } + + private void close() throws IOException { + data.close(); + index.close(); + } + } + + private static final class ScannedSegment { + private final Path dataPath; + private final FileChannel data; + private final FileChannel index; + private final Path indexPath; + private final SegmentHeader header; + private final boolean catalogSelectedCurrent; + private final MessageDigest contentDigest; + private final List expectedIndex = new ArrayList<>(); + private long firstBlock = -1; + private long lastBlock = -1; + private long count; + private long entryCount; + private long logicalBytes; + private long encodedBytes; + private long dataEnd = StateArchiveFileFormatV3.PART_HEADER_LENGTH; + private byte[] firstDigest; + private byte[] lastDigest; + private byte[] startHistory; + private byte[] endHistory; + private byte[] content; + private SegmentSeal seal; + private byte[] manifestDigest; + private long markedCount; + private long markedLogicalBytes; + private long markedEncodedBytes; + private long lastMarkerEndOffset = StateArchiveFileFormatV3.PART_HEADER_LENGTH; + private long lastCheckpointSequence = -1; + private byte[] previousMarkerDigest = new byte[StateArchiveFileFormatV3.HASH_LENGTH]; + private ScannedMarker lastMarker; + private boolean tailDamaged; + + private ScannedSegment(Path dataPath, FileChannel data, FileChannel index, + Path indexPath, SegmentHeader header, byte[] headerBytes, + boolean catalogSelectedCurrent) { + this.dataPath = dataPath; + this.data = data; + this.index = index; + this.indexPath = indexPath; + this.header = header; + this.catalogSelectedCurrent = catalogSelectedCurrent; + this.contentDigest = newContentDigest(headerBytes); + } + + private void addBlock(long offset, byte[] frame) { + if (ByteBuffer.wrap(frame).getShort(COMPRESSION_ID_OFFSET) + != header.getCompressionId()) { + throw new IllegalArgumentException("State Archive segment frame compression mismatch"); + } + long number = blockNumber(frame); + byte[] digest = encodedFrameDigest(frame); + if (count == 0) { + firstBlock = number; + startHistory = previousHistoryDigest(frame); + firstDigest = digest; + } else if (number != lastBlock + 1) { + throw new IllegalArgumentException("Non-contiguous State Archive segment block"); + } + lastBlock = number; + lastDigest = digest; + endHistory = Arrays.copyOfRange(frame, RESULT_HISTORY_DIGEST_OFFSET, + RESULT_HISTORY_DIGEST_OFFSET + StateArchiveFileFormatV3.HASH_LENGTH); + count++; + entryCount += ByteBuffer.wrap(frame).getLong(ENTRY_COUNT_OFFSET); + logicalBytes += ByteBuffer.wrap(frame).getLong(RAW_PAYLOAD_LENGTH_OFFSET); + encodedBytes += frame.length; + dataEnd = offset + frame.length; + contentDigest.update(frame); + expectedIndex.add(new BlockIndexEntry(number, offset, frame.length, + ByteBuffer.wrap(digest).getLong())); + } + + private void addMarker(long offset, DurableMarker marker, byte[] frame) { + long markerBlocks = count - markedCount; + long firstMarkedBlock = markerBlocks == 0 ? -1 : lastBlock - markerBlocks + 1; + if (marker.getLaneId() != header.getLaneId() + || marker.getSegmentSeq() != header.getSegmentSeq() + || marker.getCheckpointSequence() <= lastCheckpointSequence + || markerBlocks <= 0 || marker.getBlockCount() != markerBlocks + || marker.getFirstBlock() != firstMarkedBlock + || marker.getLastBlock() != lastBlock + || marker.getCoveredStartOffset() != lastMarkerEndOffset + || marker.getMarkerEndOffset() != offset + frame.length + || marker.getLogicalBytes() != logicalBytes - markedLogicalBytes + || marker.getEncodedBytes() != encodedBytes - markedEncodedBytes + || !Arrays.equals(marker.getResultHistoryDigest(), endHistory) + || !Arrays.equals(marker.getPreviousMarkerDigest(), previousMarkerDigest)) { + throw new IllegalArgumentException("State Archive durable marker range mismatch"); + } + contentDigest.update(frame); + dataEnd = offset + frame.length; + markedCount = count; + markedLogicalBytes = logicalBytes; + markedEncodedBytes = encodedBytes; + lastMarkerEndOffset = marker.getMarkerEndOffset(); + lastCheckpointSequence = marker.getCheckpointSequence(); + previousMarkerDigest = marker.getEncodedFrameDigest(); + lastMarker = new ScannedMarker(offset, marker); + } + + private void setSeal(SegmentSeal value) { + if (seal != null) { + throw new IllegalArgumentException("Duplicate State Archive segment seal"); + } + seal = value; + } + + private void finish(boolean recovering) throws IOException { + if (count == 0) { + if (recovering && seal == null) { + return; + } + throw new IllegalArgumentException("Empty State Archive segment"); + } + if (header.getActualFirstBlock() != firstBlock + || !Arrays.equals(header.getPreviousHistoryDigest(), startHistory)) { + throw new IllegalArgumentException("State Archive segment first identity mismatch"); + } + if (seal != null || !recovering) { + validateIndex(); + } + if (seal != null) { + content = contentDigest.digest(); + if (seal.getLaneId() != header.getLaneId() + || seal.getSegmentSeq() != header.getSegmentSeq() + || seal.getActualFirstBlock() != firstBlock + || seal.getActualLastBlock() != lastBlock + || seal.getBlockFrameCount() != count || seal.getEntryCount() != entryCount + || seal.getLogicalPayloadBytes() != logicalBytes + || seal.getEncodedBlockFrameBytes() != encodedBytes + || seal.getDataEndOffset() != dataEnd + || seal.getPhysicalFileBytes() != data.size() + || !Arrays.equals(seal.getFirstBlockFrameDigest(), firstDigest) + || !Arrays.equals(seal.getLastBlockFrameDigest(), lastDigest) + || !Arrays.equals(seal.getStartHistoryDigest(), startHistory) + || !Arrays.equals(seal.getEndHistoryDigest(), endHistory) + || !Arrays.equals(seal.getSegmentContentDigest(), content)) { + throw new IllegalArgumentException("State Archive segment seal mismatch"); + } + Path manifestPath = dataPath.resolveSibling( + dataPath.getFileName().toString().replace(".dat", ".manifest")); + if (!Files.isRegularFile(manifestPath)) { + if (!catalogSelectedCurrent) { + throw new IllegalArgumentException("State Archive sealed manifest is missing"); + } + SegmentManifest recovered = new SegmentManifest(header.getLaneId(), + header.getSegmentSeq(), firstBlock, lastBlock, count, entryCount, logicalBytes, + encodedBytes, data.size(), StateArchiveFileFormatV3.BLOCK_INDEX_HEADER_LENGTH + + count * StateArchiveFileFormatV3.BLOCK_INDEX_ENTRY_LENGTH, + header.getPreviousSegmentDigest(), endHistory); + publishRecoveredManifest(manifestPath, + StateArchiveSegmentFormatV3.encodeManifest(recovered)); + } + SegmentManifest manifest = StateArchiveSegmentFormatV3.decodeManifest( + Files.readAllBytes(manifestPath)); + long indexBytes = StateArchiveFileFormatV3.BLOCK_INDEX_HEADER_LENGTH + + count * StateArchiveFileFormatV3.BLOCK_INDEX_ENTRY_LENGTH; + if (manifest.getLaneId() != header.getLaneId() + || manifest.getSegmentSeq() != header.getSegmentSeq() + || manifest.getFirstBlock() != firstBlock || manifest.getLastBlock() != lastBlock + || manifest.getBlockFrameCount() != count || manifest.getEntryCount() != entryCount + || manifest.getLogicalPayloadBytes() != logicalBytes + || manifest.getEncodedBlockFrameBytes() != encodedBytes + || manifest.getDataFileBytes() != data.size() + || manifest.getBlockIndexBytes() != indexBytes + || !Arrays.equals(manifest.getPreviousSegmentDigest(), + header.getPreviousSegmentDigest()) + || !Arrays.equals(manifest.getFinalHistoryDigest(), endHistory)) { + throw new IllegalArgumentException("State Archive sealed manifest identity mismatch"); + } + manifestDigest = manifest.getManifestDigest(); + data.close(); + index.close(); + } + } + + private void validateIndex() throws IOException { + long expectedLength = StateArchiveFileFormatV3.BLOCK_INDEX_HEADER_LENGTH + + count * StateArchiveFileFormatV3.BLOCK_INDEX_ENTRY_LENGTH; + if (index == null || index.size() != expectedLength) { + throw new IllegalArgumentException("State Archive block index length mismatch"); + } + for (int position = 0; position < expectedIndex.size(); position++) { + BlockIndexEntry actual = StateArchiveSegmentFormatV3.decodeBlockIndexEntry( + readExact(index, StateArchiveFileFormatV3.BLOCK_INDEX_HEADER_LENGTH + + (long) position * StateArchiveFileFormatV3.BLOCK_INDEX_ENTRY_LENGTH, + StateArchiveFileFormatV3.BLOCK_INDEX_ENTRY_LENGTH)); + BlockIndexEntry expected = expectedIndex.get(position); + if (actual.getBlockNumber() != expected.getBlockNumber() + || actual.getFrameOffset() != expected.getFrameOffset() + || actual.getFrameLength() != expected.getFrameLength() + || actual.getEncodedFrameDigestPrefix() + != expected.getEncodedFrameDigestPrefix()) { + throw new IllegalArgumentException("State Archive block index entry mismatch"); + } + } + } + + private LaneTarget laneTarget(long commonHead) throws IOException { + int keepCount = retainedCount(commonHead); + long originalDataEnd = data.size(); + long originalIndexEnd = index == null ? 0 : index.size(); + if (keepCount == 0) { + return new LaneTarget(header.getLaneId(), + StateArchiveFiveLaneRecoveryIntentV3.DELETE_PAIR + | (index == null + ? StateArchiveFiveLaneRecoveryIntentV3.ORIGINAL_INDEX_MISSING : 0), + header.getSegmentSeq(), originalDataEnd, originalIndexEnd, + StateArchiveFiveLaneRecoveryIntentV3.NO_TARGET_SEGMENT, 0, 0, + header.getHeaderDigest(), new byte[32], new byte[32]); + } + long targetDataEnd = expectedIndex.get(keepCount - 1).getFrameOffset() + + expectedIndex.get(keepCount - 1).getFrameLength(); + byte[] targetIndex = targetIndexBytes(keepCount); + int flags = targetDataEnd < originalDataEnd + ? StateArchiveFiveLaneRecoveryIntentV3.DATA_TRUNCATE : 0; + if (index == null) { + flags |= StateArchiveFiveLaneRecoveryIntentV3.INDEX_REPLACE + | StateArchiveFiveLaneRecoveryIntentV3.ORIGINAL_INDEX_MISSING; + } else if (index.size() != targetIndex.length + || !Arrays.equals(digestFilePrefix( + StateArchiveFileFormatV3.RECOVERY_INDEX_FILE_DOMAIN, index, index.size()), + StateArchiveFiveLaneRecoveryIntentV3.indexFileDigest(targetIndex))) { + flags |= StateArchiveFiveLaneRecoveryIntentV3.INDEX_REPLACE; + } + return new LaneTarget(header.getLaneId(), flags, header.getSegmentSeq(), + originalDataEnd, originalIndexEnd, header.getSegmentSeq(), targetDataEnd, + targetIndex.length, header.getHeaderDigest(), + digestFilePrefix(StateArchiveFileFormatV3.RECOVERY_DATA_PREFIX_DOMAIN, + data, targetDataEnd), + StateArchiveFiveLaneRecoveryIntentV3.indexFileDigest(targetIndex)); + } + + private int retainedCount(long commonHead) { + int keepCount = 0; + while (keepCount < expectedIndex.size() + && expectedIndex.get(keepCount).getBlockNumber() <= commonHead) { + keepCount++; + } + return keepCount; + } + + private byte[] targetIndexBytes(int keepCount) { + ByteBuffer bytes = ByteBuffer.allocate(StateArchiveFileFormatV3.BLOCK_INDEX_HEADER_LENGTH + + keepCount * StateArchiveFileFormatV3.BLOCK_INDEX_ENTRY_LENGTH); + bytes.put(StateArchiveSegmentFormatV3.encodeBlockIndexHeader( + new BlockIndexHeader(header.getLaneId(), header.getSegmentSeq(), + header.getHeaderDigest()))); + for (int position = 0; position < keepCount; position++) { + bytes.put(StateArchiveSegmentFormatV3.encodeBlockIndexEntry( + expectedIndex.get(position))); + } + return bytes.array(); + } + + private void repairTo(long commonHead, RecoveryFaultHook faultHook) throws IOException { + int keepCount = retainedCount(commonHead); + if (keepCount == 0) { + close(); + Files.deleteIfExists(indexPath); + faultHook.after(RecoveryStage.LANE_INDEX_APPLIED, header.getLaneId()); + Files.deleteIfExists(dataPath); + syncDirectory(dataPath.getParent()); + faultHook.after(RecoveryStage.LANE_DATA_APPLIED, header.getLaneId()); + return; + } + long keepDataEnd = keepCount == 0 ? StateArchiveFileFormatV3.PART_HEADER_LENGTH + : expectedIndex.get(keepCount - 1).getFrameOffset() + + expectedIndex.get(keepCount - 1).getFrameLength(); + if (data.size() != keepDataEnd || tailDamaged) { + data.truncate(keepDataEnd); + data.force(false); + faultHook.after(RecoveryStage.LANE_DATA_APPLIED, header.getLaneId()); + } + boolean rewrite = index == null + || index.size() != StateArchiveFileFormatV3.BLOCK_INDEX_HEADER_LENGTH + + (long) keepCount * StateArchiveFileFormatV3.BLOCK_INDEX_ENTRY_LENGTH; + if (!rewrite && index.size() >= StateArchiveFileFormatV3.BLOCK_INDEX_HEADER_LENGTH) { + try { + StateArchiveSegmentFormatV3.decodeBlockIndexHeader(readExact(index, 0, + StateArchiveFileFormatV3.BLOCK_INDEX_HEADER_LENGTH)); + for (int position = 0; position < keepCount; position++) { + BlockIndexEntry actual = StateArchiveSegmentFormatV3.decodeBlockIndexEntry( + readExact(index, StateArchiveFileFormatV3.BLOCK_INDEX_HEADER_LENGTH + + (long) position * StateArchiveFileFormatV3.BLOCK_INDEX_ENTRY_LENGTH, + StateArchiveFileFormatV3.BLOCK_INDEX_ENTRY_LENGTH)); + BlockIndexEntry expected = expectedIndex.get(position); + if (!sameEntry(actual, expected)) { + rewrite = true; + break; + } + } + } catch (IllegalArgumentException invalidIndex) { + rewrite = true; + } + } + if (rewrite) { + byte[] targetBytes = targetIndexBytes(keepCount); + Path temporary = indexPath.resolveSibling(indexPath.getFileName() + ".recovery.tmp"); + try (FileChannel temporaryIndex = FileChannel.open(temporary, + StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, + StandardOpenOption.WRITE)) { + writeFully(temporaryIndex, ByteBuffer.wrap(targetBytes)); + temporaryIndex.force(true); + } + if (index != null && index.isOpen()) { + index.close(); + } + try { + Files.move(temporary, indexPath, StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException unsupported) { + throw new IOException("State Archive index repair requires atomic move", unsupported); + } + syncDirectory(indexPath.getParent()); + faultHook.after(RecoveryStage.LANE_INDEX_APPLIED, header.getLaneId()); + } + } + + private static boolean sameEntry(BlockIndexEntry left, BlockIndexEntry right) { + return left.getBlockNumber() == right.getBlockNumber() + && left.getFrameOffset() == right.getFrameOffset() + && left.getFrameLength() == right.getFrameLength() + && left.getEncodedFrameDigestPrefix() == right.getEncodedFrameDigestPrefix(); + } + + private void close() throws IOException { + if (data.isOpen()) { + data.close(); + } + if (index != null && index.isOpen()) { + index.close(); + } + } + + private LaneState openState() { + LaneState state = new LaneState(header.getLaneId(), header.getSegmentSeq(), firstBlock, + header.getHeaderDigest(), startHistory, data, index, contentDigest); + state.lastBlock = lastBlock; + state.blockFrameCount = count; + state.entryCount = entryCount; + state.logicalPayloadBytes = logicalBytes; + state.encodedBlockFrameBytes = encodedBytes; + state.dataEndOffset = dataEnd; + state.firstFrameDigest = firstDigest; + state.lastFrameDigest = lastDigest; + state.endHistoryDigest = endHistory; + state.markedBlockFrameCount = markedCount; + state.markedLogicalBytes = markedLogicalBytes; + state.markedEncodedBytes = markedEncodedBytes; + state.lastMarkerEndOffset = lastMarkerEndOffset; + state.lastMarkerOffset = lastMarker == null ? -1 : lastMarker.offset; + state.previousMarkerDigest = previousMarkerDigest; + state.lastCheckpointSequence = lastCheckpointSequence; + state.lastCommonTargetDigest = lastMarker == null ? null + : lastMarker.marker.getCommonTargetDigest(); + return state; + } + + private byte[] chainDigest() { + return StateArchiveSegmentFormatV3.segmentChainDigest(header.getLaneId(), + header.getSegmentSeq(), header.getHeaderDigest(), content, + seal.getEncodedFrameDigest()); + } + + private SealedSegment sealedMap() { + return new SealedSegment(header.getLaneId(), header.getSegmentSeq(), firstBlock, + lastBlock, count, seal.getPhysicalFileBytes(), + StateArchiveFileFormatV3.BLOCK_INDEX_HEADER_LENGTH + + count * StateArchiveFileFormatV3.BLOCK_INDEX_ENTRY_LENGTH, + header.getHeaderDigest(), content, + seal.getEncodedFrameDigest(), manifestDigest); + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/StateArchiveHistoryCatalogV3.java b/chainbase/src/main/java/org/tron/core/db2/archive/StateArchiveHistoryCatalogV3.java new file mode 100644 index 00000000000..a0a8bbcf8e8 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/StateArchiveHistoryCatalogV3.java @@ -0,0 +1,439 @@ +package org.tron.core.db2.archive; + +import com.google.common.hash.Hashing; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; +import org.tron.core.db2.archive.StateArchiveSegmentFormatV3.CurrentSegment; +import org.tron.core.db2.archive.StateArchiveSegmentFormatV3.SealedSegment; + +/** Atomic structural catalog for the five-lane append-file history. */ +final class StateArchiveHistoryCatalogV3 { + + static final String DIRECTORY = "catalog"; + static final String CURRENT = "CURRENT"; + private static final String GENERATIONS = "generations"; + private static final int GENERATION_MAGIC = 0x53434733; // SCG3 + private static final int CURRENT_MAGIC = 0x53435533; // SCU3 + private static final int GENERATION_TRAILER_MAGIC = 0x33474353; // 3GCS + private static final int CURRENT_TRAILER_MAGIC = 0x33554353; // 3UCS + private static final int HEADER_LENGTH = 256; + private static final int CURRENT_RECORD_LENGTH = 112; + private static final int TRAILER_LENGTH = 48; + private static final int CURRENT_LENGTH = 96; + + private final Path root; + private final Path generations; + private Generation selected; + + private StateArchiveHistoryCatalogV3(Path archiveRoot, Generation selected) { + root = archiveRoot.resolve(DIRECTORY); + generations = root.resolve(GENERATIONS); + this.selected = selected; + } + + static StateArchiveHistoryCatalogV3 openOrEmpty(Path archiveRoot) throws IOException { + Path root = archiveRoot.resolve(DIRECTORY); + Path current = root.resolve(CURRENT); + if (!Files.exists(current)) { + return new StateArchiveHistoryCatalogV3(archiveRoot, null); + } + CurrentPointer pointer = decodeCurrent(Files.readAllBytes(current)); + Path generationPath = root.resolve(GENERATIONS).resolve(fileName(pointer.generation)); + if (!Files.isRegularFile(generationPath)) { + throw new IOException("State Archive Catalog selected generation is missing"); + } + byte[] encoded = Files.readAllBytes(generationPath); + Generation generation = decodeGeneration(encoded); + if (generation.generation != pointer.generation + || !Arrays.equals(generation.digest, pointer.digest)) { + throw new IOException("State Archive Catalog CURRENT identity mismatch"); + } + return new StateArchiveHistoryCatalogV3(archiveRoot, generation); + } + + boolean isPublished() { + return selected != null; + } + + Generation selected() { + if (selected == null) { + throw new IllegalStateException("State Archive Catalog has no selected generation"); + } + return selected; + } + + void publish(long segmentTargetBytes, List current, + List sealed) throws IOException { + long generation = selected == null ? 0 : selected.generation + 1; + byte[] previous = selected == null ? new byte[32] : selected.digest; + Generation replacement = new Generation(generation, previous, segmentTargetBytes, + current, sealed, null); + byte[] encoded = encodeGeneration(replacement); + Generation verified = decodeGeneration(encoded); + Files.createDirectories(generations); + Path temporary = generations.resolve(fileName(generation) + ".tmp"); + Path target = generations.resolve(fileName(generation)); + writeForced(temporary, encoded); + if (Files.isRegularFile(target)) { + Generation orphan = decodeGeneration(Files.readAllBytes(target)); + if (!Arrays.equals(orphan.digest, verified.digest)) { + throw new IOException("State Archive Catalog orphan generation identity differs"); + } + Files.delete(temporary); + } else { + atomicMove(temporary, target); + syncDirectory(generations); + } + byte[] currentBytes = encodeCurrent(generation, verified.digest); + Path currentTemporary = root.resolve(CURRENT + ".tmp"); + writeForced(currentTemporary, currentBytes); + atomicMove(currentTemporary, root.resolve(CURRENT)); + syncDirectory(root); + selected = verified; + } + + private static byte[] encodeGeneration(Generation generation) { + generation.validate(); + int totalLength = Math.addExact(HEADER_LENGTH + TRAILER_LENGTH, + Math.addExact(generation.current.size() * CURRENT_RECORD_LENGTH, + generation.sealed.size() * StateArchiveFileFormatV3.SEGMENT_MAP_ENTRY_LENGTH)); + ByteBuffer bytes = ByteBuffer.allocate(totalLength); + bytes.putInt(GENERATION_MAGIC); + bytes.putShort(StateArchiveFileFormatV3.MAJOR_VERSION); + bytes.putShort(StateArchiveFileFormatV3.MINOR_VERSION); + bytes.putInt(HEADER_LENGTH); + bytes.putInt(0); + bytes.putLong(totalLength); + bytes.putLong(generation.generation); + bytes.putLong(generation.segmentTargetBytes); + bytes.putInt(generation.current.size()); + bytes.putInt(generation.sealed.size()); + bytes.putInt(CURRENT_RECORD_LENGTH); + bytes.putInt(StateArchiveFileFormatV3.SEGMENT_MAP_ENTRY_LENGTH); + bytes.put(generation.previousDigest); + bytes.put(StateArchiveFileFormatV3.compositeFormatDigest()); + bytes.put(StateArchiveFileFormatV3.fiveLaneDescriptorDigest()); + bytes.put(laneSetDigest()); + bytes.put(new byte[72]); + for (CurrentSegment segment : generation.current) { + bytes.put(encodeCurrentRecord(segment)); + } + for (SealedSegment segment : generation.sealed) { + bytes.put(StateArchiveSegmentFormatV3.encodeSealedMapRecord(segment)); + } + int digestOffset = totalLength - TRAILER_LENGTH; + if (bytes.position() != digestOffset) { + throw new IllegalStateException("Invalid State Archive Catalog generation layout"); + } + bytes.put(StateArchiveFileFormatV3.sha256(Arrays.copyOf(bytes.array(), digestOffset))); + bytes.putLong(totalLength); + bytes.putInt(crc32c(bytes.array(), 0, totalLength - 8)); + bytes.putInt(GENERATION_TRAILER_MAGIC); + return bytes.array(); + } + + private static Generation decodeGeneration(byte[] encoded) throws IOException { + try { + if (encoded == null || encoded.length < HEADER_LENGTH + TRAILER_LENGTH) { + throw new IllegalArgumentException("Catalog generation is truncated"); + } + ByteBuffer bytes = ByteBuffer.wrap(encoded); + require(bytes.getInt() == GENERATION_MAGIC, "Catalog generation magic mismatch"); + require(bytes.getShort() == StateArchiveFileFormatV3.MAJOR_VERSION, + "Catalog generation major version mismatch"); + require(bytes.getShort() == StateArchiveFileFormatV3.MINOR_VERSION, + "Catalog generation minor version mismatch"); + require(bytes.getInt() == HEADER_LENGTH, "Catalog generation header length mismatch"); + require(bytes.getInt() == 0, "Catalog generation flags mismatch"); + require(bytes.getLong() == encoded.length, "Catalog generation length mismatch"); + long generation = bytes.getLong(); + long segmentTargetBytes = bytes.getLong(); + int currentCount = bytes.getInt(); + int sealedCount = bytes.getInt(); + require(bytes.getInt() == CURRENT_RECORD_LENGTH, + "Catalog current record length mismatch"); + require(bytes.getInt() == StateArchiveFileFormatV3.SEGMENT_MAP_ENTRY_LENGTH, + "Catalog sealed record length mismatch"); + byte[] previous = read(bytes, 32); + require(Arrays.equals(read(bytes, 32), StateArchiveFileFormatV3.compositeFormatDigest()), + "Catalog format identity mismatch"); + require(Arrays.equals(read(bytes, 32), StateArchiveFileFormatV3.fiveLaneDescriptorDigest()), + "Catalog descriptor identity mismatch"); + require(Arrays.equals(read(bytes, 32), laneSetDigest()), + "Catalog lane set identity mismatch"); + requireZero(bytes, 72); + require(generation >= 0 && segmentTargetBytes > StateArchiveFileFormatV3.PART_HEADER_LENGTH + && (currentCount == 0 + || currentCount == StateArchiveFileFormatV3.fiveLaneIds().length) + && sealedCount >= 0, "Catalog generation header is invalid"); + long expectedLength = HEADER_LENGTH + TRAILER_LENGTH + + (long) currentCount * CURRENT_RECORD_LENGTH + + (long) sealedCount * StateArchiveFileFormatV3.SEGMENT_MAP_ENTRY_LENGTH; + require(expectedLength == encoded.length, "Catalog generation record count mismatch"); + List current = new ArrayList<>(); + for (int index = 0; index < currentCount; index++) { + current.add(decodeCurrentRecord(read(bytes, CURRENT_RECORD_LENGTH))); + } + List sealed = new ArrayList<>(); + for (int index = 0; index < sealedCount; index++) { + sealed.add(StateArchiveSegmentFormatV3.decodeSealedMapRecord( + read(bytes, StateArchiveFileFormatV3.SEGMENT_MAP_ENTRY_LENGTH))); + } + int digestOffset = encoded.length - TRAILER_LENGTH; + byte[] digest = read(bytes, 32); + require(Arrays.equals(digest, + StateArchiveFileFormatV3.sha256(Arrays.copyOf(encoded, digestOffset))), + "Catalog generation digest mismatch"); + require(bytes.getLong() == encoded.length, "Catalog repeated length mismatch"); + require(bytes.getInt() == crc32c(encoded, 0, encoded.length - 8), + "Catalog generation checksum mismatch"); + require(bytes.getInt() == GENERATION_TRAILER_MAGIC, + "Catalog generation trailer mismatch"); + return new Generation(generation, previous, segmentTargetBytes, current, sealed, digest); + } catch (IllegalArgumentException invalid) { + throw new IOException("State Archive Catalog generation is corrupt", invalid); + } + } + + private static byte[] encodeCurrentRecord(CurrentSegment segment) { + return ByteBuffer.allocate(CURRENT_RECORD_LENGTH) + .putShort((short) segment.getLaneId()) + .putShort(StateArchiveFileFormatV3.laneKind(segment.getLaneId())) + .putInt(0).putLong(segment.getSegmentSeq()).putLong(segment.getFirstBlock()) + .putLong(segment.getCurrentLastBlock()).putLong(segment.getDataEndOffset()) + .putLong(segment.getBlockFrameCount()).put(segment.getHeaderDigest()) + .put(parentDigest(segment.getLaneId(), segment.getSegmentSeq())).array(); + } + + private static CurrentSegment decodeCurrentRecord(byte[] encoded) { + ByteBuffer bytes = ByteBuffer.wrap(encoded); + int laneId = Short.toUnsignedInt(bytes.getShort()); + require(bytes.getShort() == StateArchiveFileFormatV3.laneKind(laneId), + "Catalog current lane kind mismatch"); + require(bytes.getInt() == 0, "Catalog current flags mismatch"); + long sequence = bytes.getLong(); + CurrentSegment current = new CurrentSegment(laneId, sequence, bytes.getLong(), + bytes.getLong(), bytes.getLong(), bytes.getLong(), read(bytes, 32)); + require(Arrays.equals(read(bytes, 32), parentDigest(laneId, sequence)), + "Catalog current parent digest mismatch"); + return current; + } + + private static byte[] parentDigest(int laneId, long sequence) { + return StateArchiveFileFormatV3.sha256( + ByteBuffer.allocate(Short.BYTES + Long.BYTES).putShort((short) laneId) + .putLong(sequence).array()); + } + + private static byte[] laneSetDigest() { + int[] lanes = StateArchiveFileFormatV3.fiveLaneIds(); + ByteBuffer bytes = ByteBuffer.allocate(lanes.length * Short.BYTES); + for (int lane : lanes) { + bytes.putShort((short) lane); + } + return StateArchiveFileFormatV3.sha256(bytes.array()); + } + + private static byte[] encodeCurrent(long generation, byte[] digest) { + ByteBuffer bytes = ByteBuffer.allocate(CURRENT_LENGTH); + bytes.putInt(CURRENT_MAGIC).putShort(StateArchiveFileFormatV3.MAJOR_VERSION) + .putShort(StateArchiveFileFormatV3.MINOR_VERSION).putLong(generation).put(digest) + .put(new byte[36]); + bytes.putInt(crc32c(bytes.array(), 0, CURRENT_LENGTH - 12)); + bytes.putInt(CURRENT_TRAILER_MAGIC); + return bytes.array(); + } + + private static CurrentPointer decodeCurrent(byte[] encoded) throws IOException { + try { + require(encoded != null && encoded.length == CURRENT_LENGTH, + "Catalog CURRENT length mismatch"); + ByteBuffer bytes = ByteBuffer.wrap(encoded); + require(bytes.getInt() == CURRENT_MAGIC, "Catalog CURRENT magic mismatch"); + require(bytes.getShort() == StateArchiveFileFormatV3.MAJOR_VERSION, + "Catalog CURRENT major version mismatch"); + require(bytes.getShort() == StateArchiveFileFormatV3.MINOR_VERSION, + "Catalog CURRENT minor version mismatch"); + long generation = bytes.getLong(); + byte[] digest = read(bytes, 32); + requireZero(bytes, 36); + require(bytes.getInt() == crc32c(encoded, 0, CURRENT_LENGTH - 12), + "Catalog CURRENT checksum mismatch"); + require(bytes.getInt() == CURRENT_TRAILER_MAGIC, "Catalog CURRENT trailer mismatch"); + require(generation >= 0, "Catalog CURRENT generation is invalid"); + return new CurrentPointer(generation, digest); + } catch (IllegalArgumentException invalid) { + throw new IOException("State Archive Catalog CURRENT is corrupt", invalid); + } + } + + private static void writeForced(Path path, byte[] encoded) throws IOException { + try (FileChannel channel = FileChannel.open(path, StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE)) { + ByteBuffer bytes = ByteBuffer.wrap(encoded); + while (bytes.hasRemaining()) { + channel.write(bytes); + } + channel.force(true); + } + } + + private static void atomicMove(Path source, Path target) throws IOException { + try { + Files.move(source, target, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException unsupported) { + throw new IOException("State Archive Catalog requires atomic publication", unsupported); + } + } + + private static void syncDirectory(Path directory) throws IOException { + try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) { + channel.force(true); + } + } + + private static byte[] read(ByteBuffer bytes, int length) { + byte[] value = new byte[length]; + bytes.get(value); + return value; + } + + private static void requireZero(ByteBuffer bytes, int length) { + for (int index = 0; index < length; index++) { + require(bytes.get() == 0, "Catalog reserved bytes are non-zero"); + } + } + + private static void require(boolean condition, String message) { + if (!condition) { + throw new IllegalArgumentException(message); + } + } + + private static int crc32c(byte[] bytes, int offset, int length) { + return Hashing.crc32c().hashBytes(bytes, offset, length).asInt(); + } + + private static String fileName(long generation) { + return String.format("catalog-%020d.bin", generation); + } + + static final class Generation { + private final long generation; + private final byte[] previousDigest; + private final long segmentTargetBytes; + private final List current; + private final List sealed; + private final byte[] digest; + + private Generation(long generation, byte[] previousDigest, long segmentTargetBytes, + List current, List sealed, byte[] digest) { + this.generation = generation; + this.previousDigest = Arrays.copyOf(Objects.requireNonNull(previousDigest), 32); + this.segmentTargetBytes = segmentTargetBytes; + this.current = sortedCurrent(current); + this.sealed = sortedSealed(sealed); + this.digest = digest == null ? null : Arrays.copyOf(digest, digest.length); + validate(); + } + + private void validate() { + require(previousDigest.length == 32 && (digest == null || digest.length == 32), + "Catalog digest length mismatch"); + int[] expected = StateArchiveFileFormatV3.fiveLaneIds(); + require(current.isEmpty() || current.size() == expected.length, + "Catalog must contain zero or five current lanes"); + require(!current.isEmpty() || sealed.isEmpty(), + "Catalog cannot omit current lanes after sealed history"); + for (int index = 0; index < expected.length; index++) { + require(current.isEmpty() || current.get(index).getLaneId() == expected[index], + "Catalog current lane set mismatch"); + } + int previousLane = -1; + long previousSequence = -1; + long previousLast = -1; + for (SealedSegment segment : sealed) { + if (segment.getLaneId() != previousLane) { + previousLane = segment.getLaneId(); + previousSequence = -1; + previousLast = -1; + } + require(segment.getSegmentSeq() == previousSequence + 1, + "Catalog sealed sequence has a gap"); + if (previousLast >= 0) { + require(segment.getFirstBlock() == previousLast + 1, + "Catalog sealed block range has a gap"); + } + previousSequence = segment.getSegmentSeq(); + previousLast = segment.getLastBlock(); + } + for (CurrentSegment segment : current) { + long lastSequence = -1; + long lastBlock = -1; + for (SealedSegment sealedSegment : sealed) { + if (sealedSegment.getLaneId() == segment.getLaneId()) { + lastSequence = sealedSegment.getSegmentSeq(); + lastBlock = sealedSegment.getLastBlock(); + } + } + require(segment.getSegmentSeq() == lastSequence + 1, + "Catalog current sequence does not follow sealed segments"); + if (lastBlock >= 0) { + require(segment.getFirstBlock() == lastBlock + 1, + "Catalog current block range does not follow sealed segments"); + } + } + } + + long getGeneration() { + return generation; + } + + List getCurrent() { + return current; + } + + List getSealed() { + return sealed; + } + + byte[] getDigest() { + return Arrays.copyOf(digest, digest.length); + } + } + + private static List sortedCurrent(List input) { + List copy = new ArrayList<>(Objects.requireNonNull(input)); + copy.sort(Comparator.comparingInt(CurrentSegment::getLaneId)); + return Collections.unmodifiableList(copy); + } + + private static List sortedSealed(List input) { + List copy = new ArrayList<>(Objects.requireNonNull(input)); + copy.sort(Comparator.comparingInt(SealedSegment::getLaneId) + .thenComparingLong(SealedSegment::getSegmentSeq)); + return Collections.unmodifiableList(copy); + } + + private static final class CurrentPointer { + private final long generation; + private final byte[] digest; + + private CurrentPointer(long generation, byte[] digest) { + this.generation = generation; + this.digest = digest; + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/StateArchiveHotBatchDescriptor.java b/chainbase/src/main/java/org/tron/core/db2/archive/StateArchiveHotBatchDescriptor.java new file mode 100644 index 00000000000..739d481e93c --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/StateArchiveHotBatchDescriptor.java @@ -0,0 +1,214 @@ +package org.tron.core.db2.archive; + +import com.google.common.hash.Hasher; +import com.google.common.hash.Hashing; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import org.tron.core.db2.stateroot.PathStateStoreManifest.Engine; + +/** Immutable logical binding between transient Archive diffs and one Hot DB prepare batch. */ +public final class StateArchiveHotBatchDescriptor { + + public static final int HOT_FORMAT_VERSION = 2; + private static final int DIGEST_LENGTH = 32; + + private final Engine engine; + private final long parentPublishedBlock; + private final byte[] parentPublishedHash; + private final BlockSnapshotMeta firstBlock; + private final BlockSnapshotMeta lastBlock; + private final long encodedBytes; + private final byte[] parentContentDigest; + private final byte[] resultContentDigest; + private final byte[] orderedRecordDigest; + private final List blocks; + + StateArchiveHotBatchDescriptor(Engine engine, long parentPublishedBlock, + byte[] parentPublishedHash, BlockSnapshotMeta firstBlock, BlockSnapshotMeta lastBlock, + long encodedBytes, byte[] parentContentDigest, byte[] resultContentDigest, + byte[] orderedRecordDigest, List blocks) { + this.engine = Objects.requireNonNull(engine, "engine"); + if (parentPublishedBlock < 0 || encodedBytes <= 0) { + throw new IllegalArgumentException("Hot Archive batch counters are invalid"); + } + this.parentPublishedBlock = parentPublishedBlock; + this.parentPublishedHash = digest(parentPublishedHash, "parentPublishedHash"); + this.firstBlock = Objects.requireNonNull(firstBlock, "firstBlock"); + this.lastBlock = Objects.requireNonNull(lastBlock, "lastBlock"); + this.encodedBytes = encodedBytes; + this.parentContentDigest = digest(parentContentDigest, "parentContentDigest"); + this.resultContentDigest = digest(resultContentDigest, "resultContentDigest"); + this.orderedRecordDigest = digest(orderedRecordDigest, "orderedRecordDigest"); + List admitted = new ArrayList<>(Objects.requireNonNull(blocks, "blocks")); + if (admitted.isEmpty() || admitted.size() != lastBlock.getBlockNumber() + - firstBlock.getBlockNumber() + 1L + || firstBlock.getBlockNumber() != parentPublishedBlock + 1 + || !firstBlock.equals(admitted.get(0).meta) + || !lastBlock.equals(admitted.get(admitted.size() - 1).meta)) { + throw new IllegalArgumentException("Hot Archive batch block range is invalid"); + } + BlockDigest previous = null; + Hasher orderedRecords = Hashing.sha256().newHasher(); + for (BlockDigest block : admitted) { + BlockDigest current = Objects.requireNonNull(block, "block"); + if (current.meta.getEpoch() != current.meta.getBlockNumber()) { + throw new IllegalArgumentException("Hot Archive batch requires block epochs"); + } else if (previous == null) { + if (!Arrays.equals(current.meta.getParentHash(), this.parentPublishedHash)) { + throw new IllegalArgumentException("Hot Archive batch parent hash differs"); + } + } else if (current.meta.getBlockNumber() != previous.meta.getBlockNumber() + 1 + || !Arrays.equals(current.meta.getParentHash(), previous.meta.getBlockHash())) { + throw new IllegalArgumentException("Hot Archive batch block chain is not consecutive"); + } + orderedRecords.putLong(current.meta.getBlockNumber()) + .putBytes(current.archiveRecordDigest); + previous = current; + } + if (!Arrays.equals(this.orderedRecordDigest, orderedRecords.hash().asBytes())) { + throw new IllegalArgumentException("Hot Archive batch aggregate digest differs"); + } + this.blocks = Collections.unmodifiableList(admitted); + } + + /** Reconstructs and validates a descriptor decoded from a coordination payload. */ + public static StateArchiveHotBatchDescriptor restore(Engine engine, + long parentPublishedBlock, byte[] parentPublishedHash, BlockSnapshotMeta firstBlock, + BlockSnapshotMeta lastBlock, long encodedBytes, byte[] parentContentDigest, + byte[] resultContentDigest, byte[] orderedRecordDigest, List blocks) { + return new StateArchiveHotBatchDescriptor(engine, parentPublishedBlock, + parentPublishedHash, firstBlock, lastBlock, encodedBytes, parentContentDigest, + resultContentDigest, orderedRecordDigest, blocks); + } + + public Engine getEngine() { + return engine; + } + + public long getParentPublishedBlock() { + return parentPublishedBlock; + } + + public byte[] getParentPublishedHash() { + return copy(parentPublishedHash); + } + + public BlockSnapshotMeta getFirstBlock() { + return firstBlock; + } + + public BlockSnapshotMeta getLastBlock() { + return lastBlock; + } + + public long getBlockCount() { + return blocks.size(); + } + + public long getEncodedBytes() { + return encodedBytes; + } + + public byte[] getParentContentDigest() { + return copy(parentContentDigest); + } + + public byte[] getResultContentDigest() { + return copy(resultContentDigest); + } + + public byte[] getOrderedRecordDigest() { + return copy(orderedRecordDigest); + } + + public List getBlocks() { + return blocks; + } + + @Override + public boolean equals(Object object) { + if (this == object) { + return true; + } + if (!(object instanceof StateArchiveHotBatchDescriptor)) { + return false; + } + StateArchiveHotBatchDescriptor that = (StateArchiveHotBatchDescriptor) object; + return engine == that.engine + && parentPublishedBlock == that.parentPublishedBlock + && encodedBytes == that.encodedBytes + && firstBlock.equals(that.firstBlock) + && lastBlock.equals(that.lastBlock) + && blocks.equals(that.blocks) + && Arrays.equals(parentPublishedHash, that.parentPublishedHash) + && Arrays.equals(parentContentDigest, that.parentContentDigest) + && Arrays.equals(resultContentDigest, that.resultContentDigest) + && Arrays.equals(orderedRecordDigest, that.orderedRecordDigest); + } + + @Override + public int hashCode() { + int result = Objects.hash(engine, parentPublishedBlock, firstBlock, lastBlock, encodedBytes, + blocks); + result = 31 * result + Arrays.hashCode(parentPublishedHash); + result = 31 * result + Arrays.hashCode(parentContentDigest); + result = 31 * result + Arrays.hashCode(resultContentDigest); + result = 31 * result + Arrays.hashCode(orderedRecordDigest); + return result; + } + + private static byte[] digest(byte[] value, String name) { + byte[] admitted = copy(Objects.requireNonNull(value, name)); + if (admitted.length != DIGEST_LENGTH) { + throw new IllegalArgumentException(name + " must contain exactly 32 bytes"); + } + return admitted; + } + + private static byte[] copy(byte[] value) { + return Arrays.copyOf(value, value.length); + } + + /** Digest-only per-block Archive identity retained by the coordination payload. */ + public static final class BlockDigest { + private final BlockSnapshotMeta meta; + private final byte[] archiveRecordDigest; + + BlockDigest(BlockSnapshotMeta meta, byte[] archiveRecordDigest) { + this.meta = Objects.requireNonNull(meta, "meta"); + this.archiveRecordDigest = digest(archiveRecordDigest, "archiveRecordDigest"); + } + + public static BlockDigest restore(BlockSnapshotMeta meta, byte[] archiveRecordDigest) { + return new BlockDigest(meta, archiveRecordDigest); + } + + public BlockSnapshotMeta getMeta() { + return meta; + } + + public byte[] getArchiveRecordDigest() { + return copy(archiveRecordDigest); + } + + @Override + public boolean equals(Object object) { + if (!(object instanceof BlockDigest)) { + return false; + } + BlockDigest that = (BlockDigest) object; + return meta.equals(that.meta) + && Arrays.equals(archiveRecordDigest, that.archiveRecordDigest); + } + + @Override + public int hashCode() { + int result = meta.hashCode(); + result = 31 * result + Arrays.hashCode(archiveRecordDigest); + return result; + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/StateArchiveHotBatchDescriptorCodec.java b/chainbase/src/main/java/org/tron/core/db2/archive/StateArchiveHotBatchDescriptorCodec.java new file mode 100644 index 00000000000..4e0211b02c6 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/StateArchiveHotBatchDescriptorCodec.java @@ -0,0 +1,158 @@ +package org.tron.core.db2.archive; + +import com.google.common.hash.Hashing; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.EOFException; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import org.tron.core.db2.archive.StateArchiveHotBatchDescriptor.BlockDigest; +import org.tron.core.db2.stateroot.PathStateStoreManifest.Engine; + +/** Checksummed persistent encoding for one exact Hot Archive batch descriptor. */ +final class StateArchiveHotBatchDescriptorCodec { + + private static final int MAGIC = 0x53414844; // SAHD + private static final short VERSION = 2; + private static final int DIGEST_LENGTH = 32; + private static final int HEADER_LENGTH = 44; + private static final int MAX_BLOCKS = 100_000; + private static final int MAX_ENCODED_LENGTH = 32 * 1024 * 1024; + + byte[] encode(StateArchiveHotBatchDescriptor descriptor) { + try { + ByteArrayOutputStream bodyBytes = new ByteArrayOutputStream(); + DataOutputStream body = new DataOutputStream(bodyBytes); + body.writeShort(StateArchiveHotBatchDescriptor.HOT_FORMAT_VERSION); + body.writeShort(engineTag(descriptor.getEngine())); + body.writeLong(descriptor.getParentPublishedBlock()); + body.write(descriptor.getParentPublishedHash()); + writeMeta(body, descriptor.getFirstBlock()); + writeMeta(body, descriptor.getLastBlock()); + body.writeLong(descriptor.getBlockCount()); + body.writeLong(descriptor.getEncodedBytes()); + body.write(descriptor.getParentContentDigest()); + body.write(descriptor.getResultContentDigest()); + body.write(descriptor.getOrderedRecordDigest()); + for (BlockDigest block : descriptor.getBlocks()) { + writeMeta(body, block.getMeta()); + body.write(block.getArchiveRecordDigest()); + } + body.flush(); + byte[] payload = bodyBytes.toByteArray(); + ByteArrayOutputStream encodedBytes = new ByteArrayOutputStream(HEADER_LENGTH + + payload.length); + DataOutputStream encoded = new DataOutputStream(encodedBytes); + encoded.writeInt(MAGIC); + encoded.writeShort(VERSION); + encoded.writeShort(0); + encoded.writeInt(payload.length); + encoded.write(Hashing.sha256().hashBytes(payload).asBytes()); + encoded.write(payload); + encoded.flush(); + return encodedBytes.toByteArray(); + } catch (IOException impossible) { + throw new IllegalStateException("in-memory Hot descriptor encoding failed", impossible); + } + } + + StateArchiveHotBatchDescriptor decode(byte[] supplied) throws IOException { + if (supplied == null || supplied.length < HEADER_LENGTH + || supplied.length > MAX_ENCODED_LENGTH) { + throw new ArchivePersistenceException("Hot Archive descriptor length is invalid"); + } + byte[] encoded = Arrays.copyOf(supplied, supplied.length); + try (DataInputStream input = new DataInputStream(new ByteArrayInputStream(encoded))) { + if (input.readInt() != MAGIC || input.readShort() != VERSION || input.readShort() != 0) { + throw new ArchivePersistenceException("Hot Archive descriptor format is unsupported"); + } + int bodyLength = input.readInt(); + byte[] checksum = readExact(input, DIGEST_LENGTH); + if (bodyLength < 0 || HEADER_LENGTH + (long) bodyLength != encoded.length) { + throw new ArchivePersistenceException("Hot Archive descriptor length is invalid"); + } + byte[] body = readExact(input, bodyLength); + if (!Arrays.equals(checksum, Hashing.sha256().hashBytes(body).asBytes())) { + throw new ArchivePersistenceException("Hot Archive descriptor checksum differs"); + } + return decodeBody(body); + } catch (EOFException truncated) { + throw new ArchivePersistenceException("Hot Archive descriptor is truncated", truncated); + } + } + + private StateArchiveHotBatchDescriptor decodeBody(byte[] body) throws IOException { + try (DataInputStream input = new DataInputStream(new ByteArrayInputStream(body))) { + if (input.readUnsignedShort() != StateArchiveHotBatchDescriptor.HOT_FORMAT_VERSION) { + throw new ArchivePersistenceException("Hot Archive descriptor version differs"); + } + Engine engine = engine(input.readUnsignedShort()); + long parentBlock = input.readLong(); + byte[] parentHash = readExact(input, DIGEST_LENGTH); + BlockSnapshotMeta first = readMeta(input); + BlockSnapshotMeta last = readMeta(input); + long blockCount = input.readLong(); + long encodedBytes = input.readLong(); + byte[] parentContent = readExact(input, DIGEST_LENGTH); + byte[] resultContent = readExact(input, DIGEST_LENGTH); + byte[] orderedRecords = readExact(input, DIGEST_LENGTH); + if (blockCount <= 0 || blockCount > MAX_BLOCKS) { + throw new ArchivePersistenceException("Hot Archive descriptor block count is invalid"); + } + List blocks = new ArrayList<>((int) blockCount); + for (long index = 0; index < blockCount; index++) { + BlockSnapshotMeta meta = readMeta(input); + blocks.add(BlockDigest.restore(meta, readExact(input, DIGEST_LENGTH))); + } + if (input.available() != 0) { + throw new ArchivePersistenceException("Hot Archive descriptor has trailing bytes"); + } + try { + return StateArchiveHotBatchDescriptor.restore(engine, parentBlock, parentHash, first, + last, encodedBytes, parentContent, resultContent, orderedRecords, blocks); + } catch (IllegalArgumentException invalid) { + throw new ArchivePersistenceException("Hot Archive descriptor is inconsistent", invalid); + } + } catch (EOFException truncated) { + throw new ArchivePersistenceException("Hot Archive descriptor is truncated", truncated); + } + } + + private static void writeMeta(DataOutputStream output, BlockSnapshotMeta meta) + throws IOException { + output.writeLong(meta.getEpoch()); + output.writeLong(meta.getBlockNumber()); + output.write(meta.getBlockHash()); + output.write(meta.getParentHash()); + output.writeLong(meta.getTimestamp()); + } + + private static BlockSnapshotMeta readMeta(DataInputStream input) throws IOException { + return new BlockSnapshotMeta(input.readLong(), input.readLong(), + readExact(input, DIGEST_LENGTH), readExact(input, DIGEST_LENGTH), input.readLong()); + } + + private static byte[] readExact(DataInputStream input, int length) throws IOException { + byte[] value = new byte[length]; + input.readFully(value); + return value; + } + + private static int engineTag(Engine engine) { + return engine == Engine.LEVELDB ? 1 : 2; + } + + private static Engine engine(int tag) throws IOException { + if (tag == 1) { + return Engine.LEVELDB; + } + if (tag == 2) { + return Engine.ROCKSDB; + } + throw new ArchivePersistenceException("Hot Archive descriptor engine is unsupported"); + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/StateArchiveHotCheckpointMaterializer.java b/chainbase/src/main/java/org/tron/core/db2/archive/StateArchiveHotCheckpointMaterializer.java new file mode 100644 index 00000000000..da5d6010ee4 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/StateArchiveHotCheckpointMaterializer.java @@ -0,0 +1,124 @@ +package org.tron.core.db2.archive; + +import java.io.IOException; +import java.util.List; +import java.util.Objects; +import org.tron.core.db2.core.CommonCheckpointCapture; +import org.tron.core.db2.core.CommonCheckpointMaterializer; +import org.tron.core.db2.core.CommonCheckpointPayload; +import org.tron.core.db2.core.CommonCheckpointTarget; + +/** Default-off State Archive participant backed only by the independent Hot DB. */ +public final class StateArchiveHotCheckpointMaterializer + implements CommonCheckpointMaterializer, StateArchiveCheckpointPlanner { + + private final StateArchiveHotStore hotStore; + + public StateArchiveHotCheckpointMaterializer(StateArchiveHotStore hotStore) { + this.hotStore = Objects.requireNonNull(hotStore, "hotStore"); + } + + /** Computes the exact Hot batch identity without writing bodies or checkpoint metadata. */ + @Override + public synchronized StateArchiveHotBatchDescriptor planCheckpoint( + List diffs) throws IOException { + return hotStore.planCheckpoint(Objects.requireNonNull(diffs, "diffs")); + } + + /** Prepares one capture whose payload, descriptor and transient bodies share one identity. */ + @Override + public synchronized CommonCheckpointTarget prepare(CommonCheckpointCapture capture) + throws IOException { + CommonCheckpointCapture admitted = Objects.requireNonNull(capture, "capture"); + CommonCheckpointTarget target = CommonCheckpointTarget.from(admitted.getPayload()); + prepare(target, admitted.getArchiveBinding(), admitted.getArchiveDiffs()); + return target; + } + + synchronized void prepare(CommonCheckpointTarget target, List diffs) + throws IOException { + if (inspect(Objects.requireNonNull(target, "target")) != Status.NEEDS_MATERIALIZATION) { + hotStore.prepareCheckpoint(target.getPayloadDigest(), diffs); + return; + } + prepare(target, hotStore.planCheckpoint(diffs), diffs); + } + + /** Prepares transient bodies only when their computed identity equals the v2 binding. */ + synchronized void prepare(CommonCheckpointTarget target, + StateArchiveHotBatchDescriptor descriptor, List diffs) + throws IOException { + CommonCheckpointTarget admitted = Objects.requireNonNull(target, "target"); + List admittedDiffs = Objects.requireNonNull(diffs, "diffs"); + if (admittedDiffs.isEmpty() + || !admitted.getFirstBlock().equals(admittedDiffs.get(0).getMeta()) + || !admitted.getLastBlock().equals( + admittedDiffs.get(admittedDiffs.size() - 1).getMeta())) { + throw new ArchivePersistenceException( + "Hot Archive checkpoint block range differs from target"); + } + hotStore.requireFormatIdentity(admitted.getFormatIdentity()); + hotStore.prepareCheckpoint(admitted.getPayloadDigest(), descriptor, admittedDiffs); + } + + /** Reconciles only an unpublished Hot DB tail to a caller-validated recovery authority. */ + public synchronized long reconcilePreparedTail(BlockSnapshotMeta authority) + throws IOException { + return hotStore.reconcilePreparedTail(authority); + } + + @Override + public Authority authority() { + return Authority.STATE_ARCHIVE; + } + + @Override + public synchronized Status inspect(CommonCheckpointTarget target) throws IOException { + CommonCheckpointTarget admitted = Objects.requireNonNull(target, "target"); + hotStore.requireFormatIdentity(admitted.getFormatIdentity()); + StateArchiveHotStore.HotCheckpointStatus status = hotStore.inspectCheckpoint( + admitted.getPayloadDigest(), admitted.getArchiveBinding()); + switch (status) { + case MATERIALIZED: + return Status.MATERIALIZED; + case PUBLISHED: + return Status.PUBLISHED; + default: + return Status.NEEDS_MATERIALIZATION; + } + } + + /** + * The common coordinator may verify an already prepared target, but must never ask its WAL + * payload to materialize Archive bodies. + */ + @Override + public synchronized void materialize(CommonCheckpointPayload payload, + CommonCheckpointTarget target) throws IOException { + CommonCheckpointPayload admittedPayload = Objects.requireNonNull(payload, "payload"); + CommonCheckpointTarget admittedTarget = Objects.requireNonNull(target, "target"); + if (!admittedTarget.equals(CommonCheckpointTarget.from(admittedPayload))) { + throw new IOException("Hot Archive checkpoint payload and target differ"); + } + if (admittedPayload.getVersion() + != CommonCheckpointPayload.COORDINATION_FORMAT_VERSION) { + throw new IOException("Hot Archive materializer requires coordination payload v2"); + } + if (inspect(admittedTarget) == Status.NEEDS_MATERIALIZATION) { + throw new IOException( + "Hot Archive target must be prepared before common checkpoint WAL publication"); + } + } + + @Override + public synchronized void publish(CommonCheckpointTarget target) throws IOException { + CommonCheckpointTarget admitted = Objects.requireNonNull(target, "target"); + hotStore.requireFormatIdentity(admitted.getFormatIdentity()); + hotStore.publishCheckpoint(admitted.getPayloadDigest(), admitted.getArchiveBinding()); + } + + @Override + public synchronized void close() throws IOException { + hotStore.close(); + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/StateArchiveHotStore.java b/chainbase/src/main/java/org/tron/core/db2/archive/StateArchiveHotStore.java new file mode 100644 index 00000000000..20105e54df6 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/StateArchiveHotStore.java @@ -0,0 +1,1640 @@ +package org.tron.core.db2.archive; + +import com.google.common.hash.Hashing; +import com.google.common.hash.Hasher; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.Closeable; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.EOFException; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.tron.core.config.args.StorageConfig.NativeDbConfig; +import org.tron.core.config.args.StorageConfig.StateArchiveHotStoreConfig; +import org.tron.core.db2.archive.BlockReverseDiff.DbGroup; +import org.tron.core.db2.archive.BlockReverseDiff.Entry; +import org.tron.core.db2.stateroot.PathStateStoreManifest.Engine; + +/** + * Independent hot State Archive database with one writable and bounded sealed generations. + * + *

This store owns history data and native WAL durability. It deliberately has no dependency on + * the common-checkpoint WAL; a later coordinator integration may only consume its durable metadata. + */ +public final class StateArchiveHotStore implements Closeable { + + static final String CURRENT = "CURRENT"; + static final String CURRENT_TEMP = "CURRENT.tmp"; + static final String GENERATIONS = "generations"; + static final String DATABASE = "keys"; + + private static final int HASH_LENGTH = 32; + private static final int CATALOG_MAGIC = 0x53414843; // SAHC + private static final short CATALOG_VERSION = 1; + private static final int CATALOG_LENGTH = Integer.BYTES + 2 * Short.BYTES + Long.BYTES + + HASH_LENGTH + Integer.BYTES; + private static final int RECORD_MAGIC = 0x53414842; // SAHB + private static final short RECORD_VERSION = 2; + private static final byte[] BODY_PREFIX = new byte[]{0x42}; + private static final byte[] INDEX_PREFIX = new byte[]{0x4b}; + private static final String BLOCKS_COLUMN = + StateArchiveIndexDatabase.HOT_BLOCKS_COLUMN_FAMILY; + private static final byte[] META_FORMAT = bytes("meta/format"); + private static final byte[] META_ID = bytes("meta/id"); + private static final byte[] META_BASE_BLOCK = bytes("meta/base-block"); + private static final byte[] META_BASE_HASH = bytes("meta/base-hash"); + private static final byte[] META_START_BLOCK = bytes("meta/start-block"); + private static final byte[] META_END_BLOCK = bytes("meta/end-block"); + private static final byte[] META_HEAD_HASH = bytes("meta/head-hash"); + private static final byte[] META_BLOCK_COUNT = bytes("meta/block-count"); + private static final byte[] META_ENCODED_BYTES = bytes("meta/encoded-bytes"); + private static final byte[] META_CONTENT_DIGEST = bytes("meta/content-digest"); + private static final byte[] META_SEALED = bytes("meta/sealed"); + private static final byte[] META_PUBLISHED_BLOCK = bytes("meta/published-block"); + private static final byte[] META_PUBLISHED_HASH = bytes("meta/published-hash"); + private static final byte[] META_PUBLISHED_CONTENT_DIGEST = + bytes("meta/published-content-digest"); + private static final byte[] META_PUBLISHED_TARGET = bytes("meta/published-target"); + private static final byte[] META_PUBLISHED_DESCRIPTOR = bytes("meta/published-descriptor"); + private static final byte[] META_PREPARED_TARGET = bytes("meta/prepared-target"); + private static final byte[] META_PREPARED_DESCRIPTOR = bytes("meta/prepared-descriptor"); + private static final byte[] META_TRUNCATE_BLOCK = bytes("meta/truncate-block"); + private static final byte[] META_TRUNCATE_HASH = bytes("meta/truncate-hash"); + private static final byte[] ZERO_DIGEST = new byte[HASH_LENGTH]; + + private final Path root; + private final Path generations; + private final byte[] formatIdentity; + private final Engine engine; + private final int maxFrozenGenerations; + private final long maxBlocks; + private final long maxEncodedBytes; + private final int yellowFrozenGenerations; + private final int redFrozenGenerations; + private final NativeDbConfig dbSettings; + private final BlockHistoryCodec historyCodec = new BlockHistoryCodec(); + private final StateArchiveHotBatchDescriptorCodec descriptorCodec = + new StateArchiveHotBatchDescriptorCodec(); + private final FaultHook faultHook; + private final List frozen = new ArrayList<>(); + + private GenerationMeta current; + private StateArchiveIndexDatabase.Writer writer; + private boolean closed; + + private StateArchiveHotStore(Path root, byte[] formatIdentity, Engine engine, + int maxFrozenGenerations, long maxBlocks, long maxEncodedBytes, + int yellowFrozenGenerations, int redFrozenGenerations, NativeDbConfig dbSettings, + FaultHook faultHook) { + this.root = root; + this.generations = root.resolve(GENERATIONS); + this.formatIdentity = copyDigest(formatIdentity, "formatIdentity"); + this.engine = Objects.requireNonNull(engine, "engine"); + this.maxFrozenGenerations = positive(maxFrozenGenerations, "maxFrozenGenerations"); + this.maxBlocks = positive(maxBlocks, "maxBlocks"); + this.maxEncodedBytes = positive(maxEncodedBytes, "maxEncodedBytes"); + this.yellowFrozenGenerations = positive(yellowFrozenGenerations, + "yellowFrozenGenerations"); + this.redFrozenGenerations = positive(redFrozenGenerations, "redFrozenGenerations"); + if (yellowFrozenGenerations > redFrozenGenerations + || redFrozenGenerations > maxFrozenGenerations) { + throw new IllegalArgumentException("frozen watermarks must satisfy yellow <= red <= max"); + } + this.dbSettings = Objects.requireNonNull(dbSettings, "dbSettings"); + this.faultHook = Objects.requireNonNull(faultHook, "faultHook"); + } + + /** Opens the isolated Hot DB with its dedicated, default-off configuration object. */ + public static StateArchiveHotStore openOrCreate(Path root, byte[] formatIdentity, + Engine engine, long baseBlockNumber, byte[] baseBlockHash, + StateArchiveHotStoreConfig config) throws IOException { + Objects.requireNonNull(config, "config"); + config.validate(); + if (!config.isEnabled()) { + throw new IllegalStateException("State Archive Hot DB is disabled"); + } + return openOrCreate(root, formatIdentity, engine, baseBlockNumber, baseBlockHash, + config.getMaxFrozenGenerations(), config.getMaxBlocks(), config.getMaxEncodedBytes(), + config.getYellowFrozenGenerations(), config.getRedFrozenGenerations(), + config.getDbSettings(), stage -> { }); + } + + public static StateArchiveHotStore openOrCreate(Path root, byte[] formatIdentity, + Engine engine, long baseBlockNumber, byte[] baseBlockHash, int maxFrozenGenerations, + long maxBlocks, long maxEncodedBytes) throws IOException { + return openOrCreate(root, formatIdentity, engine, baseBlockNumber, baseBlockHash, + maxFrozenGenerations, maxBlocks, maxEncodedBytes, + Math.max(1, maxFrozenGenerations / 2), maxFrozenGenerations, + NativeDbConfig.large(), stage -> { }); + } + + static StateArchiveHotStore openOrCreate(Path suppliedRoot, byte[] formatIdentity, + Engine engine, long baseBlockNumber, byte[] baseBlockHash, int maxFrozenGenerations, + long maxBlocks, long maxEncodedBytes, FaultHook faultHook) throws IOException { + return openOrCreate(suppliedRoot, formatIdentity, engine, baseBlockNumber, baseBlockHash, + maxFrozenGenerations, maxBlocks, maxEncodedBytes, + Math.max(1, maxFrozenGenerations / 2), maxFrozenGenerations, + NativeDbConfig.large(), faultHook); + } + + private static StateArchiveHotStore openOrCreate(Path suppliedRoot, byte[] formatIdentity, + Engine engine, long baseBlockNumber, byte[] baseBlockHash, int maxFrozenGenerations, + long maxBlocks, long maxEncodedBytes, int yellowFrozenGenerations, + int redFrozenGenerations, NativeDbConfig dbSettings, FaultHook faultHook) throws IOException { + if (baseBlockNumber < 0) { + throw new IllegalArgumentException("baseBlockNumber must not be negative"); + } + byte[] baseHash = copyDigest(baseBlockHash, "baseBlockHash"); + Path root = Objects.requireNonNull(suppliedRoot, "root").toAbsolutePath().normalize(); + StateArchiveHotStore store = new StateArchiveHotStore(root, formatIdentity, engine, + maxFrozenGenerations, maxBlocks, maxEncodedBytes, yellowFrozenGenerations, + redFrozenGenerations, dbSettings, faultHook); + store.initialize(baseBlockNumber, baseHash); + return store; + } + + private void initialize(long baseBlockNumber, byte[] baseBlockHash) throws IOException { + Files.createDirectories(generations); + Catalog catalog; + Path currentFile = root.resolve(CURRENT); + if (Files.isRegularFile(currentFile, LinkOption.NOFOLLOW_LINKS)) { + catalog = loadCatalog(currentFile); + catalog.require(formatIdentity, engine); + } else { + List existing = generationIds(); + if (existing.isEmpty()) { + createGeneration(0, baseBlockNumber, baseBlockHash, ZERO_DIGEST, null); + persistCatalog(0); + catalog = new Catalog(formatIdentity, engine, 0); + } else if (existing.size() == 1 && existing.get(0) == 0) { + GenerationMeta initial = loadGeneration(0); + initial.requireBase(baseBlockNumber, baseBlockHash); + persistCatalog(0); + catalog = new Catalog(formatIdentity, engine, 0); + } else { + throw new ArchivePersistenceException( + "Hot Archive CURRENT is missing with ambiguous generations"); + } + } + + GenerationMeta selected = loadGeneration(catalog.currentGeneration); + if (selected.sealed) { + selected = recoverRotation(selected); + } + if (selected.id == 0) { + selected.requireBase(baseBlockNumber, baseBlockHash); + } + loadAndValidateGenerations(selected); + current = selected; + writer = StateArchiveIndexDatabase.openHotWriter( + generationPath(current.id).resolve(DATABASE), + engine, dbSettings); + resumeTruncateIfPresent(); + } + + /** Atomically appends a contiguous group of solidified block diffs to the current Hot DB. */ + public synchronized void appendSolidified(List diffs) throws IOException { + append(diffs, null, null); + } + + /** Materializes one checkpoint target without advancing the reader-visible published head. */ + public synchronized void prepareCheckpoint(byte[] targetDigest, List diffs) + throws IOException { + byte[] target = copyDigest(targetDigest, "targetDigest"); + if (Objects.requireNonNull(diffs, "diffs").isEmpty()) { + throw new IllegalArgumentException("Hot Archive checkpoint must contain blocks"); + } + HotCheckpointStatus status = inspectCheckpoint(target); + if (status != HotCheckpointStatus.NEEDS_MATERIALIZATION) { + return; + } + prepareCheckpoint(target, planCheckpoint(diffs), diffs); + } + + /** Materializes only when the caller's v2 binding equals a fresh no-write plan. */ + public synchronized void prepareCheckpoint(byte[] targetDigest, + StateArchiveHotBatchDescriptor descriptor, List diffs) + throws IOException { + byte[] target = copyDigest(targetDigest, "targetDigest"); + StateArchiveHotBatchDescriptor supplied = Objects.requireNonNull(descriptor, "descriptor"); + if (inspectCheckpoint(target, supplied) != HotCheckpointStatus.NEEDS_MATERIALIZATION) { + return; + } + if (!supplied.equals(planCheckpoint(diffs))) { + throw new ArchivePersistenceException("Hot Archive checkpoint descriptor differs"); + } + append(diffs, target, supplied); + } + + /** Computes the exact logical and encoded identity of one candidate batch without writing. */ + public synchronized StateArchiveHotBatchDescriptor planCheckpoint(List diffs) + throws IOException { + ensureOpen(); + if (current.prepared != null || current.publishedBlock != current.endBlock) { + throw new ArchivePersistenceException( + "Hot Archive cannot plan across an unpublished checkpoint"); + } + return planCheckpointDescriptor(engine, current.publishedBlock, current.publishedHash, + current.publishedContentDigest, diffs, historyCodec); + } + + /** Pure v2 descriptor planner shared with append-file Common compatibility binding. */ + static StateArchiveHotBatchDescriptor planCheckpointDescriptor(Engine engine, + long baseBlock, byte[] baseHash, byte[] baseContentDigest, + List diffs) { + return planCheckpointDescriptor(engine, baseBlock, baseHash, baseContentDigest, diffs, + new BlockHistoryCodec()); + } + + private static StateArchiveHotBatchDescriptor planCheckpointDescriptor(Engine engine, + long baseBlock, byte[] baseHash, byte[] baseContentDigest, + List diffs, BlockHistoryCodec codec) { + List admitted = new ArrayList<>(Objects.requireNonNull(diffs, "diffs")); + if (admitted.isEmpty()) { + throw new IllegalArgumentException("Hot Archive checkpoint must contain blocks"); + } + long previousBlock = baseBlock; + byte[] previousHash = Arrays.copyOf(Objects.requireNonNull(baseHash, "baseHash"), + baseHash.length); + byte[] resultContentDigest = Arrays.copyOf( + Objects.requireNonNull(baseContentDigest, "baseContentDigest"), + baseContentDigest.length); + long encodedBytes = 0; + Hasher orderedRecords = Hashing.sha256().newHasher(); + List blocks = new ArrayList<>(); + for (BlockReverseDiff diff : admitted) { + BlockReverseDiff block = Objects.requireNonNull(diff, "diff"); + BlockSnapshotMeta meta = block.getMeta(); + if (meta.getEpoch() != meta.getBlockNumber() + || meta.getBlockNumber() != previousBlock + 1 + || !Arrays.equals(meta.getParentHash(), previousHash)) { + throw new IllegalArgumentException( + "Hot Archive checkpoint identity is not contiguous"); + } + byte[] record = encodeRecord(block, codec); + byte[] recordDigest = Hashing.sha256().hashBytes(record).asBytes(); + encodedBytes = Math.addExact(encodedBytes, record.length); + resultContentDigest = nextContentDigest(resultContentDigest, meta, record); + orderedRecords.putLong(meta.getBlockNumber()).putBytes(recordDigest); + blocks.add(new StateArchiveHotBatchDescriptor.BlockDigest(meta, recordDigest)); + previousBlock = meta.getBlockNumber(); + previousHash = meta.getBlockHash(); + } + return new StateArchiveHotBatchDescriptor(Objects.requireNonNull(engine, "engine"), baseBlock, + baseHash, admitted.get(0).getMeta(), + admitted.get(admitted.size() - 1).getMeta(), encodedBytes, + baseContentDigest, resultContentDigest, + orderedRecords.hash().asBytes(), blocks); + } + + private void append(List diffs, byte[] preparedTarget, + StateArchiveHotBatchDescriptor preparedDescriptor) throws IOException { + ensureOpen(); + Objects.requireNonNull(diffs, "diffs"); + if (diffs.isEmpty()) { + return; + } + if (current.sealed) { + throw new ArchivePersistenceException("Hot Archive current generation is sealed"); + } + if (current.prepared != null) { + throw new ArchivePersistenceException( + "Hot Archive cannot append across a prepared checkpoint target"); + } + + List mutations = new ArrayList<>(); + Set newKeys = new HashSet<>(); + long previousBlock = current.endBlock; + byte[] previousHash = current.headHash; + long startBlock = current.startBlock; + long blockCount = current.blockCount; + long encodedBytes = current.encodedBytes; + byte[] contentDigest = current.contentDigest; + for (BlockReverseDiff diff : diffs) { + Objects.requireNonNull(diff, "diff"); + BlockSnapshotMeta meta = diff.getMeta(); + if (meta.getEpoch() != meta.getBlockNumber()) { + throw new IllegalArgumentException("Hot Archive requires block-boundary epochs"); + } + if (meta.getBlockNumber() != previousBlock + 1 + || !Arrays.equals(meta.getParentHash(), previousHash)) { + throw new IllegalArgumentException("Hot Archive block sequence is not contiguous"); + } + byte[] bodyKey = bodyKey(meta.getBlockNumber()); + requireNewKey(BLOCKS_COLUMN, bodyKey, newKeys); + byte[] record = encodeRecord(diff); + mutations.add(StateArchiveIndexDatabase.put(BLOCKS_COLUMN, bodyKey, record)); + for (DbGroup group : diff.getGroups()) { + String storeColumn = storeColumn(group.getDbName()); + for (Entry entry : group.getEntries()) { + byte[] indexKey = indexKey(group.getDbName(), entry.getKey(), meta.getBlockNumber()); + requireNewKey(storeColumn, indexKey, newKeys); + mutations.add(StateArchiveIndexDatabase.put(storeColumn, indexKey, + longBytes(meta.getBlockNumber()))); + } + } + if (startBlock < 0) { + startBlock = meta.getBlockNumber(); + } + previousBlock = meta.getBlockNumber(); + previousHash = meta.getBlockHash(); + blockCount++; + encodedBytes = Math.addExact(encodedBytes, record.length); + contentDigest = nextContentDigest(contentDigest, meta, record); + } + mutations.add(StateArchiveIndexDatabase.put(META_START_BLOCK, longBytes(startBlock))); + mutations.add(StateArchiveIndexDatabase.put(META_END_BLOCK, longBytes(previousBlock))); + mutations.add(StateArchiveIndexDatabase.put(META_HEAD_HASH, previousHash)); + mutations.add(StateArchiveIndexDatabase.put(META_BLOCK_COUNT, longBytes(blockCount))); + mutations.add(StateArchiveIndexDatabase.put(META_ENCODED_BYTES, longBytes(encodedBytes))); + mutations.add(StateArchiveIndexDatabase.put(META_CONTENT_DIGEST, contentDigest)); + PreparedTarget prepared = null; + long publishedBlock = previousBlock; + byte[] publishedHash = previousHash; + byte[] publishedContentDigest = contentDigest; + byte[] publishedTarget = current.publishedTarget; + StateArchiveHotBatchDescriptor publishedDescriptor = current.publishedDescriptor; + if (preparedTarget != null) { + prepared = new PreparedTarget(preparedTarget, + Objects.requireNonNull(preparedDescriptor, "preparedDescriptor")); + publishedBlock = current.publishedBlock; + publishedHash = current.publishedHash; + publishedContentDigest = current.publishedContentDigest; + mutations.add(StateArchiveIndexDatabase.put(META_PREPARED_TARGET, prepared.targetDigest)); + mutations.add(StateArchiveIndexDatabase.put(META_PREPARED_DESCRIPTOR, + descriptorCodec.encode(prepared.descriptor))); + } else { + mutations.add(StateArchiveIndexDatabase.put(META_PUBLISHED_BLOCK, + longBytes(publishedBlock))); + mutations.add(StateArchiveIndexDatabase.put(META_PUBLISHED_HASH, publishedHash)); + mutations.add(StateArchiveIndexDatabase.put(META_PUBLISHED_CONTENT_DIGEST, + publishedContentDigest)); + } + if (prepared != null + && (blockCount >= maxBlocks || encodedBytes >= maxEncodedBytes) + && frozen.size() >= maxFrozenGenerations) { + throw new ArchivePersistenceException( + "Hot Archive cannot prepare a rotation with a full frozen backlog"); + } + writer.write(mutations, true); + current = new GenerationMeta(current.id, current.baseBlock, current.baseHash, startBlock, + previousBlock, previousHash, blockCount, encodedBytes, contentDigest, false, + publishedBlock, publishedHash, publishedContentDigest, publishedTarget, + publishedDescriptor, prepared); + if (prepared != null) { + faultHook.after(Stage.AFTER_PREPARE); + } + } + + /** Atomically publishes an already materialized exact checkpoint target. */ + public synchronized void publishCheckpoint(byte[] targetDigest) throws IOException { + ensureOpen(); + byte[] target = copyDigest(targetDigest, "targetDigest"); + HotCheckpointStatus status = inspectCheckpoint(target); + if (status == HotCheckpointStatus.PUBLISHED) { + rotatePublishedCurrentIfDue(); + return; + } + if (status != HotCheckpointStatus.MATERIALIZED || current.prepared == null) { + throw new ArchivePersistenceException( + "Hot Archive checkpoint target is not materialized"); + } + List mutations = Arrays.asList( + StateArchiveIndexDatabase.put(META_PUBLISHED_BLOCK, longBytes(current.endBlock)), + StateArchiveIndexDatabase.put(META_PUBLISHED_HASH, current.headHash), + StateArchiveIndexDatabase.put(META_PUBLISHED_CONTENT_DIGEST, current.contentDigest), + StateArchiveIndexDatabase.put(META_PUBLISHED_TARGET, target), + StateArchiveIndexDatabase.put(META_PUBLISHED_DESCRIPTOR, + descriptorCodec.encode(current.prepared.descriptor)), + StateArchiveIndexDatabase.delete(META_PREPARED_TARGET), + StateArchiveIndexDatabase.delete(META_PREPARED_DESCRIPTOR)); + writer.write(mutations, true); + current = current.published(target, current.prepared.descriptor); + faultHook.after(Stage.AFTER_PUBLISH); + rotatePublishedCurrentIfDue(); + } + + synchronized void requireFormatIdentity(byte[] expected) throws IOException { + ensureOpen(); + if (!Arrays.equals(formatIdentity, copyDigest(expected, "formatIdentity"))) { + throw new ArchivePersistenceException("Hot Archive checkpoint format differs"); + } + } + + private void rotatePublishedCurrentIfDue() throws IOException { + if (shouldSealCurrent()) { + sealCurrent(); + } + } + + public synchronized HotCheckpointStatus inspectCheckpoint(byte[] targetDigest) + throws IOException { + ensureOpen(); + byte[] target = copyDigest(targetDigest, "targetDigest"); + if (Arrays.equals(current.publishedTarget, target)) { + return HotCheckpointStatus.PUBLISHED; + } + if (current.prepared != null) { + if (Arrays.equals(current.prepared.targetDigest, target)) { + return HotCheckpointStatus.MATERIALIZED; + } + throw new ArchivePersistenceException( + "Hot Archive prepared checkpoint target differs"); + } + return HotCheckpointStatus.NEEDS_MATERIALIZATION; + } + + public synchronized HotCheckpointStatus inspectCheckpoint(byte[] targetDigest, + StateArchiveHotBatchDescriptor descriptor) throws IOException { + StateArchiveHotBatchDescriptor expected = Objects.requireNonNull(descriptor, "descriptor"); + HotCheckpointStatus status = inspectCheckpoint(targetDigest); + if (status == HotCheckpointStatus.MATERIALIZED + && !expected.equals(current.prepared.descriptor)) { + throw new ArchivePersistenceException("Hot Archive prepared descriptor differs"); + } + if (status == HotCheckpointStatus.PUBLISHED + && !expected.equals(current.publishedDescriptor)) { + throw new ArchivePersistenceException("Hot Archive published descriptor differs"); + } + return status; + } + + public synchronized void publishCheckpoint(byte[] targetDigest, + StateArchiveHotBatchDescriptor descriptor) throws IOException { + HotCheckpointStatus status = inspectCheckpoint(targetDigest, descriptor); + if (status == HotCheckpointStatus.NEEDS_MATERIALIZATION) { + throw new ArchivePersistenceException("Hot Archive checkpoint target is not materialized"); + } + publishCheckpoint(targetDigest); + } + + /** Seals current and atomically publishes a new writable generation. */ + public synchronized long sealCurrent() throws IOException { + ensureOpen(); + if (current.blockCount == 0) { + throw new IllegalStateException("Hot Archive cannot seal an empty generation"); + } + if (current.prepared != null || current.publishedBlock != current.endBlock) { + throw new ArchivePersistenceException( + "Hot Archive cannot seal an unpublished current generation"); + } + if (frozen.size() >= maxFrozenGenerations) { + throw new ArchivePersistenceException("Hot Archive frozen backlog reached its limit"); + } + writer.write(Collections.singletonList( + StateArchiveIndexDatabase.put(META_SEALED, new byte[]{1})), true); + current = current.sealed(); + writer.close(); + writer = null; + faultHook.after(Stage.AFTER_SEAL); + + long frozenId = current.id; + long nextId = Math.addExact(frozenId, 1); + GenerationMeta next = createOrValidateNext(current, nextId); + faultHook.after(Stage.AFTER_NEW_GENERATION); + persistCatalog(nextId); + faultHook.after(Stage.AFTER_CURRENT); + frozen.add(current); + current = next; + writer = StateArchiveIndexDatabase.openHotWriter(generationPath(nextId).resolve(DATABASE), + engine, dbSettings); + return frozenId; + } + + public synchronized boolean shouldSealCurrent() { + ensureOpenUnchecked(); + return current.blockCount >= maxBlocks || current.encodedBytes >= maxEncodedBytes; + } + + /** Finds the first changed block after {@code targetBlock} across frozen and current Hot DBs. */ + public synchronized Optional findOldValueAfter(String dbName, byte[] rawKey, + long targetBlock) throws IOException { + ensureOpen(); + Objects.requireNonNull(dbName, "dbName"); + Objects.requireNonNull(rawKey, "rawKey"); + if (targetBlock < -1) { + throw new IllegalArgumentException("targetBlock must not be less than -1"); + } + if (targetBlock == Long.MAX_VALUE) { + return Optional.empty(); + } + byte[] prefix = indexPrefix(dbName, rawKey); + String storeColumn; + try { + storeColumn = storeColumn(dbName); + } catch (IllegalArgumentException unknownStore) { + return Optional.empty(); + } + byte[] seek = ByteBuffer.allocate(prefix.length + Long.BYTES).put(prefix) + .putLong(targetBlock + 1).array(); + long candidate = Long.MAX_VALUE; + for (GenerationMeta generation : allGenerations()) { + if (generation.blockCount == 0 || generation.publishedBlock <= targetBlock) { + continue; + } + try (StateArchiveIndexDatabase.Reader reader = StateArchiveIndexDatabase.openHotReader( + generationPath(generation.id).resolve(DATABASE), engine, dbSettings)) { + StateArchiveIndexDatabase.KeyValue found = reader.seek(storeColumn, seek); + if (found != null && isIndexCandidate(found.getKey(), prefix)) { + long block = ByteBuffer.wrap(found.getKey(), prefix.length, Long.BYTES).getLong(); + if (!Arrays.equals(found.getValue(), longBytes(block))) { + throw new ArchivePersistenceException("Hot Archive index locator is corrupt"); + } + if (block <= generation.publishedBlock) { + candidate = Math.min(candidate, block); + } + } + } + } + if (candidate == Long.MAX_VALUE) { + return Optional.empty(); + } + BlockReverseDiff diff = loadBlock(candidate); + return Optional.of(new HotLookup(candidate, findExactOldValue(diff, dbName, rawKey))); + } + + public synchronized BlockReverseDiff loadBlock(long blockNumber) throws IOException { + ensureOpen(); + for (GenerationMeta generation : allGenerations()) { + if (generation.blockCount == 0 || blockNumber < generation.startBlock + || blockNumber > generation.publishedBlock) { + continue; + } + try (StateArchiveIndexDatabase.Reader reader = StateArchiveIndexDatabase.openHotReader( + generationPath(generation.id).resolve(DATABASE), engine, dbSettings)) { + byte[] encoded = reader.get(BLOCKS_COLUMN, bodyKey(blockNumber)); + if (encoded == null) { + throw new ArchivePersistenceException("Hot Archive body is missing for indexed block"); + } + return decodeRecord(encoded); + } + } + throw new ArchivePersistenceException("Hot Archive block is outside committed coverage"); + } + + public synchronized long getCurrentGenerationId() { + ensureOpenUnchecked(); + return current.id; + } + + public synchronized List getFrozenGenerationIds() { + ensureOpenUnchecked(); + return frozen.stream().map(generation -> generation.id).collect(Collectors.toList()); + } + + public synchronized long getCommittedHead() { + ensureOpenUnchecked(); + return current.publishedBlock; + } + + public synchronized byte[] getCommittedHeadHash() { + ensureOpenUnchecked(); + return Arrays.copyOf(current.publishedHash, current.publishedHash.length); + } + + public synchronized Optional getPublishedTargetDigest() { + ensureOpenUnchecked(); + return current.publishedTarget == null ? Optional.empty() + : Optional.of(Arrays.copyOf(current.publishedTarget, current.publishedTarget.length)); + } + + public synchronized long getMaterializedHead() { + ensureOpenUnchecked(); + return current.endBlock; + } + + /** + * Removes only the writable generation suffix above an externally recovered authority. + * Frozen generations are never changed, and equal-height identity drift fails closed. + */ + public synchronized long reconcilePreparedTail(BlockSnapshotMeta authority) throws IOException { + ensureOpen(); + BlockSnapshotMeta admitted = Objects.requireNonNull(authority, "authority"); + long ceiling = admitted.getBlockNumber(); + if (ceiling < current.baseBlock) { + throw new ArchivePersistenceException( + "Hot Archive recovery ceiling precedes the current generation base"); + } + if (!frozen.isEmpty() && frozen.get(frozen.size() - 1).endBlock > ceiling) { + throw new ArchivePersistenceException( + "Hot Archive recovery cannot truncate a frozen generation"); + } + if (current.publishedBlock > ceiling) { + throw new ArchivePersistenceException( + "Hot Archive recovery cannot truncate published history"); + } + if (current.endBlock < ceiling) { + return 0; + } + requireCurrentIdentity(ceiling, admitted.getBlockHash()); + if (current.endBlock == ceiling) { + return 0; + } + if (current.prepared == null || current.publishedBlock != ceiling) { + throw new ArchivePersistenceException( + "Hot Archive recovery ceiling does not bound one prepared tail"); + } + writer.write(Arrays.asList( + StateArchiveIndexDatabase.put(META_TRUNCATE_BLOCK, longBytes(ceiling)), + StateArchiveIndexDatabase.put(META_TRUNCATE_HASH, admitted.getBlockHash())), true); + faultHook.after(Stage.AFTER_TRUNCATE_INTENT); + return completeTruncate(new RecoveryCeiling(ceiling, admitted.getBlockHash())); + } + + /** Returns a self-consistent Hot DB capacity and frozen-backlog snapshot. */ + public synchronized Statistics getStatistics() { + ensureOpenUnchecked(); + long frozenBlocks = 0; + long frozenBytes = 0; + for (GenerationMeta generation : frozen) { + frozenBlocks = Math.addExact(frozenBlocks, generation.blockCount); + frozenBytes = Math.addExact(frozenBytes, generation.encodedBytes); + } + int frozenCount = frozen.size(); + BacklogLevel level = frozenCount >= redFrozenGenerations ? BacklogLevel.RED + : frozenCount >= yellowFrozenGenerations ? BacklogLevel.YELLOW : BacklogLevel.GREEN; + return new Statistics(current.id, current.startBlock, current.endBlock, current.blockCount, + current.encodedBytes, frozenCount, frozenBlocks, frozenBytes, + maxFrozenGenerations, yellowFrozenGenerations, redFrozenGenerations, level, + shouldSealCurrent(), maxBlocks, maxEncodedBytes, current.publishedBlock, + current.prepared != null); + } + + @Override + public synchronized void close() throws IOException { + if (!closed) { + closed = true; + if (writer != null) { + writer.close(); + writer = null; + } + } + } + + private GenerationMeta recoverRotation(GenerationMeta sealed) throws IOException { + if (sealed.blockCount == 0) { + throw new ArchivePersistenceException("Hot Archive sealed generation is empty"); + } + long nextId = Math.addExact(sealed.id, 1); + GenerationMeta next = createOrValidateNext(sealed, nextId); + persistCatalog(nextId); + return next; + } + + private void resumeTruncateIfPresent() throws IOException { + byte[] block = writer.get(META_TRUNCATE_BLOCK); + byte[] hash = writer.get(META_TRUNCATE_HASH); + if (block == null && hash == null) { + return; + } + if (block == null || block.length != Long.BYTES || hash == null + || hash.length != HASH_LENGTH) { + throw new ArchivePersistenceException("Hot Archive truncate intent is corrupt"); + } + RecoveryCeiling ceiling = new RecoveryCeiling(ByteBuffer.wrap(block).getLong(), hash); + if (ceiling.blockNumber < current.baseBlock || ceiling.blockNumber >= current.endBlock) { + throw new ArchivePersistenceException("Hot Archive truncate intent range is invalid"); + } + if (current.prepared == null || current.publishedBlock != ceiling.blockNumber) { + throw new ArchivePersistenceException( + "Hot Archive truncate intent does not bound one prepared tail"); + } + requireCurrentIdentity(ceiling.blockNumber, ceiling.blockHash); + completeTruncate(ceiling); + } + + private long completeTruncate(RecoveryCeiling ceiling) throws IOException { + GenerationMeta retained = rebuildCurrentThrough(ceiling.blockNumber) + .published(current.publishedTarget, current.publishedDescriptor); + long removed = current.endBlock - ceiling.blockNumber; + for (long block = current.endBlock; block > ceiling.blockNumber; block--) { + byte[] body = writer.get(BLOCKS_COLUMN, bodyKey(block)); + if (body == null) { + continue; + } + BlockReverseDiff diff = decodeRecord(body); + if (diff.getMeta().getBlockNumber() != block) { + throw new ArchivePersistenceException( + "Hot Archive truncate body identity differs"); + } + List deletes = new ArrayList<>(); + for (DbGroup group : diff.getGroups()) { + for (Entry entry : group.getEntries()) { + deletes.add(StateArchiveIndexDatabase.delete(storeColumn(group.getDbName()), + indexKey(group.getDbName(), entry.getKey(), block))); + } + } + deletes.add(StateArchiveIndexDatabase.delete(BLOCKS_COLUMN, bodyKey(block))); + writer.write(deletes, true); + faultHook.after(Stage.AFTER_TRUNCATE_DELETE_BATCH); + } + List publish = retained.mutableMetadata(); + publish.add(StateArchiveIndexDatabase.delete(META_TRUNCATE_BLOCK)); + publish.add(StateArchiveIndexDatabase.delete(META_TRUNCATE_HASH)); + publish.add(StateArchiveIndexDatabase.delete(META_PREPARED_TARGET)); + publish.add(StateArchiveIndexDatabase.delete(META_PREPARED_DESCRIPTOR)); + writer.write(publish, true); + current = retained; + faultHook.after(Stage.AFTER_TRUNCATE_METADATA); + return removed; + } + + private GenerationMeta rebuildCurrentThrough(long ceiling) throws IOException { + GenerationMeta retained = GenerationMeta.empty(current.id, current.baseBlock, + current.baseHash); + for (long block = current.baseBlock + 1; block <= ceiling; block++) { + byte[] record = writer.get(BLOCKS_COLUMN, bodyKey(block)); + if (record == null) { + throw new ArchivePersistenceException( + "Hot Archive retained prefix body is missing"); + } + BlockReverseDiff diff = decodeRecord(record); + BlockSnapshotMeta meta = diff.getMeta(); + if (meta.getBlockNumber() != block + || !Arrays.equals(meta.getParentHash(), retained.headHash)) { + throw new ArchivePersistenceException( + "Hot Archive retained prefix identity differs"); + } + retained = retained.appended(meta, record); + } + return retained; + } + + private void requireCurrentIdentity(long blockNumber, byte[] expectedHash) throws IOException { + byte[] actual; + if (blockNumber == current.baseBlock) { + actual = current.baseHash; + } else { + byte[] record = writer.get(BLOCKS_COLUMN, bodyKey(blockNumber)); + if (record == null) { + throw new ArchivePersistenceException( + "Hot Archive recovery ceiling body is missing"); + } + actual = decodeRecord(record).getMeta().getBlockHash(); + } + if (!Arrays.equals(actual, expectedHash)) { + throw new ArchivePersistenceException( + "Hot Archive recovery ceiling hash differs"); + } + } + + private GenerationMeta createOrValidateNext(GenerationMeta parent, long nextId) + throws IOException { + Path path = generationPath(nextId); + if (Files.exists(path, LinkOption.NOFOLLOW_LINKS)) { + GenerationMeta existing = loadGeneration(nextId); + existing.requireBase(parent.endBlock, parent.headHash); + existing.requirePublication(parent.publishedTarget, parent.publishedDescriptor); + if (existing.sealed || existing.blockCount != 0) { + throw new ArchivePersistenceException("Hot Archive recovery child is not empty current"); + } + return existing; + } + return createGeneration(nextId, parent.endBlock, parent.headHash, parent.publishedTarget, + parent.publishedDescriptor); + } + + private GenerationMeta createGeneration(long id, long baseBlock, byte[] baseHash, + byte[] publishedTarget, StateArchiveHotBatchDescriptor publishedDescriptor) + throws IOException { + Path path = generationPath(id); + Files.createDirectory(path); + StateArchiveIndexEngineManifest.openOrCreate(path, engine); + GenerationMeta meta = GenerationMeta.empty(id, baseBlock, baseHash) + .published(publishedTarget, publishedDescriptor); + try (StateArchiveIndexDatabase.Writer created = StateArchiveIndexDatabase.openHotWriter( + path.resolve(DATABASE), engine, dbSettings)) { + created.write(meta.createMutations(formatIdentity), true); + } + HistorySegmentStore.syncDirectory(generations); + return meta; + } + + private void loadAndValidateGenerations(GenerationMeta selected) throws IOException { + frozen.clear(); + List ids = generationIds(); + GenerationMeta previous = null; + boolean foundCurrent = false; + for (long id : ids) { + if (id > selected.id) { + throw new ArchivePersistenceException("Hot Archive has unpublished future generation"); + } + GenerationMeta meta = id == selected.id ? selected : loadGeneration(id); + if (previous != null) { + meta.requireBase(previous.endBlock, previous.headHash); + } + if (id == selected.id) { + if (meta.sealed) { + throw new ArchivePersistenceException("Hot Archive CURRENT points to sealed generation"); + } + foundCurrent = true; + } else { + if (!meta.sealed || meta.blockCount == 0) { + throw new ArchivePersistenceException("Hot Archive frozen generation is not sealed"); + } + frozen.add(meta); + } + previous = meta; + } + if (!foundCurrent || frozen.size() > maxFrozenGenerations) { + throw new ArchivePersistenceException("Hot Archive generation catalog is inconsistent"); + } + } + + private GenerationMeta loadGeneration(long id) throws IOException { + Path path = generationPath(id); + StateArchiveIndexEngineManifest.require(path, engine); + try (StateArchiveIndexDatabase.Reader reader = StateArchiveIndexDatabase.openHotReader( + path.resolve(DATABASE), engine, dbSettings)) { + byte[] storedFormat = required(reader, META_FORMAT, HASH_LENGTH); + if (!Arrays.equals(formatIdentity, storedFormat)) { + throw new ArchivePersistenceException("Hot Archive generation format differs"); + } + long storedId = readLong(reader, META_ID); + if (storedId != id) { + throw new ArchivePersistenceException("Hot Archive generation id differs"); + } + long baseBlock = readLong(reader, META_BASE_BLOCK); + byte[] baseHash = required(reader, META_BASE_HASH, HASH_LENGTH); + long startBlock = readLong(reader, META_START_BLOCK); + long endBlock = readLong(reader, META_END_BLOCK); + byte[] headHash = required(reader, META_HEAD_HASH, HASH_LENGTH); + long blockCount = readLong(reader, META_BLOCK_COUNT); + long encodedBytes = readLong(reader, META_ENCODED_BYTES); + byte[] contentDigest = required(reader, META_CONTENT_DIGEST, HASH_LENGTH); + byte[] sealed = required(reader, META_SEALED, 1); + long publishedBlock = readLong(reader, META_PUBLISHED_BLOCK); + byte[] publishedHash = required(reader, META_PUBLISHED_HASH, HASH_LENGTH); + byte[] publishedContentDigest = required(reader, META_PUBLISHED_CONTENT_DIGEST, + HASH_LENGTH); + byte[] publishedTarget = required(reader, META_PUBLISHED_TARGET, HASH_LENGTH); + byte[] encodedPublishedDescriptor = reader.get(META_PUBLISHED_DESCRIPTOR); + StateArchiveHotBatchDescriptor publishedDescriptor = encodedPublishedDescriptor == null + ? null : descriptorCodec.decode(encodedPublishedDescriptor); + byte[] preparedTarget = reader.get(META_PREPARED_TARGET); + byte[] encodedPreparedDescriptor = reader.get(META_PREPARED_DESCRIPTOR); + if ((preparedTarget == null) != (encodedPreparedDescriptor == null) + || preparedTarget != null && preparedTarget.length != HASH_LENGTH) { + throw new ArchivePersistenceException("Hot Archive prepared target is corrupt"); + } + PreparedTarget prepared = preparedTarget == null ? null + : new PreparedTarget(preparedTarget, + descriptorCodec.decode(encodedPreparedDescriptor)); + if (sealed[0] != 0 && sealed[0] != 1) { + throw new ArchivePersistenceException("Hot Archive sealed flag is invalid"); + } + GenerationMeta meta = new GenerationMeta(id, baseBlock, baseHash, startBlock, endBlock, + headHash, blockCount, encodedBytes, contentDigest, sealed[0] == 1, + publishedBlock, publishedHash, publishedContentDigest, publishedTarget, + publishedDescriptor, prepared); + meta.validate(); + if (publishedDescriptor != null && publishedDescriptor.getEngine() != engine) { + throw new ArchivePersistenceException("Hot Archive published descriptor engine differs"); + } + if (publishedDescriptor != null && blockCount > 0 + && publishedDescriptor.getFirstBlock().getBlockNumber() >= startBlock) { + requireExactDescriptor(reader, publishedDescriptor); + } + boolean truncateIntent = reader.get(META_TRUNCATE_BLOCK) != null + || reader.get(META_TRUNCATE_HASH) != null; + if (prepared != null && !truncateIntent) { + requireExactDescriptor(reader, prepared.descriptor); + } + return meta; + } + } + + private void requireExactDescriptor(StateArchiveIndexDatabase.Reader reader, + StateArchiveHotBatchDescriptor descriptor) throws IOException { + if (descriptor.getEngine() != engine) { + throw new ArchivePersistenceException("Hot Archive descriptor engine differs"); + } + long encodedBytes = 0; + byte[] contentDigest = descriptor.getParentContentDigest(); + for (StateArchiveHotBatchDescriptor.BlockDigest block : descriptor.getBlocks()) { + byte[] record = reader.get(BLOCKS_COLUMN, bodyKey(block.getMeta().getBlockNumber())); + if (record == null) { + throw new ArchivePersistenceException("Hot Archive descriptor body is missing"); + } + BlockReverseDiff diff = decodeRecord(record); + if (!diff.getMeta().equals(block.getMeta()) + || !Arrays.equals(Hashing.sha256().hashBytes(record).asBytes(), + block.getArchiveRecordDigest())) { + throw new ArchivePersistenceException("Hot Archive descriptor body identity differs"); + } + encodedBytes = Math.addExact(encodedBytes, record.length); + contentDigest = nextContentDigest(contentDigest, diff.getMeta(), record); + } + if (encodedBytes != descriptor.getEncodedBytes() + || !Arrays.equals(contentDigest, descriptor.getResultContentDigest())) { + throw new ArchivePersistenceException("Hot Archive descriptor batch result differs"); + } + } + + private List generationIds() throws IOException { + try (Stream paths = Files.list(generations)) { + List ids = new ArrayList<>(); + for (Path path : paths.collect(Collectors.toList())) { + if (!Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) { + throw new ArchivePersistenceException("Hot Archive generations contains a non-directory"); + } + String name = path.getFileName().toString(); + if (!name.matches("[0-9]{20}")) { + throw new ArchivePersistenceException("Hot Archive generation name is invalid"); + } + try { + ids.add(Long.parseLong(name)); + } catch (NumberFormatException invalid) { + throw new ArchivePersistenceException("Hot Archive generation id overflows", invalid); + } + } + ids.sort(Comparator.naturalOrder()); + return ids; + } + } + + private List allGenerations() { + List result = new ArrayList<>(frozen); + result.add(current); + return result; + } + + private Path generationPath(long id) { + return generations.resolve(String.format("%020d", id)); + } + + private void persistCatalog(long currentId) throws IOException { + byte[] encoded = new Catalog(formatIdentity, engine, currentId).encode(); + Path temporary = root.resolve(CURRENT_TEMP); + try (FileChannel channel = FileChannel.open(temporary, StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE)) { + ByteBuffer buffer = ByteBuffer.wrap(encoded); + while (buffer.hasRemaining()) { + channel.write(buffer); + } + channel.force(true); + } + try { + Files.move(temporary, root.resolve(CURRENT), StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException unsupported) { + throw new ArchivePersistenceException( + "Hot Archive filesystem does not support atomic CURRENT publication", unsupported); + } + HistorySegmentStore.syncDirectory(root); + } + + private static Catalog loadCatalog(Path path) throws IOException { + byte[] encoded = Files.readAllBytes(path); + if (encoded.length != CATALOG_LENGTH) { + throw new ArchivePersistenceException("Hot Archive CURRENT length is invalid"); + } + byte[] payload = Arrays.copyOf(encoded, encoded.length - Integer.BYTES); + int checksum = ByteBuffer.wrap(encoded, payload.length, Integer.BYTES).getInt(); + if (checksum != Hashing.crc32c().hashBytes(payload).asInt()) { + throw new ArchivePersistenceException("Hot Archive CURRENT checksum differs"); + } + try (DataInputStream input = new DataInputStream(new ByteArrayInputStream(payload))) { + if (input.readInt() != CATALOG_MAGIC || input.readShort() != CATALOG_VERSION) { + throw new ArchivePersistenceException("Hot Archive CURRENT format is unsupported"); + } + Engine engine = engine(input.readUnsignedShort()); + long generation = input.readLong(); + byte[] format = new byte[HASH_LENGTH]; + input.readFully(format); + if (generation < 0 || input.available() != 0) { + throw new ArchivePersistenceException("Hot Archive CURRENT payload is invalid"); + } + return new Catalog(format, engine, generation); + } catch (EOFException truncated) { + throw new ArchivePersistenceException("Hot Archive CURRENT is truncated", truncated); + } + } + + private byte[] encodeRecord(BlockReverseDiff diff) { + return encodeRecord(diff, historyCodec); + } + + private static byte[] encodeRecord(BlockReverseDiff diff, BlockHistoryCodec codec) { + try { + byte[] history = codec.encode(diff); + ByteArrayOutputStream bytes = new ByteArrayOutputStream(history.length + 16); + DataOutputStream output = new DataOutputStream(bytes); + output.writeInt(RECORD_MAGIC); + output.writeShort(RECORD_VERSION); + output.writeShort(0); + output.writeInt(history.length); + output.write(history); + output.flush(); + byte[] payload = bytes.toByteArray(); + output.writeInt(Hashing.crc32c().hashBytes(payload).asInt()); + output.flush(); + return bytes.toByteArray(); + } catch (IOException impossible) { + throw new IllegalStateException("Unexpected Hot Archive record encoding failure", impossible); + } + } + + private BlockReverseDiff decodeRecord(byte[] encoded) throws IOException { + if (encoded.length < 16) { + throw new ArchivePersistenceException("Hot Archive record is truncated"); + } + byte[] payload = Arrays.copyOf(encoded, encoded.length - Integer.BYTES); + int checksum = ByteBuffer.wrap(encoded, payload.length, Integer.BYTES).getInt(); + if (checksum != Hashing.crc32c().hashBytes(payload).asInt()) { + throw new ArchivePersistenceException("Hot Archive record checksum differs"); + } + try (DataInputStream input = new DataInputStream(new ByteArrayInputStream(payload))) { + if (input.readInt() != RECORD_MAGIC || input.readShort() != RECORD_VERSION) { + throw new ArchivePersistenceException("Hot Archive record format is unsupported"); + } + short flags = input.readShort(); + if (flags != 0) { + throw new ArchivePersistenceException("Hot Archive record flags are unsupported"); + } + int historyLength = input.readInt(); + if (historyLength <= 0 || historyLength != input.available()) { + throw new ArchivePersistenceException("Hot Archive history length is invalid"); + } + byte[] history = new byte[historyLength]; + input.readFully(history); + BlockReverseDiff decoded; + try { + decoded = historyCodec.decode(history); + } catch (IllegalArgumentException invalid) { + throw new ArchivePersistenceException("Hot Archive history is corrupt", invalid); + } + return decoded; + } catch (EOFException truncated) { + throw new ArchivePersistenceException("Hot Archive record is truncated", truncated); + } + } + + private void requireNewKey(String columnFamily, byte[] key, Set newKeys) + throws IOException { + byte[] column = columnFamily.getBytes(StandardCharsets.UTF_8); + byte[] qualified = ByteBuffer.allocate(Integer.BYTES + column.length + key.length) + .putInt(column.length).put(column).put(key).array(); + if (!newKeys.add(new ByteArrayKey(qualified)) || writer.get(columnFamily, key) != null) { + throw new ArchivePersistenceException("Hot Archive append would overwrite existing data"); + } + } + + private static String storeColumn(String dbName) { + return StateArchiveIndexDatabase.hotStoreColumnFamily(dbName); + } + + private static OldValue findExactOldValue(BlockReverseDiff diff, String dbName, byte[] rawKey) + throws IOException { + for (DbGroup group : diff.getGroups()) { + if (!group.getDbName().equals(dbName)) { + continue; + } + for (Entry entry : group.getEntries()) { + if (Arrays.equals(entry.getKey(), rawKey)) { + return entry.getOldValue(); + } + } + } + throw new ArchivePersistenceException("Hot Archive index does not match authoritative body"); + } + + private static boolean isIndexCandidate(byte[] key, byte[] prefix) { + return key.length == prefix.length + Long.BYTES + && Arrays.equals(prefix, Arrays.copyOf(key, prefix.length)); + } + + private static byte[] bodyKey(long blockNumber) { + return ByteBuffer.allocate(BODY_PREFIX.length + Long.BYTES).put(BODY_PREFIX) + .putLong(blockNumber).array(); + } + + private static byte[] indexKey(String dbName, byte[] rawKey, long blockNumber) { + byte[] prefix = indexPrefix(dbName, rawKey); + return ByteBuffer.allocate(prefix.length + Long.BYTES).put(prefix).putLong(blockNumber).array(); + } + + private static byte[] indexPrefix(String dbName, byte[] rawKey) { + byte[] name = dbName.getBytes(StandardCharsets.UTF_8); + if (name.length == 0 || name.length > 0xffff) { + throw new IllegalArgumentException("Hot Archive dbName length is invalid"); + } + return ByteBuffer.allocate(INDEX_PREFIX.length + Short.BYTES + name.length + + Integer.BYTES + rawKey.length).put(INDEX_PREFIX).putShort((short) name.length) + .put(name).putInt(rawKey.length).put(rawKey).array(); + } + + private static byte[] nextContentDigest(byte[] previous, BlockSnapshotMeta meta, byte[] record) { + return Hashing.sha256().newHasher().putBytes(previous).putLong(meta.getBlockNumber()) + .putBytes(meta.getBlockHash()).putBytes(Hashing.sha256().hashBytes(record).asBytes()) + .hash().asBytes(); + } + + private static byte[] required(StateArchiveIndexDatabase.Reader reader, byte[] key, int length) + throws IOException { + byte[] value = reader.get(key); + if (value == null || value.length != length) { + throw new ArchivePersistenceException("Hot Archive generation metadata is missing"); + } + return value; + } + + private static long readLong(StateArchiveIndexDatabase.Reader reader, byte[] key) + throws IOException { + return ByteBuffer.wrap(required(reader, key, Long.BYTES)).getLong(); + } + + private static byte[] longBytes(long value) { + return ByteBuffer.allocate(Long.BYTES).putLong(value).array(); + } + + private static byte[] bytes(String value) { + return value.getBytes(StandardCharsets.US_ASCII); + } + + private static int positive(int value, String name) { + if (value <= 0) { + throw new IllegalArgumentException(name + " must be positive"); + } + return value; + } + + private static long positive(long value, String name) { + if (value <= 0) { + throw new IllegalArgumentException(name + " must be positive"); + } + return value; + } + + private static byte[] copyDigest(byte[] value, String name) { + Objects.requireNonNull(value, name); + if (value.length != HASH_LENGTH) { + throw new IllegalArgumentException(name + " must contain exactly 32 bytes"); + } + return Arrays.copyOf(value, value.length); + } + + private void ensureOpen() throws IOException { + if (closed || writer == null) { + throw new IOException("Hot Archive store is closed or requires recovery"); + } + } + + private void ensureOpenUnchecked() { + if (closed || writer == null) { + throw new IllegalStateException("Hot Archive store is closed or requires recovery"); + } + } + + public static final class HotLookup { + private final long blockNumber; + private final OldValue oldValue; + + private HotLookup(long blockNumber, OldValue oldValue) { + this.blockNumber = blockNumber; + this.oldValue = oldValue; + } + + public long getBlockNumber() { + return blockNumber; + } + + public OldValue getOldValue() { + return oldValue; + } + } + + public enum BacklogLevel { + GREEN, + YELLOW, + RED + } + + public enum HotCheckpointStatus { + NEEDS_MATERIALIZATION, + MATERIALIZED, + PUBLISHED + } + + /** Immutable values suitable for logging, gauges, or a future management API. */ + public static final class Statistics { + private final long currentGenerationId; + private final long currentStartBlock; + private final long currentEndBlock; + private final long currentBlocks; + private final long currentEncodedBytes; + private final int frozenGenerations; + private final long frozenBlocks; + private final long frozenEncodedBytes; + private final int maxFrozenGenerations; + private final int yellowFrozenGenerations; + private final int redFrozenGenerations; + private final BacklogLevel backlogLevel; + private final boolean rotationDue; + private final long maxBlocks; + private final long maxEncodedBytes; + private final long publishedBlock; + private final boolean prepared; + + private Statistics(long currentGenerationId, long currentStartBlock, long currentEndBlock, + long currentBlocks, long currentEncodedBytes, int frozenGenerations, long frozenBlocks, + long frozenEncodedBytes, int maxFrozenGenerations, int yellowFrozenGenerations, + int redFrozenGenerations, BacklogLevel backlogLevel, boolean rotationDue, long maxBlocks, + long maxEncodedBytes, long publishedBlock, boolean prepared) { + this.currentGenerationId = currentGenerationId; + this.currentStartBlock = currentStartBlock; + this.currentEndBlock = currentEndBlock; + this.currentBlocks = currentBlocks; + this.currentEncodedBytes = currentEncodedBytes; + this.frozenGenerations = frozenGenerations; + this.frozenBlocks = frozenBlocks; + this.frozenEncodedBytes = frozenEncodedBytes; + this.maxFrozenGenerations = maxFrozenGenerations; + this.yellowFrozenGenerations = yellowFrozenGenerations; + this.redFrozenGenerations = redFrozenGenerations; + this.backlogLevel = backlogLevel; + this.rotationDue = rotationDue; + this.maxBlocks = maxBlocks; + this.maxEncodedBytes = maxEncodedBytes; + this.publishedBlock = publishedBlock; + this.prepared = prepared; + } + + public long getCurrentGenerationId() { + return currentGenerationId; + } + + public long getCurrentStartBlock() { + return currentStartBlock; + } + + public long getCurrentEndBlock() { + return currentEndBlock; + } + + public long getCurrentBlocks() { + return currentBlocks; + } + + public long getCurrentEncodedBytes() { + return currentEncodedBytes; + } + + public int getFrozenGenerations() { + return frozenGenerations; + } + + public long getFrozenBlocks() { + return frozenBlocks; + } + + public long getFrozenEncodedBytes() { + return frozenEncodedBytes; + } + + public int getMaxFrozenGenerations() { + return maxFrozenGenerations; + } + + public int getYellowFrozenGenerations() { + return yellowFrozenGenerations; + } + + public int getRedFrozenGenerations() { + return redFrozenGenerations; + } + + public BacklogLevel getBacklogLevel() { + return backlogLevel; + } + + public boolean isRotationDue() { + return rotationDue; + } + + public long getMaxBlocks() { + return maxBlocks; + } + + public long getMaxEncodedBytes() { + return maxEncodedBytes; + } + + public long getPublishedBlock() { + return publishedBlock; + } + + public boolean hasPreparedCheckpoint() { + return prepared; + } + } + + enum Stage { + AFTER_SEAL, + AFTER_NEW_GENERATION, + AFTER_CURRENT, + AFTER_PREPARE, + AFTER_PUBLISH, + AFTER_TRUNCATE_INTENT, + AFTER_TRUNCATE_DELETE_BATCH, + AFTER_TRUNCATE_METADATA + } + + @FunctionalInterface + interface FaultHook { + void after(Stage stage) throws IOException; + } + + private static final class RecoveryCeiling { + private final long blockNumber; + private final byte[] blockHash; + + private RecoveryCeiling(long blockNumber, byte[] blockHash) { + this.blockNumber = blockNumber; + this.blockHash = copyDigest(blockHash, "blockHash"); + } + } + + private static final class PreparedTarget { + private final byte[] targetDigest; + private final StateArchiveHotBatchDescriptor descriptor; + + private PreparedTarget(byte[] targetDigest, StateArchiveHotBatchDescriptor descriptor) { + this.targetDigest = copyDigest(targetDigest, "targetDigest"); + this.descriptor = Objects.requireNonNull(descriptor, "descriptor"); + } + } + + private static final class Catalog { + private final byte[] formatIdentity; + private final Engine engine; + private final long currentGeneration; + + private Catalog(byte[] formatIdentity, Engine engine, long currentGeneration) { + this.formatIdentity = copyDigest(formatIdentity, "formatIdentity"); + this.engine = Objects.requireNonNull(engine, "engine"); + this.currentGeneration = currentGeneration; + } + + private void require(byte[] expectedFormat, Engine expectedEngine) throws IOException { + if (!Arrays.equals(formatIdentity, expectedFormat) || engine != expectedEngine) { + throw new ArchivePersistenceException("Hot Archive CURRENT identity differs"); + } + } + + private byte[] encode() { + ByteBuffer payload = ByteBuffer.allocate(CATALOG_LENGTH - Integer.BYTES) + .putInt(CATALOG_MAGIC).putShort(CATALOG_VERSION).putShort((short) engineTag(engine)) + .putLong(currentGeneration).put(formatIdentity); + byte[] bytes = payload.array(); + return ByteBuffer.allocate(CATALOG_LENGTH).put(bytes) + .putInt(Hashing.crc32c().hashBytes(bytes).asInt()).array(); + } + } + + private static final class GenerationMeta { + private final long id; + private final long baseBlock; + private final byte[] baseHash; + private final long startBlock; + private final long endBlock; + private final byte[] headHash; + private final long blockCount; + private final long encodedBytes; + private final byte[] contentDigest; + private final boolean sealed; + private final long publishedBlock; + private final byte[] publishedHash; + private final byte[] publishedContentDigest; + private final byte[] publishedTarget; + private final StateArchiveHotBatchDescriptor publishedDescriptor; + private final PreparedTarget prepared; + + private GenerationMeta(long id, long baseBlock, byte[] baseHash, long startBlock, + long endBlock, byte[] headHash, long blockCount, long encodedBytes, + byte[] contentDigest, boolean sealed, long publishedBlock, byte[] publishedHash, + byte[] publishedContentDigest, byte[] publishedTarget, + StateArchiveHotBatchDescriptor publishedDescriptor, PreparedTarget prepared) { + this.id = id; + this.baseBlock = baseBlock; + this.baseHash = copyDigest(baseHash, "baseHash"); + this.startBlock = startBlock; + this.endBlock = endBlock; + this.headHash = copyDigest(headHash, "headHash"); + this.blockCount = blockCount; + this.encodedBytes = encodedBytes; + this.contentDigest = copyDigest(contentDigest, "contentDigest"); + this.sealed = sealed; + this.publishedBlock = publishedBlock; + this.publishedHash = copyDigest(publishedHash, "publishedHash"); + this.publishedContentDigest = copyDigest(publishedContentDigest, + "publishedContentDigest"); + this.publishedTarget = copyDigest(publishedTarget, "publishedTarget"); + this.publishedDescriptor = publishedDescriptor; + this.prepared = prepared; + } + + private static GenerationMeta empty(long id, long baseBlock, byte[] baseHash) { + return new GenerationMeta(id, baseBlock, baseHash, -1, baseBlock, baseHash, 0, 0, + ZERO_DIGEST, false, baseBlock, baseHash, ZERO_DIGEST, ZERO_DIGEST, null, null); + } + + private GenerationMeta sealed() { + return new GenerationMeta(id, baseBlock, baseHash, startBlock, endBlock, headHash, + blockCount, encodedBytes, contentDigest, true, publishedBlock, publishedHash, + publishedContentDigest, publishedTarget, publishedDescriptor, prepared); + } + + private GenerationMeta appended(BlockSnapshotMeta meta, byte[] record) { + long first = blockCount == 0 ? meta.getBlockNumber() : startBlock; + return new GenerationMeta(id, baseBlock, baseHash, first, meta.getBlockNumber(), + meta.getBlockHash(), Math.addExact(blockCount, 1), + Math.addExact(encodedBytes, record.length), + nextContentDigest(contentDigest, meta, record), false, meta.getBlockNumber(), + meta.getBlockHash(), nextContentDigest(contentDigest, meta, record), publishedTarget, + publishedDescriptor, null); + } + + private GenerationMeta published(byte[] targetDigest, + StateArchiveHotBatchDescriptor descriptor) { + return new GenerationMeta(id, baseBlock, baseHash, startBlock, endBlock, headHash, + blockCount, encodedBytes, contentDigest, sealed, endBlock, headHash, contentDigest, + targetDigest, descriptor, null); + } + + private List mutableMetadata() { + List mutations = new ArrayList<>(); + mutations.add(StateArchiveIndexDatabase.put(META_START_BLOCK, longBytes(startBlock))); + mutations.add(StateArchiveIndexDatabase.put(META_END_BLOCK, longBytes(endBlock))); + mutations.add(StateArchiveIndexDatabase.put(META_HEAD_HASH, headHash)); + mutations.add(StateArchiveIndexDatabase.put(META_BLOCK_COUNT, longBytes(blockCount))); + mutations.add(StateArchiveIndexDatabase.put(META_ENCODED_BYTES, longBytes(encodedBytes))); + mutations.add(StateArchiveIndexDatabase.put(META_CONTENT_DIGEST, contentDigest)); + mutations.add(StateArchiveIndexDatabase.put(META_PUBLISHED_BLOCK, + longBytes(publishedBlock))); + mutations.add(StateArchiveIndexDatabase.put(META_PUBLISHED_HASH, publishedHash)); + mutations.add(StateArchiveIndexDatabase.put(META_PUBLISHED_CONTENT_DIGEST, + publishedContentDigest)); + mutations.add(StateArchiveIndexDatabase.put(META_PUBLISHED_TARGET, publishedTarget)); + mutations.add(publishedDescriptor == null + ? StateArchiveIndexDatabase.delete(META_PUBLISHED_DESCRIPTOR) + : StateArchiveIndexDatabase.put(META_PUBLISHED_DESCRIPTOR, + new StateArchiveHotBatchDescriptorCodec().encode(publishedDescriptor))); + return mutations; + } + + private List createMutations(byte[] formatIdentity) { + List mutations = new ArrayList<>(); + mutations.add(StateArchiveIndexDatabase.put(META_FORMAT, formatIdentity)); + mutations.add(StateArchiveIndexDatabase.put(META_ID, longBytes(id))); + mutations.add(StateArchiveIndexDatabase.put(META_BASE_BLOCK, longBytes(baseBlock))); + mutations.add(StateArchiveIndexDatabase.put(META_BASE_HASH, baseHash)); + mutations.add(StateArchiveIndexDatabase.put(META_START_BLOCK, longBytes(startBlock))); + mutations.add(StateArchiveIndexDatabase.put(META_END_BLOCK, longBytes(endBlock))); + mutations.add(StateArchiveIndexDatabase.put(META_HEAD_HASH, headHash)); + mutations.add(StateArchiveIndexDatabase.put(META_BLOCK_COUNT, longBytes(blockCount))); + mutations.add(StateArchiveIndexDatabase.put(META_ENCODED_BYTES, longBytes(encodedBytes))); + mutations.add(StateArchiveIndexDatabase.put(META_CONTENT_DIGEST, contentDigest)); + mutations.add(StateArchiveIndexDatabase.put(META_SEALED, new byte[]{0})); + mutations.add(StateArchiveIndexDatabase.put(META_PUBLISHED_BLOCK, + longBytes(publishedBlock))); + mutations.add(StateArchiveIndexDatabase.put(META_PUBLISHED_HASH, publishedHash)); + mutations.add(StateArchiveIndexDatabase.put(META_PUBLISHED_CONTENT_DIGEST, + publishedContentDigest)); + mutations.add(StateArchiveIndexDatabase.put(META_PUBLISHED_TARGET, publishedTarget)); + if (publishedDescriptor != null) { + mutations.add(StateArchiveIndexDatabase.put(META_PUBLISHED_DESCRIPTOR, + new StateArchiveHotBatchDescriptorCodec().encode(publishedDescriptor))); + } + return mutations; + } + + private void validate() throws IOException { + if (id < 0 || baseBlock < 0 || blockCount < 0 || encodedBytes < 0 + || endBlock < baseBlock || publishedBlock < baseBlock || publishedBlock > endBlock) { + throw new ArchivePersistenceException("Hot Archive generation metadata is invalid"); + } + if (blockCount == 0) { + if (startBlock != -1 || endBlock != baseBlock || !Arrays.equals(headHash, baseHash) + || encodedBytes != 0 || !Arrays.equals(contentDigest, ZERO_DIGEST) || sealed) { + throw new ArchivePersistenceException("Hot Archive empty generation is inconsistent"); + } + } else if (startBlock != baseBlock + 1 || endBlock - startBlock + 1 != blockCount) { + throw new ArchivePersistenceException("Hot Archive generation coverage is inconsistent"); + } + if (publishedBlock == baseBlock + && (!Arrays.equals(publishedHash, baseHash) + || !Arrays.equals(publishedContentDigest, ZERO_DIGEST))) { + throw new ArchivePersistenceException("Hot Archive published base is inconsistent"); + } + if (publishedBlock == endBlock + && (!Arrays.equals(publishedHash, headHash) + || !Arrays.equals(publishedContentDigest, contentDigest))) { + throw new ArchivePersistenceException("Hot Archive published head is inconsistent"); + } + if (prepared == null && publishedBlock != endBlock) { + throw new ArchivePersistenceException("Hot Archive unpublished tail has no target"); + } + if (prepared != null + && (sealed + || prepared.descriptor.getParentPublishedBlock() != publishedBlock + || !Arrays.equals(prepared.descriptor.getParentPublishedHash(), publishedHash) + || !Arrays.equals(prepared.descriptor.getParentContentDigest(), + publishedContentDigest) + || prepared.descriptor.getFirstBlock().getBlockNumber() != publishedBlock + 1 + || prepared.descriptor.getLastBlock().getBlockNumber() != endBlock + || !Arrays.equals(prepared.descriptor.getLastBlock().getBlockHash(), headHash) + || !Arrays.equals(prepared.descriptor.getResultContentDigest(), contentDigest))) { + throw new ArchivePersistenceException("Hot Archive prepared target is inconsistent"); + } + boolean initialPublication = Arrays.equals(publishedTarget, ZERO_DIGEST); + if (initialPublication != (publishedDescriptor == null)) { + throw new ArchivePersistenceException("Hot Archive published descriptor is missing"); + } + if (publishedDescriptor != null + && (publishedDescriptor.getLastBlock().getBlockNumber() != publishedBlock + || !Arrays.equals(publishedDescriptor.getLastBlock().getBlockHash(), publishedHash))) { + throw new ArchivePersistenceException("Hot Archive published descriptor is inconsistent"); + } + if (publishedDescriptor != null && blockCount > 0 + && publishedDescriptor.getFirstBlock().getBlockNumber() >= startBlock + && !Arrays.equals(publishedDescriptor.getResultContentDigest(), + publishedContentDigest)) { + throw new ArchivePersistenceException( + "Hot Archive published descriptor content differs"); + } + if (sealed && publishedBlock != endBlock) { + throw new ArchivePersistenceException("Hot Archive sealed generation is unpublished"); + } + } + + private void requireBase(long expectedBlock, byte[] expectedHash) throws IOException { + if (baseBlock != expectedBlock || !Arrays.equals(baseHash, expectedHash)) { + throw new ArchivePersistenceException("Hot Archive generation parent differs"); + } + } + + private void requirePublication(byte[] expectedTarget, + StateArchiveHotBatchDescriptor expectedDescriptor) throws IOException { + if (!Arrays.equals(publishedTarget, expectedTarget) + || !Objects.equals(publishedDescriptor, expectedDescriptor)) { + throw new ArchivePersistenceException( + "Hot Archive generation publication differs"); + } + } + + } + + private static final class ByteArrayKey { + private final byte[] value; + + private ByteArrayKey(byte[] value) { + this.value = Arrays.copyOf(value, value.length); + } + + @Override + public boolean equals(Object object) { + return object instanceof ByteArrayKey + && Arrays.equals(value, ((ByteArrayKey) object).value); + } + + @Override + public int hashCode() { + return Arrays.hashCode(value); + } + } + + private static int engineTag(Engine engine) { + return engine == Engine.LEVELDB ? 1 : 2; + } + + private static Engine engine(int tag) throws IOException { + if (tag == 1) { + return Engine.LEVELDB; + } + if (tag == 2) { + return Engine.ROCKSDB; + } + throw new ArchivePersistenceException("Hot Archive engine tag is unsupported"); + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/StateArchiveIndexDatabase.java b/chainbase/src/main/java/org/tron/core/db2/archive/StateArchiveIndexDatabase.java new file mode 100644 index 00000000000..5ad2df8b908 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/StateArchiveIndexDatabase.java @@ -0,0 +1,1105 @@ +package org.tron.core.db2.archive; + +import static org.fusesource.leveldbjni.JniDBFactory.factory; + +import java.io.Closeable; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.OptionalLong; +import java.util.Set; +import org.iq80.leveldb.DB; +import org.iq80.leveldb.DBIterator; +import org.iq80.leveldb.ReadOptions; +import org.iq80.leveldb.Snapshot; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.tron.common.parameter.CommonParameter; +import org.tron.core.config.args.StorageConfig.NativeDbConfig; +import org.tron.core.db2.stateroot.PathStateStoreManifest.Engine; + +/** Engine-neutral native store for Archive indexes and independent hot-history generations. */ +final class StateArchiveIndexDatabase { + + private static final Logger logger = LoggerFactory.getLogger("DB"); + static final String DEFAULT_COLUMN_FAMILY = "default"; + static final String HOT_BLOCKS_COLUMN_FAMILY = "blocks"; + private static final Map LEVEL_DATABASES = new HashMap<>(); + private static final Map ROCKS_DATABASES = new HashMap<>(); + private static final Map IMMUTABLE_ROCKS_DATABASES = + new HashMap<>(); + private static final Map HOT_ROCKS_DATABASES = new HashMap<>(); + private static final List HOT_COLUMN_FAMILIES = createHotColumnFamilies(); + private static final Set HOT_COLUMN_FAMILY_SET = + Collections.unmodifiableSet(new LinkedHashSet<>(HOT_COLUMN_FAMILIES)); + + private StateArchiveIndexDatabase() { + } + + static Reader openReader(Path directory, Engine engine) throws IOException { + return openReader(directory, engine, configuredOptions()); + } + + static Reader openReader(Path directory, Engine engine, NativeDbConfig suppliedConfig) + throws IOException { + Path path = normalize(directory); + NativeDbConfig config = Objects.requireNonNull(suppliedConfig, "suppliedConfig"); + return engine == Engine.LEVELDB ? new LevelReader(acquireLevel(path, false, config)) + : new RocksReader(acquireRocks(path, false, config)); + } + + static Reader openImmutableReader(Path directory, Engine engine) throws IOException { + Path path = normalize(directory); + NativeDbConfig config = configuredOptions(); + return engine == Engine.LEVELDB ? new LevelReader(acquireLevel(path, false, config)) + : new RocksReader(acquireImmutableRocks(path, config)); + } + + static Writer openWriter(Path directory, Engine engine) throws IOException { + return openWriter(directory, engine, configuredOptions()); + } + + static Writer openWriter(Path directory, Engine engine, NativeDbConfig suppliedConfig) + throws IOException { + Path path = normalize(directory); + NativeDbConfig config = Objects.requireNonNull(suppliedConfig, "suppliedConfig"); + return engine == Engine.LEVELDB ? new LevelWriter(acquireLevel(path, true, config)) + : new RocksWriter(acquireRocks(path, true, config)); + } + + static Reader openHotReader(Path directory, Engine engine, NativeDbConfig suppliedConfig) + throws IOException { + Path path = normalize(directory); + NativeDbConfig config = Objects.requireNonNull(suppliedConfig, "suppliedConfig"); + return engine == Engine.LEVELDB ? new LevelReader(acquireLevel(path, false, config)) + : new HotRocksReader(acquireHotRocks(path, false, config)); + } + + static Writer openHotWriter(Path directory, Engine engine, NativeDbConfig suppliedConfig) + throws IOException { + Path path = normalize(directory); + NativeDbConfig config = Objects.requireNonNull(suppliedConfig, "suppliedConfig"); + return engine == Engine.LEVELDB ? new LevelWriter(acquireLevel(path, true, config)) + : new HotRocksWriter(acquireHotRocks(path, true, config)); + } + + static String hotStoreColumnFamily(String dbName) { + int storeId = ArchiveParticipantDescriptor.current().getStoreId(dbName); + return String.format("store-s%04d-%s", storeId, dbName); + } + + static List hotColumnFamilies() { + return HOT_COLUMN_FAMILIES; + } + + private static List createHotColumnFamilies() { + List stores = new ArrayList<>( + ArchiveParticipantDescriptor.current().getParticipants()); + stores.sort(Comparator.comparingInt( + ArchiveParticipantDescriptor.current()::getStoreId)); + List names = new ArrayList<>(); + names.add(DEFAULT_COLUMN_FAMILY); + names.add(HOT_BLOCKS_COLUMN_FAMILY); + for (String store : stores) { + names.add(hotStoreColumnFamily(store)); + } + return Collections.unmodifiableList(names); + } + + static void checkpoint(Path source, Path target, Engine engine) throws IOException { + Path from = normalize(source); + Path to = normalize(target); + if (engine == Engine.ROCKSDB) { + checkpointRocks(from, to); + return; + } + checkpointLevel(from, to); + } + + static void checkpointImmutable(Path source, Path target, Engine engine) throws IOException { + Path from = normalize(source); + Path to = normalize(target); + if (engine == Engine.ROCKSDB) { + checkpointFrozenRocks(from, to); + return; + } + checkpointLevel(from, to); + } + + static synchronized int openReferenceCount(Path directory, Engine engine) { + Path path = normalize(directory); + if (engine == Engine.LEVELDB) { + SharedLevelDatabase shared = LEVEL_DATABASES.get(path); + return shared == null ? 0 : shared.references; + } + SharedRocksDatabase shared = ROCKS_DATABASES.get(path); + return shared == null ? 0 : shared.references; + } + + private static void checkpointLevel(Path source, Path target) throws IOException { + SharedLevelDatabase shared = acquireLevel(source, false, configuredOptions()); + boolean suspended = false; + try { + shared.database.suspendCompactions(); + suspended = true; + Files.createDirectory(target); + try (java.util.stream.Stream entries = Files.list(source)) { + for (Path entry : (Iterable) entries::iterator) { + if (!Files.isRegularFile(entry, LinkOption.NOFOLLOW_LINKS)) { + continue; + } + String name = entry.getFileName().toString(); + if ("LOCK".equals(name) || "LOG".equals(name) || "LOG.old".equals(name)) { + continue; + } + Path destination = target.resolve(name); + if (name.endsWith(".sst") || name.endsWith(".ldb")) { + Files.createLink(destination, entry); + } else { + Files.copy(entry, destination, StandardCopyOption.COPY_ATTRIBUTES); + try (java.nio.channels.FileChannel channel = java.nio.channels.FileChannel.open( + destination, java.nio.file.StandardOpenOption.WRITE)) { + channel.force(true); + } + } + } + } + HistorySegmentStore.syncDirectory(target); + } catch (InterruptedException failure) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while checkpointing LevelDB Archive index", failure); + } finally { + if (suspended) { + shared.database.resumeCompactions(); + } + releaseLevel(shared); + } + } + + static Mutation put(byte[] key, byte[] value) { + return put(DEFAULT_COLUMN_FAMILY, key, value); + } + + static Mutation put(String columnFamily, byte[] key, byte[] value) { + return new Mutation(columnFamily, key, value); + } + + static Mutation delete(byte[] key) { + return delete(DEFAULT_COLUMN_FAMILY, key); + } + + static Mutation delete(String columnFamily, byte[] key) { + return new Mutation(columnFamily, key, null); + } + + private static Path normalize(Path directory) { + return Objects.requireNonNull(directory, "directory").toAbsolutePath().normalize(); + } + + private static String requireColumnFamily(String columnFamily) { + String name = Objects.requireNonNull(columnFamily, "columnFamily"); + if (!HOT_COLUMN_FAMILY_SET.contains(name)) { + throw new IllegalArgumentException("Unknown Hot Archive column family: " + name); + } + return name; + } + + private static void requireDefaultColumnFamily(String columnFamily) { + if (!DEFAULT_COLUMN_FAMILY.equals(requireColumnFamily(columnFamily))) { + throw new IllegalArgumentException( + "Flat Archive database only supports the default column family"); + } + } + + private static synchronized SharedLevelDatabase acquireLevel(Path directory, boolean create, + NativeDbConfig config) + throws IOException { + SharedLevelDatabase shared = LEVEL_DATABASES.get(directory); + if (shared == null) { + org.iq80.leveldb.Options options = new org.iq80.leveldb.Options() + .createIfMissing(create) + .paranoidChecks(true) + .verifyChecksums(true) + .compressionType(org.iq80.leveldb.CompressionType.SNAPPY) + .blockSize(config.getBlockSize()) + .writeBufferSize(config.getWriteBufferSize()) + .cacheSize(config.getCacheSize()) + .maxOpenFiles(config.getMaxOpenFiles()); + try { + shared = new SharedLevelDatabase(directory, factory.open(directory.toFile(), options)); + } catch (IOException | RuntimeException failure) { + if (failure instanceof IOException) { + throw (IOException) failure; + } + throw failure; + } + LEVEL_DATABASES.put(directory, shared); + logger.info("Archive native database opened: directory={}, engine=LEVELDB, blockBytes={}, " + + "writeBufferBytes={}, cacheBytes={}, maxOpenFiles={}", directory, + config.getBlockSize(), config.getWriteBufferSize(), config.getCacheSize(), + config.getMaxOpenFiles()); + } + shared.references++; + return shared; + } + + private static synchronized SharedRocksDatabase acquireRocks(Path directory, boolean create, + NativeDbConfig config) throws IOException { + SharedRocksDatabase shared = ROCKS_DATABASES.get(directory); + if (shared == null) { + RocksResources resources = new RocksResources(config, create); + try { + shared = new SharedRocksDatabase(directory, + org.rocksdb.RocksDB.open(resources.options, directory.toString()), resources, false); + } catch (org.rocksdb.RocksDBException | RuntimeException failure) { + resources.close(); + throw new IOException("Failed to open RocksDB Archive serving index", failure); + } + ROCKS_DATABASES.put(directory, shared); + logger.info("Archive native database opened: directory={}, engine=ROCKSDB, blockBytes={}, " + + "writeBufferBytes={}, cacheBytes={}, maxOpenFiles={}", directory, + config.getBlockSize(), config.getWriteBufferSize(), config.getCacheSize(), + config.getMaxOpenFiles()); + } + shared.references++; + return shared; + } + + private static synchronized SharedRocksDatabase acquireImmutableRocks(Path directory, + NativeDbConfig config) throws IOException { + SharedRocksDatabase shared = IMMUTABLE_ROCKS_DATABASES.get(directory); + if (shared == null) { + RocksResources resources = new RocksResources(config, false); + try { + shared = new SharedRocksDatabase(directory, + org.rocksdb.RocksDB.openReadOnly(resources.options, directory.toString()), resources, + true); + } catch (org.rocksdb.RocksDBException | RuntimeException failure) { + resources.close(); + throw new IOException("Failed to open immutable RocksDB Archive serving index", failure); + } + IMMUTABLE_ROCKS_DATABASES.put(directory, shared); + logger.info("Immutable Archive native database opened: directory={}, engine=ROCKSDB, " + + "blockBytes={}, cacheBytes={}, maxOpenFiles={}", directory, + config.getBlockSize(), config.getCacheSize(), config.getMaxOpenFiles()); + } + shared.references++; + return shared; + } + + private static synchronized SharedHotRocksDatabase acquireHotRocks(Path directory, + boolean create, NativeDbConfig config) throws IOException { + SharedHotRocksDatabase shared = HOT_ROCKS_DATABASES.get(directory); + if (shared == null) { + RocksColumnFamilyResources resources = new RocksColumnFamilyResources(config, create); + try { + resources.open(directory); + shared = new SharedHotRocksDatabase(directory, resources); + } catch (org.rocksdb.RocksDBException | RuntimeException failure) { + resources.close(); + throw new IOException("Failed to open RocksDB Hot Archive column families", failure); + } + HOT_ROCKS_DATABASES.put(directory, shared); + logger.info("Hot Archive native database opened: directory={}, engine=ROCKSDB, " + + "columnFamilies={}, blockBytes={}, writeBufferBytes={}, cacheBytes={}, " + + "maxOpenFiles={}", directory, resources.handles.size(), config.getBlockSize(), + config.getWriteBufferSize(), config.getCacheSize(), config.getMaxOpenFiles()); + } + shared.references++; + return shared; + } + + private static synchronized void releaseRocks(SharedRocksDatabase shared) { + if (--shared.references != 0) { + return; + } + if (shared.immutable) { + IMMUTABLE_ROCKS_DATABASES.remove(shared.directory); + } else { + ROCKS_DATABASES.remove(shared.directory); + } + shared.database.close(); + shared.resources.close(); + } + + private static synchronized void releaseHotRocks(SharedHotRocksDatabase shared) { + if (--shared.references != 0) { + return; + } + HOT_ROCKS_DATABASES.remove(shared.directory); + shared.resources.close(); + } + + private static void checkpointRocks(Path source, Path target) throws IOException { + SharedRocksDatabase shared = acquireRocks(source, false, configuredOptions()); + try (org.rocksdb.Checkpoint checkpoint = org.rocksdb.Checkpoint.create(shared.database)) { + checkpoint.createCheckpoint(target.toString()); + } catch (org.rocksdb.RocksDBException failure) { + throw new IOException("Failed to checkpoint RocksDB Archive serving index", failure); + } finally { + releaseRocks(shared); + } + } + + private static void checkpointFrozenRocks(Path source, Path target) throws IOException { + Files.createDirectory(target); + try (java.util.stream.Stream entries = Files.list(source)) { + for (Path entry : (Iterable) entries::iterator) { + if (!Files.isRegularFile(entry, LinkOption.NOFOLLOW_LINKS)) { + continue; + } + String name = entry.getFileName().toString(); + if ("LOCK".equals(name) || "LOG".equals(name) || "LOG.old".equals(name)) { + continue; + } + Path destination = target.resolve(name); + if (name.endsWith(".sst")) { + Files.createLink(destination, entry); + } else { + Files.copy(entry, destination, StandardCopyOption.COPY_ATTRIBUTES); + try (java.nio.channels.FileChannel channel = java.nio.channels.FileChannel.open( + destination, java.nio.file.StandardOpenOption.WRITE)) { + channel.force(true); + } + } + } + } + HistorySegmentStore.syncDirectory(target); + } + + private static NativeDbConfig configuredOptions() { + org.tron.core.config.args.Storage storage = CommonParameter.getInstance().getStorage(); + NativeDbConfig config = storage == null ? null + : storage.getStateArchiveServingIndexDbSettings(); + return config == null ? NativeDbConfig.large() : config; + } + + private static synchronized void releaseLevel(SharedLevelDatabase shared) throws IOException { + if (--shared.references != 0) { + return; + } + LEVEL_DATABASES.remove(shared.directory); + shared.database.close(); + } + + interface Reader extends Closeable { + + default byte[] get(byte[] key) throws IOException { + return get(DEFAULT_COLUMN_FAMILY, key); + } + + byte[] get(String columnFamily, byte[] key) throws IOException; + + default KeyValue seek(byte[] key) throws IOException { + return seek(DEFAULT_COLUMN_FAMILY, key); + } + + KeyValue seek(String columnFamily, byte[] key) throws IOException; + + Cursor cursor() throws IOException; + + OptionalLong readLongProperty(String name) throws IOException; + } + + interface Writer extends Closeable { + + default byte[] get(byte[] key) throws IOException { + return get(DEFAULT_COLUMN_FAMILY, key); + } + + byte[] get(String columnFamily, byte[] key) throws IOException; + + void write(List mutations) throws IOException; + + void write(List mutations, boolean sync) throws IOException; + } + + interface Cursor extends Closeable { + + void seek(byte[] key) throws IOException; + + KeyValue next() throws IOException; + } + + static final class Mutation { + private final String columnFamily; + private final byte[] key; + private final byte[] value; + + private Mutation(String columnFamily, byte[] key, byte[] value) { + this.columnFamily = requireColumnFamily(columnFamily); + this.key = Arrays.copyOf(Objects.requireNonNull(key, "key"), key.length); + this.value = value == null ? null : Arrays.copyOf(value, value.length); + } + } + + static final class KeyValue { + private final byte[] key; + private final byte[] value; + + private KeyValue(byte[] key, byte[] value) { + this.key = Arrays.copyOf(key, key.length); + this.value = Arrays.copyOf(value, value.length); + } + + byte[] getKey() { + return Arrays.copyOf(key, key.length); + } + + byte[] getValue() { + return Arrays.copyOf(value, value.length); + } + } + + private static final class SharedLevelDatabase { + private final Path directory; + private final DB database; + private int references; + + private SharedLevelDatabase(Path directory, DB database) { + this.directory = directory; + this.database = database; + } + } + + private static final class SharedRocksDatabase { + private final Path directory; + private final org.rocksdb.RocksDB database; + private final RocksResources resources; + private final boolean immutable; + private int references; + + private SharedRocksDatabase(Path directory, org.rocksdb.RocksDB database, + RocksResources resources, boolean immutable) { + this.directory = directory; + this.database = database; + this.resources = resources; + this.immutable = immutable; + } + } + + private static final class SharedHotRocksDatabase { + private final Path directory; + private final RocksColumnFamilyResources resources; + private int references; + + private SharedHotRocksDatabase(Path directory, RocksColumnFamilyResources resources) { + this.directory = directory; + this.resources = resources; + } + } + + private static final class RocksResources implements Closeable { + private final org.rocksdb.LRUCache cache; + private final org.rocksdb.BloomFilter filter; + private final org.rocksdb.Options options; + + private RocksResources(NativeDbConfig config, boolean create) { + org.rocksdb.RocksDB.loadLibrary(); + cache = new org.rocksdb.LRUCache(config.getCacheSize()); + filter = new org.rocksdb.BloomFilter(config.getBloomBitsPerKey(), false); + org.rocksdb.BlockBasedTableConfig table = new org.rocksdb.BlockBasedTableConfig() + .setBlockSize(config.getBlockSize()) + .setChecksumType(org.rocksdb.ChecksumType.kCRC32c) + .setBlockCache(cache) + .setCacheIndexAndFilterBlocks(true) + .setPinL0FilterAndIndexBlocksInCache(false) + .setWholeKeyFiltering(true) + .setFilter(filter); + options = new org.rocksdb.Options() + .setCreateIfMissing(create) + .setParanoidChecks(true) + .setCompressionType(org.rocksdb.CompressionType.SNAPPY_COMPRESSION) + .setWriteBufferSize(config.getWriteBufferSize()) + .setMaxWriteBufferNumber(config.getMaxWriteBufferNumber()) + .setMinWriteBufferNumberToMerge(1) + .setMaxOpenFiles(config.getMaxOpenFiles()) + .setNumLevels(config.getLevelNumber()) + .setLevelCompactionDynamicLevelBytes(true) + .setLevel0FileNumCompactionTrigger(config.getLevel0FileNumCompactionTrigger()) + .setLevel0SlowdownWritesTrigger(config.getLevel0SlowdownWritesTrigger()) + .setLevel0StopWritesTrigger(config.getLevel0StopWritesTrigger()) + .setMaxBackgroundCompactions(config.getBackgroundCompactions()) + .setMaxBackgroundFlushes(config.getBackgroundFlushes()) + .setTargetFileSizeBase(config.getTargetFileSizeBase()) + .setMaxBytesForLevelBase(config.getMaxBytesForLevelBase()) + .setMaxBytesForLevelMultiplier(config.getMaxBytesForLevelMultiplier()) + .setTableFormatConfig(table); + } + + @Override + public void close() { + options.close(); + filter.close(); + cache.close(); + } + } + + private static final class RocksColumnFamilyResources implements Closeable { + private final NativeDbConfig config; + private final boolean create; + private final org.rocksdb.DBOptions databaseOptions; + private final org.rocksdb.LRUCache cache; + private final List filters = new ArrayList<>(); + private final List columnOptions = new ArrayList<>(); + private final List handles = new ArrayList<>(); + private final Map handlesByName = + new LinkedHashMap<>(); + private org.rocksdb.RocksDB database; + + private RocksColumnFamilyResources(NativeDbConfig config, boolean create) { + org.rocksdb.RocksDB.loadLibrary(); + this.config = config; + this.create = create; + cache = new org.rocksdb.LRUCache(config.getCacheSize()); + databaseOptions = new org.rocksdb.DBOptions() + .setCreateIfMissing(create) + .setCreateMissingColumnFamilies(create) + .setParanoidChecks(true) + .setMaxOpenFiles(config.getMaxOpenFiles()) + .setMaxBackgroundCompactions(config.getBackgroundCompactions()) + .setMaxBackgroundFlushes(config.getBackgroundFlushes()); + } + + private void open(Path directory) throws org.rocksdb.RocksDBException, IOException { + List expected = hotColumnFamilies(); + if (Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) { + Set actual = listColumnFamilies(directory); + if (!actual.equals(new LinkedHashSet<>(expected))) { + throw new ArchivePersistenceException( + "Hot Archive RocksDB column-family layout differs; rebuild/resync is required"); + } + } else if (!create) { + throw new ArchivePersistenceException("Hot Archive RocksDB directory is missing"); + } + List descriptors = new ArrayList<>(); + for (String name : expected) { + org.rocksdb.BloomFilter filter = new org.rocksdb.BloomFilter( + config.getBloomBitsPerKey(), false); + filters.add(filter); + org.rocksdb.BlockBasedTableConfig table = new org.rocksdb.BlockBasedTableConfig() + .setBlockSize(config.getBlockSize()) + .setChecksumType(org.rocksdb.ChecksumType.kCRC32c) + .setBlockCache(cache) + .setCacheIndexAndFilterBlocks(true) + .setPinL0FilterAndIndexBlocksInCache(false) + .setWholeKeyFiltering(true) + .setFilter(filter); + org.rocksdb.ColumnFamilyOptions options = new org.rocksdb.ColumnFamilyOptions() + .setCompressionType(org.rocksdb.CompressionType.SNAPPY_COMPRESSION) + .setWriteBufferSize(config.getWriteBufferSize()) + .setMaxWriteBufferNumber(config.getMaxWriteBufferNumber()) + .setMinWriteBufferNumberToMerge(1) + .setNumLevels(config.getLevelNumber()) + .setLevelCompactionDynamicLevelBytes(true) + .setLevel0FileNumCompactionTrigger(config.getLevel0FileNumCompactionTrigger()) + .setLevel0SlowdownWritesTrigger(config.getLevel0SlowdownWritesTrigger()) + .setLevel0StopWritesTrigger(config.getLevel0StopWritesTrigger()) + .setTargetFileSizeBase(config.getTargetFileSizeBase()) + .setMaxBytesForLevelBase(config.getMaxBytesForLevelBase()) + .setMaxBytesForLevelMultiplier(config.getMaxBytesForLevelMultiplier()) + .setTableFormatConfig(table); + columnOptions.add(options); + descriptors.add(new org.rocksdb.ColumnFamilyDescriptor( + name.getBytes(StandardCharsets.UTF_8), options)); + } + database = org.rocksdb.RocksDB.open(databaseOptions, directory.toString(), descriptors, + handles); + if (handles.size() != expected.size()) { + throw new ArchivePersistenceException("Hot Archive RocksDB did not open every column"); + } + for (int index = 0; index < expected.size(); index++) { + handlesByName.put(expected.get(index), handles.get(index)); + } + } + + private Set listColumnFamilies(Path directory) + throws org.rocksdb.RocksDBException { + try (org.rocksdb.Options options = new org.rocksdb.Options()) { + Set names = new LinkedHashSet<>(); + for (byte[] name : org.rocksdb.RocksDB.listColumnFamilies( + options, directory.toString())) { + names.add(new String(name, StandardCharsets.UTF_8)); + } + return names; + } + } + + private org.rocksdb.ColumnFamilyHandle handle(String columnFamily) { + org.rocksdb.ColumnFamilyHandle handle = handlesByName.get( + requireColumnFamily(columnFamily)); + if (handle == null) { + throw new IllegalArgumentException( + "Hot Archive column family was not opened: " + columnFamily); + } + return handle; + } + + @Override + public void close() { + for (org.rocksdb.ColumnFamilyHandle handle : handles) { + handle.close(); + } + handles.clear(); + handlesByName.clear(); + if (database != null) { + database.close(); + database = null; + } + for (org.rocksdb.ColumnFamilyOptions options : columnOptions) { + options.close(); + } + for (org.rocksdb.BloomFilter filter : filters) { + filter.close(); + } + databaseOptions.close(); + cache.close(); + } + } + + private static final class LevelReader implements Reader { + private final SharedLevelDatabase shared; + private final Snapshot snapshot; + private final ReadOptions reads; + private boolean closed; + + private LevelReader(SharedLevelDatabase shared) throws IOException { + this.shared = shared; + Snapshot openedSnapshot = null; + ReadOptions openedReads = null; + try { + openedSnapshot = shared.database.getSnapshot(); + openedReads = new ReadOptions().fillCache(true).snapshot(openedSnapshot); + } catch (RuntimeException failure) { + if (openedSnapshot != null) { + openedSnapshot.close(); + } + releaseLevel(shared); + throw failure; + } + this.snapshot = openedSnapshot; + this.reads = openedReads; + } + + @Override + public byte[] get(String columnFamily, byte[] key) { + requireColumnFamily(columnFamily); + return shared.database.get(key, reads); + } + + @Override + public KeyValue seek(String columnFamily, byte[] key) throws IOException { + requireColumnFamily(columnFamily); + try (DBIterator iterator = shared.database.iterator(reads)) { + iterator.seek(key); + if (!iterator.hasNext()) { + return null; + } + Map.Entry entry = iterator.next(); + return new KeyValue(entry.getKey(), entry.getValue()); + } + } + + @Override + public Cursor cursor() { + return new LevelCursor(shared.database.iterator(reads)); + } + + @Override + public OptionalLong readLongProperty(String name) { + return OptionalLong.empty(); + } + + @Override + public void close() throws IOException { + if (!closed) { + closed = true; + snapshot.close(); + releaseLevel(shared); + } + } + } + + private static final class LevelWriter implements Writer { + private final SharedLevelDatabase shared; + private boolean closed; + + private LevelWriter(SharedLevelDatabase shared) { + this.shared = shared; + } + + @Override + public byte[] get(String columnFamily, byte[] key) { + requireColumnFamily(columnFamily); + return shared.database.get(key); + } + + @Override + public void write(List mutations) throws IOException { + write(mutations, true); + } + + @Override + public void write(List mutations, boolean sync) throws IOException { + try (org.iq80.leveldb.WriteBatch batch = shared.database.createWriteBatch()) { + for (Mutation mutation : mutations) { + if (mutation.value == null) { + batch.delete(mutation.key); + } else { + batch.put(mutation.key, mutation.value); + } + } + shared.database.write(batch, new org.iq80.leveldb.WriteOptions().sync(sync)); + } + } + + @Override + public void close() throws IOException { + if (!closed) { + closed = true; + releaseLevel(shared); + } + } + } + + private static final class LevelCursor implements Cursor { + private final DBIterator iterator; + + private LevelCursor(DBIterator iterator) { + this.iterator = iterator; + } + + @Override + public void seek(byte[] key) { + iterator.seek(key); + } + + @Override + public KeyValue next() { + if (!iterator.hasNext()) { + return null; + } + Map.Entry entry = iterator.next(); + return new KeyValue(entry.getKey(), entry.getValue()); + } + + @Override + public void close() throws IOException { + iterator.close(); + } + } + + private static final class RocksReader implements Reader { + private final SharedRocksDatabase shared; + private final org.rocksdb.Snapshot snapshot; + private final org.rocksdb.ReadOptions reads; + private boolean closed; + + private RocksReader(SharedRocksDatabase shared) { + this.shared = shared; + snapshot = shared.database.getSnapshot(); + try { + reads = new org.rocksdb.ReadOptions().setVerifyChecksums(true).setFillCache(true) + .setSnapshot(snapshot); + } catch (RuntimeException failure) { + shared.database.releaseSnapshot(snapshot); + releaseRocks(shared); + throw failure; + } + } + + @Override + public byte[] get(String columnFamily, byte[] key) throws IOException { + requireDefaultColumnFamily(columnFamily); + try { + return shared.database.get(reads, key); + } catch (org.rocksdb.RocksDBException failure) { + throw new IOException("Failed to read RocksDB Archive serving index", failure); + } + } + + @Override + public KeyValue seek(String columnFamily, byte[] key) throws IOException { + requireDefaultColumnFamily(columnFamily); + try (org.rocksdb.ReadOptions seekReads = snapshotReads(snapshot); + org.rocksdb.RocksIterator iterator = shared.database.newIterator(seekReads)) { + iterator.seek(key); + if (!iterator.isValid()) { + iterator.status(); + return null; + } + return new KeyValue(iterator.key(), iterator.value()); + } catch (org.rocksdb.RocksDBException failure) { + throw new IOException("Failed to seek RocksDB Archive serving index", failure); + } + } + + @Override + public Cursor cursor() { + return new RocksCursor(shared.database, snapshotReads(snapshot), true); + } + + @Override + public OptionalLong readLongProperty(String name) { + try { + return OptionalLong.of(shared.database.getLongProperty(name)); + } catch (org.rocksdb.RocksDBException | IllegalArgumentException failure) { + return OptionalLong.empty(); + } + } + + @Override + public void close() { + if (!closed) { + closed = true; + reads.close(); + shared.database.releaseSnapshot(snapshot); + releaseRocks(shared); + } + } + + private static org.rocksdb.ReadOptions snapshotReads(org.rocksdb.Snapshot snapshot) { + return new org.rocksdb.ReadOptions().setVerifyChecksums(true).setFillCache(true) + .setSnapshot(snapshot); + } + } + + private static final class RocksWriter implements Writer { + private final SharedRocksDatabase shared; + private boolean closed; + + private RocksWriter(SharedRocksDatabase shared) { + this.shared = shared; + } + + @Override + public byte[] get(String columnFamily, byte[] key) throws IOException { + requireDefaultColumnFamily(columnFamily); + try { + return shared.database.get(key); + } catch (org.rocksdb.RocksDBException failure) { + throw new IOException("Failed to read RocksDB Archive serving index", failure); + } + } + + @Override + public void write(List mutations) throws IOException { + write(mutations, true); + } + + @Override + public void write(List mutations, boolean sync) throws IOException { + try (org.rocksdb.WriteBatch batch = new org.rocksdb.WriteBatch()) { + for (Mutation mutation : mutations) { + requireDefaultColumnFamily(mutation.columnFamily); + if (mutation.value == null) { + batch.delete(mutation.key); + } else { + batch.put(mutation.key, mutation.value); + } + } + try (org.rocksdb.WriteOptions selected = new org.rocksdb.WriteOptions().setSync(sync)) { + shared.database.write(selected, batch); + } + } catch (org.rocksdb.RocksDBException failure) { + throw new IOException("Failed to write RocksDB Archive serving index", failure); + } + } + + @Override + public void close() { + if (!closed) { + closed = true; + releaseRocks(shared); + } + } + } + + private static final class HotRocksReader implements Reader { + private final SharedHotRocksDatabase shared; + private final org.rocksdb.Snapshot snapshot; + private final org.rocksdb.ReadOptions reads; + private boolean closed; + + private HotRocksReader(SharedHotRocksDatabase shared) { + this.shared = shared; + snapshot = shared.resources.database.getSnapshot(); + try { + reads = new org.rocksdb.ReadOptions().setVerifyChecksums(true).setFillCache(true) + .setSnapshot(snapshot); + } catch (RuntimeException failure) { + shared.resources.database.releaseSnapshot(snapshot); + releaseHotRocks(shared); + throw failure; + } + } + + @Override + public byte[] get(String columnFamily, byte[] key) throws IOException { + try { + return shared.resources.database.get(shared.resources.handle(columnFamily), reads, key); + } catch (org.rocksdb.RocksDBException failure) { + throw new IOException("Failed to read RocksDB Hot Archive column family", failure); + } + } + + @Override + public KeyValue seek(String columnFamily, byte[] key) throws IOException { + try (org.rocksdb.ReadOptions seekReads = snapshotReads(snapshot); + org.rocksdb.RocksIterator iterator = shared.resources.database.newIterator( + shared.resources.handle(columnFamily), seekReads)) { + iterator.seek(key); + if (!iterator.isValid()) { + iterator.status(); + return null; + } + return new KeyValue(iterator.key(), iterator.value()); + } catch (org.rocksdb.RocksDBException failure) { + throw new IOException("Failed to seek RocksDB Hot Archive column family", failure); + } + } + + @Override + public Cursor cursor() { + return new RocksCursor(shared.resources.database, + shared.resources.handle(DEFAULT_COLUMN_FAMILY), snapshotReads(snapshot), true); + } + + @Override + public OptionalLong readLongProperty(String name) { + try { + return OptionalLong.of(shared.resources.database.getLongProperty( + shared.resources.handle(DEFAULT_COLUMN_FAMILY), name)); + } catch (org.rocksdb.RocksDBException | IllegalArgumentException failure) { + return OptionalLong.empty(); + } + } + + @Override + public void close() { + if (!closed) { + closed = true; + reads.close(); + shared.resources.database.releaseSnapshot(snapshot); + releaseHotRocks(shared); + } + } + + private static org.rocksdb.ReadOptions snapshotReads(org.rocksdb.Snapshot snapshot) { + return new org.rocksdb.ReadOptions().setVerifyChecksums(true).setFillCache(true) + .setSnapshot(snapshot); + } + } + + private static final class HotRocksWriter implements Writer { + private final SharedHotRocksDatabase shared; + private boolean closed; + + private HotRocksWriter(SharedHotRocksDatabase shared) { + this.shared = shared; + } + + @Override + public byte[] get(String columnFamily, byte[] key) throws IOException { + try { + return shared.resources.database.get(shared.resources.handle(columnFamily), key); + } catch (org.rocksdb.RocksDBException failure) { + throw new IOException("Failed to read RocksDB Hot Archive column family", failure); + } + } + + @Override + public void write(List mutations) throws IOException { + write(mutations, true); + } + + @Override + public void write(List mutations, boolean sync) throws IOException { + try (org.rocksdb.WriteBatch batch = new org.rocksdb.WriteBatch()) { + for (Mutation mutation : mutations) { + org.rocksdb.ColumnFamilyHandle handle = shared.resources.handle(mutation.columnFamily); + if (mutation.value == null) { + batch.delete(handle, mutation.key); + } else { + batch.put(handle, mutation.key, mutation.value); + } + } + try (org.rocksdb.WriteOptions selected = new org.rocksdb.WriteOptions().setSync(sync)) { + shared.resources.database.write(selected, batch); + } + } catch (org.rocksdb.RocksDBException failure) { + throw new IOException("Failed to write RocksDB Hot Archive column families", failure); + } + } + + @Override + public void close() { + if (!closed) { + closed = true; + releaseHotRocks(shared); + } + } + } + + private static final class RocksCursor implements Cursor { + private final org.rocksdb.ReadOptions reads; + private final org.rocksdb.RocksIterator iterator; + private final boolean ownsReadOptions; + + private RocksCursor(org.rocksdb.RocksDB database, org.rocksdb.ReadOptions reads, + boolean ownsReadOptions) { + this.reads = reads; + this.iterator = database.newIterator(reads); + this.ownsReadOptions = ownsReadOptions; + } + + private RocksCursor(org.rocksdb.RocksDB database, + org.rocksdb.ColumnFamilyHandle columnFamily, org.rocksdb.ReadOptions reads, + boolean ownsReadOptions) { + this.reads = reads; + this.iterator = database.newIterator(columnFamily, reads); + this.ownsReadOptions = ownsReadOptions; + } + + @Override + public void seek(byte[] key) { + iterator.seek(key); + } + + @Override + public KeyValue next() throws IOException { + if (!iterator.isValid()) { + try { + iterator.status(); + } catch (org.rocksdb.RocksDBException failure) { + throw new IOException("Failed to scan RocksDB Archive serving index", failure); + } + return null; + } + KeyValue value = new KeyValue(iterator.key(), iterator.value()); + iterator.next(); + return value; + } + + @Override + public void close() { + iterator.close(); + if (ownsReadOptions) { + reads.close(); + } + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/StateArchiveIndexEngineManifest.java b/chainbase/src/main/java/org/tron/core/db2/archive/StateArchiveIndexEngineManifest.java new file mode 100644 index 00000000000..e03ec911304 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/StateArchiveIndexEngineManifest.java @@ -0,0 +1,151 @@ +package org.tron.core.db2.archive; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.util.Arrays; +import java.util.Objects; +import org.tron.core.db2.stateroot.PathStateStoreManifest.Engine; + +/** Durable engine identity that prevents a checkpoint serving index from changing engine. */ +final class StateArchiveIndexEngineManifest { + + static final String FILE = "ENGINE"; + private static final String TEMP = "ENGINE.tmp"; + private static final int MAGIC = 0x53414945; // SAIE + private static final short LEGACY_SHA_VERSION = 1; + private static final short VERSION = 2; + private static final int BODY_LENGTH = Integer.BYTES + 2 * Short.BYTES; + private static final int CHECKSUM_LENGTH = Integer.BYTES; + private static final int LEGACY_DIGEST_LENGTH = 32; + private static final int ENCODED_LENGTH = BODY_LENGTH + CHECKSUM_LENGTH; + + private StateArchiveIndexEngineManifest() { + } + + static void openOrCreate(Path directory, Engine engine) throws IOException { + openOrCreate(directory, engine, false); + } + + static void openOrCreateLegacy(Path directory, Engine engine) throws IOException { + openOrCreate(directory, engine, true); + } + + private static void openOrCreate(Path directory, Engine engine, boolean admitLegacyRocks) + throws IOException { + Path root = Objects.requireNonNull(directory, "directory"); + Engine selected = Objects.requireNonNull(engine, "engine"); + Files.createDirectories(root); + if (!Files.isDirectory(root, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("State Archive serving-index path is not a directory"); + } + Path manifest = root.resolve(FILE); + if (Files.exists(manifest, LinkOption.NOFOLLOW_LINKS)) { + require(root, selected); + return; + } + Path database = root.resolve("keys"); + if (Files.exists(database, LinkOption.NOFOLLOW_LINKS)) { + if (!admitLegacyRocks || selected != Engine.ROCKSDB) { + throw new IOException("State Archive serving index has no engine identity"); + } + } + byte[] encoded = encode(selected); + Path temporary = root.resolve(TEMP); + try { + Files.write(temporary, encoded, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE, + StandardOpenOption.SYNC); + try { + Files.move(temporary, manifest, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException failure) { + throw new IOException("State Archive serving engine publication must be atomic", failure); + } + HistorySegmentStore.syncDirectory(root); + } catch (IOException | RuntimeException failure) { + Files.deleteIfExists(temporary); + throw failure; + } + } + + static void require(Path directory, Engine engine) throws IOException { + Engine expected = Objects.requireNonNull(engine, "engine"); + if (load(directory) != expected) { + throw new IOException("State Archive serving index engine differs: expected " + expected); + } + } + + static Engine load(Path directory) throws IOException { + Path manifest = Objects.requireNonNull(directory, "directory").resolve(FILE); + if (!Files.isRegularFile(manifest, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("State Archive serving engine identity is missing"); + } + byte[] encoded = Files.readAllBytes(manifest); + if (encoded.length != ENCODED_LENGTH + && encoded.length != BODY_LENGTH + LEGACY_DIGEST_LENGTH) { + throw new IOException("State Archive serving engine identity length is invalid"); + } + byte[] body = Arrays.copyOf(encoded, BODY_LENGTH); + try (DataInputStream input = new DataInputStream(new ByteArrayInputStream(body))) { + int magic = input.readInt(); + short version = input.readShort(); + if (magic != MAGIC || version != VERSION && version != LEGACY_SHA_VERSION) { + throw new IOException("State Archive serving engine identity is unsupported"); + } + if (version == VERSION) { + if (encoded.length != ENCODED_LENGTH + || java.nio.ByteBuffer.wrap(encoded, BODY_LENGTH, CHECKSUM_LENGTH).getInt() + != com.google.common.hash.Hashing.crc32c().hashBytes(body).asInt()) { + throw new IOException("State Archive serving engine identity checksum differs"); + } + } else if (encoded.length != BODY_LENGTH + LEGACY_DIGEST_LENGTH + || !Arrays.equals(Arrays.copyOfRange(encoded, BODY_LENGTH, encoded.length), + com.google.common.hash.Hashing.sha256().hashBytes(body).asBytes())) { + throw new IOException("State Archive serving engine identity checksum differs"); + } + int tag = input.readUnsignedShort(); + if (tag == tag(Engine.LEVELDB)) { + return Engine.LEVELDB; + } + if (tag == tag(Engine.ROCKSDB)) { + return Engine.ROCKSDB; + } + throw new IOException("State Archive serving engine identity tag is unsupported"); + } + } + + private static byte[] encode(Engine engine) { + try { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(ENCODED_LENGTH); + DataOutputStream output = new DataOutputStream(bytes); + output.writeInt(MAGIC); + output.writeShort(VERSION); + output.writeShort(tag(engine)); + output.flush(); + byte[] body = bytes.toByteArray(); + output.writeInt(com.google.common.hash.Hashing.crc32c().hashBytes(body).asInt()); + output.flush(); + return bytes.toByteArray(); + } catch (IOException impossible) { + throw new IllegalStateException("in-memory serving engine encoding failed", impossible); + } + } + + private static int tag(Engine engine) { + switch (engine) { + case LEVELDB: + return 1; + case ROCKSDB: + return 2; + default: + throw new IllegalArgumentException("Unsupported State Archive serving engine: " + engine); + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/StateArchiveRuntimeOwner.java b/chainbase/src/main/java/org/tron/core/db2/archive/StateArchiveRuntimeOwner.java new file mode 100644 index 00000000000..996c33be333 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/StateArchiveRuntimeOwner.java @@ -0,0 +1,1028 @@ +package org.tron.core.db2.archive; + +import java.io.Closeable; +import java.io.IOException; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import org.tron.core.db2.archive.ArchiveProgressEnvelope.Kind; +import org.tron.core.db2.core.SnapshotManager; + +/** Sole owner for exact-27 State Archive resources from recovered startup through shutdown. */ +public final class StateArchiveRuntimeOwner implements Closeable { + + static final String SERVING_INDEX_RUNTIME_DIRECTORY = ".serving-index-runtime"; + + public enum ServingIndexStage { + BEFORE_BUILD, + GENERATION_INSTALLED, + CURRENT_PUBLISHED + } + + @FunctionalInterface + public interface ServingIndexFaultHook { + + void afterStage(ServingIndexStage stage) throws IOException; + } + + public enum ReadableStateStage { + CANONICAL_REFRESHED, + LATEST_PUBLISHED, + READABLE_PUBLISHED + } + + @FunctionalInterface + public interface ReadableStateFaultHook { + + void afterStage(ReadableStateStage stage) throws IOException; + } + + public enum State { + RECOVERED, + RUNNING, + QUIESCING, + CLOSED, + FAILED_CLOSED + } + + private final SnapshotManager snapshotManager; + private final Path archiveDirectory; + private final long maxSegmentSize; + private final List participants; + private final BlockSnapshotMeta recoveredHead; + private final int startupRecoveryActionCount; + private final ServingIndexFaultHook servingIndexFaultHook; + private final ReadableStateFaultHook readableStateFaultHook; + private ArchiveRuntimeAttachment attachment; + private ArchiveRuntimeQueryGate queryGate; + private Closeable latestCoordinator; + private Closeable servingCatalog; + private PersistentServingKeyIndexCatalog servingIndexCatalog; + // Process-owned writer: checkpoint targets borrow it and only owner shutdown closes it. + private PersistentServingKeyIndexGeneration.RuntimeBuilder servingIndexBuilder; + private LatestStateGenerationCoordinator latestStateCoordinator; + private BlockSnapshotMeta latestAuthorityHead; + private volatile BlockSnapshotMeta readableHead; + private Closeable sink; + private ArchiveHistoryWriter historyWriter; + private ServingIndexApplyStatistics lastServingApply; + private State state; + private boolean detached; + private IOException terminalFailure; + + public StateArchiveRuntimeOwner(SnapshotManager snapshotManager, + ArchiveRuntimeAttachment attachment, ArchiveRuntimeQueryGate queryGate, + Closeable latestCoordinator, Closeable servingCatalog, + List participants) { + this.snapshotManager = Objects.requireNonNull(snapshotManager, "snapshotManager"); + this.archiveDirectory = null; + this.maxSegmentSize = 0; + this.attachment = Objects.requireNonNull(attachment, "attachment"); + this.queryGate = Objects.requireNonNull(queryGate, "queryGate"); + this.latestCoordinator = Objects.requireNonNull(latestCoordinator, "latestCoordinator"); + this.servingCatalog = Objects.requireNonNull(servingCatalog, "servingCatalog"); + this.servingIndexCatalog = null; + this.servingIndexBuilder = null; + this.latestStateCoordinator = null; + this.latestAuthorityHead = null; + this.readableHead = null; + this.lastServingApply = null; + if (!(attachment.getSink() instanceof Closeable)) { + throw new IllegalArgumentException("Attached archive sink must be Closeable"); + } + this.sink = (Closeable) attachment.getSink(); + this.participants = immutableParticipants(participants); + this.recoveredHead = null; + this.startupRecoveryActionCount = 0; + this.servingIndexFaultHook = stage -> { }; + this.readableStateFaultHook = stage -> { }; + this.state = State.RUNNING; + validateUniqueOwnership(); + } + + private StateArchiveRuntimeOwner(SnapshotManager snapshotManager, + Path archiveDirectory, long maxSegmentSize, BlockSnapshotMeta recoveredHead, + int startupRecoveryActionCount, ServingIndexFaultHook servingIndexFaultHook, + ReadableStateFaultHook readableStateFaultHook) { + this.snapshotManager = Objects.requireNonNull(snapshotManager, "snapshotManager"); + this.archiveDirectory = Objects.requireNonNull(archiveDirectory, "archiveDirectory"); + this.maxSegmentSize = maxSegmentSize; + this.attachment = null; + this.queryGate = null; + this.latestCoordinator = null; + this.servingCatalog = null; + this.servingIndexCatalog = null; + this.servingIndexBuilder = null; + this.latestStateCoordinator = null; + this.latestAuthorityHead = null; + this.lastServingApply = null; + this.readableHead = null; + this.participants = Collections.emptyList(); + this.sink = null; + this.recoveredHead = Objects.requireNonNull(recoveredHead, "recoveredHead"); + this.startupRecoveryActionCount = startupRecoveryActionCount; + this.servingIndexFaultHook = Objects.requireNonNull(servingIndexFaultHook, + "servingIndexFaultHook"); + this.readableStateFaultHook = Objects.requireNonNull(readableStateFaultHook, + "readableStateFaultHook"); + this.state = State.RECOVERED; + } + + /** Opens and validates the committed history authority before normal writes are attached. */ + public static StateArchiveRuntimeOwner recover(SnapshotManager snapshotManager, + Path archiveDirectory, long maxSegmentSize) throws IOException { + return recover(snapshotManager, archiveDirectory, maxSegmentSize, stage -> { }); + } + + public static StateArchiveRuntimeOwner recover(SnapshotManager snapshotManager, + Path archiveDirectory, long maxSegmentSize, ServingIndexFaultHook servingIndexFaultHook) + throws IOException { + return recover(snapshotManager, archiveDirectory, maxSegmentSize, servingIndexFaultHook, + stage -> { }); + } + + public static StateArchiveRuntimeOwner recover(SnapshotManager snapshotManager, + Path archiveDirectory, long maxSegmentSize, ServingIndexFaultHook servingIndexFaultHook, + ReadableStateFaultHook readableStateFaultHook) throws IOException { + Objects.requireNonNull(snapshotManager, "snapshotManager"); + Path root = Objects.requireNonNull(archiveDirectory, "archiveDirectory"); + HistoryCommitMarker head; + try (ArchiveHistoryWriter history = new ArchiveHistoryWriter(root, maxSegmentSize, + ArchiveStoreScope.getStateDatabases())) { + head = history.committedHead(); + } + if (head == null) { + throw new ArchivePersistenceException("State Archive recovered H head is missing"); + } + return new StateArchiveRuntimeOwner(snapshotManager, root, maxSegmentSize, head.getMeta(), 0, + servingIndexFaultHook, readableStateFaultHook); + } + + /** + * Atomically establishes one empty-diff H baseline at the persisted Chainbase head, then + * reopens it through the ordinary startup path. No legacy participant/progress path is created. + */ + public static StateArchiveRuntimeOwner bootstrapAndRecover(SnapshotManager snapshotManager, + Path archiveDirectory, long maxSegmentSize, BlockSnapshotMeta baseHead) throws IOException { + return bootstrapAndRecover(snapshotManager, archiveDirectory, maxSegmentSize, baseHead, + stage -> { }); + } + + public static StateArchiveRuntimeOwner bootstrapAndRecover(SnapshotManager snapshotManager, + Path archiveDirectory, long maxSegmentSize, BlockSnapshotMeta baseHead, + ServingIndexFaultHook servingIndexFaultHook) throws IOException { + return bootstrapAndRecover(snapshotManager, archiveDirectory, maxSegmentSize, baseHead, + servingIndexFaultHook, stage -> { }); + } + + public static StateArchiveRuntimeOwner bootstrapAndRecover(SnapshotManager snapshotManager, + Path archiveDirectory, long maxSegmentSize, BlockSnapshotMeta baseHead, + ServingIndexFaultHook servingIndexFaultHook, + ReadableStateFaultHook readableStateFaultHook) throws IOException { + Objects.requireNonNull(snapshotManager, "snapshotManager"); + Path root = Objects.requireNonNull(archiveDirectory, "archiveDirectory"); + BlockSnapshotMeta head = Objects.requireNonNull(baseHead, "baseHead"); + Path parent = Objects.requireNonNull(root.getParent(), "archive parent directory"); + requireEmptyBootstrapTarget(root); + Files.createDirectories(parent); + Path staging = parent.resolve("." + root.getFileName() + ".bootstrap-" + UUID.randomUUID()); + List names = storeNames(); + try { + HistoryCommitMarker marker; + try (ArchiveHistoryWriter writer = new ArchiveHistoryWriter(staging, maxSegmentSize, + new java.util.LinkedHashSet<>(names))) { + writer.accept(new BlockReverseDiff(head, Collections.emptyList())); + marker = Objects.requireNonNull(writer.committedHead(), "bootstrap history head"); + } + + ArchiveBootstrapAnchor.store(staging, marker, names); + + try (StateArchiveRuntimeOwner verified = recover(snapshotManager, staging, + maxSegmentSize)) { + if (!head.equals(verified.getRecoveredHead()) + || verified.getStartupRecoveryActionCount() != 0) { + throw new ArchivePersistenceException( + "Fresh State Archive baseline did not recover at a zero-action fixed point"); + } + } + if (Files.exists(root, LinkOption.NOFOLLOW_LINKS)) { + Files.delete(root); + } + try { + Files.move(staging, root, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException failure) { + throw new ArchivePersistenceException( + "State Archive bootstrap requires atomic directory publication", failure); + } + HistorySegmentStore.syncDirectory(parent); + return recover(snapshotManager, root, maxSegmentSize, servingIndexFaultHook, + readableStateFaultHook); + } catch (IOException | RuntimeException failure) { + throw failure; + } + } + + public synchronized State getState() { + return state; + } + + public BlockSnapshotMeta getRecoveredHead() { + if (recoveredHead == null) { + throw new IllegalStateException("State Archive runtime has no startup recovery head"); + } + return recoveredHead; + } + + public int getStartupRecoveryActionCount() { + return startupRecoveryActionCount; + } + + /** Continues this recovered owner into one atomically attached normal-write runtime. */ + public synchronized ArchiveHistoryWriter attachNormalWriter(OldValueCollector collector, + int queueCapacity, BlockSnapshotMeta canonicalHead) throws IOException { + return attachNormalWriter(collector, queueCapacity, canonicalHead, Collections.emptyMap()); + } + + public synchronized ArchiveHistoryWriter attachNormalWriter(OldValueCollector collector, + int queueCapacity, BlockSnapshotMeta canonicalHead, + Map supplementalStores) + throws IOException { + if (state != State.RECOVERED) { + throw new IllegalStateException("State Archive owner is not recovered"); + } + ArchiveHistoryWriter writer = null; + AsyncArchiveHistorySink asyncSink = null; + PersistentServingKeyIndexCatalog catalog = null; + PersistentServingKeyIndexGeneration.RuntimeBuilder builder = null; + LatestStateGenerationCoordinator latest = null; + ArchiveRuntimeAttachment candidate = null; + boolean attached = false; + try { + writer = new ArchiveHistoryWriter(archiveDirectory, maxSegmentSize, + ArchiveStoreScope.getStateDatabases()); + if (!recoveredHead.equals(writer.committedHeadMeta())) { + throw new ArchivePersistenceException( + "Recovered archive head changed before normal writer attachment"); + } + ArchiveWalStartupValidator.requireFixedPoint(writer, canonicalHead, + snapshotManager.getRecoveredArchiveWalBinding(), + storeNames()); + catalog = openOrCreateServingCatalog(writer); + validateServingIndex(writer, catalog, canonicalHead); + try (PersistentServingKeyIndexGeneration current = catalog.pin()) { + builder = current.openRuntimeBuilder( + archiveDirectory.resolve(SERVING_INDEX_RUNTIME_DIRECTORY)); + } + latest = LatestStateGenerationCoordinatorFactory.create(snapshotManager, + supplementalStores, this::readLatestAuthority); + restoreLatestState(writer, catalog, builder, latest, canonicalHead); + if (lastServingApply == null) { + lastServingApply = ServingIndexApplyStatistics.zeroAction(canonicalHead); + } + asyncSink = new AsyncArchiveHistorySink(writer, queueCapacity); + ArchiveHistoryWriter attachedWriter = writer; + PersistentServingKeyIndexCatalog attachedCatalog = catalog; + PersistentServingKeyIndexGeneration.RuntimeBuilder attachedBuilder = builder; + LatestStateGenerationCoordinator attachedLatest = latest; + candidate = new ArchiveRuntimeAttachment(collector, asyncSink, + target -> publishServingIndex(attachedWriter, attachedCatalog, target), + target -> publishReadableState(attachedWriter, attachedCatalog, attachedBuilder, + attachedLatest, target)); + snapshotManager.attachArchiveRuntime(candidate); + attached = true; + snapshotManager.markArchiveReadableThrough(canonicalHead.getEpoch()); + validateReadableState(catalog, latest, canonicalHead); + attachment = candidate; + sink = asyncSink; + historyWriter = writer; + servingIndexCatalog = catalog; + servingIndexBuilder = builder; + latestStateCoordinator = latest; + latestCoordinator = latest; + servingCatalog = catalog; + readableHead = canonicalHead; + queryGate = new ArchiveRuntimeQueryGate(new ArchiveGenerationCapsule(catalog, + archiveDirectory, maxSegmentSize, latest, this::readReadableAuthority)); + state = State.RUNNING; + return writer; + } catch (IOException | RuntimeException failure) { + if (attached) { + snapshotManager.detachArchiveRuntime(candidate); + } + if (asyncSink != null) { + try { + asyncSink.close(); + } catch (IOException closeFailure) { + failure.addSuppressed(closeFailure); + } + } else if (writer != null) { + try { + writer.close(); + } catch (IOException closeFailure) { + failure.addSuppressed(closeFailure); + } + } + if (builder != null) { + try { + builder.close(); + } catch (IOException | RuntimeException closeFailure) { + failure.addSuppressed(closeFailure); + } + } + if (catalog != null) { + try { + catalog.close(); + } catch (IOException | RuntimeException closeFailure) { + failure.addSuppressed(closeFailure); + } + } + if (latest != null) { + try { + latest.close(); + } catch (IOException | RuntimeException closeFailure) { + failure.addSuppressed(closeFailure); + } + } + throw failure; + } + } + + public synchronized ArchiveHistoryWriter getHistoryWriter() { + if (state != State.RUNNING || historyWriter == null) { + throw new IllegalStateException("State Archive normal writer is not attached"); + } + return historyWriter; + } + + /** Acquires one request-owned historical snapshot from the currently published fixed point. */ + public synchronized ArchiveRuntimeQueryGate.Lease pinHistoricalState(long targetBlock) + throws IOException { + if (state != State.RUNNING || queryGate == null) { + throw new IllegalStateException("State Archive historical query runtime is not running"); + } + return queryGate.pin(targetBlock); + } + + /** Machine-checks the latest committed H against the last Chainbase WAL binding. */ + public synchronized BlockSnapshotMeta verifyNormalWriteFixedPoint() throws IOException { + ArchiveHistoryWriter writer = getHistoryWriter(); + HistoryCommitMarker head = Objects.requireNonNull(writer.committedHead(), + "archive history head"); + ArchiveWalBinding binding = snapshotManager.getLatestArchiveWalBinding(); + if (binding == null) { + binding = snapshotManager.getRecoveredArchiveWalBinding(); + } + ArchiveWalStartupValidator.requireFixedPoint(writer, head.getMeta(), + binding, storeNames()); + validateServingIndex(writer, requireServingIndexCatalog(), head.getMeta()); + validateReadableState(requireServingIndexCatalog(), head.getMeta()); + return head.getMeta(); + } + + /** Returns an explicit measured snapshot of the current reader-visible v5 serving index. */ + public synchronized ServingIndexInspection inspectServingIndex() throws IOException { + BlockSnapshotMeta head = verifyNormalWriteFixedPoint(); + PersistentServingKeyIndexCatalog catalog = requireServingIndexCatalog(); + try (PersistentServingKeyIndexGeneration serving = catalog.pin()) { + PersistentServingKeyIndexGeneration.GenerationStatistics generation = + serving.inspectStatistics(); + ServingIndexApplyStatistics apply = Objects.requireNonNull(lastServingApply, + "last serving apply statistics"); + if (apply.getIndexedThrough() != head.getEpoch()) { + throw new ArchivePersistenceException( + "Serving inspection apply boundary differs from readable fixed point"); + } + long backlog = head.getEpoch() - generation.getIndexedThrough(); + if (backlog != 0) { + throw new ArchivePersistenceException( + "Serving inspection requires zero history-to-index backlog"); + } + return new ServingIndexInspection(head, generation, apply, backlog); + } + } + + private ArchiveProgressEnvelope readLatestAuthority() { + BlockSnapshotMeta target = latestAuthorityHead; + if (target == null) { + throw new ArchivePersistenceException("Latest-state authority is not being published"); + } + return new ArchiveProgressEnvelope(Kind.READER_VISIBLE, null, target.getEpoch(), + target.getBlockHash(), new byte[16], new byte[32], storeNames()); + } + + private synchronized void restoreLatestState(ArchiveHistoryWriter writer, + PersistentServingKeyIndexCatalog catalog, + PersistentServingKeyIndexGeneration.RuntimeBuilder builder, + LatestStateGenerationCoordinator latest, BlockSnapshotMeta target) throws IOException { + try (PersistentServingKeyIndexGeneration serving = catalog.pin()) { + validateServingGeneration(writer, serving, target); + if (serving.isLatestSourceIdentityBound()) { + publishExistingLatest(latest, serving, target); + return; + } + } + bindAndPublishLatest(writer, catalog, builder, latest, target); + } + + private synchronized void publishReadableState(ArchiveHistoryWriter writer, + PersistentServingKeyIndexCatalog catalog, + PersistentServingKeyIndexGeneration.RuntimeBuilder builder, + LatestStateGenerationCoordinator latest, BlockSnapshotMeta target) throws IOException { + if (!target.equals(writer.committedHeadMeta())) { + throw new ArchivePersistenceException( + "Readable-state target differs from committed history head"); + } + readableStateFaultHook.afterStage(ReadableStateStage.CANONICAL_REFRESHED); + bindAndPublishLatest(writer, catalog, builder, latest, target); + readableStateFaultHook.afterStage(ReadableStateStage.LATEST_PUBLISHED); + long previousReadable = snapshotManager.getArchiveReadableEpoch(); + BlockSnapshotMeta previousReadableHead = readableHead; + snapshotManager.markArchiveReadableThrough(target.getEpoch()); + try { + validateReadableState(catalog, target); + readableHead = target; + readableStateFaultHook.afterStage(ReadableStateStage.READABLE_PUBLISHED); + } catch (IOException | RuntimeException failure) { + snapshotManager.markArchiveReadableThrough(previousReadable); + readableHead = previousReadableHead; + throw failure; + } + } + + private void publishExistingLatest(LatestStateGenerationCoordinator latest, + PersistentServingKeyIndexGeneration serving, BlockSnapshotMeta target) throws IOException { + latestAuthorityHead = target; + try (LatestStateGenerationCoordinator.Candidate candidate = + latest.acquire(serving.getGenerationId())) { + if (!latest.publish(null, candidate, serving)) { + throw new ArchivePersistenceException("Latest-state startup publication changed"); + } + } finally { + latestAuthorityHead = null; + } + } + + private void bindAndPublishLatest(ArchiveHistoryWriter writer, + PersistentServingKeyIndexCatalog catalog, + PersistentServingKeyIndexGeneration.RuntimeBuilder builder, + LatestStateGenerationCoordinator latest, BlockSnapshotMeta target) throws IOException { + String expectedLatest = latest.getCurrentGenerationId(); + long started = System.nanoTime(); + latestAuthorityHead = target; + try (PersistentServingKeyIndexGeneration current = catalog.pin()) { + ServingIndexIncrementalPlan plan = writer.planServingIncrement(current.getIndexedThrough(), + current.getHeadHash()); + boolean targetAlreadyIndexed = plan.getIndexedFrom() == plan.getIndexedThrough(); + if (targetAlreadyIndexed && current.isLatestSourceIdentityBound()) { + try (LatestStateGenerationCoordinator.Candidate candidate = + latest.acquire(current.getGenerationId())) { + if (Arrays.equals(current.getLatestSourceIdentityDigest(), + candidate.getSourceIdentityDigest())) { + if (!latest.publish(expectedLatest, candidate, current)) { + throw new ArchivePersistenceException( + "Latest-state generation changed during zero-action publish"); + } + lastServingApply = ServingIndexApplyStatistics.from(plan, false, + System.nanoTime() - started); + return; + } + } + } + try (LatestStateGenerationCoordinator.Candidate candidate = + latest.acquire(generationId(target))) { + publishServingAndLatest(catalog, builder, latest, current, plan, candidate, + expectedLatest, target); + lastServingApply = ServingIndexApplyStatistics.from(plan, true, + System.nanoTime() - started); + } + } finally { + latestAuthorityHead = null; + } + } + + private void publishServingAndLatest(PersistentServingKeyIndexCatalog catalog, + PersistentServingKeyIndexGeneration.RuntimeBuilder builder, + LatestStateGenerationCoordinator latest, PersistentServingKeyIndexGeneration current, + ServingIndexIncrementalPlan plan, LatestStateGenerationCoordinator.Candidate candidate, + String expectedLatest, BlockSnapshotMeta target) throws IOException { + String generationId = candidate.getGenerationId(); + Path shadow = archiveDirectory.resolve(".serving-index-build-" + UUID.randomUUID()); + try (PersistentServingKeyIndexGeneration built = builder.extendExact(shadow, + generationId, plan, candidate.getSourceIdentityDigest())) { + validateIncrementCandidate(current, plan, built, target); + } + String expectedServing = catalog.getCurrentGenerationId(); + if (!catalog.publish(expectedServing, shadow)) { + throw new ArchivePersistenceException( + "Serving index catalog changed during latest-state publication"); + } + try (PersistentServingKeyIndexGeneration serving = catalog.pin()) { + if (!latest.publish(expectedLatest, candidate, serving)) { + throw new ArchivePersistenceException("Latest-state generation changed during publish"); + } + } + } + + private static void validateIncrementCandidate(PersistentServingKeyIndexGeneration current, + ServingIndexIncrementalPlan plan, PersistentServingKeyIndexGeneration built, + BlockSnapshotMeta target) throws IOException { + if (!built.isExactOnlyFormat() + || built.getIndexedFrom() != current.getIndexedFrom() + || built.getIndexedThrough() != target.getEpoch() + || !Arrays.equals(built.getHeadHash(), target.getBlockHash()) + || !built.getParticipatingDatabases().equals(storeNames())) { + throw new ArchivePersistenceException("Incremental serving candidate identity mismatch"); + } + for (String store : storeNames()) { + PersistentServingKeyIndexGeneration.PersistentStoreCoverage coverage = + built.getPersistentStoreCoverage(store); + if (coverage.getIndexedThrough() != plan.getIndexedThrough()) { + throw new ArchivePersistenceException( + "Incremental serving candidate Store coverage mismatch: " + store); + } + } + } + + private void validateReadableState(PersistentServingKeyIndexCatalog catalog, + BlockSnapshotMeta target) throws IOException { + LatestStateGenerationCoordinator latest = latestStateCoordinator; + if (latest == null) { + throw new ArchivePersistenceException("Latest-state coordinator is not attached"); + } + validateReadableState(catalog, latest, target); + } + + private void validateReadableState(PersistentServingKeyIndexCatalog catalog, + LatestStateGenerationCoordinator latest, BlockSnapshotMeta target) throws IOException { + try (PersistentServingKeyIndexGeneration serving = catalog.pin()) { + if (!serving.isLatestSourceIdentityBound() + || !serving.getGenerationId().equals(latest.getCurrentGenerationId()) + || serving.getIndexedThrough() != target.getEpoch() + || !Arrays.equals(serving.getHeadHash(), target.getBlockHash()) + || snapshotManager.getArchiveReadableEpoch() != target.getEpoch()) { + throw new ArchivePersistenceException("Archive P/H/I/latest/R fixed point mismatch"); + } + } + } + + private ArchiveProgressEnvelope readReadableAuthority() throws IOException { + BlockSnapshotMeta head = readableHead; + PersistentServingKeyIndexCatalog catalog = servingIndexCatalog; + LatestStateGenerationCoordinator latest = latestStateCoordinator; + // H and the WAL binding may advance before the next reader generation is published. Queries + // continue to pin the previous immutable serving/latest generation under this owner's lock; + // write/recovery callers retain the stricter P/H/I/latest/R check above. + if (head == null || catalog == null || latest == null + || snapshotManager.getArchiveReadableEpoch() != head.getEpoch()) { + throw new ArchivePersistenceException( + "Archive historical query has no published reader generation"); + } + try (PersistentServingKeyIndexGeneration serving = catalog.pin()) { + if (!serving.isLatestSourceIdentityBound() + || !serving.getGenerationId().equals(latest.getCurrentGenerationId()) + || serving.getIndexedThrough() != head.getEpoch() + || !Arrays.equals(serving.getHeadHash(), head.getBlockHash())) { + throw new ArchivePersistenceException( + "Archive historical query generation is outside readable R"); + } + } + return new ArchiveProgressEnvelope(Kind.READER_VISIBLE, null, head.getEpoch(), + head.getBlockHash(), new byte[16], new byte[32], storeNames()); + } + + private PersistentServingKeyIndexCatalog openOrCreateServingCatalog( + ArchiveHistoryWriter writer) throws IOException { + Path root = archiveDirectory.resolve("serving-index"); + if (Files.exists(root, LinkOption.NOFOLLOW_LINKS)) { + PersistentServingKeyIndexCatalog catalog = + PersistentServingKeyIndexCatalog.open(root, + StateArchiveCheckpointServingIndex.configuredEngine(), this::afterCatalogStage); + try { + upgradeServingExactIndex(writer, catalog); + return catalog; + } catch (IOException | RuntimeException failure) { + try { + catalog.close(); + } catch (IOException | RuntimeException closeFailure) { + failure.addSuppressed(closeFailure); + } + throw failure; + } + } + String generationId = generationId(writer.committedHeadMeta()); + Path shadow = archiveDirectory.resolve(".serving-index-build-" + UUID.randomUUID()); + ServingIndexIncrementalPlan plan = writer.planServingRebuild(); + long started = System.nanoTime(); + try (PersistentServingKeyIndexGeneration ignored = + PersistentServingKeyIndexGeneration.buildExact(shadow, generationId, plan, + new byte[32])) { + // The catalog reopens and validates the immutable generation before publishing it. + } + PersistentServingKeyIndexCatalog catalog = PersistentServingKeyIndexCatalog.create(root, + shadow, this::afterCatalogStage); + lastServingApply = ServingIndexApplyStatistics.from(plan, true, + System.nanoTime() - started); + return catalog; + } + + private void upgradeServingExactIndex(ArchiveHistoryWriter writer, + PersistentServingKeyIndexCatalog catalog) throws IOException { + String expected = catalog.getCurrentGenerationId(); + byte[] latestSourceIdentityDigest; + BlockSnapshotMeta target = writer.committedHeadMeta(); + try (PersistentServingKeyIndexGeneration current = catalog.pin()) { + if (current.isExactOnlyFormat() + && current.getIndexedThrough() == target.getEpoch() + && Arrays.equals(current.getHeadHash(), target.getBlockHash())) { + validateServingGeneration(writer, current, target); + return; + } + latestSourceIdentityDigest = current.getLatestSourceIdentityDigest(); + if (current.isExactOnlyFormat()) { + ServingIndexIncrementalPlan plan = writer.planServingIncrement( + current.getIndexedThrough(), current.getHeadHash()); + long started = System.nanoTime(); + Path shadow = archiveDirectory.resolve(".serving-index-build-" + UUID.randomUUID()); + try (PersistentServingKeyIndexGeneration candidate = current.extendExact(shadow, + generationId(target), plan, latestSourceIdentityDigest)) { + validateIncrementCandidate(current, plan, candidate, target); + } + if (!catalog.publish(expected, shadow)) { + throw new ArchivePersistenceException( + "Serving index catalog changed during startup catch-up"); + } + lastServingApply = ServingIndexApplyStatistics.from(plan, true, + System.nanoTime() - started); + return; + } + validateServingGeneration(writer, current, target); + } + Path shadow = archiveDirectory.resolve(".serving-index-build-" + UUID.randomUUID()); + ServingIndexIncrementalPlan plan = writer.planServingRebuild(); + long started = System.nanoTime(); + try (PersistentServingKeyIndexGeneration candidate = + PersistentServingKeyIndexGeneration.buildExact(shadow, generationId(target), + plan, latestSourceIdentityDigest)) { + validateServingGeneration(writer, candidate, target); + } + if (!catalog.publish(expected, shadow)) { + throw new ArchivePersistenceException( + "Serving index catalog changed during exact-index upgrade"); + } + lastServingApply = ServingIndexApplyStatistics.from(plan, true, + System.nanoTime() - started); + } + + private synchronized void publishServingIndex(ArchiveHistoryWriter writer, + PersistentServingKeyIndexCatalog catalog, BlockSnapshotMeta target) throws IOException { + BlockSnapshotMeta historyHead = writer.committedHeadMeta(); + if (!target.equals(historyHead)) { + throw new ArchivePersistenceException( + "Serving index target differs from committed history head"); + } + servingIndexFaultHook.afterStage(ServingIndexStage.BEFORE_BUILD); + try (PersistentServingKeyIndexGeneration current = catalog.pin()) { + if (!current.isExactOnlyFormat() || current.getIndexedThrough() > target.getEpoch()) { + throw new ArchivePersistenceException( + "Serving index cannot increment from current durable I"); + } + writer.planServingIncrement(current.getIndexedThrough(), current.getHeadHash()); + } + } + + private void afterCatalogStage(PersistentServingKeyIndexCatalog.PublicationStage stage) + throws IOException { + servingIndexFaultHook.afterStage(stage + == PersistentServingKeyIndexCatalog.PublicationStage.GENERATION_INSTALLED + ? ServingIndexStage.GENERATION_INSTALLED : ServingIndexStage.CURRENT_PUBLISHED); + } + + private static String generationId(BlockSnapshotMeta target) { + return "h-" + target.getEpoch() + "-" + UUID.randomUUID(); + } + + private PersistentServingKeyIndexCatalog requireServingIndexCatalog() { + if (servingIndexCatalog == null) { + throw new IllegalStateException("State Archive serving index is not attached"); + } + return servingIndexCatalog; + } + + private static void validateServingIndex(ArchiveHistoryWriter writer, + PersistentServingKeyIndexCatalog catalog, BlockSnapshotMeta target) throws IOException { + try (PersistentServingKeyIndexGeneration pinned = catalog.pin()) { + validateServingGeneration(writer, pinned, target); + } + } + + private static void validateServingGeneration(ArchiveHistoryWriter writer, + PersistentServingKeyIndexGeneration generation, BlockSnapshotMeta target) + throws IOException { + if (generation.isExactOnlyFormat()) { + ServingIndexIncrementalPlan expected = writer.planServingRebuild(); + if (generation.getIndexedFrom() != expected.getIndexedFrom() + || generation.getIndexedThrough() != target.getEpoch() + || expected.getIndexedThrough() != target.getEpoch() + || !Arrays.equals(generation.getHeadHash(), target.getBlockHash()) + || !Arrays.equals(expected.getHeadHash(), target.getBlockHash()) + || !Arrays.equals(generation.getAuthoritativePrefixDigest(), + PersistentServingKeyIndexGeneration.sourceDigestForRebuild(expected)) + || !generation.getParticipatingDatabases().equals(storeNames())) { + throw new ArchivePersistenceException( + "Exact serving index differs from committed history authority"); + } + for (String store : storeNames()) { + generation.getPersistentStoreCoverage(store); + } + return; + } + ServingKeyIndexGeneration expected = writer.buildServingIdentity("expected"); + List stores = storeNames(); + if (generation.getIndexedFrom() != expected.getIndexedFrom() + || generation.getIndexedThrough() != target.getEpoch() + || expected.getIndexedThrough() != target.getEpoch() + || !Arrays.equals(generation.getHeadHash(), target.getBlockHash()) + || !Arrays.equals(expected.getHeadHash(), target.getBlockHash()) + || !Arrays.equals(generation.getAuthoritativePrefixDigest(), + expected.getAuthoritativePrefixDigest()) + || !generation.getParticipatingDatabases().equals(stores)) { + throw new ArchivePersistenceException( + "Serving index generation differs from committed history authority"); + } + for (String store : stores) { + if (!expected.getStoreCoverage(store).isPresent()) { + throw new ArchivePersistenceException( + "Serving index identity is missing Store coverage: " + store); + } + } + } + + /** One fixed-point inspection result; collection performs no publication or mutation. */ + public static final class ServingIndexInspection { + private final BlockSnapshotMeta readableHead; + private final PersistentServingKeyIndexGeneration.GenerationStatistics generation; + private final ServingIndexApplyStatistics lastApply; + private final long historyToServingBacklog; + + private ServingIndexInspection(BlockSnapshotMeta readableHead, + PersistentServingKeyIndexGeneration.GenerationStatistics generation, + ServingIndexApplyStatistics lastApply, long historyToServingBacklog) { + this.readableHead = readableHead; + this.generation = generation; + this.lastApply = lastApply; + this.historyToServingBacklog = historyToServingBacklog; + } + + public BlockSnapshotMeta getReadableHead() { + return readableHead; + } + + public PersistentServingKeyIndexGeneration.GenerationStatistics getGeneration() { + return generation; + } + + public ServingIndexApplyStatistics getLastApply() { + return lastApply; + } + + public long getHistoryToServingBacklog() { + return historyToServingBacklog; + } + } + + /** In-memory counters for the last successful serving publication at readable R. */ + public static final class ServingIndexApplyStatistics { + private final long indexedFrom; + private final long indexedThrough; + private final int validatedCommitCount; + private final Map changedEntriesByStore; + private final boolean generationCreated; + private final long elapsedNanos; + + private ServingIndexApplyStatistics(long indexedFrom, long indexedThrough, + int validatedCommitCount, Map changedEntriesByStore, + boolean generationCreated, long elapsedNanos) { + this.indexedFrom = indexedFrom; + this.indexedThrough = indexedThrough; + this.validatedCommitCount = validatedCommitCount; + this.changedEntriesByStore = Collections.unmodifiableMap( + new LinkedHashMap<>(changedEntriesByStore)); + this.generationCreated = generationCreated; + this.elapsedNanos = elapsedNanos; + } + + private static ServingIndexApplyStatistics from(ServingIndexIncrementalPlan plan, + boolean generationCreated, long elapsedNanos) { + Map changed = new LinkedHashMap<>(); + plan.getChangesByDatabase().forEach((store, entries) -> + changed.put(store, (long) entries.size())); + return new ServingIndexApplyStatistics(plan.getIndexedFrom(), plan.getIndexedThrough(), + plan.getSourceStepDigests().size(), changed, generationCreated, + Math.max(0, elapsedNanos)); + } + + private static ServingIndexApplyStatistics zeroAction(BlockSnapshotMeta head) { + Map changed = new LinkedHashMap<>(); + storeNames().forEach(store -> changed.put(store, 0L)); + return new ServingIndexApplyStatistics(head.getEpoch(), head.getEpoch(), 0, changed, + false, 0); + } + + public long getIndexedFrom() { + return indexedFrom; + } + + public long getIndexedThrough() { + return indexedThrough; + } + + public int getValidatedCommitCount() { + return validatedCommitCount; + } + + public Map getChangedEntriesByStore() { + return changedEntriesByStore; + } + + public boolean isGenerationCreated() { + return generationCreated; + } + + public long getElapsedNanos() { + return elapsedNanos; + } + + public long getChangedEntryCount() { + return changedEntriesByStore.values().stream().mapToLong(Long::longValue).sum(); + } + + public boolean isThroughputAvailable() { + return elapsedNanos > 0 && validatedCommitCount > 0; + } + + public double getChangedEntriesPerSecond() { + requireThroughput(); + return getChangedEntryCount() * 1_000_000_000.0 / elapsedNanos; + } + + public double getValidatedCommitsPerSecond() { + requireThroughput(); + return validatedCommitCount * 1_000_000_000.0 / elapsedNanos; + } + + private void requireThroughput() { + if (!isThroughputAvailable()) { + throw new IllegalStateException("Serving apply throughput is unavailable"); + } + } + } + + /** Quiesces, detaches and closes owned resources without waiting for active query leases. */ + @Override + public synchronized void close() throws IOException { + if (state == State.CLOSED) { + return; + } + if (state == State.FAILED_CLOSED) { + throw terminalFailure; + } + if (state == State.RECOVERED) { + IOException failure = closeParticipants(); + if (failure == null) { + state = State.CLOSED; + return; + } + terminalFailure = failure; + state = State.FAILED_CLOSED; + throw failure; + } + state = State.QUIESCING; + if (queryGate != null) { + queryGate.quiesce(); + } + readableHead = null; + if (!detached) { + ArchiveRuntimeAttachment returned = snapshotManager.detachArchiveRuntime(attachment); + if (returned != attachment) { + throw new IllegalStateException("SnapshotManager returned a foreign archive attachment"); + } + detached = true; + } + if (queryGate != null && !queryGate.isDrained()) { + throw new IllegalStateException( + "State Archive runtime still has active query leases: " + + queryGate.getActiveLeaseCount()); + } + if (queryGate != null) { + queryGate.close(); + } + + IOException failure = null; + if (latestCoordinator != null) { + failure = closeOwned("latest coordinator", latestCoordinator, failure); + } + if (servingIndexBuilder != null) { + failure = closeOwned("serving index runtime builder", servingIndexBuilder, failure); + } + if (servingCatalog != null) { + failure = closeOwned("serving catalog", servingCatalog, failure); + } + for (int i = participants.size() - 1; i >= 0; i--) { + failure = closeOwned("archive participant " + i, participants.get(i), failure); + } + failure = closeOwned("archive history sink", sink, failure); + if (failure == null) { + state = State.CLOSED; + return; + } + terminalFailure = failure; + state = State.FAILED_CLOSED; + throw failure; + } + + private IOException closeParticipants() { + IOException failure = null; + for (int i = participants.size() - 1; i >= 0; i--) { + failure = closeOwned("archive participant " + i, participants.get(i), failure); + } + return failure; + } + + private static void requireEmptyBootstrapTarget(Path root) throws IOException { + if (!Files.exists(root, LinkOption.NOFOLLOW_LINKS)) { + return; + } + if (Files.isSymbolicLink(root) + || !Files.isDirectory(root, LinkOption.NOFOLLOW_LINKS)) { + throw new ArchivePersistenceException("State Archive bootstrap target is not a directory"); + } + try (java.util.stream.Stream entries = Files.list(root)) { + if (entries.findAny().isPresent()) { + throw new ArchivePersistenceException("State Archive bootstrap target is not empty"); + } + } + } + + private static List storeNames() { + List names = new ArrayList<>(ArchiveStoreScope.getStateDatabases()); + Collections.sort(names); + return names; + } + + private static List immutableParticipants( + List participants) { + List source = Objects.requireNonNull(participants, "participants"); + List copy = new ArrayList<>(source.size()); + for (Closeable participant : source) { + copy.add(Objects.requireNonNull(participant, "participant")); + } + return Collections.unmodifiableList(copy); + } + + private void validateUniqueOwnership() { + Set unique = Collections.newSetFromMap(new IdentityHashMap()); + requireUnique(unique, latestCoordinator, "latestCoordinator"); + requireUnique(unique, servingCatalog, "servingCatalog"); + for (int i = 0; i < participants.size(); i++) { + requireUnique(unique, participants.get(i), "participant[" + i + "]"); + } + requireUnique(unique, sink, "sink"); + } + + private static void requireUnique(Set unique, Closeable resource, String name) { + if (!unique.add(resource)) { + throw new IllegalArgumentException("Archive runtime resource has multiple owners: " + name); + } + } + + private static IOException closeOwned(String name, Closeable resource, IOException current) { + try { + resource.close(); + return current; + } catch (IOException failure) { + return append(current, failure); + } catch (RuntimeException failure) { + return append(current, new IOException("Failed to close " + name, failure)); + } + } + + private static IOException append(IOException current, IOException failure) { + if (current == null) { + return failure; + } + current.addSuppressed(failure); + return current; + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/StateArchiveSegmentFormatV3.java b/chainbase/src/main/java/org/tron/core/db2/archive/StateArchiveSegmentFormatV3.java new file mode 100644 index 00000000000..188682d81cc --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/StateArchiveSegmentFormatV3.java @@ -0,0 +1,1319 @@ +package org.tron.core.db2.archive; + +import com.google.common.hash.Hashing; +import java.nio.ByteBuffer; +import java.util.Arrays; +import java.util.Objects; + +/** Byte-exact codecs for the default-off State Archive v3 size-rolled segment metadata. */ +public final class StateArchiveSegmentFormatV3 { + + private static final int HEADER_DIGEST_OFFSET = 476; + private static final int HEADER_CRC_OFFSET = 508; + private static final int SEAL_DIGEST_OFFSET = 320; + private static final int SEAL_CRC_OFFSET = 360; + private static final int MARKER_DIGEST_OFFSET = 288; + private static final int MARKER_CRC_OFFSET = 328; + private static final int BLOCK_INDEX_HEADER_DIGEST_OFFSET = 92; + private static final int BLOCK_INDEX_HEADER_CRC_OFFSET = 124; + private static final int MANIFEST_DIGEST_OFFSET = 256; + private static final int MANIFEST_CRC_OFFSET = 296; + + private StateArchiveSegmentFormatV3() { + } + + /** Rotation is decided before the next frame and never considers that frame's length. */ + public static boolean shouldRotate(long blockFrameCount, long dataEndOffset, + long segmentTargetBytes) { + if (blockFrameCount < 0 || dataEndOffset < StateArchiveFileFormatV3.PART_HEADER_LENGTH + || segmentTargetBytes <= StateArchiveFileFormatV3.PART_HEADER_LENGTH) { + throw new IllegalArgumentException("Invalid State Archive segment rotation state"); + } + return blockFrameCount > 0 && dataEndOffset >= segmentTargetBytes; + } + + public static byte[] encodeHeader(SegmentHeader header) { + Objects.requireNonNull(header, "header"); + header.validate(); + ByteBuffer bytes = ByteBuffer.allocate(StateArchiveFileFormatV3.PART_HEADER_LENGTH); + bytes.putInt(StateArchiveFileFormatV3.SEGMENT_MAGIC); + bytes.putShort(StateArchiveFileFormatV3.MAJOR_VERSION); + bytes.putShort(StateArchiveFileFormatV3.MINOR_VERSION); + bytes.putInt(StateArchiveFileFormatV3.PART_HEADER_LENGTH); + bytes.putShort(StateArchiveFileFormatV3.laneKind(header.laneId)); + bytes.putShort((short) header.laneId); + bytes.putShort(StateArchiveFileFormatV3.SEGMENT_LAYOUT_ID); + bytes.putShort((short) 0); + bytes.putLong(header.segmentSeq); + bytes.putLong(header.actualFirstBlock); + bytes.putLong(header.actualFirstBlock); + bytes.putLong(StateArchiveFileFormatV3.SEGMENT_TARGET_BYTES); + bytes.putLong(StateArchiveFileFormatV3.MAX_BLOCK_FRAME_BYTES); + bytes.put(StateArchiveFileFormatV3.fiveLaneDescriptorDigest()); + bytes.put(StateArchiveFileFormatV3.compositeFormatDigest()); + bytes.put(header.previousSegmentDigest); + bytes.put(header.previousHistoryDigest); + bytes.putShort(StateArchiveFileFormatV3.laneBodyCodec(header.laneId)); + bytes.putShort(header.compressionId); + bytes.putShort(StateArchiveFileFormatV3.KEY_ORDER_ID); + bytes.putShort(StateArchiveFileFormatV3.DIGEST_ID); + bytes.putShort(StateArchiveFileFormatV3.CHECKSUM_ID); + bytes.putShort(StateArchiveFileFormatV3.IDENTITY_KIND); + bytes.put(new byte[276]); + if (bytes.position() != HEADER_DIGEST_OFFSET) { + throw new IllegalStateException("Invalid State Archive segment header layout"); + } + bytes.put(StateArchiveFileFormatV3.sha256(StateArchiveFileFormatV3.SEGMENT_HEADER_DOMAIN, + Arrays.copyOf(bytes.array(), HEADER_DIGEST_OFFSET))); + bytes.putInt(crc32c(bytes.array(), 0, HEADER_CRC_OFFSET)); + if (bytes.hasRemaining()) { + throw new IllegalStateException("Invalid State Archive segment header length"); + } + return bytes.array(); + } + + public static SegmentHeader decodeHeader(byte[] encoded) { + requireLength(encoded, StateArchiveFileFormatV3.PART_HEADER_LENGTH, "segment header"); + ByteBuffer bytes = ByteBuffer.wrap(encoded); + requireInt(bytes, StateArchiveFileFormatV3.SEGMENT_MAGIC, "segment magic"); + requireShort(bytes, StateArchiveFileFormatV3.MAJOR_VERSION, "major version"); + requireShort(bytes, StateArchiveFileFormatV3.MINOR_VERSION, "minor version"); + requireInt(bytes, StateArchiveFileFormatV3.PART_HEADER_LENGTH, "header length"); + short laneKind = bytes.getShort(); + int laneId = Short.toUnsignedInt(bytes.getShort()); + if (laneKind != StateArchiveFileFormatV3.laneKind(laneId)) { + throw new IllegalArgumentException("State Archive segment lane kind mismatch"); + } + requireShort(bytes, StateArchiveFileFormatV3.SEGMENT_LAYOUT_ID, "segment layout"); + requireShort(bytes, (short) 0, "segment flags"); + long segmentSeq = requireNonNegative(bytes.getLong(), "segment sequence"); + long firstBlock = requireNonNegative(bytes.getLong(), "first block"); + if (bytes.getLong() != firstBlock) { + throw new IllegalArgumentException("State Archive segment first epoch mismatch"); + } + if (bytes.getLong() != StateArchiveFileFormatV3.SEGMENT_TARGET_BYTES + || bytes.getLong() != StateArchiveFileFormatV3.MAX_BLOCK_FRAME_BYTES) { + throw new IllegalArgumentException("State Archive segment size policy mismatch"); + } + requireArray(getBytes(bytes, 32), StateArchiveFileFormatV3.fiveLaneDescriptorDigest(), + "placement descriptor"); + requireArray(getBytes(bytes, 32), StateArchiveFileFormatV3.compositeFormatDigest(), + "composite format"); + byte[] previousSegmentDigest = getBytes(bytes, 32); + byte[] previousHistoryDigest = getBytes(bytes, 32); + requireShort(bytes, StateArchiveFileFormatV3.laneBodyCodec(laneId), "body codec"); + short compressionId = bytes.getShort(); + requireCompression(compressionId); + requireShort(bytes, StateArchiveFileFormatV3.KEY_ORDER_ID, "key order"); + requireShort(bytes, StateArchiveFileFormatV3.DIGEST_ID, "digest algorithm"); + requireShort(bytes, StateArchiveFileFormatV3.CHECKSUM_ID, "checksum algorithm"); + requireShort(bytes, StateArchiveFileFormatV3.IDENTITY_KIND, "identity kind"); + requireZero(bytes, 276, "segment reserved bytes"); + byte[] headerDigest = getBytes(bytes, 32); + requireArray(headerDigest, StateArchiveFileFormatV3.sha256( + StateArchiveFileFormatV3.SEGMENT_HEADER_DOMAIN, + Arrays.copyOf(encoded, HEADER_DIGEST_OFFSET)), "segment header digest"); + int expectedCrc = bytes.getInt(); + if (expectedCrc != crc32c(encoded, 0, HEADER_CRC_OFFSET)) { + throw new IllegalArgumentException("State Archive segment header checksum mismatch"); + } + return new SegmentHeader(laneId, segmentSeq, firstBlock, previousSegmentDigest, + previousHistoryDigest, compressionId, headerDigest); + } + + /** Returns the deterministic predecessor anchor for segment sequence zero of one lane. */ + public static byte[] laneBaselineDigest(int laneId) { + StateArchiveFileFormatV3.laneKind(laneId); + return StateArchiveFileFormatV3.sha256( + StateArchiveFileFormatV3.LANE_SEGMENT_BASELINE_DOMAIN, + StateArchiveFileFormatV3.compositeFormatDigest(), + StateArchiveFileFormatV3.fiveLaneDescriptorDigest(), + ByteBuffer.allocate(Short.BYTES).putShort((short) laneId).array()); + } + + /** Computes the link stored as previousSegmentDigest in the next segment header. */ + public static byte[] segmentChainDigest(int laneId, long segmentSeq, + byte[] segmentHeaderDigest, byte[] segmentContentDigest, byte[] sealFrameDigest) { + StateArchiveFileFormatV3.laneKind(laneId); + requireNonNegative(segmentSeq, "segment sequence"); + byte[] identity = ByteBuffer.allocate(Short.BYTES + Long.BYTES) + .putShort((short) laneId).putLong(segmentSeq).array(); + return StateArchiveFileFormatV3.sha256(StateArchiveFileFormatV3.SEGMENT_CHAIN_DOMAIN, + identity, + requireHash(segmentHeaderDigest, "segment header digest"), + requireHash(segmentContentDigest, "segment content digest"), + requireHash(sealFrameDigest, "seal frame digest")); + } + + public static byte[] encodeSeal(SegmentSeal seal) { + Objects.requireNonNull(seal, "seal"); + seal.validate(); + int totalLength = StateArchiveFileFormatV3.SEAL_HEADER_LENGTH + + StateArchiveFileFormatV3.FRAME_TRAILER_LENGTH; + ByteBuffer bytes = ByteBuffer.allocate(totalLength); + bytes.putInt(StateArchiveFileFormatV3.FRAME_MAGIC); + bytes.putShort(StateArchiveFileFormatV3.MAJOR_VERSION); + bytes.putShort(StateArchiveFileFormatV3.MINOR_VERSION); + bytes.putShort(StateArchiveFileFormatV3.PART_SEAL_FRAME_TYPE); + bytes.putShort((short) 0); + bytes.putInt(StateArchiveFileFormatV3.SEAL_HEADER_LENGTH); + bytes.putLong(totalLength); + bytes.putLong(0); + bytes.putShort((short) seal.laneId); + bytes.putShort((short) 0); + bytes.putLong(seal.segmentSeq); + bytes.putLong(seal.actualFirstBlock); + bytes.putLong(seal.actualLastBlock); + bytes.putLong(seal.blockFrameCount); + bytes.putLong(seal.entryCount); + bytes.putLong(seal.logicalPayloadBytes); + bytes.putLong(seal.encodedBlockFrameBytes); + bytes.putLong(seal.dataEndOffset); + bytes.putLong(seal.physicalFileBytes); + bytes.put(StateArchiveFileFormatV3.fiveLaneDescriptorDigest()); + bytes.put(seal.firstBlockFrameDigest); + bytes.put(seal.lastBlockFrameDigest); + bytes.put(seal.startHistoryDigest); + bytes.put(seal.endHistoryDigest); + bytes.put(seal.segmentContentDigest); + bytes.put(new byte[20]); + if (bytes.position() != SEAL_DIGEST_OFFSET) { + throw new IllegalStateException("Invalid State Archive seal header layout"); + } + byte[] encodedFrameDigest = StateArchiveFileFormatV3.sha256( + StateArchiveFileFormatV3.ENCODED_SEGMENT_SEAL_DOMAIN, + Arrays.copyOf(bytes.array(), SEAL_DIGEST_OFFSET)); + bytes.put(encodedFrameDigest); + bytes.putLong(totalLength); + bytes.putInt(crc32c(bytes.array(), 0, SEAL_CRC_OFFSET)); + bytes.putInt(StateArchiveFileFormatV3.FRAME_TRAILER_MAGIC); + if (bytes.hasRemaining()) { + throw new IllegalStateException("Invalid State Archive seal frame length"); + } + return bytes.array(); + } + + public static SegmentSeal decodeSeal(byte[] encoded) { + int totalLength = StateArchiveFileFormatV3.SEAL_HEADER_LENGTH + + StateArchiveFileFormatV3.FRAME_TRAILER_LENGTH; + requireLength(encoded, totalLength, "seal frame"); + ByteBuffer bytes = ByteBuffer.wrap(encoded); + requireInt(bytes, StateArchiveFileFormatV3.FRAME_MAGIC, "seal magic"); + requireShort(bytes, StateArchiveFileFormatV3.MAJOR_VERSION, "seal major version"); + requireShort(bytes, StateArchiveFileFormatV3.MINOR_VERSION, "seal minor version"); + requireShort(bytes, StateArchiveFileFormatV3.PART_SEAL_FRAME_TYPE, "seal frame type"); + requireShort(bytes, (short) 0, "seal flags"); + requireInt(bytes, StateArchiveFileFormatV3.SEAL_HEADER_LENGTH, "seal header length"); + requireLong(bytes, totalLength, "seal total length"); + requireLong(bytes, 0, "seal payload length"); + int laneId = Short.toUnsignedInt(bytes.getShort()); + StateArchiveFileFormatV3.laneKind(laneId); + requireShort(bytes, (short) 0, "seal reserved field"); + long segmentSeq = requireNonNegative(bytes.getLong(), "segment sequence"); + long firstBlock = requireNonNegative(bytes.getLong(), "first block"); + long lastBlock = requireNonNegative(bytes.getLong(), "last block"); + long blockFrameCount = requireNonNegative(bytes.getLong(), "block frame count"); + long entryCount = requireNonNegative(bytes.getLong(), "entry count"); + long logicalPayloadBytes = requireNonNegative(bytes.getLong(), + "logical payload bytes"); + long encodedBlockFrameBytes = requireNonNegative(bytes.getLong(), + "encoded block frame bytes"); + long dataEndOffset = requireNonNegative(bytes.getLong(), "data end offset"); + long physicalFileBytes = requireNonNegative(bytes.getLong(), "physical file bytes"); + requireArray(getBytes(bytes, 32), StateArchiveFileFormatV3.fiveLaneDescriptorDigest(), + "seal placement descriptor"); + SegmentSeal seal = new SegmentSeal(laneId, + segmentSeq, firstBlock, lastBlock, blockFrameCount, entryCount, + logicalPayloadBytes, encodedBlockFrameBytes, dataEndOffset, physicalFileBytes, + getBytes(bytes, 32), getBytes(bytes, 32), getBytes(bytes, 32), + getBytes(bytes, 32), getBytes(bytes, 32), null); + requireZero(bytes, 20, "seal reserved bytes"); + byte[] encodedFrameDigest = getBytes(bytes, 32); + requireArray(encodedFrameDigest, StateArchiveFileFormatV3.sha256( + StateArchiveFileFormatV3.ENCODED_SEGMENT_SEAL_DOMAIN, + Arrays.copyOf(encoded, SEAL_DIGEST_OFFSET)), "seal encoded frame digest"); + requireLong(bytes, totalLength, "repeated seal total length"); + if (bytes.getInt() != crc32c(encoded, 0, SEAL_CRC_OFFSET)) { + throw new IllegalArgumentException("State Archive seal checksum mismatch"); + } + requireInt(bytes, StateArchiveFileFormatV3.FRAME_TRAILER_MAGIC, + "seal trailer magic"); + return seal.withEncodedFrameDigest(encodedFrameDigest); + } + + public static byte[] encodeDurableMarker(DurableMarker marker) { + Objects.requireNonNull(marker, "marker"); + marker.validate(); + int totalLength = StateArchiveFileFormatV3.MARKER_HEADER_LENGTH + + StateArchiveFileFormatV3.FRAME_TRAILER_LENGTH; + ByteBuffer bytes = ByteBuffer.allocate(totalLength); + bytes.putInt(StateArchiveFileFormatV3.FRAME_MAGIC); + bytes.putShort(StateArchiveFileFormatV3.MAJOR_VERSION); + bytes.putShort(StateArchiveFileFormatV3.MINOR_VERSION); + bytes.putShort(StateArchiveFileFormatV3.DURABLE_MARKER_FRAME_TYPE); + bytes.putShort((short) 0); + bytes.putInt(StateArchiveFileFormatV3.MARKER_HEADER_LENGTH); + bytes.putLong(totalLength); + bytes.putLong(0); + bytes.putLong(marker.checkpointSequence); + bytes.putLong(marker.firstEpoch); + bytes.putLong(marker.lastEpoch); + bytes.putLong(marker.firstBlock); + bytes.putLong(marker.lastBlock); + bytes.put(marker.lastBlockHash); + bytes.put(StateArchiveFileFormatV3.fiveLaneDescriptorDigest()); + bytes.put(marker.resultHistoryDigest); + bytes.putLong(marker.coveredStartOffset); + bytes.putLong(marker.markerEndOffset); + bytes.putLong(marker.blockCount); + bytes.putLong(marker.logicalBytes); + bytes.putLong(marker.encodedBytes); + bytes.put(marker.previousMarkerDigest); + bytes.put(marker.commonTargetDigest); + bytes.putShort((short) marker.laneId); + bytes.putShort((short) 0); + bytes.putLong(marker.segmentSeq); + bytes.putInt(0); + if (bytes.position() != MARKER_DIGEST_OFFSET) { + throw new IllegalStateException("Invalid State Archive durable marker layout"); + } + byte[] digest = StateArchiveFileFormatV3.sha256( + StateArchiveFileFormatV3.ENCODED_MARKER_DOMAIN, + Arrays.copyOf(bytes.array(), MARKER_DIGEST_OFFSET)); + bytes.put(digest); + bytes.putLong(totalLength); + bytes.putInt(crc32c(bytes.array(), 0, MARKER_CRC_OFFSET)); + bytes.putInt(StateArchiveFileFormatV3.FRAME_TRAILER_MAGIC); + if (bytes.hasRemaining()) { + throw new IllegalStateException("Invalid State Archive durable marker length"); + } + return bytes.array(); + } + + public static DurableMarker decodeDurableMarker(byte[] encoded) { + int totalLength = StateArchiveFileFormatV3.MARKER_HEADER_LENGTH + + StateArchiveFileFormatV3.FRAME_TRAILER_LENGTH; + requireLength(encoded, totalLength, "durable marker"); + ByteBuffer bytes = ByteBuffer.wrap(encoded); + requireInt(bytes, StateArchiveFileFormatV3.FRAME_MAGIC, "marker magic"); + requireShort(bytes, StateArchiveFileFormatV3.MAJOR_VERSION, "marker major version"); + requireShort(bytes, StateArchiveFileFormatV3.MINOR_VERSION, "marker minor version"); + requireShort(bytes, StateArchiveFileFormatV3.DURABLE_MARKER_FRAME_TYPE, + "marker frame type"); + requireShort(bytes, (short) 0, "marker flags"); + requireInt(bytes, StateArchiveFileFormatV3.MARKER_HEADER_LENGTH, + "marker header length"); + requireLong(bytes, totalLength, "marker total length"); + requireLong(bytes, 0, "marker payload length"); + long checkpointSequence = requireNonNegative(bytes.getLong(), "checkpoint sequence"); + long firstEpoch = requireNonNegative(bytes.getLong(), "first epoch"); + long lastEpoch = requireNonNegative(bytes.getLong(), "last epoch"); + long firstBlock = requireNonNegative(bytes.getLong(), "first block"); + long lastBlock = requireNonNegative(bytes.getLong(), "last block"); + byte[] lastBlockHash = getBytes(bytes, 32); + requireArray(getBytes(bytes, 32), StateArchiveFileFormatV3.fiveLaneDescriptorDigest(), + "marker placement descriptor"); + byte[] resultHistoryDigest = getBytes(bytes, 32); + long coveredStartOffset = requireNonNegative(bytes.getLong(), "covered start offset"); + long markerEndOffset = requireNonNegative(bytes.getLong(), "marker end offset"); + long blockCount = requireNonNegative(bytes.getLong(), "marker block count"); + long logicalBytes = requireNonNegative(bytes.getLong(), "marker logical bytes"); + long encodedBytes = requireNonNegative(bytes.getLong(), "marker encoded bytes"); + byte[] previousMarkerDigest = getBytes(bytes, 32); + byte[] commonTargetDigest = getBytes(bytes, 32); + int laneId = Short.toUnsignedInt(bytes.getShort()); + requireShort(bytes, (short) 0, "marker lane reserved field"); + long segmentSeq = requireNonNegative(bytes.getLong(), "marker segment sequence"); + requireInt(bytes, 0, "marker reserved tail"); + DurableMarker marker = new DurableMarker(checkpointSequence, firstEpoch, lastEpoch, + firstBlock, lastBlock, lastBlockHash, resultHistoryDigest, coveredStartOffset, + markerEndOffset, blockCount, logicalBytes, encodedBytes, previousMarkerDigest, + commonTargetDigest, laneId, segmentSeq); + byte[] digest = getBytes(bytes, 32); + requireArray(digest, StateArchiveFileFormatV3.sha256( + StateArchiveFileFormatV3.ENCODED_MARKER_DOMAIN, + Arrays.copyOf(encoded, MARKER_DIGEST_OFFSET)), "marker encoded frame digest"); + requireLong(bytes, totalLength, "repeated marker total length"); + if (bytes.getInt() != crc32c(encoded, 0, MARKER_CRC_OFFSET)) { + throw new IllegalArgumentException("State Archive durable marker checksum mismatch"); + } + requireInt(bytes, StateArchiveFileFormatV3.FRAME_TRAILER_MAGIC, + "marker trailer magic"); + return marker.withEncodedFrameDigest(digest); + } + + public static byte[] encodeBlockIndexHeader(BlockIndexHeader header) { + Objects.requireNonNull(header, "header"); + header.validate(); + ByteBuffer bytes = ByteBuffer.allocate(StateArchiveFileFormatV3.BLOCK_INDEX_HEADER_LENGTH); + bytes.putInt(StateArchiveFileFormatV3.BLOCK_INDEX_MAGIC); + bytes.putShort(StateArchiveFileFormatV3.MAJOR_VERSION); + bytes.putShort(StateArchiveFileFormatV3.MINOR_VERSION); + bytes.putShort((short) StateArchiveFileFormatV3.BLOCK_INDEX_HEADER_LENGTH); + bytes.putShort((short) StateArchiveFileFormatV3.BLOCK_INDEX_ENTRY_LENGTH); + bytes.putShort((short) header.laneId); + bytes.putShort(StateArchiveFileFormatV3.SEGMENT_LAYOUT_ID); + bytes.putLong(header.segmentSeq); + bytes.put(header.dataSegmentHeaderDigest); + bytes.put(StateArchiveFileFormatV3.fiveLaneDescriptorDigest()); + bytes.putInt(0); + if (bytes.position() != BLOCK_INDEX_HEADER_DIGEST_OFFSET) { + throw new IllegalStateException("Invalid State Archive block index header layout"); + } + bytes.put(StateArchiveFileFormatV3.sha256( + StateArchiveFileFormatV3.BLOCK_INDEX_HEADER_DOMAIN, + Arrays.copyOf(bytes.array(), BLOCK_INDEX_HEADER_DIGEST_OFFSET))); + bytes.putInt(crc32c(bytes.array(), 0, BLOCK_INDEX_HEADER_CRC_OFFSET)); + if (bytes.hasRemaining()) { + throw new IllegalStateException("Invalid State Archive block index header length"); + } + return bytes.array(); + } + + public static BlockIndexHeader decodeBlockIndexHeader(byte[] encoded) { + requireLength(encoded, StateArchiveFileFormatV3.BLOCK_INDEX_HEADER_LENGTH, + "block index header"); + ByteBuffer bytes = ByteBuffer.wrap(encoded); + requireInt(bytes, StateArchiveFileFormatV3.BLOCK_INDEX_MAGIC, "block index magic"); + requireShort(bytes, StateArchiveFileFormatV3.MAJOR_VERSION, "index major version"); + requireShort(bytes, StateArchiveFileFormatV3.MINOR_VERSION, "index minor version"); + requireShort(bytes, (short) StateArchiveFileFormatV3.BLOCK_INDEX_HEADER_LENGTH, + "index header length"); + requireShort(bytes, (short) StateArchiveFileFormatV3.BLOCK_INDEX_ENTRY_LENGTH, + "index entry length"); + int laneId = Short.toUnsignedInt(bytes.getShort()); + StateArchiveFileFormatV3.laneKind(laneId); + requireShort(bytes, StateArchiveFileFormatV3.SEGMENT_LAYOUT_ID, + "index segment layout"); + long segmentSeq = requireNonNegative(bytes.getLong(), "index segment sequence"); + byte[] dataHeaderDigest = getBytes(bytes, 32); + requireArray(getBytes(bytes, 32), StateArchiveFileFormatV3.fiveLaneDescriptorDigest(), + "index placement descriptor"); + requireInt(bytes, 0, "index reserved field"); + byte[] headerDigest = getBytes(bytes, 32); + requireArray(headerDigest, StateArchiveFileFormatV3.sha256( + StateArchiveFileFormatV3.BLOCK_INDEX_HEADER_DOMAIN, + Arrays.copyOf(encoded, BLOCK_INDEX_HEADER_DIGEST_OFFSET)), + "block index header digest"); + if (bytes.getInt() != crc32c(encoded, 0, BLOCK_INDEX_HEADER_CRC_OFFSET)) { + throw new IllegalArgumentException("State Archive block index checksum mismatch"); + } + return new BlockIndexHeader(laneId, segmentSeq, dataHeaderDigest, headerDigest); + } + + public static byte[] encodeBlockIndexEntry(BlockIndexEntry entry) { + Objects.requireNonNull(entry, "entry"); + entry.validate(); + return ByteBuffer.allocate(StateArchiveFileFormatV3.BLOCK_INDEX_ENTRY_LENGTH) + .putLong(entry.blockNumber) + .putLong(entry.frameOffset) + .putInt(entry.frameLength) + .putInt(0) + .putLong(entry.encodedFrameDigestPrefix) + .array(); + } + + public static BlockIndexEntry decodeBlockIndexEntry(byte[] encoded) { + requireLength(encoded, StateArchiveFileFormatV3.BLOCK_INDEX_ENTRY_LENGTH, + "block index entry"); + ByteBuffer bytes = ByteBuffer.wrap(encoded); + BlockIndexEntry entry = new BlockIndexEntry( + requireNonNegative(bytes.getLong(), "index block number"), + requireNonNegative(bytes.getLong(), "index frame offset"), + bytes.getInt(), bytes.getInt(), bytes.getLong()); + entry.validate(); + return entry; + } + + public static byte[] encodeSealedMapRecord(SealedSegment segment) { + Objects.requireNonNull(segment, "segment"); + segment.validate(); + ByteBuffer bytes = ByteBuffer.allocate(StateArchiveFileFormatV3.SEGMENT_MAP_ENTRY_LENGTH); + bytes.putShort((short) segment.laneId); + bytes.putShort(StateArchiveFileFormatV3.laneKind(segment.laneId)); + bytes.putInt(0); + bytes.putLong(segment.segmentSeq); + bytes.putLong(segment.firstBlock); + bytes.putLong(segment.lastBlock); + bytes.putLong(segment.blockFrameCount); + bytes.putLong(segment.dataFileBytes); + bytes.putLong(segment.blockIndexBytes); + bytes.put(segment.segmentHeaderDigest); + bytes.put(segment.segmentContentDigest); + bytes.put(segment.sealFrameDigest); + bytes.put(segment.manifestDigest); + bytes.putLong(0); + if (bytes.hasRemaining()) { + throw new IllegalStateException("Invalid State Archive SegmentMap record length"); + } + return bytes.array(); + } + + public static SealedSegment decodeSealedMapRecord(byte[] encoded) { + requireLength(encoded, StateArchiveFileFormatV3.SEGMENT_MAP_ENTRY_LENGTH, + "SegmentMap record"); + ByteBuffer bytes = ByteBuffer.wrap(encoded); + int laneId = Short.toUnsignedInt(bytes.getShort()); + if (bytes.getShort() != StateArchiveFileFormatV3.laneKind(laneId)) { + throw new IllegalArgumentException("State Archive SegmentMap lane kind mismatch"); + } + requireInt(bytes, 0, "SegmentMap flags"); + SealedSegment result = new SealedSegment(laneId, + requireNonNegative(bytes.getLong(), "segment sequence"), + requireNonNegative(bytes.getLong(), "first block"), + requireNonNegative(bytes.getLong(), "last block"), + requireNonNegative(bytes.getLong(), "block count"), + requireNonNegative(bytes.getLong(), "data file bytes"), + requireNonNegative(bytes.getLong(), "block index bytes"), + getBytes(bytes, 32), getBytes(bytes, 32), getBytes(bytes, 32), getBytes(bytes, 32)); + requireLong(bytes, 0, "SegmentMap reserved field"); + result.validate(); + return result; + } + + /** Encodes the frozen 304-byte SAM3 sealed-segment manifest. */ + public static byte[] encodeManifest(SegmentManifest manifest) { + Objects.requireNonNull(manifest, "manifest"); + manifest.validate(); + ByteBuffer bytes = ByteBuffer.allocate(StateArchiveFileFormatV3.MANIFEST_TOTAL_LENGTH); + bytes.putInt(StateArchiveFileFormatV3.SEGMENT_MANIFEST_MAGIC); + bytes.putShort(StateArchiveFileFormatV3.MAJOR_VERSION); + bytes.putShort(StateArchiveFileFormatV3.MINOR_VERSION); + bytes.putInt(StateArchiveFileFormatV3.MANIFEST_HEADER_LENGTH); + bytes.putInt(0); + bytes.putLong(StateArchiveFileFormatV3.MANIFEST_TOTAL_LENGTH); + bytes.putShort(StateArchiveFileFormatV3.laneKind(manifest.laneId)); + bytes.putShort((short) manifest.laneId); + bytes.putShort(StateArchiveFileFormatV3.SEGMENT_LAYOUT_ID); + bytes.putShort((short) 0); + bytes.putLong(manifest.segmentSeq); + bytes.putLong(manifest.firstBlock); + bytes.putLong(manifest.lastBlock); + bytes.putLong(manifest.blockFrameCount); + bytes.putLong(manifest.entryCount); + bytes.putLong(manifest.logicalPayloadBytes); + bytes.putLong(manifest.encodedBlockFrameBytes); + bytes.putLong(manifest.dataFileBytes); + bytes.putLong(manifest.blockIndexBytes); + bytes.put(StateArchiveFileFormatV3.fiveLaneDescriptorDigest()); + bytes.put(StateArchiveFileFormatV3.compositeFormatDigest()); + bytes.put(manifest.previousSegmentDigest); + bytes.put(manifest.finalHistoryDigest); + bytes.put(new byte[24]); + if (bytes.position() != MANIFEST_DIGEST_OFFSET) { + throw new IllegalStateException("Invalid State Archive manifest header layout"); + } + bytes.put(StateArchiveFileFormatV3.sha256( + Arrays.copyOf(bytes.array(), MANIFEST_DIGEST_OFFSET))); + bytes.putLong(StateArchiveFileFormatV3.MANIFEST_TOTAL_LENGTH); + bytes.putInt(crc32c(bytes.array(), 0, MANIFEST_CRC_OFFSET)); + bytes.putInt(StateArchiveFileFormatV3.SEGMENT_MANIFEST_TRAILER_MAGIC); + if (bytes.hasRemaining()) { + throw new IllegalStateException("Invalid State Archive manifest length"); + } + return bytes.array(); + } + + /** Decodes and validates the frozen 304-byte SAM3 sealed-segment manifest. */ + public static SegmentManifest decodeManifest(byte[] encoded) { + requireLength(encoded, StateArchiveFileFormatV3.MANIFEST_TOTAL_LENGTH, + "segment manifest"); + ByteBuffer bytes = ByteBuffer.wrap(encoded); + requireInt(bytes, StateArchiveFileFormatV3.SEGMENT_MANIFEST_MAGIC, "manifest magic"); + requireShort(bytes, StateArchiveFileFormatV3.MAJOR_VERSION, "manifest major version"); + requireShort(bytes, StateArchiveFileFormatV3.MINOR_VERSION, "manifest minor version"); + requireInt(bytes, StateArchiveFileFormatV3.MANIFEST_HEADER_LENGTH, + "manifest header length"); + requireInt(bytes, 0, "manifest flags"); + requireLong(bytes, StateArchiveFileFormatV3.MANIFEST_TOTAL_LENGTH, + "manifest total length"); + short laneKind = bytes.getShort(); + int laneId = Short.toUnsignedInt(bytes.getShort()); + if (laneKind != StateArchiveFileFormatV3.laneKind(laneId)) { + throw new IllegalArgumentException("State Archive manifest lane kind mismatch"); + } + requireShort(bytes, StateArchiveFileFormatV3.SEGMENT_LAYOUT_ID, + "manifest segment layout"); + requireShort(bytes, (short) 0, "manifest reserved field"); + long segmentSeq = requireNonNegative(bytes.getLong(), "manifest segment sequence"); + long firstBlock = requireNonNegative(bytes.getLong(), "manifest first block"); + long lastBlock = requireNonNegative(bytes.getLong(), "manifest last block"); + long blockFrameCount = requireNonNegative(bytes.getLong(), "manifest block count"); + long entryCount = requireNonNegative(bytes.getLong(), "manifest entry count"); + long logicalPayloadBytes = requireNonNegative(bytes.getLong(), + "manifest logical payload bytes"); + long encodedBlockFrameBytes = requireNonNegative(bytes.getLong(), + "manifest encoded frame bytes"); + long dataFileBytes = requireNonNegative(bytes.getLong(), "manifest data bytes"); + long blockIndexBytes = requireNonNegative(bytes.getLong(), + "manifest block index bytes"); + requireArray(getBytes(bytes, 32), StateArchiveFileFormatV3.fiveLaneDescriptorDigest(), + "manifest descriptor digest"); + requireArray(getBytes(bytes, 32), StateArchiveFileFormatV3.compositeFormatDigest(), + "manifest composite format digest"); + byte[] previousSegmentDigest = getBytes(bytes, 32); + byte[] finalHistoryDigest = getBytes(bytes, 32); + requireZero(bytes, 24, "manifest reserved bytes"); + byte[] manifestDigest = getBytes(bytes, 32); + requireArray(manifestDigest, StateArchiveFileFormatV3.sha256( + Arrays.copyOf(encoded, MANIFEST_DIGEST_OFFSET)), "manifest digest"); + requireLong(bytes, StateArchiveFileFormatV3.MANIFEST_TOTAL_LENGTH, + "manifest repeated length"); + if (bytes.getInt() != crc32c(encoded, 0, MANIFEST_CRC_OFFSET)) { + throw new IllegalArgumentException("State Archive manifest checksum mismatch"); + } + requireInt(bytes, StateArchiveFileFormatV3.SEGMENT_MANIFEST_TRAILER_MAGIC, + "manifest trailer magic"); + return new SegmentManifest(laneId, segmentSeq, firstBlock, lastBlock, blockFrameCount, + entryCount, logicalPayloadBytes, encodedBlockFrameBytes, dataFileBytes, blockIndexBytes, + previousSegmentDigest, finalHistoryDigest, manifestDigest); + } + + private static void requireCompression(short compressionId) { + if (compressionId != StateArchiveFileFormatV3.COMPRESSION_NONE + && compressionId != StateArchiveFileFormatV3.COMPRESSION_RAW_DEFLATE_LEVEL_1) { + throw new IllegalArgumentException("Unknown State Archive compression ID"); + } + } + + private static byte[] requireHash(byte[] value, String name) { + requireLength(value, StateArchiveFileFormatV3.HASH_LENGTH, name); + return Arrays.copyOf(value, value.length); + } + + private static void requireLength(byte[] value, int length, String name) { + if (value == null || value.length != length) { + throw new IllegalArgumentException("Invalid State Archive " + name + " length"); + } + } + + private static byte[] getBytes(ByteBuffer bytes, int length) { + byte[] result = new byte[length]; + bytes.get(result); + return result; + } + + private static void requireZero(ByteBuffer bytes, int length, String name) { + for (int index = 0; index < length; index++) { + if (bytes.get() != 0) { + throw new IllegalArgumentException("Non-zero State Archive " + name); + } + } + } + + private static long requireNonNegative(long value, String name) { + if (value < 0) { + throw new IllegalArgumentException("Negative State Archive " + name); + } + return value; + } + + private static void requireInt(ByteBuffer bytes, int expected, String name) { + if (bytes.getInt() != expected) { + throw new IllegalArgumentException("State Archive " + name + " mismatch"); + } + } + + private static void requireShort(ByteBuffer bytes, short expected, String name) { + if (bytes.getShort() != expected) { + throw new IllegalArgumentException("State Archive " + name + " mismatch"); + } + } + + private static void requireLong(ByteBuffer bytes, long expected, String name) { + if (bytes.getLong() != expected) { + throw new IllegalArgumentException("State Archive " + name + " mismatch"); + } + } + + private static void requireArray(byte[] actual, byte[] expected, String name) { + if (!Arrays.equals(actual, expected)) { + throw new IllegalArgumentException("State Archive " + name + " mismatch"); + } + } + + private static int crc32c(byte[] bytes, int offset, int length) { + return Hashing.crc32c().hashBytes(bytes, offset, length).asInt(); + } + + public static final class DurableMarker { + private final long checkpointSequence; + private final long firstEpoch; + private final long lastEpoch; + private final long firstBlock; + private final long lastBlock; + private final byte[] lastBlockHash; + private final byte[] resultHistoryDigest; + private final long coveredStartOffset; + private final long markerEndOffset; + private final long blockCount; + private final long logicalBytes; + private final long encodedBytes; + private final byte[] previousMarkerDigest; + private final byte[] commonTargetDigest; + private final int laneId; + private final long segmentSeq; + private final byte[] encodedFrameDigest; + + public DurableMarker(long checkpointSequence, long firstEpoch, long lastEpoch, + long firstBlock, long lastBlock, byte[] lastBlockHash, + byte[] resultHistoryDigest, long coveredStartOffset, long markerEndOffset, + long blockCount, long logicalBytes, long encodedBytes, + byte[] previousMarkerDigest, byte[] commonTargetDigest, int laneId, + long segmentSeq) { + this(checkpointSequence, firstEpoch, lastEpoch, firstBlock, lastBlock, + lastBlockHash, resultHistoryDigest, coveredStartOffset, markerEndOffset, + blockCount, logicalBytes, encodedBytes, previousMarkerDigest, + commonTargetDigest, laneId, segmentSeq, null); + } + + private DurableMarker(long checkpointSequence, long firstEpoch, long lastEpoch, + long firstBlock, long lastBlock, byte[] lastBlockHash, + byte[] resultHistoryDigest, long coveredStartOffset, long markerEndOffset, + long blockCount, long logicalBytes, long encodedBytes, + byte[] previousMarkerDigest, byte[] commonTargetDigest, int laneId, + long segmentSeq, byte[] encodedFrameDigest) { + this.checkpointSequence = checkpointSequence; + this.firstEpoch = firstEpoch; + this.lastEpoch = lastEpoch; + this.firstBlock = firstBlock; + this.lastBlock = lastBlock; + this.lastBlockHash = requireHash(lastBlockHash, "marker last block hash"); + this.resultHistoryDigest = requireHash(resultHistoryDigest, + "marker result history digest"); + this.coveredStartOffset = coveredStartOffset; + this.markerEndOffset = markerEndOffset; + this.blockCount = blockCount; + this.logicalBytes = logicalBytes; + this.encodedBytes = encodedBytes; + this.previousMarkerDigest = requireHash(previousMarkerDigest, + "previous marker digest"); + this.commonTargetDigest = requireHash(commonTargetDigest, + "marker Common target digest"); + this.laneId = laneId; + this.segmentSeq = segmentSeq; + this.encodedFrameDigest = encodedFrameDigest == null ? null + : requireHash(encodedFrameDigest, "marker encoded frame digest"); + validate(); + } + + private DurableMarker withEncodedFrameDigest(byte[] digest) { + return new DurableMarker(checkpointSequence, firstEpoch, lastEpoch, firstBlock, + lastBlock, lastBlockHash, resultHistoryDigest, coveredStartOffset, + markerEndOffset, blockCount, logicalBytes, encodedBytes, + previousMarkerDigest, commonTargetDigest, laneId, segmentSeq, digest); + } + + private void validate() { + StateArchiveFileFormatV3.laneKind(laneId); + int totalLength = StateArchiveFileFormatV3.MARKER_HEADER_LENGTH + + StateArchiveFileFormatV3.FRAME_TRAILER_LENGTH; + if (checkpointSequence < 0 || firstEpoch < 0 || lastEpoch < firstEpoch + || firstBlock < 0 || lastBlock < firstBlock + || firstEpoch != firstBlock || lastEpoch != lastBlock + || blockCount <= 0 || lastBlock - firstBlock != blockCount - 1 + || logicalBytes < 0 || encodedBytes <= 0 + || coveredStartOffset < StateArchiveFileFormatV3.PART_HEADER_LENGTH + || coveredStartOffset > Long.MAX_VALUE - encodedBytes - totalLength + || markerEndOffset != coveredStartOffset + encodedBytes + totalLength + || segmentSeq < 0) { + throw new IllegalArgumentException("Invalid State Archive durable marker state"); + } + } + + public long getCheckpointSequence() { + return checkpointSequence; + } + + public long getFirstBlock() { + return firstBlock; + } + + public long getLastBlock() { + return lastBlock; + } + + public byte[] getLastBlockHash() { + return Arrays.copyOf(lastBlockHash, lastBlockHash.length); + } + + public byte[] getResultHistoryDigest() { + return Arrays.copyOf(resultHistoryDigest, resultHistoryDigest.length); + } + + public long getCoveredStartOffset() { + return coveredStartOffset; + } + + public long getMarkerEndOffset() { + return markerEndOffset; + } + + public long getBlockCount() { + return blockCount; + } + + public long getLogicalBytes() { + return logicalBytes; + } + + public long getEncodedBytes() { + return encodedBytes; + } + + public byte[] getPreviousMarkerDigest() { + return Arrays.copyOf(previousMarkerDigest, previousMarkerDigest.length); + } + + public byte[] getCommonTargetDigest() { + return Arrays.copyOf(commonTargetDigest, commonTargetDigest.length); + } + + public int getLaneId() { + return laneId; + } + + public long getSegmentSeq() { + return segmentSeq; + } + + public byte[] getEncodedFrameDigest() { + return encodedFrameDigest == null ? null + : Arrays.copyOf(encodedFrameDigest, encodedFrameDigest.length); + } + } + + public static final class SegmentSeal { + private final int laneId; + private final long segmentSeq; + private final long actualFirstBlock; + private final long actualLastBlock; + private final long blockFrameCount; + private final long entryCount; + private final long logicalPayloadBytes; + private final long encodedBlockFrameBytes; + private final long dataEndOffset; + private final long physicalFileBytes; + private final byte[] firstBlockFrameDigest; + private final byte[] lastBlockFrameDigest; + private final byte[] startHistoryDigest; + private final byte[] endHistoryDigest; + private final byte[] segmentContentDigest; + private final byte[] encodedFrameDigest; + + public SegmentSeal(int laneId, long segmentSeq, long actualFirstBlock, + long actualLastBlock, long blockFrameCount, long entryCount, + long logicalPayloadBytes, long encodedBlockFrameBytes, long dataEndOffset, + long physicalFileBytes, byte[] firstBlockFrameDigest, + byte[] lastBlockFrameDigest, byte[] startHistoryDigest, + byte[] endHistoryDigest, byte[] segmentContentDigest) { + this(laneId, segmentSeq, actualFirstBlock, actualLastBlock, blockFrameCount, + entryCount, logicalPayloadBytes, encodedBlockFrameBytes, dataEndOffset, + physicalFileBytes, firstBlockFrameDigest, lastBlockFrameDigest, + startHistoryDigest, endHistoryDigest, segmentContentDigest, null); + } + + private SegmentSeal(int laneId, long segmentSeq, long actualFirstBlock, + long actualLastBlock, long blockFrameCount, long entryCount, + long logicalPayloadBytes, long encodedBlockFrameBytes, long dataEndOffset, + long physicalFileBytes, byte[] firstBlockFrameDigest, + byte[] lastBlockFrameDigest, byte[] startHistoryDigest, + byte[] endHistoryDigest, byte[] segmentContentDigest, + byte[] encodedFrameDigest) { + this.laneId = laneId; + this.segmentSeq = segmentSeq; + this.actualFirstBlock = actualFirstBlock; + this.actualLastBlock = actualLastBlock; + this.blockFrameCount = blockFrameCount; + this.entryCount = entryCount; + this.logicalPayloadBytes = logicalPayloadBytes; + this.encodedBlockFrameBytes = encodedBlockFrameBytes; + this.dataEndOffset = dataEndOffset; + this.physicalFileBytes = physicalFileBytes; + this.firstBlockFrameDigest = requireHash(firstBlockFrameDigest, + "first block frame digest"); + this.lastBlockFrameDigest = requireHash(lastBlockFrameDigest, + "last block frame digest"); + this.startHistoryDigest = requireHash(startHistoryDigest, + "start history digest"); + this.endHistoryDigest = requireHash(endHistoryDigest, "end history digest"); + this.segmentContentDigest = requireHash(segmentContentDigest, + "segment content digest"); + this.encodedFrameDigest = encodedFrameDigest == null ? null + : requireHash(encodedFrameDigest, "seal encoded frame digest"); + validate(); + } + + private SegmentSeal withEncodedFrameDigest(byte[] digest) { + return new SegmentSeal(laneId, segmentSeq, actualFirstBlock, actualLastBlock, + blockFrameCount, entryCount, logicalPayloadBytes, encodedBlockFrameBytes, + dataEndOffset, physicalFileBytes, firstBlockFrameDigest, + lastBlockFrameDigest, startHistoryDigest, endHistoryDigest, + segmentContentDigest, digest); + } + + private void validate() { + StateArchiveFileFormatV3.laneKind(laneId); + int sealLength = StateArchiveFileFormatV3.SEAL_HEADER_LENGTH + + StateArchiveFileFormatV3.FRAME_TRAILER_LENGTH; + if (segmentSeq < 0 || actualFirstBlock < 0 || actualLastBlock < actualFirstBlock + || blockFrameCount <= 0 + || actualLastBlock - actualFirstBlock != blockFrameCount - 1 + || entryCount < 0 || logicalPayloadBytes < 0 || encodedBlockFrameBytes < 0 + || dataEndOffset < StateArchiveFileFormatV3.PART_HEADER_LENGTH + || dataEndOffset > Long.MAX_VALUE - sealLength + || physicalFileBytes != dataEndOffset + sealLength) { + throw new IllegalArgumentException("Invalid State Archive segment seal state"); + } + } + + public int getLaneId() { + return laneId; + } + + public long getSegmentSeq() { + return segmentSeq; + } + + public long getActualFirstBlock() { + return actualFirstBlock; + } + + public long getActualLastBlock() { + return actualLastBlock; + } + + public long getBlockFrameCount() { + return blockFrameCount; + } + + public long getEntryCount() { + return entryCount; + } + + public long getLogicalPayloadBytes() { + return logicalPayloadBytes; + } + + public long getEncodedBlockFrameBytes() { + return encodedBlockFrameBytes; + } + + public long getDataEndOffset() { + return dataEndOffset; + } + + public long getPhysicalFileBytes() { + return physicalFileBytes; + } + + public byte[] getFirstBlockFrameDigest() { + return Arrays.copyOf(firstBlockFrameDigest, firstBlockFrameDigest.length); + } + + public byte[] getLastBlockFrameDigest() { + return Arrays.copyOf(lastBlockFrameDigest, lastBlockFrameDigest.length); + } + + public byte[] getStartHistoryDigest() { + return Arrays.copyOf(startHistoryDigest, startHistoryDigest.length); + } + + public byte[] getEndHistoryDigest() { + return Arrays.copyOf(endHistoryDigest, endHistoryDigest.length); + } + + public byte[] getSegmentContentDigest() { + return Arrays.copyOf(segmentContentDigest, segmentContentDigest.length); + } + + public byte[] getEncodedFrameDigest() { + return encodedFrameDigest == null ? null + : Arrays.copyOf(encodedFrameDigest, encodedFrameDigest.length); + } + } + + public static final class BlockIndexHeader { + private final int laneId; + private final long segmentSeq; + private final byte[] dataSegmentHeaderDigest; + private final byte[] headerDigest; + + public BlockIndexHeader(int laneId, long segmentSeq, + byte[] dataSegmentHeaderDigest) { + this(laneId, segmentSeq, dataSegmentHeaderDigest, null); + } + + private BlockIndexHeader(int laneId, long segmentSeq, + byte[] dataSegmentHeaderDigest, byte[] headerDigest) { + this.laneId = laneId; + this.segmentSeq = segmentSeq; + this.dataSegmentHeaderDigest = requireHash(dataSegmentHeaderDigest, + "data segment header digest"); + this.headerDigest = headerDigest == null ? null + : requireHash(headerDigest, "block index header digest"); + validate(); + } + + private void validate() { + StateArchiveFileFormatV3.laneKind(laneId); + requireNonNegative(segmentSeq, "index segment sequence"); + } + + public int getLaneId() { + return laneId; + } + + public long getSegmentSeq() { + return segmentSeq; + } + + public byte[] getDataSegmentHeaderDigest() { + return Arrays.copyOf(dataSegmentHeaderDigest, dataSegmentHeaderDigest.length); + } + + public byte[] getHeaderDigest() { + return headerDigest == null ? null : Arrays.copyOf(headerDigest, headerDigest.length); + } + } + + public static final class BlockIndexEntry { + private final long blockNumber; + private final long frameOffset; + private final int frameLength; + private final int flags; + private final long encodedFrameDigestPrefix; + + public BlockIndexEntry(long blockNumber, long frameOffset, int frameLength, + long encodedFrameDigestPrefix) { + this(blockNumber, frameOffset, frameLength, 0, encodedFrameDigestPrefix); + } + + private BlockIndexEntry(long blockNumber, long frameOffset, int frameLength, + int flags, long encodedFrameDigestPrefix) { + this.blockNumber = blockNumber; + this.frameOffset = frameOffset; + this.frameLength = frameLength; + this.flags = flags; + this.encodedFrameDigestPrefix = encodedFrameDigestPrefix; + validate(); + } + + private void validate() { + if (blockNumber < 0 || frameOffset < StateArchiveFileFormatV3.PART_HEADER_LENGTH + || frameLength < StateArchiveFileFormatV3.BLOCK_HEADER_LENGTH + + StateArchiveFileFormatV3.FRAME_TRAILER_LENGTH + || frameLength > StateArchiveFileFormatV3.MAX_BLOCK_FRAME_BYTES || flags != 0) { + throw new IllegalArgumentException("Invalid State Archive block index entry"); + } + } + + public long getBlockNumber() { + return blockNumber; + } + + public long getFrameOffset() { + return frameOffset; + } + + public int getFrameLength() { + return frameLength; + } + + public long getEncodedFrameDigestPrefix() { + return encodedFrameDigestPrefix; + } + } + + public static final class SegmentHeader { + private final int laneId; + private final long segmentSeq; + private final long actualFirstBlock; + private final byte[] previousSegmentDigest; + private final byte[] previousHistoryDigest; + private final short compressionId; + private final byte[] headerDigest; + + public SegmentHeader(int laneId, long segmentSeq, long actualFirstBlock, + byte[] previousSegmentDigest, byte[] previousHistoryDigest, short compressionId) { + this(laneId, segmentSeq, actualFirstBlock, previousSegmentDigest, + previousHistoryDigest, compressionId, null); + } + + private SegmentHeader(int laneId, long segmentSeq, long actualFirstBlock, + byte[] previousSegmentDigest, byte[] previousHistoryDigest, short compressionId, + byte[] headerDigest) { + this.laneId = laneId; + this.segmentSeq = segmentSeq; + this.actualFirstBlock = actualFirstBlock; + this.previousSegmentDigest = requireHash(previousSegmentDigest, + "previous segment digest"); + this.previousHistoryDigest = requireHash(previousHistoryDigest, + "previous history digest"); + this.compressionId = compressionId; + this.headerDigest = headerDigest == null ? null : requireHash(headerDigest, + "segment header digest"); + } + + private void validate() { + StateArchiveFileFormatV3.laneKind(laneId); + requireNonNegative(segmentSeq, "segment sequence"); + requireNonNegative(actualFirstBlock, "first block"); + requireCompression(compressionId); + } + + public int getLaneId() { + return laneId; + } + + public long getSegmentSeq() { + return segmentSeq; + } + + public long getActualFirstBlock() { + return actualFirstBlock; + } + + public byte[] getPreviousSegmentDigest() { + return Arrays.copyOf(previousSegmentDigest, previousSegmentDigest.length); + } + + public byte[] getPreviousHistoryDigest() { + return Arrays.copyOf(previousHistoryDigest, previousHistoryDigest.length); + } + + public short getCompressionId() { + return compressionId; + } + + public byte[] getHeaderDigest() { + return headerDigest == null ? null : Arrays.copyOf(headerDigest, headerDigest.length); + } + } + + public static final class SegmentManifest { + private final int laneId; + private final long segmentSeq; + private final long firstBlock; + private final long lastBlock; + private final long blockFrameCount; + private final long entryCount; + private final long logicalPayloadBytes; + private final long encodedBlockFrameBytes; + private final long dataFileBytes; + private final long blockIndexBytes; + private final byte[] previousSegmentDigest; + private final byte[] finalHistoryDigest; + private final byte[] manifestDigest; + + public SegmentManifest(int laneId, long segmentSeq, long firstBlock, long lastBlock, + long blockFrameCount, long entryCount, long logicalPayloadBytes, + long encodedBlockFrameBytes, long dataFileBytes, long blockIndexBytes, + byte[] previousSegmentDigest, byte[] finalHistoryDigest) { + this(laneId, segmentSeq, firstBlock, lastBlock, blockFrameCount, entryCount, + logicalPayloadBytes, encodedBlockFrameBytes, dataFileBytes, blockIndexBytes, + previousSegmentDigest, finalHistoryDigest, null); + } + + private SegmentManifest(int laneId, long segmentSeq, long firstBlock, long lastBlock, + long blockFrameCount, long entryCount, long logicalPayloadBytes, + long encodedBlockFrameBytes, long dataFileBytes, long blockIndexBytes, + byte[] previousSegmentDigest, byte[] finalHistoryDigest, byte[] manifestDigest) { + this.laneId = laneId; + this.segmentSeq = segmentSeq; + this.firstBlock = firstBlock; + this.lastBlock = lastBlock; + this.blockFrameCount = blockFrameCount; + this.entryCount = entryCount; + this.logicalPayloadBytes = logicalPayloadBytes; + this.encodedBlockFrameBytes = encodedBlockFrameBytes; + this.dataFileBytes = dataFileBytes; + this.blockIndexBytes = blockIndexBytes; + this.previousSegmentDigest = requireHash(previousSegmentDigest, + "manifest previous segment digest"); + this.finalHistoryDigest = requireHash(finalHistoryDigest, + "manifest final history digest"); + this.manifestDigest = manifestDigest == null ? null + : requireHash(manifestDigest, "manifest digest"); + validate(); + } + + private void validate() { + StateArchiveFileFormatV3.laneKind(laneId); + if (segmentSeq < 0 || firstBlock < 0 || lastBlock < firstBlock + || blockFrameCount <= 0 || lastBlock - firstBlock + 1 != blockFrameCount + || entryCount < 0 || logicalPayloadBytes < 0 || encodedBlockFrameBytes < 0 + || dataFileBytes < StateArchiveFileFormatV3.PART_HEADER_LENGTH + + StateArchiveFileFormatV3.SEAL_HEADER_LENGTH + + StateArchiveFileFormatV3.FRAME_TRAILER_LENGTH + || blockIndexBytes != StateArchiveFileFormatV3.BLOCK_INDEX_HEADER_LENGTH + + blockFrameCount * StateArchiveFileFormatV3.BLOCK_INDEX_ENTRY_LENGTH) { + throw new IllegalArgumentException("Invalid State Archive segment manifest state"); + } + } + + public int getLaneId() { return laneId; } + public long getSegmentSeq() { return segmentSeq; } + public long getFirstBlock() { return firstBlock; } + public long getLastBlock() { return lastBlock; } + public long getBlockFrameCount() { return blockFrameCount; } + public long getEntryCount() { return entryCount; } + public long getLogicalPayloadBytes() { return logicalPayloadBytes; } + public long getEncodedBlockFrameBytes() { return encodedBlockFrameBytes; } + public long getDataFileBytes() { return dataFileBytes; } + public long getBlockIndexBytes() { return blockIndexBytes; } + public byte[] getPreviousSegmentDigest() { + return Arrays.copyOf(previousSegmentDigest, previousSegmentDigest.length); + } + public byte[] getFinalHistoryDigest() { + return Arrays.copyOf(finalHistoryDigest, finalHistoryDigest.length); + } + public byte[] getManifestDigest() { + return manifestDigest == null ? null : Arrays.copyOf(manifestDigest, + manifestDigest.length); + } + } + + public static final class CurrentSegment { + private final int laneId; + private final long segmentSeq; + private final long firstBlock; + private final long currentLastBlock; + private final long dataEndOffset; + private final long blockFrameCount; + private final byte[] headerDigest; + + public CurrentSegment(int laneId, long segmentSeq, long firstBlock, long currentLastBlock, + long dataEndOffset, long blockFrameCount, byte[] headerDigest) { + StateArchiveFileFormatV3.laneKind(laneId); + if (segmentSeq < 0 || firstBlock < 0 || currentLastBlock < firstBlock + || blockFrameCount <= 0 || currentLastBlock - firstBlock + 1 != blockFrameCount + || dataEndOffset < StateArchiveFileFormatV3.PART_HEADER_LENGTH) { + throw new IllegalArgumentException("Invalid State Archive current SegmentMap state"); + } + this.laneId = laneId; + this.segmentSeq = segmentSeq; + this.firstBlock = firstBlock; + this.currentLastBlock = currentLastBlock; + this.dataEndOffset = dataEndOffset; + this.blockFrameCount = blockFrameCount; + this.headerDigest = requireHash(headerDigest, "current segment header digest"); + } + + public int getLaneId() { + return laneId; + } + + public long getSegmentSeq() { + return segmentSeq; + } + + public long getFirstBlock() { + return firstBlock; + } + + public long getCurrentLastBlock() { + return currentLastBlock; + } + + public long getDataEndOffset() { + return dataEndOffset; + } + + public long getBlockFrameCount() { + return blockFrameCount; + } + + public byte[] getHeaderDigest() { + return Arrays.copyOf(headerDigest, headerDigest.length); + } + } + + public static final class SealedSegment { + private final int laneId; + private final long segmentSeq; + private final long firstBlock; + private final long lastBlock; + private final long blockFrameCount; + private final long dataFileBytes; + private final long blockIndexBytes; + private final byte[] segmentHeaderDigest; + private final byte[] segmentContentDigest; + private final byte[] sealFrameDigest; + private final byte[] manifestDigest; + + public SealedSegment(int laneId, long segmentSeq, long firstBlock, long lastBlock, + long blockFrameCount, long dataFileBytes, long blockIndexBytes, + byte[] segmentHeaderDigest, byte[] segmentContentDigest, byte[] sealFrameDigest, + byte[] manifestDigest) { + this.laneId = laneId; + this.segmentSeq = segmentSeq; + this.firstBlock = firstBlock; + this.lastBlock = lastBlock; + this.blockFrameCount = blockFrameCount; + this.dataFileBytes = dataFileBytes; + this.blockIndexBytes = blockIndexBytes; + this.segmentHeaderDigest = requireHash(segmentHeaderDigest, "segment header digest"); + this.segmentContentDigest = requireHash(segmentContentDigest, "segment content digest"); + this.sealFrameDigest = requireHash(sealFrameDigest, "seal frame digest"); + this.manifestDigest = requireHash(manifestDigest, "manifest digest"); + validate(); + } + + private void validate() { + StateArchiveFileFormatV3.laneKind(laneId); + if (segmentSeq < 0 || firstBlock < 0 || lastBlock < firstBlock + || blockFrameCount <= 0 || lastBlock - firstBlock + 1 != blockFrameCount + || dataFileBytes < StateArchiveFileFormatV3.PART_HEADER_LENGTH + + StateArchiveFileFormatV3.SEAL_HEADER_LENGTH + + StateArchiveFileFormatV3.FRAME_TRAILER_LENGTH + || blockIndexBytes < StateArchiveFileFormatV3.BLOCK_INDEX_HEADER_LENGTH) { + throw new IllegalArgumentException("Invalid State Archive sealed SegmentMap state"); + } + } + + public int getLaneId() { + return laneId; + } + + public long getSegmentSeq() { + return segmentSeq; + } + + public long getFirstBlock() { + return firstBlock; + } + + public long getLastBlock() { + return lastBlock; + } + + public long getBlockFrameCount() { + return blockFrameCount; + } + + public long getDataFileBytes() { + return dataFileBytes; + } + + public long getBlockIndexBytes() { + return blockIndexBytes; + } + + public byte[] getSegmentHeaderDigest() { + return Arrays.copyOf(segmentHeaderDigest, segmentHeaderDigest.length); + } + + public byte[] getSegmentContentDigest() { + return Arrays.copyOf(segmentContentDigest, segmentContentDigest.length); + } + + public byte[] getSealFrameDigest() { + return Arrays.copyOf(sealFrameDigest, sealFrameDigest.length); + } + + public byte[] getManifestDigest() { + return Arrays.copyOf(manifestDigest, manifestDigest.length); + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/StateArchiveServingIndexBuildCoordinatorV3.java b/chainbase/src/main/java/org/tron/core/db2/archive/StateArchiveServingIndexBuildCoordinatorV3.java new file mode 100644 index 00000000000..3848b8889bf --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/StateArchiveServingIndexBuildCoordinatorV3.java @@ -0,0 +1,338 @@ +package org.tron.core.db2.archive; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.UUID; +import org.bouncycastle.util.encoders.Hex; +import org.tron.core.db2.core.CommonCheckpointTarget; +import org.tron.core.db2.stateroot.PathStateStoreManifest.Engine; + +/** Single-owner bulk-to-live coordinator for the append-file v3 exact-27 serving index. */ +public final class StateArchiveServingIndexBuildCoordinatorV3 implements AutoCloseable { + + static final String DIRECTORY = "serving-index-v3"; + private final Path archiveRoot; + private final Path catalogRoot; + private final Engine engine; + private final int bulkStartBlocks; + private final List pending = new ArrayList<>(); + private PersistentServingKeyIndexCatalog catalog; + private CommonCheckpointTarget committedHead; + private byte[] latestSourceIdentity; + private long indexedThrough = -1; + private byte[] indexedHash; + private long buildSequence; + private long syncSession; + private Mode mode = Mode.RECOVERING; + private boolean closed; + + public StateArchiveServingIndexBuildCoordinatorV3(Path archiveRoot, Engine engine, + int bulkStartBlocks) throws IOException { + this.archiveRoot = Objects.requireNonNull(archiveRoot, "archiveRoot"); + this.catalogRoot = archiveRoot.resolve(DIRECTORY); + this.engine = Objects.requireNonNull(engine, "engine"); + if (bulkStartBlocks <= 0) { + throw new IllegalArgumentException("bulkStartBlocks must be positive"); + } + this.bulkStartBlocks = bulkStartBlocks; + if (Files.isRegularFile(catalogRoot.resolve("current"), LinkOption.NOFOLLOW_LINKS)) { + catalog = PersistentServingKeyIndexCatalog.open(catalogRoot, engine, stage -> { }); + try (PersistentServingKeyIndexGeneration current = catalog.pin()) { + indexedThrough = current.getIndexedThrough(); + indexedHash = current.getHeadHash(); + } + } + mode = Mode.BULK_CATCH_UP; + } + + /** Accepts only a Common-published contiguous range; bulk flushes at the configured threshold. */ + public synchronized BuildProgress offerCommittedRange(List diffs, + CommonCheckpointTarget target) throws IOException { + requireOpen(); + if (mode != Mode.BULK_CATCH_UP) { + throw new IllegalStateException("Serving bulk input is closed after handoff"); + } + admit(diffs, target); + if (pending.size() >= bulkStartBlocks) { + flushPending(); + } + return progress(); + } + + synchronized void recoverCommittedRange(List supplied, + CommonCheckpointTarget publishedHead, boolean finalRange) throws IOException { + requireOpen(); + if (mode != Mode.BULK_CATCH_UP) { + throw new IllegalStateException("Serving recovery requires bulk mode"); + } + List diffs = new ArrayList<>(Objects.requireNonNull(supplied, "diffs")); + if (diffs.isEmpty()) { + if (finalRange) { + CommonCheckpointTarget target = Objects.requireNonNull(publishedHead, "publishedHead"); + if (indexedThrough != target.getLastBlock().getBlockNumber() + || !Arrays.equals(indexedHash, target.getLastBlock().getBlockHash())) { + throw new IOException("Serving recovery zero-action boundary mismatch"); + } + committedHead = target; + } + return; + } + BlockSnapshotMeta previous = pending.isEmpty() ? null : pending.get(pending.size() - 1).getMeta(); + if (previous == null && indexedThrough >= 0) { + BlockSnapshotMeta first = diffs.get(0).getMeta(); + if (first.getBlockNumber() != indexedThrough + 1 + || !Arrays.equals(first.getParentHash(), indexedHash)) { + throw new IOException("Serving recovery suffix does not extend durable I"); + } + } + for (BlockReverseDiff diff : diffs) { + if (previous != null && (diff.getMeta().getBlockNumber() + != previous.getBlockNumber() + 1 + || !Arrays.equals(diff.getMeta().getParentHash(), previous.getBlockHash()))) { + throw new IOException("Serving recovery suffix has a gap"); + } + previous = diff.getMeta(); + } + pending.addAll(diffs); + latestSourceIdentity = StateArchiveFileFormatV3.sha256( + new BlockHistoryCodec().encode(diffs.get(diffs.size() - 1))); + if (finalRange) { + CommonCheckpointTarget target = Objects.requireNonNull(publishedHead, "publishedHead"); + if (!previous.equals(target.getLastBlock())) { + throw new IOException("Serving recovery suffix differs from published W"); + } + committedHead = target; + latestSourceIdentity = target.getPayloadDigest(); + } + if (pending.size() >= bulkStartBlocks || finalRange) { + flushPending(); + } + } + + /** Drains the FIFO through the exact Common boundary and returns a generation-bound live handle. */ + public synchronized LiveServingIndexer completeInitialSync(CommonCheckpointTarget boundary) + throws IOException { + requireOpen(); + if (mode != Mode.BULK_CATCH_UP || committedHead == null + || !committedHead.equals(Objects.requireNonNull(boundary, "boundary"))) { + throw new IllegalArgumentException("Serving handoff boundary is not the committed head"); + } + mode = Mode.HANDOFF_DRAINING; + try { + flushPending(); + if (indexedThrough != boundary.getLastBlock().getBlockNumber() + || !Arrays.equals(indexedHash, boundary.getLastBlock().getBlockHash())) { + throw new IOException("Serving handoff did not reach the exact Common boundary"); + } + mode = Mode.LIVE_IMMEDIATE; + syncSession++; + return new LiveServingIndexer(syncSession, buildSequence, + catalog.getCurrentGenerationId()); + } catch (IOException | RuntimeException failure) { + mode = Mode.CATCH_UP_REQUIRED; + throw failure; + } + } + + public synchronized BuildProgress status() { + requireOpen(); + return progress(); + } + + synchronized void flushRecoveryBatch() throws IOException { + requireOpen(); + flushPending(); + } + + @Override + public synchronized void close() throws IOException { + if (!closed) { + closed = true; + mode = Mode.CLOSED; + if (catalog != null) { + catalog.close(); + } + } + } + + private void admit(List supplied, CommonCheckpointTarget target) { + List diffs = new ArrayList<>(Objects.requireNonNull(supplied, "diffs")); + CommonCheckpointTarget admittedTarget = Objects.requireNonNull(target, "target"); + if (diffs.isEmpty() || diffs.contains(null) + || !diffs.get(0).getMeta().equals(admittedTarget.getFirstBlock()) + || !diffs.get(diffs.size() - 1).getMeta().equals(admittedTarget.getLastBlock())) { + throw new IllegalArgumentException("Serving committed range differs from Common target"); + } + BlockSnapshotMeta expectedParent; + if (!pending.isEmpty()) { + expectedParent = pending.get(pending.size() - 1).getMeta(); + } else if (committedHead != null) { + expectedParent = committedHead.getLastBlock(); + } else if (indexedThrough >= 0) { + expectedParent = new BlockSnapshotMeta(indexedThrough, indexedThrough, indexedHash, + new byte[32], 0); + } else { + expectedParent = null; + } + BlockSnapshotMeta previous = expectedParent; + for (BlockReverseDiff diff : diffs) { + BlockSnapshotMeta meta = diff.getMeta(); + if (previous != null && (meta.getBlockNumber() != previous.getBlockNumber() + 1 + || !Arrays.equals(meta.getParentHash(), previous.getBlockHash()))) { + throw new IllegalArgumentException("Serving committed ranges are not contiguous"); + } + previous = meta; + } + pending.addAll(diffs); + committedHead = admittedTarget; + latestSourceIdentity = admittedTarget.getPayloadDigest(); + } + + private void flushPending() throws IOException { + if (pending.isEmpty()) { + return; + } + long base = indexedThrough >= 0 ? indexedThrough + : pending.get(0).getMeta().getBlockNumber() - 1; + byte[] baseHash = indexedHash == null ? pending.get(0).getMeta().getParentHash() : indexedHash; + ServingIndexIncrementalPlan plan = ServingIndexIncrementalPlan.planCommittedDiffs( + base, baseHash, pending); + byte[] sourceIdentity = Objects.requireNonNull(latestSourceIdentity, + "latestSourceIdentity"); + String generationId = generationId(plan.getIndexedThrough(), plan.getHeadHash()); + Path shadow = archiveRoot.resolve(".serving-index-v3-" + UUID.randomUUID()); + try { + if (catalog == null) { + try (PersistentServingKeyIndexGeneration ignored = + PersistentServingKeyIndexGeneration.buildExact(shadow, generationId, plan, + sourceIdentity, engine, () -> { })) { + // Full descriptor and exact-27 coverage are verified again by catalog creation. + } + catalog = PersistentServingKeyIndexCatalog.create(catalogRoot, shadow); + } else { + String expected = catalog.getCurrentGenerationId(); + try (PersistentServingKeyIndexGeneration current = catalog.pin(); + PersistentServingKeyIndexGeneration ignored = current.extendExact(shadow, + generationId, plan, sourceIdentity)) { + // Verify immutable generation before CAS publication. + } + if (!catalog.publish(expected, shadow)) { + throw new IOException("Serving generation changed during FIFO publication"); + } + } + indexedThrough = plan.getIndexedThrough(); + indexedHash = plan.getHeadHash(); + buildSequence++; + pending.clear(); + } catch (IOException | RuntimeException failure) { + mode = Mode.CATCH_UP_REQUIRED; + throw failure; + } + } + + private String generationId(long blockNumber, byte[] hash) { + return String.format("append-v3-%020d-%s-%08d", blockNumber, + Hex.toHexString(Arrays.copyOf(hash, 6)), buildSequence + 1); + } + + private BuildProgress progress() { + long committed = committedHead == null ? indexedThrough + : committedHead.getLastBlock().getBlockNumber(); + return new BuildProgress(mode, indexedThrough, committed, pending.size(), buildSequence); + } + + private void requireOpen() { + if (closed) { + throw new IllegalStateException("Serving coordinator is closed"); + } + } + + public enum Mode { + RECOVERING, + BULK_CATCH_UP, + HANDOFF_DRAINING, + LIVE_IMMEDIATE, + CATCH_UP_REQUIRED, + DEGRADED, + CLOSED + } + + public static final class BuildProgress { + private final Mode mode; + private final long indexedThrough; + private final long committedThrough; + private final int pendingBlocks; + private final long buildSequence; + + BuildProgress(Mode mode, long indexedThrough, long committedThrough, + int pendingBlocks, long buildSequence) { + this.mode = mode; + this.indexedThrough = indexedThrough; + this.committedThrough = committedThrough; + this.pendingBlocks = pendingBlocks; + this.buildSequence = buildSequence; + } + + public Mode getMode() { return mode; } + public long getIndexedThrough() { return indexedThrough; } + public long getCommittedThrough() { return committedThrough; } + public int getPendingBlocks() { return pendingBlocks; } + public long getBuildSequence() { return buildSequence; } + } + + public final class LiveServingIndexer { + private final long session; + private long sequence; + private String generation; + private boolean valid = true; + + private LiveServingIndexer(long session, long sequence, String generation) { + this.session = session; + this.sequence = sequence; + this.generation = generation; + } + + /** Publishes every block in the committed range as an individual durable generation. */ + public BuildProgress indexNow(List diffs, CommonCheckpointTarget target) + throws IOException { + synchronized (StateArchiveServingIndexBuildCoordinatorV3.this) { + requireOpen(); + if (!valid || mode != Mode.LIVE_IMMEDIATE || session != syncSession + || sequence != buildSequence || !generation.equals(catalog.getCurrentGenerationId())) { + throw new IllegalStateException("Serving live handle is stale"); + } + List admitted = new ArrayList<>(Objects.requireNonNull(diffs, "diffs")); + int published = 0; + boolean rangeAccepted = false; + try { + admit(admitted, target); + rangeAccepted = true; + pending.clear(); + for (BlockReverseDiff diff : admitted) { + pending.add(diff); + flushPending(); + published++; + } + sequence = buildSequence; + generation = catalog.getCurrentGenerationId(); + return progress(); + } catch (IOException | RuntimeException failure) { + if (rangeAccepted) { + pending.clear(); + pending.addAll(admitted.subList(published, admitted.size())); + } + valid = false; + mode = Mode.CATCH_UP_REQUIRED; + throw failure; + } + } + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/archive/StateArchiveServingWorkerV3.java b/chainbase/src/main/java/org/tron/core/db2/archive/StateArchiveServingWorkerV3.java new file mode 100644 index 00000000000..5a707501f34 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/archive/StateArchiveServingWorkerV3.java @@ -0,0 +1,258 @@ +package org.tron.core.db2.archive; + +import java.io.IOException; +import java.util.Collections; +import java.util.List; +import org.tron.core.db2.archive.StateArchiveServingIndexBuildCoordinatorV3.BuildProgress; +import org.tron.core.db2.core.CommonCheckpointTarget; + +/** Single owner with a constant-size committed watermark mailbox, never a queue of bodies. */ +final class StateArchiveServingWorkerV3 implements AutoCloseable { + static final long MAX_SOURCE_BYTES = 32L * 1024 * 1024; + private final CoordinatorFactory factory; + private final StateArchiveFiveLaneSegmentWriterV3 source; + private final Object dispatch = new Object(); + private final Thread thread; + private final Runnable beforeBuild; + private CommonCheckpointTarget requested; + private CommonCheckpointTarget completed; + private CommonCheckpointTarget handoff; + private boolean live; + private boolean closing; + private long pendingSince; + private volatile IOException failure; + private volatile BuildProgress progress; + + StateArchiveServingWorkerV3(CoordinatorFactory factory, + StateArchiveFiveLaneSegmentWriterV3 source, Runnable beforeBuild) { + this.factory = factory; + this.source = source; + this.beforeBuild = beforeBuild; + progress = new BuildProgress(StateArchiveServingIndexBuildCoordinatorV3.Mode.BULK_CATCH_UP, + -1, -1, 0, 0); + thread = new Thread(this::run, "state-archive-serving-v3"); + thread.setDaemon(true); + thread.start(); + } + + void offer(CommonCheckpointTarget target) throws IOException { + synchronized (dispatch) { + synchronized (this) { + requireHealthy(); + if (requested != null && !requested.equals(target) + && (target.getFirstBlock().getBlockNumber() + != requested.getLastBlock().getBlockNumber() + 1 + || !java.util.Arrays.equals(target.getFirstBlock().getParentHash(), + requested.getLastBlock().getBlockHash()))) { + IOException gap = new IOException("Serving notification is not a committed successor"); + fail(gap); + throw gap; + } + requested = target; + if (pendingSince == 0) { + pendingSince = System.nanoTime(); + } + notifyAll(); + if (live) { + await(target, false); + } + } + } + } + + void completeInitialSync(CommonCheckpointTarget target) throws IOException { + synchronized (dispatch) { + synchronized (this) { + requireHealthy(); + if (requested == null || !requested.equals(target) || live || handoff != null) { + throw new IOException("Serving handoff differs from committed mailbox boundary"); + } + handoff = target; + notifyAll(); + await(target, true); + } + } + } + + synchronized BuildProgress status() { + BuildProgress snapshot = progress; + long committed = requested == null ? snapshot.getCommittedThrough() + : requested.getLastBlock().getBlockNumber(); + return new BuildProgress(closing ? StateArchiveServingIndexBuildCoordinatorV3.Mode.CLOSED + : failure != null ? StateArchiveServingIndexBuildCoordinatorV3.Mode.CATCH_UP_REQUIRED + : handoff != null && !live + ? StateArchiveServingIndexBuildCoordinatorV3.Mode.HANDOFF_DRAINING : snapshot.getMode(), + snapshot.getIndexedThrough(), committed, snapshot.getPendingBlocks(), + snapshot.getBuildSequence()); + } + + IOException failure() { + return failure; + } + + private void await(CommonCheckpointTarget target, boolean requireLive) throws IOException { + while (!target.equals(completed) || requireLive && !live) { + requireHealthy(); + try { + wait(); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + IOException cancelled = new IOException("Interrupted waiting for serving publication", + interrupted); + fail(cancelled); + throw cancelled; + } + } + requireHealthy(); + } + + private void requireHealthy() throws IOException { + if (failure != null) { + throw new IOException("Serving index requires recovery", failure); + } + if (closing) { + throw new IOException("Serving worker is closed"); + } + } + + private void run() { + StateArchiveServingIndexBuildCoordinatorV3 coordinator = null; + StateArchiveServingIndexBuildCoordinatorV3.LiveServingIndexer handle = null; + try { + coordinator = factory.open(); + progress = coordinator.status(); + while (true) { + CommonCheckpointTarget target; + boolean enterLive; + synchronized (this) { + while (!closing && failure == null && (requested == null || requested.equals(completed)) + && (handoff == null || live)) { + wait(); + } + if (closing || failure != null) { + return; + } + // Prototype budget: at most one second before flushing an undersized tail. + if (!live && handoff == null + && requested.getLastBlock().getBlockNumber() - progress.getIndexedThrough() < 1_000) { + long remaining = 1_000_000_000L - (System.nanoTime() - pendingSince); + if (remaining > 0) { + wait(Math.max(1, remaining / 1_000_000L)); + continue; + } + } + target = requested; + enterLive = handoff != null && !live; + } + beforeBuild.run(); + if (handle == null) { + long cursor = progress.getIndexedThrough(); + if (cursor < 0) { + cursor = source.getHistoryStartBlock() - 1; + } + long end = target.getLastBlock().getBlockNumber(); + if (cursor > end || cursor < 0) { + throw new IOException("Serving durable boundary is outside committed history"); + } + if (cursor == end) { + coordinator.recoverCommittedRange(Collections.emptyList(), target, true); + } + while (cursor < end) { + synchronized (this) { + if (closing || failure != null) { + return; + } + } + long batchEnd = Math.min(end, cursor + 1_000); + List batch; + while (true) { + try { + batch = source.readCommittedDiffs(cursor, batchEnd, MAX_SOURCE_BYTES); + break; + } catch (StateArchiveFiveLaneSegmentWriterV3.ServingReadBudgetException tooLarge) { + if (batchEnd == cursor + 1) { + throw tooLarge; + } + batchEnd = cursor + (batchEnd - cursor) / 2; + } + } + coordinator.recoverCommittedRange(batch, target, batchEnd == end); + coordinator.flushRecoveryBatch(); + progress = coordinator.status(); + cursor = batchEnd; + } + if (enterLive) { + handle = coordinator.completeInitialSync(target); + } + } else if (!target.equals(completed)) { + List batch = source.readCommittedDiffs( + progress.getIndexedThrough(), target.getLastBlock().getBlockNumber(), + MAX_SOURCE_BYTES); + handle.indexNow(batch, target); + } + progress = coordinator.status(); + synchronized (this) { + completed = target; + if (target.equals(requested)) { + pendingSince = 0; + } + live = failure == null && handle != null; + notifyAll(); + } + } + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + fail(new IOException("Serving owner interrupted", interrupted)); + } catch (IOException | RuntimeException buildFailure) { + if (coordinator != null) { + progress = coordinator.status(); + } + fail(new IOException("Serving build failed", buildFailure)); + } finally { + try { + if (coordinator != null) { + coordinator.close(); + } + } catch (IOException closeFailure) { + fail(closeFailure); + } + synchronized (this) { + notifyAll(); + } + } + } + + private synchronized void fail(IOException cause) { + failure = cause; + live = false; + progress = new BuildProgress(StateArchiveServingIndexBuildCoordinatorV3.Mode.CATCH_UP_REQUIRED, + progress.getIndexedThrough(), requested == null ? -1 + : requested.getLastBlock().getBlockNumber(), 0, progress.getBuildSequence()); + org.slf4j.LoggerFactory.getLogger("DB").error("Archive serving owner degraded", cause); + notifyAll(); + } + + @Override + public void close() throws IOException { + synchronized (this) { + closing = true; + notifyAll(); + } + boolean interrupted = false; + while (thread.isAlive()) { + try { + thread.join(); + } catch (InterruptedException retry) { + interrupted = true; + } + } + if (interrupted) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted closing serving owner; owner has terminated"); + } + } + + interface CoordinatorFactory { + StateArchiveServingIndexBuildCoordinatorV3 open() throws IOException; + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/common/Flusher.java b/chainbase/src/main/java/org/tron/core/db2/common/Flusher.java index 1faf35c4809..76beb62b08d 100644 --- a/chainbase/src/main/java/org/tron/core/db2/common/Flusher.java +++ b/chainbase/src/main/java/org/tron/core/db2/common/Flusher.java @@ -6,6 +6,11 @@ public interface Flusher { void flush(Map batch); + /** Flushes one checkpoint batch with an explicit durability barrier. */ + default void flushSynced(Map batch) { + throw new UnsupportedOperationException("Synchronous checkpoint flush is not supported"); + } + void close(); void reset(); diff --git a/chainbase/src/main/java/org/tron/core/db2/common/LevelDB.java b/chainbase/src/main/java/org/tron/core/db2/common/LevelDB.java index 5942bb7444c..5842cb322da 100644 --- a/chainbase/src/main/java/org/tron/core/db2/common/LevelDB.java +++ b/chainbase/src/main/java/org/tron/core/db2/common/LevelDB.java @@ -1,15 +1,20 @@ package org.tron.core.db2.common; import com.google.common.collect.Maps; +import java.io.IOException; +import java.util.Arrays; import java.util.HashMap; +import java.util.List; import java.util.Map; import lombok.Getter; import org.tron.common.parameter.CommonParameter; import org.tron.common.storage.WriteOptionsWrapper; import org.tron.common.storage.leveldb.LevelDbDataSourceImpl; import org.tron.core.db.common.iterator.DBIterator; +import org.tron.core.db2.archive.LatestStateGenerationAdapter.SnapshotCapableStore; +import org.tron.core.db2.archive.LatestStateGenerationAdapter.StoreSnapshot; -public class LevelDB implements DB, Flusher { +public class LevelDB implements DB, Flusher, SnapshotCapableStore { @Getter private LevelDbDataSourceImpl db; @@ -50,6 +55,57 @@ public String getDbName() { return db.getDBName(); } + @Override + public String getSourceIdentity() { + return db.getSnapshotSourceIdentity(); + } + + @Override + public StoreSnapshot pin(long blockNumber, byte[] blockHash) { + if (blockNumber < 0 || blockHash == null || blockHash.length != 32) { + throw new IllegalArgumentException("Invalid LevelDB snapshot block identity"); + } + LevelDbDataSourceImpl.PinnedSnapshot pinned = db.pinSnapshot(); + byte[] expectedHash = Arrays.copyOf(blockHash, blockHash.length); + return new StoreSnapshot() { + @Override + public String getDbName() { + return LevelDB.this.getDbName(); + } + + @Override + public String getSourceIdentity() { + return pinned.getSourceIdentity(); + } + + @Override + public long getBlockNumber() { + return blockNumber; + } + + @Override + public byte[] getBlockHash() { + return Arrays.copyOf(expectedHash, expectedHash.length); + } + + @Override + public byte[] get(byte[] physicalRawKey) { + return pinned.get(physicalRawKey); + } + + @Override + public List> range(byte[] lowerInclusive, + byte[] upperExclusive, int maxEntries) { + return pinned.range(lowerInclusive, upperExclusive, maxEntries); + } + + @Override + public void close() throws IOException { + pinned.close(); + } + }; + } + @Override public DBIterator iterator() { return db.iterator(); @@ -57,10 +113,22 @@ public DBIterator iterator() { @Override public void flush(Map batch) { + flush(batch, writeOptions); + } + + @Override + public void flushSynced(Map batch) { + try (WriteOptionsWrapper synced = WriteOptionsWrapper.getInstance().sync(true)) { + flush(batch, synced); + } + } + + private void flush(Map batch, + WriteOptionsWrapper options) { Map rows = batch.entrySet().stream() .map(e -> Maps.immutableEntry(e.getKey().getBytes(), e.getValue().getBytes())) .collect(HashMap::new, (m, k) -> m.put(k.getKey(), k.getValue()), HashMap::putAll); - db.updateByBatch(rows, writeOptions); + db.updateByBatch(rows, options); } @Override diff --git a/chainbase/src/main/java/org/tron/core/db2/common/RocksDB.java b/chainbase/src/main/java/org/tron/core/db2/common/RocksDB.java index 1d67438eceb..8fa39ae5906 100644 --- a/chainbase/src/main/java/org/tron/core/db2/common/RocksDB.java +++ b/chainbase/src/main/java/org/tron/core/db2/common/RocksDB.java @@ -1,15 +1,20 @@ package org.tron.core.db2.common; import com.google.common.collect.Maps; +import java.io.IOException; +import java.util.Arrays; import java.util.HashMap; +import java.util.List; import java.util.Map; import lombok.Getter; import org.tron.common.parameter.CommonParameter; import org.tron.common.storage.WriteOptionsWrapper; import org.tron.common.storage.rocksdb.RocksDbDataSourceImpl; import org.tron.core.db.common.iterator.DBIterator; +import org.tron.core.db2.archive.LatestStateGenerationAdapter.SnapshotCapableStore; +import org.tron.core.db2.archive.LatestStateGenerationAdapter.StoreSnapshot; -public class RocksDB implements DB, Flusher { +public class RocksDB implements DB, Flusher, SnapshotCapableStore { @Getter private RocksDbDataSourceImpl db; @@ -51,6 +56,57 @@ public String getDbName() { return db.getDBName(); } + @Override + public String getSourceIdentity() { + return db.getSnapshotSourceIdentity(); + } + + @Override + public StoreSnapshot pin(long blockNumber, byte[] blockHash) { + if (blockNumber < 0 || blockHash == null || blockHash.length != 32) { + throw new IllegalArgumentException("Invalid RocksDB snapshot block identity"); + } + RocksDbDataSourceImpl.PinnedSnapshot pinned = db.pinSnapshot(); + byte[] expectedHash = Arrays.copyOf(blockHash, blockHash.length); + return new StoreSnapshot() { + @Override + public String getDbName() { + return RocksDB.this.getDbName(); + } + + @Override + public String getSourceIdentity() { + return pinned.getSourceIdentity(); + } + + @Override + public long getBlockNumber() { + return blockNumber; + } + + @Override + public byte[] getBlockHash() { + return Arrays.copyOf(expectedHash, expectedHash.length); + } + + @Override + public byte[] get(byte[] physicalRawKey) { + return pinned.get(physicalRawKey); + } + + @Override + public List> range(byte[] lowerInclusive, + byte[] upperExclusive, int maxEntries) { + return pinned.range(lowerInclusive, upperExclusive, maxEntries); + } + + @Override + public void close() throws IOException { + pinned.close(); + } + }; + } + @Override public DBIterator iterator() { return db.iterator(); @@ -58,10 +114,22 @@ public DBIterator iterator() { @Override public void flush(Map batch) { + flush(batch, writeOptions); + } + + @Override + public void flushSynced(Map batch) { + try (WriteOptionsWrapper synced = WriteOptionsWrapper.getInstance().sync(true)) { + flush(batch, synced); + } + } + + private void flush(Map batch, + WriteOptionsWrapper options) { Map rows = batch.entrySet().stream() .map(e -> Maps.immutableEntry(e.getKey().getBytes(), e.getValue().getBytes())) .collect(HashMap::new, (m, k) -> m.put(k.getKey(), k.getValue()), HashMap::putAll); - db.updateByBatch(rows, writeOptions); + db.updateByBatch(rows, options); } @Override @@ -84,4 +152,4 @@ public DB newInstance() { public void stat() { this.db.stat(); } -} \ No newline at end of file +} diff --git a/chainbase/src/main/java/org/tron/core/db2/core/Chainbase.java b/chainbase/src/main/java/org/tron/core/db2/core/Chainbase.java index 17a047f78ae..a9d0aa73a47 100644 --- a/chainbase/src/main/java/org/tron/core/db2/core/Chainbase.java +++ b/chainbase/src/main/java/org/tron/core/db2/core/Chainbase.java @@ -36,6 +36,7 @@ public enum Cursor { private ThreadLocal cursor = new ThreadLocal<>(); private ThreadLocal offset = new ThreadLocal<>(); private Snapshot head; + private String registrationSource = Chainbase.class.getName(); public Chainbase(Snapshot head) { this.head = head; @@ -47,6 +48,14 @@ public String getDbName() { return head.getDbName(); } + public String getRegistrationSource() { + return registrationSource; + } + + public void setRegistrationSource(String registrationSource) { + this.registrationSource = registrationSource; + } + @Override public void setCursor(Cursor cursor) { this.cursor.set(cursor); diff --git a/chainbase/src/main/java/org/tron/core/db2/core/ChainbaseCheckpointMaterializer.java b/chainbase/src/main/java/org/tron/core/db2/core/ChainbaseCheckpointMaterializer.java new file mode 100644 index 00000000000..41e86880a28 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/core/ChainbaseCheckpointMaterializer.java @@ -0,0 +1,460 @@ +package org.tron.core.db2.core; + +import com.google.common.hash.Hashing; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.EOFException; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import org.tron.core.db2.archive.BlockSnapshotMeta; +import org.tron.core.db2.common.WrappedByteArray; + +/** Chainbase participant for common-checkpoint idempotent materialization and publication. */ +public final class ChainbaseCheckpointMaterializer implements CommonCheckpointMaterializer { + + static final String CURRENT_FILE = "CHAINBASE_CURRENT"; + static final String MATERIALIZED_DIRECTORY = "chainbase-checkpoint-materialized"; + private static final int MAGIC = 0x43424354; // CBCT + private static final short VERSION = 1; + private static final int DIGEST_LENGTH = 32; + private static final int RECORD_LENGTH = Integer.BYTES + 2 * Short.BYTES + + 4 * DIGEST_LENGTH + 2 * Long.BYTES + DIGEST_LENGTH; + + private final Path directory; + private final byte[] formatIdentity; + private final Map databases; + private final FaultHook faultHook; + private final CommonCheckpointBaseline baseline; + private final CommonCheckpointMaterializedStore materializedStore; + + public ChainbaseCheckpointMaterializer(Path directory, byte[] formatIdentity, + List databases) { + this(directory, formatIdentity, databases, null, null, (stage, dbName) -> { }); + } + + public ChainbaseCheckpointMaterializer(Path directory, byte[] formatIdentity, + List databases, CommonCheckpointBaseline baseline) { + this(directory, formatIdentity, databases, baseline, null, (stage, dbName) -> { }); + } + + public ChainbaseCheckpointMaterializer(Path directory, byte[] formatIdentity, + List databases, CommonCheckpointBaseline baseline, + CommonCheckpointMaterializedStore materializedStore) { + this(directory, formatIdentity, databases, baseline, materializedStore, + (stage, dbName) -> { }); + } + + ChainbaseCheckpointMaterializer(Path directory, byte[] formatIdentity, + List databases, FaultHook faultHook) { + this(directory, formatIdentity, databases, null, null, faultHook); + } + + private ChainbaseCheckpointMaterializer(Path directory, byte[] formatIdentity, + List databases, CommonCheckpointBaseline baseline, + CommonCheckpointMaterializedStore materializedStore, FaultHook faultHook) { + this.directory = Objects.requireNonNull(directory, "directory"); + this.formatIdentity = digest(formatIdentity, "formatIdentity"); + this.databases = index(databases); + this.faultHook = Objects.requireNonNull(faultHook, "faultHook"); + this.baseline = baseline; + this.materializedStore = materializedStore; + } + + @Override + public Authority authority() { + return Authority.CHAINBASE; + } + + /** Loads the compact next-format head published beside the Chainbase databases. */ + public static PublishedHead loadPublishedHead(Path directory, byte[] expectedFormatIdentity) + throws IOException { + Marker marker = load(Objects.requireNonNull(directory, "directory").resolve(CURRENT_FILE)); + if (!Arrays.equals(marker.formatIdentity, + digest(expectedFormatIdentity, "expectedFormatIdentity"))) { + throw new IOException("Chainbase published target format identity differs"); + } + return new PublishedHead(marker.lastEpoch, marker.lastBlockNumber, marker.lastBlockHash, + marker.stateRoot, marker.payloadDigest); + } + + @Override + public synchronized Status inspect(CommonCheckpointTarget target) throws IOException { + CommonCheckpointTarget admitted = requireTarget(target); + byte[] expected = encode(admitted); + Path currentPath = directory.resolve(CURRENT_FILE); + if (Files.exists(currentPath, LinkOption.NOFOLLOW_LINKS)) { + Marker current = load(currentPath); + if (Arrays.equals(current.encoded, expected)) { + requireMaterialized(admitted, expected); + return Status.PUBLISHED; + } + requireParent(current, admitted); + } else if (baseline != null) { + baseline.requireParent(admitted, "Chainbase"); + } + if (!isMaterialized(admitted, expected)) { + return Status.NEEDS_MATERIALIZATION; + } + return Status.MATERIALIZED; + } + + @Override + public synchronized void materialize(CommonCheckpointPayload payload, + CommonCheckpointTarget target) throws IOException { + CommonCheckpointPayload admittedPayload = Objects.requireNonNull(payload, "payload"); + CommonCheckpointTarget admittedTarget = requireTarget(target); + if (!admittedTarget.equals(CommonCheckpointTarget.from(admittedPayload))) { + throw new IOException("Chainbase checkpoint payload and target differ"); + } + Status status = inspect(admittedTarget); + if (status != Status.NEEDS_MATERIALIZATION) { + return; + } + for (CommonCheckpointPayload.StoreMutations store + : admittedPayload.getChainbaseStores()) { + Chainbase database = databases.get(store.getDbName()); + if (database == null) { + throw new IOException("Chainbase checkpoint Store is not registered: " + + store.getDbName()); + } + Snapshot root = database.getHead().getRoot(); + if (!(root instanceof SnapshotRoot)) { + throw new IOException("Chainbase checkpoint Store has no SnapshotRoot: " + + store.getDbName()); + } + ((SnapshotRoot) root).applyCheckpointMutations(batch(store)); + faultHook.after(Stage.AFTER_STORE_BATCH, store.getDbName()); + } + recordMaterialized(admittedTarget, encode(admittedTarget)); + faultHook.after(Stage.AFTER_MATERIALIZED_TARGET, null); + } + + @Override + public synchronized void publish(CommonCheckpointTarget target) throws IOException { + CommonCheckpointTarget admitted = requireTarget(target); + Status status = inspect(admitted); + if (status == Status.PUBLISHED) { + return; + } + if (status != Status.MATERIALIZED) { + throw new IOException("Chainbase checkpoint target is not fully materialized"); + } + replace(directory.resolve(CURRENT_FILE), encode(admitted)); + faultHook.after(Stage.AFTER_CURRENT, null); + } + + private CommonCheckpointTarget requireTarget(CommonCheckpointTarget target) throws IOException { + CommonCheckpointTarget admitted = Objects.requireNonNull(target, "target"); + if (!Arrays.equals(formatIdentity, admitted.getFormatIdentity())) { + throw new IOException("Chainbase checkpoint format identity differs"); + } + return admitted; + } + + private void requireParent(Marker current, CommonCheckpointTarget target) throws IOException { + BlockSnapshotMeta first = target.getFirstBlock(); + if (!Arrays.equals(current.formatIdentity, target.getFormatIdentity()) + || current.lastEpoch + 1 != first.getEpoch() + || current.lastBlockNumber + 1 != first.getBlockNumber() + || !Arrays.equals(current.lastBlockHash, first.getParentHash()) + || !Arrays.equals(current.stateRoot, target.getParentStateRoot())) { + throw new IOException("Chainbase CURRENT is not the checkpoint parent target"); + } + } + + private Path materializedPath(CommonCheckpointTarget target) { + return directory.resolve(MATERIALIZED_DIRECTORY).resolve(hex(target.getPayloadDigest())); + } + + private boolean isMaterialized(CommonCheckpointTarget target, byte[] expected) + throws IOException { + if (materializedStore != null && materializedStore.exists(Authority.CHAINBASE)) { + return materializedStore.matches(Authority.CHAINBASE, expected); + } + Path legacy = materializedPath(target); + if (!Files.exists(legacy, LinkOption.NOFOLLOW_LINKS)) { + return false; + } + requireExact(legacy, expected); + return true; + } + + private void requireMaterialized(CommonCheckpointTarget target, byte[] expected) + throws IOException { + if (!isMaterialized(target, expected)) { + throw new IOException("Chainbase materialized checkpoint target is missing"); + } + } + + private void recordMaterialized(CommonCheckpointTarget target, byte[] encoded) + throws IOException { + if (materializedStore == null) { + publishImmutable(materializedPath(target), encoded); + } else { + materializedStore.replace(Authority.CHAINBASE, encoded); + } + } + + private static Map batch( + CommonCheckpointPayload.StoreMutations store) { + Map batch = new LinkedHashMap<>(); + for (CommonCheckpointPayload.Mutation mutation : store.getMutations()) { + batch.put(WrappedByteArray.of(mutation.getKey()), + WrappedByteArray.of(mutation.getValue())); + } + return batch; + } + + private static Map index(List supplied) { + Map indexed = new LinkedHashMap<>(); + for (Chainbase database : Objects.requireNonNull(supplied, "databases")) { + Chainbase admitted = Objects.requireNonNull(database, "database"); + if (indexed.putIfAbsent(admitted.getDbName(), admitted) != null) { + throw new IllegalArgumentException("duplicate Chainbase checkpoint Store: " + + admitted.getDbName()); + } + } + return indexed; + } + + private static byte[] encode(CommonCheckpointTarget target) { + try { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(RECORD_LENGTH); + DataOutputStream output = new DataOutputStream(bytes); + output.writeInt(MAGIC); + output.writeShort(VERSION); + output.writeShort(0); + output.write(target.getFormatIdentity()); + output.write(target.getPayloadDigest()); + output.writeLong(target.getLastBlock().getEpoch()); + output.writeLong(target.getLastBlock().getBlockNumber()); + output.write(target.getLastBlock().getBlockHash()); + output.write(target.getStateRoot()); + output.flush(); + byte[] body = bytes.toByteArray(); + output.write(Hashing.sha256().hashBytes(body).asBytes()); + output.flush(); + return bytes.toByteArray(); + } catch (IOException impossible) { + throw new IllegalStateException("in-memory Chainbase target encoding failed", impossible); + } + } + + private static Marker load(Path path) throws IOException { + byte[] encoded = readBounded(path, RECORD_LENGTH); + if (encoded.length != RECORD_LENGTH) { + throw new IOException("Chainbase checkpoint target length is invalid"); + } + int bodyLength = encoded.length - DIGEST_LENGTH; + byte[] body = Arrays.copyOf(encoded, bodyLength); + byte[] checksum = Arrays.copyOfRange(encoded, bodyLength, encoded.length); + if (!Arrays.equals(checksum, Hashing.sha256().hashBytes(body).asBytes())) { + throw new IOException("Chainbase checkpoint target checksum differs"); + } + try (DataInputStream input = new DataInputStream(new ByteArrayInputStream(body))) { + if (input.readInt() != MAGIC || input.readShort() != VERSION || input.readShort() != 0) { + throw new IOException("Chainbase checkpoint target format is unsupported"); + } + return new Marker(encoded, readDigest(input), readDigest(input), input.readLong(), + input.readLong(), readDigest(input), readDigest(input)); + } catch (EOFException truncated) { + throw new IOException("Chainbase checkpoint target is truncated", truncated); + } + } + + private static void requireExact(Path path, byte[] expected) throws IOException { + if (!Arrays.equals(load(path).encoded, expected)) { + throw new IOException("Chainbase checkpoint target identity differs"); + } + } + + private static void publishImmutable(Path path, byte[] bytes) throws IOException { + createDirectory(path.getParent()); + if (Files.exists(path, LinkOption.NOFOLLOW_LINKS)) { + if (!Arrays.equals(readBounded(path, bytes.length), bytes)) { + throw new IOException("Chainbase immutable checkpoint target differs"); + } + return; + } + Path temporary = path.resolveSibling(path.getFileName() + ".tmp-" + UUID.randomUUID()); + try { + writeForced(temporary, bytes); + try { + Files.move(temporary, path, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException unsupported) { + throw new IOException("Chainbase filesystem lacks atomic target publication", + unsupported); + } catch (java.nio.file.FileAlreadyExistsException raced) { + if (!Arrays.equals(readBounded(path, bytes.length), bytes)) { + throw new IOException("Chainbase immutable target publication raced", raced); + } + } + syncDirectory(path.getParent()); + } finally { + Files.deleteIfExists(temporary); + } + } + + private static void replace(Path path, byte[] bytes) throws IOException { + createDirectory(path.getParent()); + Path temporary = path.resolveSibling(path.getFileName() + ".tmp-" + UUID.randomUUID()); + try { + writeForced(temporary, bytes); + try { + Files.move(temporary, path, StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException unsupported) { + throw new IOException("Chainbase filesystem lacks atomic CURRENT replacement", + unsupported); + } + syncDirectory(path.getParent()); + } finally { + Files.deleteIfExists(temporary); + } + } + + private static void writeForced(Path path, byte[] bytes) throws IOException { + try (FileChannel channel = FileChannel.open(path, StandardOpenOption.CREATE_NEW, + StandardOpenOption.WRITE)) { + ByteBuffer buffer = ByteBuffer.wrap(bytes); + while (buffer.hasRemaining()) { + channel.write(buffer); + } + channel.force(true); + } + } + + private static byte[] readBounded(Path path, long maximum) throws IOException { + if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Chainbase checkpoint path is not a regular file"); + } + long size = Files.size(path); + if (size <= 0 || size > maximum) { + throw new IOException("Chainbase checkpoint target length is invalid"); + } + return Files.readAllBytes(path); + } + + private static void createDirectory(Path path) throws IOException { + Files.createDirectories(path); + if (!Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Chainbase checkpoint path is not a directory"); + } + } + + private static void syncDirectory(Path path) throws IOException { + try (FileChannel channel = FileChannel.open(path, StandardOpenOption.READ)) { + channel.force(true); + } + } + + private static byte[] readDigest(DataInputStream input) throws IOException { + byte[] value = new byte[DIGEST_LENGTH]; + input.readFully(value); + return value; + } + + private static byte[] digest(byte[] value, String name) { + byte[] copy = Arrays.copyOf(Objects.requireNonNull(value, name), value.length); + if (copy.length != DIGEST_LENGTH) { + throw new IllegalArgumentException(name + " must contain exactly 32 bytes"); + } + return copy; + } + + private static String hex(byte[] value) { + StringBuilder encoded = new StringBuilder(value.length * 2); + for (byte current : value) { + encoded.append(Character.forDigit(current >>> 4 & 0xf, 16)); + encoded.append(Character.forDigit(current & 0xf, 16)); + } + return encoded.toString(); + } + + enum Stage { + AFTER_STORE_BATCH, + AFTER_MATERIALIZED_TARGET, + AFTER_CURRENT + } + + @FunctionalInterface + interface FaultHook { + void after(Stage stage, String dbName) throws IOException; + } + + private static final class Marker { + + private final byte[] encoded; + private final byte[] formatIdentity; + private final byte[] payloadDigest; + private final long lastEpoch; + private final long lastBlockNumber; + private final byte[] lastBlockHash; + private final byte[] stateRoot; + + private Marker(byte[] encoded, byte[] formatIdentity, byte[] payloadDigest, long lastEpoch, + long lastBlockNumber, byte[] lastBlockHash, byte[] stateRoot) { + this.encoded = encoded; + this.formatIdentity = formatIdentity; + this.payloadDigest = payloadDigest; + this.lastEpoch = lastEpoch; + this.lastBlockNumber = lastBlockNumber; + this.lastBlockHash = lastBlockHash; + this.stateRoot = stateRoot; + } + } + + /** Minimal restart identity retained by CHAINBASE_CURRENT. */ + public static final class PublishedHead { + + private final long epoch; + private final long blockNumber; + private final byte[] blockHash; + private final byte[] stateRoot; + private final byte[] payloadDigest; + + private PublishedHead(long epoch, long blockNumber, byte[] blockHash, byte[] stateRoot, + byte[] payloadDigest) { + this.epoch = epoch; + this.blockNumber = blockNumber; + this.blockHash = Arrays.copyOf(blockHash, blockHash.length); + this.stateRoot = Arrays.copyOf(stateRoot, stateRoot.length); + this.payloadDigest = Arrays.copyOf(payloadDigest, payloadDigest.length); + } + + public long getEpoch() { + return epoch; + } + + public long getBlockNumber() { + return blockNumber; + } + + public byte[] getBlockHash() { + return Arrays.copyOf(blockHash, blockHash.length); + } + + public byte[] getStateRoot() { + return Arrays.copyOf(stateRoot, stateRoot.length); + } + + public byte[] getPayloadDigest() { + return Arrays.copyOf(payloadDigest, payloadDigest.length); + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/core/CommonCheckpointBaseline.java b/chainbase/src/main/java/org/tron/core/db2/core/CommonCheckpointBaseline.java new file mode 100644 index 00000000000..75fe18c39ab --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/core/CommonCheckpointBaseline.java @@ -0,0 +1,53 @@ +package org.tron.core.db2.core; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Objects; +import org.tron.core.db2.archive.BlockSnapshotMeta; + +/** Durable parent admitted before the first next-format common checkpoint. */ +public final class CommonCheckpointBaseline { + + private final byte[] formatIdentity; + private final BlockSnapshotMeta head; + private final byte[] stateRoot; + + public CommonCheckpointBaseline(byte[] formatIdentity, BlockSnapshotMeta head, + byte[] stateRoot) { + this.formatIdentity = copy32(formatIdentity, "formatIdentity"); + this.head = Objects.requireNonNull(head, "head"); + this.stateRoot = copy32(stateRoot, "stateRoot"); + } + + public byte[] getFormatIdentity() { + return Arrays.copyOf(formatIdentity, formatIdentity.length); + } + + public BlockSnapshotMeta getHead() { + return head; + } + + public byte[] getStateRoot() { + return Arrays.copyOf(stateRoot, stateRoot.length); + } + + public void requireParent(CommonCheckpointTarget target, String authority) throws IOException { + CommonCheckpointTarget admitted = Objects.requireNonNull(target, "target"); + BlockSnapshotMeta first = admitted.getFirstBlock(); + if (!Arrays.equals(formatIdentity, admitted.getFormatIdentity()) + || head.getEpoch() + 1 != first.getEpoch() + || head.getBlockNumber() + 1 != first.getBlockNumber() + || !Arrays.equals(head.getBlockHash(), first.getParentHash()) + || !Arrays.equals(stateRoot, admitted.getParentStateRoot())) { + throw new IOException(authority + " checkpoint does not extend the admitted baseline"); + } + } + + private static byte[] copy32(byte[] value, String name) { + byte[] copy = Arrays.copyOf(Objects.requireNonNull(value, name), value.length); + if (copy.length != 32) { + throw new IllegalArgumentException(name + " must contain exactly 32 bytes"); + } + return copy; + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/core/CommonCheckpointBaselineFile.java b/chainbase/src/main/java/org/tron/core/db2/core/CommonCheckpointBaselineFile.java new file mode 100644 index 00000000000..3d0169ef4cc --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/core/CommonCheckpointBaselineFile.java @@ -0,0 +1,193 @@ +package org.tron.core.db2.core; + +import com.google.common.hash.Hashing; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.EOFException; +import java.io.IOException; +import java.nio.channels.FileChannel; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.util.Arrays; +import java.util.Objects; +import java.util.UUID; +import org.tron.core.db2.archive.BlockSnapshotMeta; + +/** Immutable, forced admission record for the first common-checkpoint parent. */ +public final class CommonCheckpointBaselineFile { + + public static final String FILE_NAME = "COMMON_BASELINE"; + public static final String BOOTSTRAP_INTENT_FILE = "COMMON_BOOTSTRAP_INTENT"; + private static final int MAGIC = 0x43424c4e; // CBLN + private static final short VERSION = 1; + private static final int DIGEST_LENGTH = 32; + private static final int LENGTH = Integer.BYTES + 2 * Short.BYTES + DIGEST_LENGTH + + 3 * Long.BYTES + 3 * DIGEST_LENGTH + DIGEST_LENGTH; + + private final Path directory; + private final Path path; + + public CommonCheckpointBaselineFile(Path directory) { + this.directory = Objects.requireNonNull(directory, "directory").toAbsolutePath().normalize(); + this.path = this.directory.resolve(FILE_NAME); + } + + /** Publishes the supplied baseline once, or returns the exact previously admitted baseline. */ + public synchronized CommonCheckpointBaseline openOrCreate(CommonCheckpointBaseline supplied) + throws IOException { + Objects.requireNonNull(supplied, "supplied"); + if (Files.exists(path, LinkOption.NOFOLLOW_LINKS)) { + CommonCheckpointBaseline loaded = load(); + if (!Arrays.equals(encode(loaded), encode(supplied))) { + throw new IOException("Common checkpoint baseline differs from canonical startup state"); + } + return loaded; + } + Files.createDirectories(directory); + Path temporary = directory.resolve(FILE_NAME + ".tmp-" + UUID.randomUUID()); + byte[] encoded = encode(supplied); + try { + try (FileChannel channel = FileChannel.open(temporary, StandardOpenOption.CREATE_NEW, + StandardOpenOption.WRITE)) { + java.nio.ByteBuffer buffer = java.nio.ByteBuffer.wrap(encoded); + while (buffer.hasRemaining()) { + channel.write(buffer); + } + channel.force(true); + } + try { + Files.move(temporary, path, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException unsupported) { + throw new IOException("Common checkpoint baseline requires atomic rename", unsupported); + } + syncDirectory(directory); + return supplied; + } finally { + Files.deleteIfExists(temporary); + } + } + + /** Durably marks that a missing PathState directory is being built for this format. */ + public synchronized void beginBootstrap(byte[] formatIdentity) throws IOException { + byte[] admitted = Arrays.copyOf(Objects.requireNonNull(formatIdentity, "formatIdentity"), + formatIdentity.length); + if (admitted.length != DIGEST_LENGTH) { + throw new IllegalArgumentException("formatIdentity must contain exactly 32 bytes"); + } + Files.createDirectories(directory); + Path intent = directory.resolve(BOOTSTRAP_INTENT_FILE); + if (Files.exists(intent, LinkOption.NOFOLLOW_LINKS)) { + if (!Arrays.equals(Files.readAllBytes(intent), admitted)) { + throw new IOException("Common checkpoint bootstrap intent format differs"); + } + return; + } + publishBytes(intent, admitted); + } + + public synchronized boolean hasBootstrapIntent(byte[] formatIdentity) throws IOException { + Path intent = directory.resolve(BOOTSTRAP_INTENT_FILE); + return Files.isRegularFile(intent, LinkOption.NOFOLLOW_LINKS) + && Arrays.equals(Files.readAllBytes(intent), formatIdentity); + } + + public synchronized void retireBootstrapIntent() throws IOException { + if (Files.deleteIfExists(directory.resolve(BOOTSTRAP_INTENT_FILE))) { + syncDirectory(directory); + } + } + + public synchronized CommonCheckpointBaseline load() throws IOException { + byte[] encoded = Files.readAllBytes(path); + if (encoded.length != LENGTH) { + throw new IOException("Common checkpoint baseline length is invalid"); + } + int bodyLength = encoded.length - DIGEST_LENGTH; + byte[] body = Arrays.copyOf(encoded, bodyLength); + if (!Arrays.equals(Arrays.copyOfRange(encoded, bodyLength, encoded.length), + Hashing.sha256().hashBytes(body).asBytes())) { + throw new IOException("Common checkpoint baseline checksum differs"); + } + try (DataInputStream input = new DataInputStream(new ByteArrayInputStream(body))) { + if (input.readInt() != MAGIC || input.readShort() != VERSION || input.readShort() != 0) { + throw new IOException("Common checkpoint baseline format is unsupported"); + } + byte[] format = readDigest(input); + long epoch = input.readLong(); + long number = input.readLong(); + long timestamp = input.readLong(); + byte[] hash = readDigest(input); + byte[] parentHash = readDigest(input); + byte[] stateRoot = readDigest(input); + return new CommonCheckpointBaseline(format, + new BlockSnapshotMeta(epoch, number, hash, parentHash, timestamp), stateRoot); + } catch (EOFException truncated) { + throw new IOException("Common checkpoint baseline is truncated", truncated); + } + } + + private static byte[] encode(CommonCheckpointBaseline baseline) { + try { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(LENGTH); + DataOutputStream output = new DataOutputStream(bytes); + BlockSnapshotMeta head = baseline.getHead(); + output.writeInt(MAGIC); + output.writeShort(VERSION); + output.writeShort(0); + output.write(baseline.getFormatIdentity()); + output.writeLong(head.getEpoch()); + output.writeLong(head.getBlockNumber()); + output.writeLong(head.getTimestamp()); + output.write(head.getBlockHash()); + output.write(head.getParentHash()); + output.write(baseline.getStateRoot()); + output.flush(); + byte[] body = bytes.toByteArray(); + output.write(Hashing.sha256().hashBytes(body).asBytes()); + output.flush(); + return bytes.toByteArray(); + } catch (IOException impossible) { + throw new IllegalStateException("In-memory common baseline encoding failed", impossible); + } + } + + private void publishBytes(Path target, byte[] encoded) throws IOException { + Path temporary = directory.resolve(target.getFileName() + ".tmp-" + UUID.randomUUID()); + try { + try (FileChannel channel = FileChannel.open(temporary, StandardOpenOption.CREATE_NEW, + StandardOpenOption.WRITE)) { + java.nio.ByteBuffer buffer = java.nio.ByteBuffer.wrap(encoded); + while (buffer.hasRemaining()) { + channel.write(buffer); + } + channel.force(true); + } + try { + Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException unsupported) { + throw new IOException("Common checkpoint bootstrap requires atomic rename", unsupported); + } + syncDirectory(directory); + } finally { + Files.deleteIfExists(temporary); + } + } + + private static byte[] readDigest(DataInputStream input) throws IOException { + byte[] value = new byte[DIGEST_LENGTH]; + input.readFully(value); + return value; + } + + private static void syncDirectory(Path directory) throws IOException { + try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) { + channel.force(true); + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/core/CommonCheckpointCapture.java b/chainbase/src/main/java/org/tron/core/db2/core/CommonCheckpointCapture.java new file mode 100644 index 00000000000..6d4c1171535 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/core/CommonCheckpointCapture.java @@ -0,0 +1,56 @@ +package org.tron.core.db2.core; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import org.tron.core.db2.archive.BlockReverseDiff; +import org.tron.core.db2.archive.StateArchiveHotBatchDescriptor; + +/** V2 coordination payload plus transient Archive bodies that are never encoded into its WAL. */ +public final class CommonCheckpointCapture { + + private final CommonCheckpointPayload payload; + private final List archiveDiffs; + private final StateArchiveHotBatchDescriptor archiveBinding; + + CommonCheckpointCapture(CommonCheckpointPayload payload, List archiveDiffs, + StateArchiveHotBatchDescriptor archiveBinding) { + this.payload = Objects.requireNonNull(payload, "payload"); + if (payload.getVersion() != CommonCheckpointPayload.COORDINATION_FORMAT_VERSION) { + throw new IllegalArgumentException("common checkpoint capture requires payload v2"); + } + this.archiveDiffs = Collections.unmodifiableList(new ArrayList<>( + Objects.requireNonNull(archiveDiffs, "archiveDiffs"))); + this.archiveBinding = Objects.requireNonNull(archiveBinding, "archiveBinding"); + if (this.archiveDiffs.size() != archiveBinding.getBlockCount() + || !archiveBinding.equals(payload.getArchiveBinding())) { + throw new IllegalArgumentException("common checkpoint capture binding differs"); + } + for (int index = 0; index < this.archiveDiffs.size(); index++) { + BlockReverseDiff diff = Objects.requireNonNull(this.archiveDiffs.get(index), + "archiveDiff"); + StateArchiveHotBatchDescriptor.BlockDigest block = archiveBinding.getBlocks().get(index); + if (!diff.getMeta().equals(block.getMeta())) { + throw new IllegalArgumentException("common checkpoint transient Archive diff differs"); + } + } + } + + public static CommonCheckpointCapture create(CommonCheckpointPayload payload, + List archiveDiffs, StateArchiveHotBatchDescriptor archiveBinding) { + return new CommonCheckpointCapture(payload, archiveDiffs, archiveBinding); + } + + public CommonCheckpointPayload getPayload() { + return payload; + } + + public List getArchiveDiffs() { + return archiveDiffs; + } + + public StateArchiveHotBatchDescriptor getArchiveBinding() { + return archiveBinding; + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/core/CommonCheckpointFile.java b/chainbase/src/main/java/org/tron/core/db2/core/CommonCheckpointFile.java new file mode 100644 index 00000000000..11403f1a153 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/core/CommonCheckpointFile.java @@ -0,0 +1,167 @@ +package org.tron.core.db2.core; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.util.Arrays; +import java.util.Objects; + +/** Standalone durable file lifecycle for one immutable common-checkpoint redo payload. */ +public final class CommonCheckpointFile { + + static final String FILE_NAME = "COMMON_CHECKPOINT"; + static final String TEMPORARY_FILE_NAME = ".COMMON_CHECKPOINT.tmp"; + + private final Path directory; + private final Path checkpoint; + private final Path temporary; + private final int maxEncodedLength; + private final CommonCheckpointPayloadCodec codec; + private final FaultHook faultHook; + + public CommonCheckpointFile(Path directory) { + this(directory, CommonCheckpointPayloadCodec.DEFAULT_MAX_ENCODED_LENGTH, + (stage, path) -> { }); + } + + CommonCheckpointFile(Path directory, int maxEncodedLength, FaultHook faultHook) { + this.directory = Objects.requireNonNull(directory, "directory").toAbsolutePath().normalize(); + this.maxEncodedLength = maxEncodedLength; + this.codec = new CommonCheckpointPayloadCodec(maxEncodedLength); + this.faultHook = Objects.requireNonNull(faultHook, "faultHook"); + this.checkpoint = this.directory.resolve(FILE_NAME); + this.temporary = this.directory.resolve(TEMPORARY_FILE_NAME); + } + + /** Publishes once; an exact existing payload is an idempotent retry and seals its directory. */ + public synchronized void publish(CommonCheckpointPayload payload) throws IOException { + byte[] encoded = codec.encode(Objects.requireNonNull(payload, "payload")); + requireDirectory(); + if (Files.exists(checkpoint, LinkOption.NOFOLLOW_LINKS)) { + requireExact(encoded); + syncDirectory(); + return; + } + if (Files.deleteIfExists(temporary)) { + syncDirectory(); + } + try (FileChannel channel = FileChannel.open(temporary, StandardOpenOption.CREATE_NEW, + StandardOpenOption.WRITE)) { + writeFully(channel, ByteBuffer.wrap(encoded)); + channel.force(true); + } + faultHook.after(Stage.AFTER_TEMPORARY_FORCE, temporary); + try { + Files.move(temporary, checkpoint, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException unsupported) { + throw new IOException("common checkpoint requires atomic publication", unsupported); + } + faultHook.after(Stage.AFTER_ATOMIC_PUBLISH, checkpoint); + syncDirectory(); + faultHook.after(Stage.AFTER_DIRECTORY_FORCE, checkpoint); + } + + public synchronized CommonCheckpointPayload loadRequired() throws IOException { + if (!Files.isRegularFile(checkpoint, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("common checkpoint is missing or not a regular file"); + } + long length = Files.size(checkpoint); + if (length < CommonCheckpointPayloadCodec.HEADER_LENGTH || length > maxEncodedLength) { + throw new IOException("common checkpoint file length is invalid"); + } + try { + return codec.decode(Files.readAllBytes(checkpoint)); + } catch (IllegalArgumentException invalid) { + throw new IOException("common checkpoint file is corrupt", invalid); + } + } + + public synchronized CommonCheckpointPayload loadIfPresent() throws IOException { + if (!Files.exists(checkpoint, LinkOption.NOFOLLOW_LINKS)) { + return null; + } + return loadRequired(); + } + + /** Returns whether startup must run redo before opening a published physical state. */ + public synchronized boolean isPresent() { + return Files.exists(checkpoint, LinkOption.NOFOLLOW_LINKS); + } + + /** Retires only this checkpoint and its non-authoritative temporary file. */ + public synchronized void retire() throws IOException { + requireDirectory(); + boolean changed = Files.deleteIfExists(checkpoint); + changed |= Files.deleteIfExists(temporary); + if (changed) { + faultHook.after(Stage.AFTER_RETIRE_DELETE, checkpoint); + } + syncDirectory(); + faultHook.after(Stage.AFTER_RETIRE_DIRECTORY_FORCE, directory); + } + + Path getCheckpointPath() { + return checkpoint; + } + + Path getTemporaryPath() { + return temporary; + } + + private void requireExact(byte[] expected) throws IOException { + if (!Files.isRegularFile(checkpoint, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("common checkpoint is not a regular file"); + } + long length = Files.size(checkpoint); + if (length != expected.length || length > maxEncodedLength + || !Arrays.equals(expected, Files.readAllBytes(checkpoint))) { + throw new IOException("immutable common checkpoint identity mismatch"); + } + try { + codec.decode(expected); + } catch (IllegalArgumentException invalid) { + throw new IOException("common checkpoint file is corrupt", invalid); + } + } + + private void requireDirectory() throws IOException { + if (Files.isSymbolicLink(directory)) { + throw new IOException("common checkpoint directory must not be a symbolic link"); + } + Files.createDirectories(directory); + if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("common checkpoint parent is not a direct directory"); + } + } + + private void syncDirectory() throws IOException { + try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) { + channel.force(true); + } + } + + private static void writeFully(FileChannel channel, ByteBuffer buffer) throws IOException { + while (buffer.hasRemaining()) { + channel.write(buffer); + } + } + + enum Stage { + AFTER_TEMPORARY_FORCE, + AFTER_ATOMIC_PUBLISH, + AFTER_DIRECTORY_FORCE, + AFTER_RETIRE_DELETE, + AFTER_RETIRE_DIRECTORY_FORCE + } + + @FunctionalInterface + interface FaultHook { + void after(Stage stage, Path path) throws IOException; + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/core/CommonCheckpointFormat.java b/chainbase/src/main/java/org/tron/core/db2/core/CommonCheckpointFormat.java new file mode 100644 index 00000000000..32ad2c01789 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/core/CommonCheckpointFormat.java @@ -0,0 +1,20 @@ +package org.tron.core.db2.core; + +import com.google.common.hash.Hashing; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; + +/** Stable identity for the first production common-checkpoint candidate. */ +public final class CommonCheckpointFormat { + + public static final String ID = "java-tron-state-archive-common-checkpoint-v1"; + private static final byte[] DIGEST = Hashing.sha256() + .hashString(ID, StandardCharsets.UTF_8).asBytes(); + + private CommonCheckpointFormat() { + } + + public static byte[] identity() { + return Arrays.copyOf(DIGEST, DIGEST.length); + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/core/CommonCheckpointHotRecovery.java b/chainbase/src/main/java/org/tron/core/db2/core/CommonCheckpointHotRecovery.java new file mode 100644 index 00000000000..c07822339e8 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/core/CommonCheckpointHotRecovery.java @@ -0,0 +1,181 @@ +package org.tron.core.db2.core; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Objects; +import org.tron.core.db2.archive.BlockSnapshotMeta; + +/** Selects one externally verified Hot DB recovery ceiling before common-checkpoint redo. */ +public final class CommonCheckpointHotRecovery { + + private static final byte[] DYNAMIC_BLOCK_NUMBER = + "latest_block_header_number".getBytes(StandardCharsets.UTF_8); + private static final byte[] DYNAMIC_BLOCK_HASH = + "latest_block_header_hash".getBytes(StandardCharsets.UTF_8); + private static final String DYNAMIC_STORE = "properties"; + private static final int HASH_LENGTH = 32; + + private final CommonCheckpointFile checkpointFile; + private final PersistentDynamicHeadSource dynamicHeadSource; + private final BlockMetaSource blockMetaSource; + private final HotTailReconciler reconciler; + + public CommonCheckpointHotRecovery(CommonCheckpointFile checkpointFile, + PersistentDynamicHeadSource dynamicHeadSource, BlockMetaSource blockMetaSource, + HotTailReconciler reconciler) { + this.checkpointFile = Objects.requireNonNull(checkpointFile, "checkpointFile"); + this.dynamicHeadSource = Objects.requireNonNull(dynamicHeadSource, "dynamicHeadSource"); + this.blockMetaSource = Objects.requireNonNull(blockMetaSource, "blockMetaSource"); + this.reconciler = Objects.requireNonNull(reconciler, "reconciler"); + } + + /** + * Reconciles Hot PREPARED data before common redo. A WAL target is authoritative even when its + * Block Store write has not happened yet; without a WAL, the persisted dynamic head must already + * match an exact Block Store record. + */ + public Result reconcileBeforeCommonRedo() throws IOException { + CommonCheckpointPayload payload = checkpointFile.loadIfPresent(); + BlockSnapshotMeta authority; + Source source; + if (payload != null) { + if (payload.getVersion() != CommonCheckpointPayload.COORDINATION_FORMAT_VERSION) { + throw new IOException("Hot Archive recovery requires common checkpoint payload v2"); + } + authority = CommonCheckpointTarget.from(payload).getLastBlock(); + requireWalDynamicIdentity(payload, authority); + BlockSnapshotMeta stored = blockMetaSource.loadIfPresent(authority.getBlockNumber()); + if (stored != null && !authority.equals(stored)) { + throw new IOException("common checkpoint WAL and Block Store identity differ"); + } + source = Source.COMMON_WAL; + } else { + PersistentDynamicHead dynamic = Objects.requireNonNull(dynamicHeadSource.load(), + "persistent dynamic head source returned null"); + BlockSnapshotMeta stored = blockMetaSource.loadIfPresent(dynamic.getBlockNumber()); + if (stored == null) { + throw new IOException("persisted dynamic head is missing from Block Store"); + } + requireNumberAndHash(dynamic.getBlockNumber(), dynamic.getBlockHash(), stored, + "persisted dynamic head and Block Store identity differ"); + authority = stored; + source = Source.PERSISTED_DYNAMIC; + } + return new Result(source, authority, reconciler.reconcile(authority)); + } + + static void requireWalDynamicIdentity(CommonCheckpointPayload payload, + BlockSnapshotMeta target) throws IOException { + CommonCheckpointPayload.StoreMutations dynamic = null; + for (CommonCheckpointPayload.StoreMutations store : payload.getChainbaseStores()) { + if (DYNAMIC_STORE.equals(store.getDbName())) { + dynamic = store; + break; + } + } + if (dynamic == null) { + throw new IOException("common checkpoint WAL has no dynamic Store mutations"); + } + byte[] encodedNumber = null; + byte[] encodedHash = null; + for (CommonCheckpointPayload.Mutation mutation : dynamic.getMutations()) { + if (Arrays.equals(DYNAMIC_BLOCK_NUMBER, mutation.getKey())) { + encodedNumber = mutation.getValue(); + } else if (Arrays.equals(DYNAMIC_BLOCK_HASH, mutation.getKey())) { + encodedHash = mutation.getValue(); + } + } + if (encodedNumber == null || encodedNumber.length != Long.BYTES + || encodedHash == null || encodedHash.length != HASH_LENGTH) { + throw new IOException("common checkpoint WAL dynamic head is incomplete"); + } + long number = ByteBuffer.wrap(encodedNumber).getLong(); + requireNumberAndHash(number, encodedHash, target, + "common checkpoint WAL dynamic head and target differ"); + } + + private static void requireNumberAndHash(long number, byte[] hash, BlockSnapshotMeta meta, + String message) throws IOException { + if (number < 0 || number != meta.getBlockNumber() + || !Arrays.equals(hash, meta.getBlockHash())) { + throw new IOException(message); + } + } + + public enum Source { + COMMON_WAL, + PERSISTED_DYNAMIC + } + + public static final class PersistentDynamicHead { + + private final long blockNumber; + private final byte[] blockHash; + + public PersistentDynamicHead(long blockNumber, byte[] blockHash) { + if (blockNumber < 0) { + throw new IllegalArgumentException("persistent dynamic block number must not be negative"); + } + this.blockNumber = blockNumber; + this.blockHash = copyHash(blockHash); + } + + public long getBlockNumber() { + return blockNumber; + } + + public byte[] getBlockHash() { + return Arrays.copyOf(blockHash, blockHash.length); + } + } + + public static final class Result { + + private final Source source; + private final BlockSnapshotMeta authority; + private final long removedBlocks; + + private Result(Source source, BlockSnapshotMeta authority, long removedBlocks) { + this.source = source; + this.authority = authority; + this.removedBlocks = removedBlocks; + } + + public Source getSource() { + return source; + } + + public BlockSnapshotMeta getAuthority() { + return authority; + } + + public long getRemovedBlocks() { + return removedBlocks; + } + } + + @FunctionalInterface + public interface PersistentDynamicHeadSource { + PersistentDynamicHead load() throws IOException; + } + + @FunctionalInterface + public interface BlockMetaSource { + BlockSnapshotMeta loadIfPresent(long blockNumber) throws IOException; + } + + @FunctionalInterface + public interface HotTailReconciler { + long reconcile(BlockSnapshotMeta authority) throws IOException; + } + + private static byte[] copyHash(byte[] value) { + byte[] copy = Arrays.copyOf(Objects.requireNonNull(value, "blockHash"), value.length); + if (copy.length != HASH_LENGTH) { + throw new IllegalArgumentException("blockHash must contain exactly 32 bytes"); + } + return copy; + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/core/CommonCheckpointMaterializedStore.java b/chainbase/src/main/java/org/tron/core/db2/core/CommonCheckpointMaterializedStore.java new file mode 100644 index 00000000000..3ac8c681a44 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/core/CommonCheckpointMaterializedStore.java @@ -0,0 +1,145 @@ +package org.tron.core.db2.core; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.util.Arrays; +import java.util.EnumSet; +import java.util.Objects; +import org.tron.core.db2.core.CommonCheckpointMaterializer.Authority; + +/** Central, bounded materialization records for all common-checkpoint authorities. */ +public final class CommonCheckpointMaterializedStore { + + static final String DIRECTORY = "materialized"; + + private final Path directory; + private final FaultHook faultHook; + private final EnumSet sealed = EnumSet.noneOf(Authority.class); + + public CommonCheckpointMaterializedStore(Path checkpointDirectory) { + this(checkpointDirectory, (stage, path) -> { }); + } + + CommonCheckpointMaterializedStore(Path checkpointDirectory, FaultHook faultHook) { + this.directory = Objects.requireNonNull(checkpointDirectory, "checkpointDirectory") + .toAbsolutePath().normalize().resolve(DIRECTORY); + this.faultHook = Objects.requireNonNull(faultHook, "faultHook"); + } + + /** Returns whether this authority's single retained slot exactly matches {@code expected}. */ + public synchronized boolean matches(Authority authority, byte[] expected) throws IOException { + Path path = path(authority); + if (!exists(authority)) { + return false; + } + requireRegular(path); + byte[] actual = Files.readAllBytes(path); + if (!Arrays.equals(actual, Objects.requireNonNull(expected, "expected"))) { + return false; + } + seal(authority); + return true; + } + + public synchronized boolean exists(Authority authority) throws IOException { + if (Files.exists(directory, LinkOption.NOFOLLOW_LINKS) + && (Files.isSymbolicLink(directory) + || !Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS))) { + throw new IOException("common checkpoint materialized path is not a direct directory"); + } + return Files.exists(path(authority), LinkOption.NOFOLLOW_LINKS); + } + + /** Atomically replaces this authority's prior slot and durably retires its old target. */ + public synchronized void replace(Authority authority, byte[] encoded) throws IOException { + byte[] admitted = Arrays.copyOf(Objects.requireNonNull(encoded, "encoded"), encoded.length); + if (admitted.length == 0) { + throw new IllegalArgumentException("materialized checkpoint record must not be empty"); + } + requireDirectory(); + Path target = path(authority); + if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) { + requireRegular(target); + if (Arrays.equals(Files.readAllBytes(target), admitted)) { + seal(authority); + return; + } + } + sealed.remove(authority); + Path temporary = directory.resolve("." + authority.name() + ".tmp"); + if (Files.deleteIfExists(temporary)) { + syncDirectory(); + } + try (FileChannel channel = FileChannel.open(temporary, StandardOpenOption.CREATE_NEW, + StandardOpenOption.WRITE)) { + ByteBuffer buffer = ByteBuffer.wrap(admitted); + while (buffer.hasRemaining()) { + channel.write(buffer); + } + channel.force(true); + } + faultHook.after(Stage.AFTER_TEMPORARY_FORCE, temporary); + try { + Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException unsupported) { + throw new IOException("common checkpoint materialized store requires atomic replace", + unsupported); + } + faultHook.after(Stage.AFTER_ATOMIC_REPLACE, target); + syncDirectory(); + sealed.addAll(EnumSet.allOf(Authority.class)); + faultHook.after(Stage.AFTER_DIRECTORY_FORCE, target); + } + + Path path(Authority authority) { + return directory.resolve(Objects.requireNonNull(authority, "authority").name()); + } + + private void requireDirectory() throws IOException { + if (Files.isSymbolicLink(directory)) { + throw new IOException("common checkpoint materialized directory must not be a symlink"); + } + Files.createDirectories(directory); + if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("common checkpoint materialized path is not a directory"); + } + } + + private void syncDirectory() throws IOException { + try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) { + channel.force(true); + } + } + + private void seal(Authority authority) throws IOException { + if (!sealed.contains(authority)) { + syncDirectory(); + sealed.addAll(EnumSet.allOf(Authority.class)); + } + } + + private static void requireRegular(Path path) throws IOException { + if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("common checkpoint materialized slot is not a regular file"); + } + } + + enum Stage { + AFTER_TEMPORARY_FORCE, + AFTER_ATOMIC_REPLACE, + AFTER_DIRECTORY_FORCE + } + + @FunctionalInterface + interface FaultHook { + void after(Stage stage, Path path) throws IOException; + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/core/CommonCheckpointMaterializer.java b/chainbase/src/main/java/org/tron/core/db2/core/CommonCheckpointMaterializer.java new file mode 100644 index 00000000000..b8e50031cad --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/core/CommonCheckpointMaterializer.java @@ -0,0 +1,51 @@ +package org.tron.core.db2.core; + +import java.io.IOException; + +/** One idempotent authority participant in common-checkpoint redo and publication. */ +public interface CommonCheckpointMaterializer extends AutoCloseable { + + Authority authority(); + + /** Opens resources that may be reused while one target crosses both coordinator barriers. */ + default void beginCheckpoint(CommonCheckpointTarget target) throws IOException { + } + + /** Releases resources opened for one target after publication, retirement, or failure. */ + default void endCheckpoint(CommonCheckpointTarget target) throws IOException { + } + + /** Releases resources owned across checkpoint targets; implementations must be idempotent. */ + @Override + default void close() throws IOException { + } + + /** + * Returns only an exact state for {@code target}. Implementations must throw when durable state + * is corrupt, ambiguous, or belongs to a different target. + */ + Status inspect(CommonCheckpointTarget target) throws IOException; + + /** Idempotently writes and forces this authority's data without publishing its public marker. */ + void materialize(CommonCheckpointPayload payload, CommonCheckpointTarget target) + throws IOException; + + /** Idempotently publishes this authority's already-materialized exact target. */ + void publish(CommonCheckpointTarget target) throws IOException; + + /** Best-effort derived work, invoked only after durable redo and WAL retirement complete. */ + default void afterCommit(CommonCheckpointTarget target) { + } + + enum Authority { + CHAINBASE, + PATH_STATE, + STATE_ARCHIVE + } + + enum Status { + NEEDS_MATERIALIZATION, + MATERIALIZED, + PUBLISHED + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/core/CommonCheckpointMemoryRebaser.java b/chainbase/src/main/java/org/tron/core/db2/core/CommonCheckpointMemoryRebaser.java new file mode 100644 index 00000000000..56e7eba893e --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/core/CommonCheckpointMemoryRebaser.java @@ -0,0 +1,17 @@ +package org.tron.core.db2.core; + +import java.io.IOException; + +/** Builds an in-memory completion plan after every durable authority published one target. */ +@FunctionalInterface +public interface CommonCheckpointMemoryRebaser { + + RebasePlan prepare(CommonCheckpointTarget target) throws IOException; + + /** A fully validated pointer-only completion that must not perform fallible work. */ + @FunctionalInterface + interface RebasePlan { + + void apply(); + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/core/CommonCheckpointPayload.java b/chainbase/src/main/java/org/tron/core/db2/core/CommonCheckpointPayload.java new file mode 100644 index 00000000000..8b033ee233a --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/core/CommonCheckpointPayload.java @@ -0,0 +1,474 @@ +package org.tron.core.db2.core; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; +import org.tron.core.db2.archive.BlockReverseDiff; +import org.tron.core.db2.archive.BlockSnapshotMeta; +import org.tron.core.db2.archive.StateArchiveHotBatchDescriptor; +import org.tron.core.db2.stateroot.PathStateFlushTarget; +import org.tron.core.db2.stateroot.PathStateSnapshotDelta; + +/** Versioned immutable redo or coordination input for one cross-authority checkpoint. */ +public final class CommonCheckpointPayload { + + public static final int FORMAT_VERSION = 3; + public static final int COORDINATION_FORMAT_VERSION = 4; + private static final int DIGEST_LENGTH = 32; + private static final Comparator MUTATION_ORDER = + (left, right) -> compareUnsigned(left.key, right.key); + + private final int version; + private final byte[] formatIdentity; + private final List blocks; + private final byte[] parentStateRoot; + private final byte[] stateRoot; + private final List chainbaseStores; + private final List pathStores; + private final List superNodeMutations; + private final StateArchiveHotBatchDescriptor archiveBinding; + + private CommonCheckpointPayload(int version, byte[] formatIdentity, List blocks, + byte[] parentStateRoot, byte[] stateRoot, List chainbaseStores, + List pathStores, List superNodeMutations, + StateArchiveHotBatchDescriptor archiveBinding) { + if (version != FORMAT_VERSION && version != COORDINATION_FORMAT_VERSION) { + throw new IllegalArgumentException("unsupported common checkpoint payload version"); + } + this.version = version; + this.formatIdentity = digest(formatIdentity, "formatIdentity"); + if (blocks.isEmpty()) { + throw new IllegalArgumentException("common checkpoint must contain at least one block"); + } + this.parentStateRoot = digest(parentStateRoot, "parentStateRoot"); + this.stateRoot = digest(stateRoot, "stateRoot"); + List admittedBlocks = new ArrayList<>(blocks); + validateBlocks(version, admittedBlocks, this.parentStateRoot, this.stateRoot, + archiveBinding); + this.blocks = Collections.unmodifiableList(admittedBlocks); + this.chainbaseStores = immutableStores(chainbaseStores); + this.pathStores = immutablePathStores(pathStores); + this.superNodeMutations = immutableMutations(superNodeMutations); + this.archiveBinding = archiveBinding; + } + + public static CommonCheckpointPayload create(byte[] formatIdentity, + PathStateFlushTarget pathState, List archiveBlocks, + List chainbaseStores) { + PathStateFlushTarget path = Objects.requireNonNull(pathState, "pathState"); + List archives = new ArrayList<>(Objects.requireNonNull(archiveBlocks, + "archiveBlocks")); + if (path.getBlocks().size() != archives.size()) { + throw new IllegalArgumentException("common checkpoint block payload count differs"); + } + List blocks = new ArrayList<>(); + for (int index = 0; index < archives.size(); index++) { + PathStateFlushTarget.BlockBinding binding = path.getBlocks().get(index); + BlockReverseDiff archive = Objects.requireNonNull(archives.get(index), "archiveBlock"); + if (!binding.getMeta().equals(archive.getMeta())) { + throw new IllegalArgumentException( + "common checkpoint Archive and PathState block identity differs"); + } + blocks.add(new BlockPayload(binding.getMeta(), binding.getParentStateRoot(), + binding.getStateRoot(), binding.getTransitionPayloadDigest(), archive)); + } + List pathStores = new ArrayList<>(); + for (PathStateFlushTarget.StoreTarget store : path.getStores()) { + pathStores.add(new PathStoreTarget(store.getStoreId(), store.getDbName(), + store.getStoreRoot(), mutations(store.getFlatMutations()), + mutations(store.getNodeMutations()))); + } + return new CommonCheckpointPayload(FORMAT_VERSION, formatIdentity, blocks, + path.getParentStateRoot(), + path.getStateRoot(), chainbaseStores, pathStores, + mutations(path.getSuperNodeMutations()), null); + } + + /** Builds a v2 coordination payload whose Archive section contains only Hot DB digests. */ + public static CommonCheckpointPayload createV2(byte[] formatIdentity, + PathStateFlushTarget pathState, StateArchiveHotBatchDescriptor archiveBinding, + List chainbaseStores) { + PathStateFlushTarget path = Objects.requireNonNull(pathState, "pathState"); + StateArchiveHotBatchDescriptor archive = Objects.requireNonNull(archiveBinding, + "archiveBinding"); + if (path.getBlocks().size() != archive.getBlocks().size()) { + throw new IllegalArgumentException("common checkpoint block payload count differs"); + } + List blocks = new ArrayList<>(); + for (int index = 0; index < path.getBlocks().size(); index++) { + PathStateFlushTarget.BlockBinding binding = path.getBlocks().get(index); + StateArchiveHotBatchDescriptor.BlockDigest digest = archive.getBlocks().get(index); + if (!binding.getMeta().equals(digest.getMeta())) { + throw new IllegalArgumentException( + "common checkpoint Hot Archive and PathState block identity differs"); + } + blocks.add(BlockPayload.coordination(binding.getMeta(), binding.getParentStateRoot(), + binding.getStateRoot(), binding.getTransitionPayloadDigest(), + digest.getArchiveRecordDigest())); + } + List pathStores = new ArrayList<>(); + for (PathStateFlushTarget.StoreTarget store : path.getStores()) { + pathStores.add(new PathStoreTarget(store.getStoreId(), store.getDbName(), + store.getStoreRoot(), mutations(store.getFlatMutations()), + mutations(store.getNodeMutations()))); + } + return new CommonCheckpointPayload(COORDINATION_FORMAT_VERSION, formatIdentity, blocks, + path.getParentStateRoot(), path.getStateRoot(), chainbaseStores, pathStores, + mutations(path.getSuperNodeMutations()), archive); + } + + static CommonCheckpointPayload coordinateV2(CommonCheckpointPayload capturedV1, + StateArchiveHotBatchDescriptor archiveBinding) { + CommonCheckpointPayload source = Objects.requireNonNull(capturedV1, "capturedV1"); + if (source.version != FORMAT_VERSION) { + throw new IllegalArgumentException("coordination conversion requires a v1 capture"); + } + StateArchiveHotBatchDescriptor archive = Objects.requireNonNull(archiveBinding, + "archiveBinding"); + if (source.blocks.size() != archive.getBlocks().size()) { + throw new IllegalArgumentException("common checkpoint block payload count differs"); + } + List blocks = new ArrayList<>(); + for (int index = 0; index < source.blocks.size(); index++) { + BlockPayload block = source.blocks.get(index); + StateArchiveHotBatchDescriptor.BlockDigest digest = archive.getBlocks().get(index); + if (!block.meta.equals(digest.getMeta())) { + throw new IllegalArgumentException( + "common checkpoint Hot Archive and captured block identity differs"); + } + blocks.add(BlockPayload.coordination(block.meta, block.parentStateRoot, block.stateRoot, + block.transitionPayloadDigest, digest.getArchiveRecordDigest())); + } + return new CommonCheckpointPayload(COORDINATION_FORMAT_VERSION, source.formatIdentity, + blocks, source.parentStateRoot, source.stateRoot, source.chainbaseStores, + source.pathStores, source.superNodeMutations, archive); + } + + static CommonCheckpointPayload restore(byte[] formatIdentity, List blocks, + byte[] parentStateRoot, byte[] stateRoot, List chainbaseStores, + List pathStores, List superNodeMutations) { + return new CommonCheckpointPayload(FORMAT_VERSION, formatIdentity, blocks, parentStateRoot, + stateRoot, chainbaseStores, pathStores, superNodeMutations, null); + } + + static CommonCheckpointPayload restoreV2(byte[] formatIdentity, List blocks, + byte[] parentStateRoot, byte[] stateRoot, List chainbaseStores, + List pathStores, List superNodeMutations, + StateArchiveHotBatchDescriptor archiveBinding) { + return new CommonCheckpointPayload(COORDINATION_FORMAT_VERSION, formatIdentity, blocks, + parentStateRoot, stateRoot, chainbaseStores, pathStores, superNodeMutations, + archiveBinding); + } + + public int getVersion() { + return version; + } + + public byte[] getFormatIdentity() { + return copy(formatIdentity); + } + + public List getBlocks() { + return blocks; + } + + public byte[] getParentStateRoot() { + return copy(parentStateRoot); + } + + public byte[] getStateRoot() { + return copy(stateRoot); + } + + public List getChainbaseStores() { + return chainbaseStores; + } + + public List getPathStores() { + return pathStores; + } + + public List getSuperNodeMutations() { + return superNodeMutations; + } + + public StateArchiveHotBatchDescriptor getArchiveBinding() { + if (archiveBinding == null) { + throw new IllegalStateException("v1 common checkpoint has no Hot Archive binding"); + } + return archiveBinding; + } + + private static List immutableStores(List supplied) { + List stores = new ArrayList<>(Objects.requireNonNull(supplied, + "chainbaseStores")); + stores.sort(Comparator.comparing(StoreMutations::getDbName)); + requireUniqueStoreNames(stores); + return Collections.unmodifiableList(stores); + } + + private static void validateBlocks(int version, List blocks, + byte[] parentStateRoot, byte[] stateRoot, + StateArchiveHotBatchDescriptor archiveBinding) { + BlockPayload previous = null; + for (BlockPayload block : blocks) { + BlockPayload current = Objects.requireNonNull(block, "block"); + if (version == FORMAT_VERSION) { + current.requireArchiveDiff(); + } else if (current.archiveDiff != null || current.archiveRecordDigest == null) { + throw new IllegalArgumentException("coordination checkpoint contains Archive body"); + } + if (previous != null + && (current.meta.getEpoch() != previous.meta.getEpoch() + 1 + || current.meta.getBlockNumber() != previous.meta.getBlockNumber() + 1 + || !Arrays.equals(current.meta.getParentHash(), previous.meta.getBlockHash()) + || !Arrays.equals(current.parentStateRoot, previous.stateRoot))) { + throw new IllegalArgumentException("common checkpoint block chain is not consecutive"); + } + previous = current; + } + if (!Arrays.equals(blocks.get(0).parentStateRoot, parentStateRoot) + || !Arrays.equals(blocks.get(blocks.size() - 1).stateRoot, stateRoot)) { + throw new IllegalArgumentException("common checkpoint target root range differs"); + } + if (version == COORDINATION_FORMAT_VERSION + && (archiveBinding == null + || !blocks.get(0).meta.equals(archiveBinding.getFirstBlock()) + || !blocks.get(blocks.size() - 1).meta.equals(archiveBinding.getLastBlock()))) { + throw new IllegalArgumentException("common checkpoint Archive binding range differs"); + } + if (version == COORDINATION_FORMAT_VERSION) { + List archiveBlocks = + archiveBinding.getBlocks(); + if (blocks.size() != archiveBlocks.size()) { + throw new IllegalArgumentException("common checkpoint Archive binding count differs"); + } + for (int index = 0; index < blocks.size(); index++) { + BlockPayload block = blocks.get(index); + StateArchiveHotBatchDescriptor.BlockDigest archive = archiveBlocks.get(index); + if (!block.meta.equals(archive.getMeta()) + || !Arrays.equals(block.archiveRecordDigest, archive.getArchiveRecordDigest())) { + throw new IllegalArgumentException("common checkpoint Archive block binding differs"); + } + } + } + } + + private static List immutablePathStores(List supplied) { + List stores = new ArrayList<>(Objects.requireNonNull(supplied, + "pathStores")); + stores.sort(Comparator.comparingInt(PathStoreTarget::getStoreId)); + for (int index = 1; index < stores.size(); index++) { + if (stores.get(index - 1).storeId == stores.get(index).storeId) { + throw new IllegalArgumentException("duplicate checkpoint path-state Store ID"); + } + } + return Collections.unmodifiableList(stores); + } + + private static void requireUniqueStoreNames(List stores) { + for (int index = 1; index < stores.size(); index++) { + if (stores.get(index - 1).dbName.equals(stores.get(index).dbName)) { + throw new IllegalArgumentException("duplicate checkpoint Chainbase Store"); + } + } + } + + private static List mutations(List supplied) { + List result = new ArrayList<>(); + for (PathStateSnapshotDelta.Mutation mutation : supplied) { + result.add(new Mutation(mutation.getKey(), mutation.getValue())); + } + return result; + } + + private static List immutableMutations(List supplied) { + List mutations = new ArrayList<>(Objects.requireNonNull(supplied, "mutations")); + mutations.sort(MUTATION_ORDER); + for (int index = 1; index < mutations.size(); index++) { + if (Arrays.equals(mutations.get(index - 1).key, mutations.get(index).key)) { + throw new IllegalArgumentException("duplicate checkpoint mutation key"); + } + } + return Collections.unmodifiableList(mutations); + } + + private static byte[] digest(byte[] value, String name) { + byte[] copy = copy(Objects.requireNonNull(value, name)); + if (copy.length != DIGEST_LENGTH) { + throw new IllegalArgumentException(name + " must contain exactly 32 bytes"); + } + return copy; + } + + private static byte[] copy(byte[] value) { + return Arrays.copyOf(value, value.length); + } + + private static int compareUnsigned(byte[] left, byte[] right) { + for (int index = 0; index < Math.min(left.length, right.length); index++) { + int compared = Integer.compare(left[index] & 0xff, right[index] & 0xff); + if (compared != 0) { + return compared; + } + } + return Integer.compare(left.length, right.length); + } + + public static final class BlockPayload { + + private final BlockSnapshotMeta meta; + private final byte[] parentStateRoot; + private final byte[] stateRoot; + private final byte[] transitionPayloadDigest; + private final BlockReverseDiff archiveDiff; + private final byte[] archiveRecordDigest; + + BlockPayload(BlockSnapshotMeta meta, byte[] parentStateRoot, byte[] stateRoot, + byte[] transitionPayloadDigest, BlockReverseDiff archiveDiff) { + this.meta = Objects.requireNonNull(meta, "meta"); + this.parentStateRoot = digest(parentStateRoot, "parentStateRoot"); + this.stateRoot = digest(stateRoot, "stateRoot"); + this.transitionPayloadDigest = digest(transitionPayloadDigest, + "transitionPayloadDigest"); + this.archiveDiff = Objects.requireNonNull(archiveDiff, "archiveDiff"); + this.archiveRecordDigest = null; + if (!meta.equals(archiveDiff.getMeta())) { + throw new IllegalArgumentException("checkpoint Archive block metadata differs"); + } + } + + private BlockPayload(BlockSnapshotMeta meta, byte[] parentStateRoot, byte[] stateRoot, + byte[] transitionPayloadDigest, byte[] archiveRecordDigest) { + this.meta = Objects.requireNonNull(meta, "meta"); + this.parentStateRoot = digest(parentStateRoot, "parentStateRoot"); + this.stateRoot = digest(stateRoot, "stateRoot"); + this.transitionPayloadDigest = digest(transitionPayloadDigest, + "transitionPayloadDigest"); + this.archiveDiff = null; + this.archiveRecordDigest = digest(archiveRecordDigest, "archiveRecordDigest"); + } + + static BlockPayload coordination(BlockSnapshotMeta meta, byte[] parentStateRoot, + byte[] stateRoot, byte[] transitionPayloadDigest, byte[] archiveRecordDigest) { + return new BlockPayload(meta, parentStateRoot, stateRoot, transitionPayloadDigest, + archiveRecordDigest); + } + + public BlockSnapshotMeta getMeta() { + return meta; + } + + public byte[] getParentStateRoot() { + return copy(parentStateRoot); + } + + public byte[] getStateRoot() { + return copy(stateRoot); + } + + public byte[] getTransitionPayloadDigest() { + return copy(transitionPayloadDigest); + } + + public BlockReverseDiff getArchiveDiff() { + return requireArchiveDiff(); + } + + public byte[] getArchiveRecordDigest() { + if (archiveRecordDigest == null) { + throw new IllegalStateException("v1 common checkpoint has no Archive record digest"); + } + return copy(archiveRecordDigest); + } + + private BlockReverseDiff requireArchiveDiff() { + if (archiveDiff == null) { + throw new IllegalStateException("v2 coordination checkpoint has no Archive body"); + } + return archiveDiff; + } + } + + public static class StoreMutations { + + private final String dbName; + private final List mutations; + + public StoreMutations(String dbName, List mutations) { + this.dbName = Objects.requireNonNull(dbName, "dbName"); + if (dbName.isEmpty()) { + throw new IllegalArgumentException("checkpoint dbName must not be empty"); + } + this.mutations = immutableMutations(mutations); + } + + public String getDbName() { + return dbName; + } + + public List getMutations() { + return mutations; + } + } + + public static final class PathStoreTarget extends StoreMutations { + + private final int storeId; + private final byte[] storeRoot; + private final List nodeMutations; + + PathStoreTarget(int storeId, String dbName, byte[] storeRoot, + List flatMutations, List nodeMutations) { + super(dbName, flatMutations); + if (storeId <= 0) { + throw new IllegalArgumentException("checkpoint path-state Store ID must be positive"); + } + this.storeId = storeId; + this.storeRoot = digest(storeRoot, "storeRoot"); + this.nodeMutations = immutableMutations(nodeMutations); + } + + public int getStoreId() { + return storeId; + } + + public byte[] getStoreRoot() { + return copy(storeRoot); + } + + public List getFlatMutations() { + return getMutations(); + } + + public List getNodeMutations() { + return nodeMutations; + } + } + + public static final class Mutation { + + private final byte[] key; + private final byte[] value; + + public Mutation(byte[] key, byte[] value) { + this.key = copy(Objects.requireNonNull(key, "key")); + this.value = value == null ? null : copy(value); + } + + public byte[] getKey() { + return copy(key); + } + + public byte[] getValue() { + return value == null ? null : copy(value); + } + + public boolean isDelete() { + return value == null; + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/core/CommonCheckpointPayloadCodec.java b/chainbase/src/main/java/org/tron/core/db2/core/CommonCheckpointPayloadCodec.java new file mode 100644 index 00000000000..ca00ea7959d --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/core/CommonCheckpointPayloadCodec.java @@ -0,0 +1,381 @@ +package org.tron.core.db2.core; + +import com.google.common.hash.Hashing; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.EOFException; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import org.tron.core.db2.archive.BlockHistoryCodec; +import org.tron.core.db2.archive.BlockReverseDiff; +import org.tron.core.db2.archive.BlockSnapshotMeta; +import org.tron.core.db2.archive.StateArchiveHotBatchDescriptor; +import org.tron.core.db2.archive.StateArchiveHotBatchDescriptor.BlockDigest; +import org.tron.core.db2.core.CommonCheckpointPayload.BlockPayload; +import org.tron.core.db2.core.CommonCheckpointPayload.Mutation; +import org.tron.core.db2.core.CommonCheckpointPayload.PathStoreTarget; +import org.tron.core.db2.core.CommonCheckpointPayload.StoreMutations; +import org.tron.core.db2.stateroot.PathStateStoreManifest.Engine; + +/** Deterministic, bounded codec for redo bodies and digest-only Archive coordination. */ +public final class CommonCheckpointPayloadCodec { + + public static final int MAGIC = 0x54434350; // TCCP + public static final short VERSION = 3; + public static final short COORDINATION_VERSION = 4; + public static final int HEADER_LENGTH = 44; + public static final int DEFAULT_MAX_ENCODED_LENGTH = 256 * 1024 * 1024; + private static final int DIGEST_LENGTH = 32; + private static final int MAX_BLOCKS = 100_000; + private static final int MAX_STORES = 1024; + private static final int MAX_MUTATIONS = 1_000_000; + private static final int MAX_NAME_LENGTH = 256; + private static final int MAX_FIELD_LENGTH = 64 * 1024 * 1024; + + private final int maxEncodedLength; + private final BlockHistoryCodec historyCodec = new BlockHistoryCodec(); + + public CommonCheckpointPayloadCodec() { + this(DEFAULT_MAX_ENCODED_LENGTH); + } + + public CommonCheckpointPayloadCodec(int maxEncodedLength) { + if (maxEncodedLength <= HEADER_LENGTH) { + throw new IllegalArgumentException("common checkpoint maximum length is too small"); + } + this.maxEncodedLength = maxEncodedLength; + } + + public byte[] encode(CommonCheckpointPayload payload) { + try { + byte[] body = encodeBody(payload); + checkEncodedLength(HEADER_LENGTH + (long) body.length); + ByteArrayOutputStream bytes = new ByteArrayOutputStream(HEADER_LENGTH + body.length); + DataOutputStream output = new DataOutputStream(bytes); + output.writeInt(MAGIC); + output.writeShort(payload.getVersion()); + output.writeShort(0); + output.writeInt(body.length); + output.write(Hashing.sha256().hashBytes(body).asBytes()); + output.write(body); + output.flush(); + return bytes.toByteArray(); + } catch (IOException impossible) { + throw new IllegalStateException("in-memory common checkpoint encoding failed", impossible); + } + } + + public CommonCheckpointPayload decode(byte[] encoded) { + if (encoded == null || encoded.length < HEADER_LENGTH) { + throw new IllegalArgumentException("common checkpoint payload is truncated"); + } + checkEncodedLength(encoded.length); + try { + DataInputStream input = new DataInputStream(new ByteArrayInputStream(encoded)); + if (input.readInt() != MAGIC) { + throw new IllegalArgumentException("invalid common checkpoint magic"); + } + short version = input.readShort(); + if (version != VERSION && version != COORDINATION_VERSION) { + throw new IllegalArgumentException("unsupported common checkpoint version"); + } + if (input.readShort() != 0) { + throw new IllegalArgumentException("unsupported common checkpoint flags"); + } + int bodyLength = input.readInt(); + byte[] expectedDigest = readExact(input, DIGEST_LENGTH); + if (bodyLength < 0 || HEADER_LENGTH + (long) bodyLength != encoded.length) { + throw new IllegalArgumentException("invalid common checkpoint body length"); + } + byte[] body = readExact(input, bodyLength); + if (!Arrays.equals(expectedDigest, Hashing.sha256().hashBytes(body).asBytes())) { + throw new IllegalArgumentException("common checkpoint payload checksum mismatch"); + } + return version == VERSION ? decodeBodyV1(body) : decodeBodyV2(body); + } catch (EOFException truncated) { + throw new IllegalArgumentException("common checkpoint payload is truncated", truncated); + } catch (IOException invalid) { + throw new IllegalArgumentException("invalid common checkpoint payload", invalid); + } + } + + public byte[] digest(CommonCheckpointPayload payload) { + return Hashing.sha256().hashBytes(encode(payload)).asBytes(); + } + + private byte[] encodeBody(CommonCheckpointPayload payload) throws IOException { + CommonCheckpointPayload admitted = java.util.Objects.requireNonNull(payload, "payload"); + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + DataOutputStream output = new DataOutputStream(bytes); + output.write(admitted.getFormatIdentity()); + output.write(admitted.getParentStateRoot()); + output.write(admitted.getStateRoot()); + output.writeInt(admitted.getBlocks().size()); + for (BlockPayload block : admitted.getBlocks()) { + writeMeta(output, block.getMeta()); + output.write(block.getParentStateRoot()); + output.write(block.getStateRoot()); + output.write(block.getTransitionPayloadDigest()); + if (admitted.getVersion() == CommonCheckpointPayload.FORMAT_VERSION) { + writeBytes(output, historyCodec.encode(block.getArchiveDiff())); + } else { + output.write(block.getArchiveRecordDigest()); + } + } + if (admitted.getVersion() == CommonCheckpointPayload.COORDINATION_FORMAT_VERSION) { + writeArchiveBinding(output, admitted.getArchiveBinding()); + } + writeStores(output, admitted.getChainbaseStores()); + output.writeInt(admitted.getPathStores().size()); + for (PathStoreTarget store : admitted.getPathStores()) { + output.writeInt(store.getStoreId()); + writeName(output, store.getDbName()); + output.write(store.getStoreRoot()); + writeMutations(output, store.getFlatMutations()); + writeMutations(output, store.getNodeMutations()); + } + writeMutations(output, admitted.getSuperNodeMutations()); + output.flush(); + return bytes.toByteArray(); + } + + private CommonCheckpointPayload decodeBodyV1(byte[] body) throws IOException { + DataInputStream input = new DataInputStream(new ByteArrayInputStream(body)); + byte[] formatIdentity = readExact(input, DIGEST_LENGTH); + byte[] parentStateRoot = readExact(input, DIGEST_LENGTH); + byte[] stateRoot = readExact(input, DIGEST_LENGTH); + int blockCount = readCount(input, MAX_BLOCKS, "block"); + List blocks = new ArrayList<>(blockCount); + for (int index = 0; index < blockCount; index++) { + BlockSnapshotMeta meta = readMeta(input); + byte[] parentRoot = readExact(input, DIGEST_LENGTH); + byte[] blockRoot = readExact(input, DIGEST_LENGTH); + byte[] transitionDigest = readExact(input, DIGEST_LENGTH); + BlockReverseDiff decoded = historyCodec.decode(readBytes(input)); + blocks.add(new BlockPayload(meta, parentRoot, blockRoot, transitionDigest, decoded)); + } + List chainbase = readStores(input); + int pathStoreCount = readCount(input, MAX_STORES, "path-state Store"); + List pathStores = new ArrayList<>(pathStoreCount); + for (int index = 0; index < pathStoreCount; index++) { + int storeId = input.readInt(); + String dbName = readName(input); + byte[] storeRoot = readExact(input, DIGEST_LENGTH); + pathStores.add(new PathStoreTarget(storeId, dbName, storeRoot, + readMutations(input), readMutations(input))); + } + List superNodes = readMutations(input); + if (input.available() != 0) { + throw new IllegalArgumentException("common checkpoint payload has trailing bytes"); + } + return CommonCheckpointPayload.restore(formatIdentity, blocks, parentStateRoot, stateRoot, + chainbase, pathStores, superNodes); + } + + private CommonCheckpointPayload decodeBodyV2(byte[] body) throws IOException { + DataInputStream input = new DataInputStream(new ByteArrayInputStream(body)); + byte[] formatIdentity = readExact(input, DIGEST_LENGTH); + byte[] parentStateRoot = readExact(input, DIGEST_LENGTH); + byte[] stateRoot = readExact(input, DIGEST_LENGTH); + int blockCount = readCount(input, MAX_BLOCKS, "block"); + List blocks = new ArrayList<>(blockCount); + List archiveBlocks = new ArrayList<>(blockCount); + for (int index = 0; index < blockCount; index++) { + BlockSnapshotMeta meta = readMeta(input); + byte[] parentRoot = readExact(input, DIGEST_LENGTH); + byte[] blockRoot = readExact(input, DIGEST_LENGTH); + byte[] transitionDigest = readExact(input, DIGEST_LENGTH); + byte[] recordDigest = readExact(input, DIGEST_LENGTH); + blocks.add(BlockPayload.coordination(meta, parentRoot, blockRoot, transitionDigest, + recordDigest)); + archiveBlocks.add(BlockDigest.restore(meta, recordDigest)); + } + StateArchiveHotBatchDescriptor archiveBinding = readArchiveBinding(input, archiveBlocks); + List chainbase = readStores(input); + int pathStoreCount = readCount(input, MAX_STORES, "path-state Store"); + List pathStores = new ArrayList<>(pathStoreCount); + for (int index = 0; index < pathStoreCount; index++) { + int storeId = input.readInt(); + String dbName = readName(input); + byte[] storeRoot = readExact(input, DIGEST_LENGTH); + pathStores.add(new PathStoreTarget(storeId, dbName, storeRoot, + readMutations(input), readMutations(input))); + } + List superNodes = readMutations(input); + if (input.available() != 0) { + throw new IllegalArgumentException("common checkpoint payload has trailing bytes"); + } + return CommonCheckpointPayload.restoreV2(formatIdentity, blocks, parentStateRoot, stateRoot, + chainbase, pathStores, superNodes, archiveBinding); + } + + private static void writeArchiveBinding(DataOutputStream output, + StateArchiveHotBatchDescriptor binding) throws IOException { + output.writeShort(StateArchiveHotBatchDescriptor.HOT_FORMAT_VERSION); + output.writeShort(engineTag(binding.getEngine())); + output.writeLong(binding.getParentPublishedBlock()); + output.write(binding.getParentPublishedHash()); + writeMeta(output, binding.getFirstBlock()); + writeMeta(output, binding.getLastBlock()); + output.writeLong(binding.getBlockCount()); + output.writeLong(binding.getEncodedBytes()); + output.write(binding.getParentContentDigest()); + output.write(binding.getResultContentDigest()); + output.write(binding.getOrderedRecordDigest()); + } + + private static StateArchiveHotBatchDescriptor readArchiveBinding(DataInputStream input, + List blocks) throws IOException { + if (input.readUnsignedShort() != StateArchiveHotBatchDescriptor.HOT_FORMAT_VERSION) { + throw new IllegalArgumentException("unsupported Hot Archive binding version"); + } + Engine engine = engine(input.readUnsignedShort()); + long parentBlock = input.readLong(); + byte[] parentHash = readExact(input, DIGEST_LENGTH); + BlockSnapshotMeta first = readMeta(input); + BlockSnapshotMeta last = readMeta(input); + long blockCount = input.readLong(); + long encodedBytes = input.readLong(); + byte[] parentContent = readExact(input, DIGEST_LENGTH); + byte[] resultContent = readExact(input, DIGEST_LENGTH); + byte[] orderedRecords = readExact(input, DIGEST_LENGTH); + if (blockCount != blocks.size()) { + throw new IllegalArgumentException("Hot Archive binding block count differs"); + } + return StateArchiveHotBatchDescriptor.restore(engine, parentBlock, parentHash, first, last, + encodedBytes, parentContent, resultContent, orderedRecords, blocks); + } + + private static int engineTag(Engine engine) { + return engine == Engine.LEVELDB ? 1 : 2; + } + + private static Engine engine(int tag) { + if (tag == 1) { + return Engine.LEVELDB; + } + if (tag == 2) { + return Engine.ROCKSDB; + } + throw new IllegalArgumentException("unsupported Hot Archive engine tag"); + } + + private void writeStores(DataOutputStream output, List stores) + throws IOException { + output.writeInt(stores.size()); + for (StoreMutations store : stores) { + writeName(output, store.getDbName()); + writeMutations(output, store.getMutations()); + } + } + + private List readStores(DataInputStream input) throws IOException { + int count = readCount(input, MAX_STORES, "Chainbase Store"); + List stores = new ArrayList<>(count); + for (int index = 0; index < count; index++) { + stores.add(new StoreMutations(readName(input), readMutations(input))); + } + return stores; + } + + private void writeMutations(DataOutputStream output, List mutations) + throws IOException { + output.writeInt(mutations.size()); + for (Mutation mutation : mutations) { + writeBytes(output, mutation.getKey()); + byte[] value = mutation.getValue(); + output.writeInt(value == null ? -1 : value.length); + if (value != null) { + output.write(value); + } + } + } + + private List readMutations(DataInputStream input) throws IOException { + int count = readCount(input, MAX_MUTATIONS, "mutation"); + List mutations = new ArrayList<>(count); + for (int index = 0; index < count; index++) { + byte[] key = readBytes(input); + int valueLength = input.readInt(); + if (valueLength < -1 || valueLength > MAX_FIELD_LENGTH) { + throw new IllegalArgumentException("invalid checkpoint mutation value length"); + } + mutations.add(new Mutation(key, + valueLength < 0 ? null : readExact(input, valueLength))); + } + return mutations; + } + + private static void writeMeta(DataOutputStream output, BlockSnapshotMeta meta) + throws IOException { + output.writeLong(meta.getEpoch()); + output.writeLong(meta.getBlockNumber()); + output.write(meta.getBlockHash()); + output.write(meta.getParentHash()); + output.writeLong(meta.getTimestamp()); + } + + private static BlockSnapshotMeta readMeta(DataInputStream input) throws IOException { + return new BlockSnapshotMeta(input.readLong(), input.readLong(), + readExact(input, DIGEST_LENGTH), readExact(input, DIGEST_LENGTH), input.readLong()); + } + + private static void writeName(DataOutputStream output, String value) throws IOException { + byte[] encoded = value.getBytes(StandardCharsets.UTF_8); + if (encoded.length == 0 || encoded.length > MAX_NAME_LENGTH) { + throw new IllegalArgumentException("invalid common checkpoint Store name length"); + } + output.writeInt(encoded.length); + output.write(encoded); + } + + private static String readName(DataInputStream input) throws IOException { + int length = input.readInt(); + if (length <= 0 || length > MAX_NAME_LENGTH) { + throw new IllegalArgumentException("invalid common checkpoint Store name length"); + } + return new String(readExact(input, length), StandardCharsets.UTF_8); + } + + private static void writeBytes(DataOutputStream output, byte[] value) throws IOException { + if (value.length > MAX_FIELD_LENGTH) { + throw new IllegalArgumentException("common checkpoint field exceeds maximum length"); + } + output.writeInt(value.length); + output.write(value); + } + + private static byte[] readBytes(DataInputStream input) throws IOException { + int length = input.readInt(); + if (length < 0 || length > MAX_FIELD_LENGTH) { + throw new IllegalArgumentException("invalid common checkpoint field length"); + } + return readExact(input, length); + } + + private static int readCount(DataInputStream input, int maximum, String name) + throws IOException { + int count = input.readInt(); + if (count < 0 || count > maximum) { + throw new IllegalArgumentException("invalid common checkpoint " + name + " count"); + } + return count; + } + + private static byte[] readExact(DataInputStream input, int length) throws IOException { + byte[] value = new byte[length]; + input.readFully(value); + return value; + } + + private void checkEncodedLength(long length) { + if (length > maxEncodedLength) { + throw new IllegalArgumentException("common checkpoint exceeds maximum encoded length"); + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/core/CommonCheckpointPayloadFactory.java b/chainbase/src/main/java/org/tron/core/db2/core/CommonCheckpointPayloadFactory.java new file mode 100644 index 00000000000..3d0b4c7b56e --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/core/CommonCheckpointPayloadFactory.java @@ -0,0 +1,160 @@ +package org.tron.core.db2.core; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import org.tron.core.db2.archive.ArchiveStoreScope; +import org.tron.core.db2.archive.BlockReverseDiff; +import org.tron.core.db2.archive.BlockSnapshotMeta; +import org.tron.core.db2.archive.StateArchiveHotBatchDescriptor; +import org.tron.core.db2.archive.StateArchiveCheckpointPlanner; +import org.tron.core.db2.common.Key; +import org.tron.core.db2.common.Value; +import org.tron.core.db2.common.WrappedByteArray; +import org.tron.core.db2.stateroot.PathStateFlushTarget; +import org.tron.core.db2.stateroot.PathStateSnapshotDelta; + +/** Builds one immutable common-checkpoint redo payload without querying durable databases. */ +public final class CommonCheckpointPayloadFactory { + + /** Captures v2 coordination data while retaining Archive bodies only in transient memory. */ + public CommonCheckpointCapture captureV2(byte[] formatIdentity, List databases, + int flushCount, StateArchiveCheckpointPlanner archivePlanner) throws IOException { + CommonCheckpointPayload captured = capture(formatIdentity, databases, flushCount); + List archiveDiffs = new ArrayList<>(); + for (CommonCheckpointPayload.BlockPayload block : captured.getBlocks()) { + archiveDiffs.add(block.getArchiveDiff()); + } + StateArchiveHotBatchDescriptor binding = Objects.requireNonNull(archivePlanner, + "archivePlanner") + .planCheckpoint(archiveDiffs); + CommonCheckpointPayload coordination = CommonCheckpointPayload.coordinateV2(captured, + binding); + return new CommonCheckpointCapture(coordination, archiveDiffs, binding); + } + + /** Captures the oldest {@code flushCount} Snapshot layers from every registered Store. */ + public CommonCheckpointPayload capture(byte[] formatIdentity, List databases, + int flushCount) { + Objects.requireNonNull(formatIdentity, "formatIdentity"); + if (flushCount <= 0) { + throw new IllegalArgumentException("common checkpoint flushCount must be positive"); + } + List admitted = new ArrayList<>(Objects.requireNonNull(databases, "databases")); + if (admitted.isEmpty()) { + throw new IllegalArgumentException("common checkpoint requires registered Stores"); + } + + Map> layersByStore = new LinkedHashMap<>(); + List expectedMetas = null; + for (Chainbase database : admitted) { + Chainbase candidate = Objects.requireNonNull(database, "database"); + List layers = layers(candidate, flushCount); + if (layersByStore.putIfAbsent(candidate.getDbName(), layers) != null) { + throw new IllegalArgumentException("duplicate common checkpoint Store: " + + candidate.getDbName()); + } + List metas = metas(layers, candidate.getDbName()); + if (expectedMetas == null) { + expectedMetas = metas; + } else if (!expectedMetas.equals(metas)) { + throw new IllegalStateException("common checkpoint block identities differ across Stores"); + } + } + + List archiveBlocks = new ArrayList<>(); + List pathDeltas = new ArrayList<>(); + boolean foundStateStore = false; + for (Map.Entry> entry : layersByStore.entrySet()) { + if (!ArchiveStoreScope.isStateDatabase(entry.getKey())) { + continue; + } + foundStateStore = true; + for (int index = 0; index < entry.getValue().size(); index++) { + SnapshotImpl layer = entry.getValue().get(index); + BlockReverseDiff archive = layer.getPreparedArchiveBlock(); + PathStateSnapshotDelta path = layer.getPreparedPathStateDelta(); + requireArtifacts(expectedMetas.get(index), archive, path, entry.getKey()); + if (archiveBlocks.size() == index) { + archiveBlocks.add(archive); + pathDeltas.add(path); + } else { + requireSameArtifacts(archiveBlocks.get(index), pathDeltas.get(index), archive, path, + entry.getKey()); + } + } + } + if (!foundStateStore) { + throw new IllegalStateException("common checkpoint has no state Store"); + } + + List stores = new ArrayList<>(); + for (Map.Entry> entry : layersByStore.entrySet()) { + if ("trans-cache".equals(entry.getKey())) { + continue; + } + Map coalesced = + new LinkedHashMap<>(); + for (SnapshotImpl layer : entry.getValue()) { + for (Map.Entry mutation : layer.getDb()) { + byte[] key = mutation.getKey().getBytes(); + coalesced.put(WrappedByteArray.of(key), new CommonCheckpointPayload.Mutation(key, + mutation.getValue().getBytes())); + } + } + if (!coalesced.isEmpty()) { + stores.add(new CommonCheckpointPayload.StoreMutations(entry.getKey(), + new ArrayList<>(coalesced.values()))); + } + } + return CommonCheckpointPayload.create(formatIdentity, + PathStateFlushTarget.coalesce(pathDeltas), archiveBlocks, stores); + } + + private static List layers(Chainbase database, int count) { + Snapshot next = database.getHead().getRoot(); + List layers = new ArrayList<>(count); + for (int index = 0; index < count; index++) { + next = next.getNext(); + if (!(next instanceof SnapshotImpl)) { + throw new IllegalStateException("common checkpoint Store has too few Snapshot layers: " + + database.getDbName()); + } + layers.add((SnapshotImpl) next); + } + return layers; + } + + private static List metas(List layers, String dbName) { + List metas = new ArrayList<>(layers.size()); + for (SnapshotImpl layer : layers) { + BlockSnapshotMeta meta = layer.getBlockSnapshotMeta(); + if (meta == null) { + throw new IllegalStateException("common checkpoint Snapshot has no block identity: " + + dbName); + } + metas.add(meta); + } + return metas; + } + + private static void requireArtifacts(BlockSnapshotMeta meta, BlockReverseDiff archive, + PathStateSnapshotDelta path, String dbName) { + if (archive == null || path == null || !meta.equals(archive.getMeta()) + || !meta.equals(path.getMeta())) { + throw new IllegalStateException("common checkpoint Snapshot artifacts differ: " + dbName); + } + } + + private static void requireSameArtifacts(BlockReverseDiff expectedArchive, + PathStateSnapshotDelta expectedPath, BlockReverseDiff archive, + PathStateSnapshotDelta path, String dbName) { + if (expectedArchive != archive || expectedPath != path) { + throw new IllegalStateException("common checkpoint artifacts differ across state Stores: " + + dbName); + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/core/CommonCheckpointRecoveryStateAdapter.java b/chainbase/src/main/java/org/tron/core/db2/core/CommonCheckpointRecoveryStateAdapter.java new file mode 100644 index 00000000000..f896394590d --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/core/CommonCheckpointRecoveryStateAdapter.java @@ -0,0 +1,66 @@ +package org.tron.core.db2.core; + +import java.io.IOException; +import java.util.Objects; +import org.tron.common.utils.Sha256Hash; +import org.tron.core.ChainBaseManager; +import org.tron.core.capsule.BlockCapsule; +import org.tron.core.db2.archive.BlockSnapshotMeta; +import org.tron.core.exception.BadItemException; +import org.tron.core.exception.ItemNotFoundException; +import org.tron.core.store.DynamicPropertiesStore; + +/** Read-only recovery identity adapter over checkpoint-redone persistent Chainbase stores. */ +public final class CommonCheckpointRecoveryStateAdapter + implements CommonCheckpointHotRecovery.PersistentDynamicHeadSource, + CommonCheckpointHotRecovery.BlockMetaSource { + + private final DynamicPropertiesStore dynamicPropertiesStore; + private final ChainBaseManager chainBaseManager; + + public CommonCheckpointRecoveryStateAdapter(DynamicPropertiesStore dynamicPropertiesStore, + ChainBaseManager chainBaseManager) { + this.dynamicPropertiesStore = Objects.requireNonNull(dynamicPropertiesStore, + "dynamicPropertiesStore"); + this.chainBaseManager = Objects.requireNonNull(chainBaseManager, "chainBaseManager"); + } + + /** Reads only the persistent root; reversible Snapshot values are not recovery authority. */ + @Override + public CommonCheckpointHotRecovery.PersistentDynamicHead load() throws IOException { + final long blockNumber; + final Sha256Hash blockHash; + try { + blockNumber = dynamicPropertiesStore.getLatestBlockHeaderNumberFromDB(); + blockHash = dynamicPropertiesStore.getLatestBlockHeaderHashFromDB(); + } catch (RuntimeException failure) { + throw new IOException("persistent dynamic block identity cannot be read", failure); + } + if (blockNumber < 0 || blockHash == null) { + throw new IOException("persistent dynamic block identity is unavailable"); + } + return new CommonCheckpointHotRecovery.PersistentDynamicHead(blockNumber, + blockHash.getBytes()); + } + + /** Loads the full canonical block metadata needed to validate an exact recovery ceiling. */ + @Override + public BlockSnapshotMeta loadIfPresent(long blockNumber) throws IOException { + if (blockNumber < 0) { + throw new IllegalArgumentException("blockNumber must not be negative"); + } + final BlockCapsule block; + try { + block = chainBaseManager.getBlockByNum(blockNumber); + } catch (ItemNotFoundException missing) { + return null; + } catch (BadItemException corrupt) { + throw new IOException("Block Store recovery metadata is corrupt", corrupt); + } + if (block.getNum() != blockNumber) { + throw new IOException("Block Store returned a different recovery height"); + } + return BlockSnapshotMeta.forBlock(blockNumber, block.getBlockId().getBytes(), + block.getParentHash().getBytes(), block.getTimeStamp()); + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/core/CommonCheckpointRedoCoordinator.java b/chainbase/src/main/java/org/tron/core/db2/core/CommonCheckpointRedoCoordinator.java new file mode 100644 index 00000000000..fd9f0094f08 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/core/CommonCheckpointRedoCoordinator.java @@ -0,0 +1,485 @@ +package org.tron.core.db2.core; + +import java.io.IOException; +import java.util.EnumMap; +import java.util.Map; +import java.util.Objects; +import java.util.function.LongSupplier; +import org.tron.core.db2.core.CommonCheckpointMaterializer.Authority; +import org.tron.core.db2.core.CommonCheckpointMaterializer.Status; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** Two-barrier, idempotent redo coordinator for one durable common checkpoint. */ +public final class CommonCheckpointRedoCoordinator implements AutoCloseable { + + private static final Logger logger = LoggerFactory.getLogger("DB"); + private static final Authority[] ORDER = { + Authority.CHAINBASE, Authority.PATH_STATE, Authority.STATE_ARCHIVE}; + + private final CommonCheckpointFile checkpointFile; + private final Map materializers; + private final FaultHook faultHook; + private final LongSupplier nanoTime; + private final TimingSink timingSink; + private boolean closed; + + public CommonCheckpointRedoCoordinator(CommonCheckpointFile checkpointFile, + CommonCheckpointMaterializer chainbase, CommonCheckpointMaterializer pathState, + CommonCheckpointMaterializer stateArchive) { + this(checkpointFile, chainbase, pathState, stateArchive, stage -> { }); + } + + CommonCheckpointRedoCoordinator(CommonCheckpointFile checkpointFile, + CommonCheckpointMaterializer chainbase, CommonCheckpointMaterializer pathState, + CommonCheckpointMaterializer stateArchive, FaultHook faultHook) { + this(checkpointFile, chainbase, pathState, stateArchive, faultHook, System::nanoTime, + CommonCheckpointRedoCoordinator::logTiming); + } + + CommonCheckpointRedoCoordinator(CommonCheckpointFile checkpointFile, + CommonCheckpointMaterializer chainbase, CommonCheckpointMaterializer pathState, + CommonCheckpointMaterializer stateArchive, FaultHook faultHook, LongSupplier nanoTime, + TimingSink timingSink) { + this.checkpointFile = Objects.requireNonNull(checkpointFile, "checkpointFile"); + this.materializers = new EnumMap<>(Authority.class); + admit(Authority.CHAINBASE, chainbase); + admit(Authority.PATH_STATE, pathState); + admit(Authority.STATE_ARCHIVE, stateArchive); + this.faultHook = Objects.requireNonNull(faultHook, "faultHook"); + this.nanoTime = Objects.requireNonNull(nanoTime, "nanoTime"); + this.timingSink = Objects.requireNonNull(timingSink, "timingSink"); + } + + /** Durably publishes the redo payload before applying it to any authority. */ + public synchronized RecoveryAction apply(CommonCheckpointPayload payload) throws IOException { + RecoveryAction action = applyDurable(payload); + notifyCommitted(CommonCheckpointTarget.from(payload)); + return action; + } + + synchronized RecoveryAction applyDurable(CommonCheckpointPayload payload) throws IOException { + requireOpen(); + CommonCheckpointPayload admitted = Objects.requireNonNull(payload, "payload"); + Timing timing = new Timing("apply", CommonCheckpointTarget.from(admitted), + admitted.getBlocks().size()); + long totalStart = nanoTime.getAsLong(); + timing.walPublishUs = timed(() -> checkpointFile.publish(admitted)); + Holder loaded = new Holder<>(); + timing.walLoadUs = timed(() -> loaded.value = checkpointFile.loadRequired()); + RecoveryAction action = redo(loaded.value, timing); + timing.totalUs = elapsedUs(totalStart); + emitTiming(timing); + return action; + } + + /** Resumes the only durable checkpoint, or performs no work when none exists. */ + public synchronized RecoveryAction recover() throws IOException { + requireOpen(); + long totalStart = nanoTime.getAsLong(); + Holder loaded = new Holder<>(); + long loadUs = timed(() -> loaded.value = checkpointFile.loadIfPresent()); + if (loaded.value == null) { + return RecoveryAction.NO_CHECKPOINT; + } + Timing timing = new Timing("recover", CommonCheckpointTarget.from(loaded.value), + loaded.value.getBlocks().size()); + timing.walLoadUs = loadUs; + RecoveryAction action = redo(loaded.value, timing); + timing.totalUs = elapsedUs(totalStart); + emitTiming(timing); + notifyCommitted(CommonCheckpointTarget.from(loaded.value)); + return action; + } + + synchronized void requireMaterializer(Authority authority, + CommonCheckpointMaterializer expected) { + if (materializers.get(Objects.requireNonNull(authority, "authority")) + != Objects.requireNonNull(expected, "expected")) { + throw new IllegalArgumentException( + "common checkpoint runtime materializer identity differs: " + authority); + } + } + + synchronized void notifyCommitted(CommonCheckpointTarget target) { + for (Authority authority : ORDER) { + try { + materializers.get(authority).afterCommit(target); + } catch (RuntimeException failure) { + logger.error("Derived post-commit work failed for {} at {}", authority, + target.getLastBlock().getBlockNumber(), failure); + } + } + } + + /** Requires every authority to expose the same fully published startup target. */ + synchronized void requirePublished(CommonCheckpointTarget target) throws IOException { + requireOpen(); + CommonCheckpointTarget admitted = Objects.requireNonNull(target, "target"); + for (Authority authority : ORDER) { + requireStatus(authority, Status.PUBLISHED, + materializers.get(authority).inspect(admitted), "startup validation"); + } + } + + /** Closes runtime-owned authority resources in reverse publication order. */ + @Override + public synchronized void close() throws IOException { + if (closed) { + return; + } + closed = true; + Throwable failure = null; + for (int index = ORDER.length - 1; index >= 0; index--) { + CommonCheckpointMaterializer materializer = materializers.get(ORDER[index]); + try { + materializer.close(); + } catch (IOException | RuntimeException closing) { + if (failure == null) { + failure = closing; + } else { + failure.addSuppressed(closing); + } + } + } + if (failure instanceof IOException) { + throw (IOException) failure; + } + if (failure != null) { + throw (RuntimeException) failure; + } + } + + private RecoveryAction redo(CommonCheckpointPayload payload, Timing timing) throws IOException { + CommonCheckpointTarget target = CommonCheckpointTarget.from(payload); + try (CheckpointScope ignored = new CheckpointScope(target, timing)) { + return redo(payload, target, timing); + } + } + + private RecoveryAction redo(CommonCheckpointPayload payload, CommonCheckpointTarget target, + Timing timing) throws IOException { + Map initial = inspectAll(target, timing); + if (initial.containsValue(Status.PUBLISHED) + && initial.containsValue(Status.NEEDS_MATERIALIZATION)) { + throw new IOException("common checkpoint has published authority before materialization " + + "barrier"); + } + + for (Authority authority : ORDER) { + CommonCheckpointMaterializer materializer = materializers.get(authority); + if (initial.get(authority) == Status.NEEDS_MATERIALIZATION) { + timing.materializeUs[authority.ordinal()] += timed( + () -> materializer.materialize(payload, target)); + timing.materializeCount[authority.ordinal()]++; + requireStatus(authority, Status.MATERIALIZED, inspect(authority, target, timing), + "materialization"); + faultHook.after(materializeStage(authority)); + } + } + + Map materialized = inspectAll(target, timing); + if (materialized.containsValue(Status.NEEDS_MATERIALIZATION)) { + throw new IOException("common checkpoint materialization barrier is incomplete"); + } + for (Authority authority : ORDER) { + CommonCheckpointMaterializer materializer = materializers.get(authority); + if (materialized.get(authority) == Status.MATERIALIZED) { + timing.publishUs[authority.ordinal()] += timed(() -> materializer.publish(target)); + timing.publishCount[authority.ordinal()]++; + requireStatus(authority, Status.PUBLISHED, inspect(authority, target, timing), + "publication"); + faultHook.after(publishStage(authority)); + } + } + + Map published = inspectAll(target, timing); + for (Authority authority : ORDER) { + requireStatus(authority, Status.PUBLISHED, published.get(authority), "retirement"); + } + faultHook.after(Stage.BEFORE_CHECKPOINT_RETIRE); + timing.walRetireUs = timed(checkpointFile::retire); + faultHook.after(Stage.AFTER_CHECKPOINT_RETIRE); + return RecoveryAction.COMPLETED_REDO; + } + + private final class CheckpointScope implements AutoCloseable { + + private final CommonCheckpointTarget target; + private final Timing timing; + private int opened; + + private CheckpointScope(CommonCheckpointTarget target, Timing timing) throws IOException { + this.target = target; + this.timing = timing; + try { + for (Authority authority : ORDER) { + timing.beginUs[authority.ordinal()] += timed( + () -> materializers.get(authority).beginCheckpoint(target)); + opened++; + } + } catch (IOException | RuntimeException failure) { + try { + close(); + } catch (IOException closing) { + failure.addSuppressed(closing); + } + throw failure; + } + } + + @Override + public void close() throws IOException { + IOException failure = null; + while (opened > 0) { + Authority authority = ORDER[--opened]; + CommonCheckpointMaterializer materializer = materializers.get(authority); + try { + timing.endUs[authority.ordinal()] += timed(() -> materializer.endCheckpoint(target)); + } catch (IOException closing) { + if (failure == null) { + failure = closing; + } else { + failure.addSuppressed(closing); + } + } + } + if (failure != null) { + throw failure; + } + } + } + + private Map inspectAll(CommonCheckpointTarget target, Timing timing) + throws IOException { + Map statuses = new EnumMap<>(Authority.class); + for (Authority authority : ORDER) { + Status status = inspect(authority, target, timing); + if (status == null) { + throw new IOException("common checkpoint " + authority + " returned null status"); + } + statuses.put(authority, status); + } + return statuses; + } + + private Status inspect(Authority authority, CommonCheckpointTarget target, Timing timing) + throws IOException { + Holder status = new Holder<>(); + timing.inspectUs[authority.ordinal()] += timed( + () -> status.value = materializers.get(authority).inspect(target)); + timing.inspectCount[authority.ordinal()]++; + return status.value; + } + + private long timed(IoAction action) throws IOException { + long start = nanoTime.getAsLong(); + action.run(); + return elapsedUs(start); + } + + private long elapsedUs(long start) { + return Math.max(0L, (nanoTime.getAsLong() - start) / 1_000L); + } + + private void emitTiming(Timing timing) { + try { + timingSink.accept(timing); + } catch (RuntimeException ignored) { + // Diagnostics must not turn an already durable checkpoint into a caller-visible failure. + } + } + + private void requireOpen() throws IOException { + if (closed) { + throw new IOException("common checkpoint coordinator is closed"); + } + } + + private static void logTiming(Timing timing) { + logger.info("Common checkpoint redo stages: mode={}, head={}, blocks={}, walPublishUs={}, " + + "walLoadUs={}, chainbaseBeginUs={}, pathStateBeginUs={}, archiveBeginUs={}, " + + "chainbaseInspectUs={}/{}, pathStateInspectUs={}/{}, archiveInspectUs={}/{}, " + + "chainbaseMaterializeUs={}/{}, pathStateMaterializeUs={}/{}, " + + "archiveMaterializeUs={}/{}, chainbasePublishUs={}/{}, " + + "pathStatePublishUs={}/{}, archivePublishUs={}/{}, walRetireUs={}, " + + "chainbaseEndUs={}, pathStateEndUs={}, archiveEndUs={}, totalUs={}", + timing.mode, timing.head, timing.blocks, timing.walPublishUs, timing.walLoadUs, + timing.begin(Authority.CHAINBASE), timing.begin(Authority.PATH_STATE), + timing.begin(Authority.STATE_ARCHIVE), timing.inspect(Authority.CHAINBASE), + timing.inspectCount(Authority.CHAINBASE), timing.inspect(Authority.PATH_STATE), + timing.inspectCount(Authority.PATH_STATE), timing.inspect(Authority.STATE_ARCHIVE), + timing.inspectCount(Authority.STATE_ARCHIVE), timing.materialize(Authority.CHAINBASE), + timing.materializeCount(Authority.CHAINBASE), timing.materialize(Authority.PATH_STATE), + timing.materializeCount(Authority.PATH_STATE), timing.materialize(Authority.STATE_ARCHIVE), + timing.materializeCount(Authority.STATE_ARCHIVE), timing.publish(Authority.CHAINBASE), + timing.publishCount(Authority.CHAINBASE), timing.publish(Authority.PATH_STATE), + timing.publishCount(Authority.PATH_STATE), timing.publish(Authority.STATE_ARCHIVE), + timing.publishCount(Authority.STATE_ARCHIVE), timing.walRetireUs, + timing.end(Authority.CHAINBASE), timing.end(Authority.PATH_STATE), + timing.end(Authority.STATE_ARCHIVE), timing.totalUs); + } + + private void admit(Authority expected, CommonCheckpointMaterializer materializer) { + CommonCheckpointMaterializer admitted = Objects.requireNonNull(materializer, + expected + " materializer"); + if (admitted.authority() != expected || materializers.put(expected, admitted) != null) { + throw new IllegalArgumentException("common checkpoint materializer authority differs: " + + expected); + } + } + + private static void requireStatus(Authority authority, Status expected, Status actual, + String operation) throws IOException { + if (actual != expected) { + throw new IOException("common checkpoint " + authority + " " + operation + + " returned " + actual + " instead of " + expected); + } + } + + static final class Timing { + + private final String mode; + private final long head; + private final int blocks; + private final long[] beginUs = new long[Authority.values().length]; + private final long[] inspectUs = new long[Authority.values().length]; + private final int[] inspectCount = new int[Authority.values().length]; + private final long[] materializeUs = new long[Authority.values().length]; + private final int[] materializeCount = new int[Authority.values().length]; + private final long[] publishUs = new long[Authority.values().length]; + private final int[] publishCount = new int[Authority.values().length]; + private final long[] endUs = new long[Authority.values().length]; + private long walPublishUs; + private long walLoadUs; + private long walRetireUs; + private long totalUs; + + private Timing(String mode, CommonCheckpointTarget target, int blocks) { + this.mode = mode; + this.head = target.getLastBlock().getBlockNumber(); + this.blocks = blocks; + } + + String getMode() { + return mode; + } + + long getHead() { + return head; + } + + int getBlocks() { + return blocks; + } + + long getWalPublishUs() { + return walPublishUs; + } + + long getWalLoadUs() { + return walLoadUs; + } + + long getWalRetireUs() { + return walRetireUs; + } + + long getTotalUs() { + return totalUs; + } + + long begin(Authority authority) { + return beginUs[authority.ordinal()]; + } + + long inspect(Authority authority) { + return inspectUs[authority.ordinal()]; + } + + int inspectCount(Authority authority) { + return inspectCount[authority.ordinal()]; + } + + long materialize(Authority authority) { + return materializeUs[authority.ordinal()]; + } + + int materializeCount(Authority authority) { + return materializeCount[authority.ordinal()]; + } + + long publish(Authority authority) { + return publishUs[authority.ordinal()]; + } + + int publishCount(Authority authority) { + return publishCount[authority.ordinal()]; + } + + long end(Authority authority) { + return endUs[authority.ordinal()]; + } + } + + @FunctionalInterface + interface TimingSink { + void accept(Timing timing); + } + + @FunctionalInterface + private interface IoAction { + void run() throws IOException; + } + + private static final class Holder { + private T value; + } + + private static Stage materializeStage(Authority authority) { + switch (authority) { + case CHAINBASE: + return Stage.AFTER_CHAINBASE_MATERIALIZE; + case PATH_STATE: + return Stage.AFTER_PATH_STATE_MATERIALIZE; + case STATE_ARCHIVE: + return Stage.AFTER_ARCHIVE_MATERIALIZE; + default: + throw new IllegalArgumentException("unsupported checkpoint authority " + authority); + } + } + + private static Stage publishStage(Authority authority) { + switch (authority) { + case CHAINBASE: + return Stage.AFTER_CHAINBASE_PUBLISH; + case PATH_STATE: + return Stage.AFTER_PATH_STATE_PUBLISH; + case STATE_ARCHIVE: + return Stage.AFTER_ARCHIVE_PUBLISH; + default: + throw new IllegalArgumentException("unsupported checkpoint authority " + authority); + } + } + + public enum RecoveryAction { + NO_CHECKPOINT, + COMPLETED_REDO + } + + enum Stage { + AFTER_CHAINBASE_MATERIALIZE, + AFTER_PATH_STATE_MATERIALIZE, + AFTER_ARCHIVE_MATERIALIZE, + AFTER_CHAINBASE_PUBLISH, + AFTER_PATH_STATE_PUBLISH, + AFTER_ARCHIVE_PUBLISH, + BEFORE_CHECKPOINT_RETIRE, + AFTER_CHECKPOINT_RETIRE + } + + @FunctionalInterface + interface FaultHook { + void after(Stage stage) throws IOException; + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/core/CommonCheckpointRuntime.java b/chainbase/src/main/java/org/tron/core/db2/core/CommonCheckpointRuntime.java new file mode 100644 index 00000000000..08b91ff3205 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/core/CommonCheckpointRuntime.java @@ -0,0 +1,332 @@ +package org.tron.core.db2.core; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import java.util.function.LongSupplier; +import org.tron.core.db2.archive.StateArchiveAppendCheckpointMaterializerV3; +import org.tron.core.db2.archive.StateArchiveCheckpointMaterializer; +import org.tron.core.db2.archive.StateArchiveCheckpointPlanner; +import org.tron.core.db2.archive.StateArchiveCheckpointReadSnapshot; +import org.tron.core.db2.archive.StateArchiveHotCheckpointMaterializer; +import org.tron.core.db2.stateroot.PathStateStoreManifest.Engine; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** Isolated composition boundary for the next-format common-checkpoint runtime. */ +public final class CommonCheckpointRuntime implements AutoCloseable { + + private static final Logger logger = LoggerFactory.getLogger("DB"); + + private final CommonCheckpointRuntimeOwner owner; + private final List databases; + private final Path archiveDirectory; + private final byte[] formatIdentity; + private final Engine engine; + private final StateArchiveCheckpointReadSnapshot.PinnedLatestStateFactory latestFactory; + private final CommonCheckpointMemoryRebaser memoryRebaser; + private final CommonCheckpointHotRecovery hotRecovery; + private final StateArchiveCheckpointPlanner archivePlanner; + private final CommonCheckpointMaterializedStore materializedStore; + private final LongSupplier nanoTime; + private final TimingSink timingSink; + private final CommonCheckpointPayloadFactory payloadFactory = new CommonCheckpointPayloadFactory(); + private final CommonCheckpointSnapshotRebaser rebaser = new CommonCheckpointSnapshotRebaser(); + private CommonCheckpointTarget publishedTarget; + + public CommonCheckpointRuntime(CommonCheckpointRuntimeOwner owner, List databases, + Path archiveDirectory, byte[] formatIdentity, Engine engine, + StateArchiveCheckpointReadSnapshot.PinnedLatestStateFactory latestFactory, + CommonCheckpointMemoryRebaser memoryRebaser) { + this(owner, databases, archiveDirectory, formatIdentity, engine, latestFactory, + memoryRebaser, null, null, null, System::nanoTime, + CommonCheckpointRuntime::logTiming); + } + + /** Constructs the default-off Hot DB v2 path selected by the dual-gated Manager branch. */ + public CommonCheckpointRuntime(CommonCheckpointRuntimeOwner owner, List databases, + Path archiveDirectory, byte[] formatIdentity, Engine engine, + StateArchiveCheckpointReadSnapshot.PinnedLatestStateFactory latestFactory, + CommonCheckpointMemoryRebaser memoryRebaser, + StateArchiveHotCheckpointMaterializer hotMaterializer, + CommonCheckpointHotRecovery hotRecovery) { + this(owner, databases, archiveDirectory, formatIdentity, engine, latestFactory, + memoryRebaser, null, Objects.requireNonNull(hotMaterializer, "hotMaterializer"), + Objects.requireNonNull(hotRecovery, "hotRecovery"), System::nanoTime, + CommonCheckpointRuntime::logTiming); + } + + /** Constructs the default-off append-file v3 participant without Hot DB recovery. */ + public CommonCheckpointRuntime(CommonCheckpointRuntimeOwner owner, List databases, + Path archiveDirectory, byte[] formatIdentity, Engine engine, + StateArchiveCheckpointReadSnapshot.PinnedLatestStateFactory latestFactory, + CommonCheckpointMemoryRebaser memoryRebaser, + StateArchiveCheckpointPlanner archivePlanner) { + this(owner, databases, archiveDirectory, formatIdentity, engine, latestFactory, + memoryRebaser, null, Objects.requireNonNull(archivePlanner, "archivePlanner"), + null, System::nanoTime, CommonCheckpointRuntime::logTiming); + } + + public CommonCheckpointRuntime(CommonCheckpointRuntimeOwner owner, List databases, + Path archiveDirectory, byte[] formatIdentity, Engine engine, + StateArchiveCheckpointReadSnapshot.PinnedLatestStateFactory latestFactory, + CommonCheckpointMemoryRebaser memoryRebaser, + CommonCheckpointMaterializedStore materializedStore) { + this(owner, databases, archiveDirectory, formatIdentity, engine, latestFactory, + memoryRebaser, materializedStore, null, null, System::nanoTime, + CommonCheckpointRuntime::logTiming); + } + + CommonCheckpointRuntime(CommonCheckpointRuntimeOwner owner, List databases, + Path archiveDirectory, byte[] formatIdentity, Engine engine, + StateArchiveCheckpointReadSnapshot.PinnedLatestStateFactory latestFactory, + CommonCheckpointMemoryRebaser memoryRebaser, LongSupplier nanoTime, + TimingSink timingSink) { + this(owner, databases, archiveDirectory, formatIdentity, engine, latestFactory, + memoryRebaser, null, null, null, nanoTime, timingSink); + } + + CommonCheckpointRuntime(CommonCheckpointRuntimeOwner owner, List databases, + Path archiveDirectory, byte[] formatIdentity, Engine engine, + StateArchiveCheckpointReadSnapshot.PinnedLatestStateFactory latestFactory, + CommonCheckpointMemoryRebaser memoryRebaser, + StateArchiveHotCheckpointMaterializer hotMaterializer, + CommonCheckpointHotRecovery hotRecovery, LongSupplier nanoTime, TimingSink timingSink) { + this(owner, databases, archiveDirectory, formatIdentity, engine, latestFactory, + memoryRebaser, null, hotMaterializer, hotRecovery, nanoTime, timingSink); + } + + CommonCheckpointRuntime(CommonCheckpointRuntimeOwner owner, List databases, + Path archiveDirectory, byte[] formatIdentity, Engine engine, + StateArchiveCheckpointReadSnapshot.PinnedLatestStateFactory latestFactory, + CommonCheckpointMemoryRebaser memoryRebaser, + CommonCheckpointMaterializedStore materializedStore, + StateArchiveCheckpointPlanner archivePlanner, + CommonCheckpointHotRecovery hotRecovery, LongSupplier nanoTime, TimingSink timingSink) { + this.owner = Objects.requireNonNull(owner, "owner"); + this.databases = new ArrayList<>(Objects.requireNonNull(databases, "databases")); + if (this.databases.isEmpty() || this.databases.contains(null)) { + throw new IllegalArgumentException("common checkpoint runtime requires registered Stores"); + } + this.archiveDirectory = Objects.requireNonNull(archiveDirectory, "archiveDirectory"); + this.formatIdentity = requireDigest(formatIdentity); + this.engine = Objects.requireNonNull(engine, "engine"); + this.latestFactory = Objects.requireNonNull(latestFactory, "latestFactory"); + this.memoryRebaser = Objects.requireNonNull(memoryRebaser, "memoryRebaser"); + this.materializedStore = materializedStore; + this.hotRecovery = hotRecovery; + this.archivePlanner = archivePlanner; + if (archivePlanner instanceof StateArchiveHotCheckpointMaterializer + != (hotRecovery != null)) { + throw new IllegalArgumentException( + "Hot common checkpoint runtime requires materializer and recovery together"); + } + if (archivePlanner != null) { + owner.requireMaterializer(archivePlanner); + } + this.nanoTime = Objects.requireNonNull(nanoTime, "nanoTime"); + this.timingSink = Objects.requireNonNull(timingSink, "timingSink"); + } + + /** Completes durable redo before this runtime admits checkpoint reads or new flushes. */ + public synchronized CommonCheckpointRedoCoordinator.RecoveryAction recoverBeforeServing() + throws IOException { + try { + if (hotRecovery != null) { + hotRecovery.reconcileBeforeCommonRedo(); + } + CommonCheckpointRedoCoordinator.RecoveryAction action = owner.recoverBeforeServing(); + publishedTarget = archivePlanner == null + ? StateArchiveCheckpointMaterializer.loadPublishedTargetIfPresent( + archiveDirectory, formatIdentity, engine, materializedStore).orElse(null) : null; + if (archivePlanner instanceof StateArchiveAppendCheckpointMaterializerV3) { + publishedTarget = ((StateArchiveAppendCheckpointMaterializerV3) archivePlanner) + .loadPublishedTargetIfPresent().orElse(null); + } + if (publishedTarget != null) { + owner.requirePublishedBeforeServing(publishedTarget); + if (archivePlanner != null) { + archivePlanner.afterCommit(publishedTarget); + } + } + return action; + } catch (IOException | RuntimeException failure) { + owner.fail(failure); + throw failure; + } + } + + /** + * Captures and applies the immutable Snapshot prefix, then rebases it without a second Store + * write. The caller must hold the SnapshotManager monitor for the whole call. + */ + public synchronized CommonCheckpointTarget checkpointAndRebase(int flushCount) + throws IOException { + try { + long totalStart = nanoTime.getAsLong(); + Timing timing = new Timing(flushCount); + long captureStart = nanoTime.getAsLong(); + CommonCheckpointCapture capture = archivePlanner == null ? null + : payloadFactory.captureV2(formatIdentity, databases, flushCount, archivePlanner); + CommonCheckpointPayload payload = capture == null + ? payloadFactory.capture(formatIdentity, databases, flushCount) : capture.getPayload(); + timing.payloadCaptureUs = elapsedUs(captureStart); + CommonCheckpointTarget target = CommonCheckpointTarget.from(payload); + timing.head = target.getLastBlock().getBlockNumber(); + if (capture != null) { + CommonCheckpointHotRecovery.requireWalDynamicIdentity(payload, target.getLastBlock()); + long hotPrepareStart = nanoTime.getAsLong(); + CommonCheckpointTarget prepared = archivePlanner.prepare(capture); + timing.hotPrepareUs = elapsedUs(hotPrepareStart); + if (!target.equals(prepared)) { + throw new IOException("Hot Archive prepared checkpoint target differs"); + } + } + long ownerApplyStart = nanoTime.getAsLong(); + owner.apply(payload, () -> { + long chainbasePrepareStart = nanoTime.getAsLong(); + CommonCheckpointSnapshotRebaser.Plan chainbasePlan = + rebaser.prepare(databases, target, flushCount); + timing.chainbaseRebasePrepareUs = elapsedUs(chainbasePrepareStart); + long pathStatePrepareStart = nanoTime.getAsLong(); + CommonCheckpointMemoryRebaser.RebasePlan pathStatePlan = memoryRebaser.prepare(target); + timing.pathStateRebasePrepareUs = elapsedUs(pathStatePrepareStart); + long chainbaseApplyStart = nanoTime.getAsLong(); + chainbasePlan.apply(); + timing.chainbaseRebaseApplyUs = elapsedUs(chainbaseApplyStart); + long pathStateApplyStart = nanoTime.getAsLong(); + pathStatePlan.apply(); + timing.pathStateRebaseApplyUs = elapsedUs(pathStateApplyStart); + }); + timing.ownerApplyUs = elapsedUs(ownerApplyStart); + publishedTarget = target; + timing.totalUs = elapsedUs(totalStart); + emitTiming(timing); + return target; + } catch (IOException | RuntimeException failure) { + owner.fail(failure); + throw failure; + } + } + + /** Pins one point-only historical request under the same publication gate. */ + public synchronized StateArchiveCheckpointReadSnapshot pinPoint(long targetBlock) + throws IOException { + if (archivePlanner != null) { + throw new IOException("Hot Archive runtime point reads are not integrated"); + } + CommonCheckpointTarget target = publishedTarget; + if (target == null) { + throw new IOException("State Archive has no published common-checkpoint target"); + } + return StateArchiveCheckpointReadSnapshot.pin(targetBlock, owner, archiveDirectory, + target, engine, latestFactory); + } + + public CommonCheckpointRuntimeOwner.State getState() { + return owner.getState(); + } + + @Override + public void close() { + owner.close(); + } + + private static byte[] requireDigest(byte[] value) { + byte[] admitted = Arrays.copyOf(Objects.requireNonNull(value, "formatIdentity"), + value.length); + if (admitted.length != 32) { + throw new IllegalArgumentException("formatIdentity must contain exactly 32 bytes"); + } + return admitted; + } + + private long elapsedUs(long start) { + return Math.max(0L, (nanoTime.getAsLong() - start) / 1_000L); + } + + private void emitTiming(Timing timing) { + try { + timingSink.accept(timing); + } catch (RuntimeException ignored) { + // Diagnostics must not turn an already completed checkpoint into a caller-visible failure. + } + } + + private static void logTiming(Timing timing) { + logger.info("Common checkpoint runtime stages: head={}, blocks={}, payloadCaptureUs={}, " + + "hotPrepareUs={}, ownerApplyUs={}, chainbaseRebasePrepareUs={}, " + + "pathStateRebasePrepareUs={}, " + + "chainbaseRebaseApplyUs={}, pathStateRebaseApplyUs={}, totalUs={}", + timing.head, timing.blocks, timing.payloadCaptureUs, timing.hotPrepareUs, + timing.ownerApplyUs, + timing.chainbaseRebasePrepareUs, timing.pathStateRebasePrepareUs, + timing.chainbaseRebaseApplyUs, timing.pathStateRebaseApplyUs, timing.totalUs); + } + + static final class Timing { + + private long head; + private final int blocks; + private long payloadCaptureUs; + private long hotPrepareUs; + private long ownerApplyUs; + private long chainbaseRebasePrepareUs; + private long pathStateRebasePrepareUs; + private long chainbaseRebaseApplyUs; + private long pathStateRebaseApplyUs; + private long totalUs; + + private Timing(int blocks) { + this.blocks = blocks; + } + + long getHead() { + return head; + } + + int getBlocks() { + return blocks; + } + + long getPayloadCaptureUs() { + return payloadCaptureUs; + } + + long getHotPrepareUs() { + return hotPrepareUs; + } + + long getOwnerApplyUs() { + return ownerApplyUs; + } + + long getChainbaseRebasePrepareUs() { + return chainbaseRebasePrepareUs; + } + + long getPathStateRebasePrepareUs() { + return pathStateRebasePrepareUs; + } + + long getChainbaseRebaseApplyUs() { + return chainbaseRebaseApplyUs; + } + + long getPathStateRebaseApplyUs() { + return pathStateRebaseApplyUs; + } + + long getTotalUs() { + return totalUs; + } + } + + @FunctionalInterface + interface TimingSink { + void accept(Timing timing); + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/core/CommonCheckpointRuntimeAttachment.java b/chainbase/src/main/java/org/tron/core/db2/core/CommonCheckpointRuntimeAttachment.java new file mode 100644 index 00000000000..1162b69d8bf --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/core/CommonCheckpointRuntimeAttachment.java @@ -0,0 +1,101 @@ +package org.tron.core.db2.core; + +import java.io.IOException; +import java.util.Objects; +import org.tron.core.db2.archive.StateArchiveCheckpointReadSnapshot; + +/** Default-off lifecycle attachment that exclusively owns one next-format runtime. */ +public final class CommonCheckpointRuntimeAttachment implements AutoCloseable { + + private final CommonCheckpointRuntime runtime; + private State state; + + private CommonCheckpointRuntimeAttachment(CommonCheckpointRuntime runtime, State state) { + this.runtime = runtime; + this.state = state; + } + + /** + * Does not invoke the factory when disabled. Enabled construction returns only after startup + * redo succeeds; a failed startup closes the newly created runtime before propagating failure. + */ + public static CommonCheckpointRuntimeAttachment open(boolean enabled, RuntimeFactory factory) + throws IOException { + Objects.requireNonNull(factory, "factory"); + if (!enabled) { + return new CommonCheckpointRuntimeAttachment(null, State.DISABLED); + } + CommonCheckpointRuntime runtime = Objects.requireNonNull(factory.open(), + "common checkpoint runtime factory returned null"); + try { + runtime.recoverBeforeServing(); + return new CommonCheckpointRuntimeAttachment(runtime, State.READY); + } catch (IOException | RuntimeException failure) { + try { + runtime.close(); + } catch (RuntimeException closeFailure) { + failure.addSuppressed(closeFailure); + } + throw failure; + } + } + + /** Applies one immutable flush prefix; any protocol failure permanently closes admission. */ + public synchronized CommonCheckpointTarget checkpointAndRebase(int flushCount) + throws IOException { + requireReady(); + try { + return runtime.checkpointAndRebase(flushCount); + } catch (IOException | RuntimeException failure) { + state = State.FAILED; + throw failure; + } + } + + /** Pins one point-only query from a fully recovered and non-failed runtime. */ + public synchronized StateArchiveCheckpointReadSnapshot pinPoint(long targetBlock) + throws IOException { + requireReady(); + return runtime.pinPoint(targetBlock); + } + + public synchronized State getState() { + return state; + } + + public synchronized boolean isEnabled() { + return runtime != null; + } + + @Override + public synchronized void close() { + if (state == State.CLOSED) { + return; + } + try { + if (runtime != null) { + runtime.close(); + } + } finally { + state = State.CLOSED; + } + } + + private void requireReady() { + if (state != State.READY) { + throw new IllegalStateException("common checkpoint attachment is not ready: " + state); + } + } + + public enum State { + DISABLED, + READY, + FAILED, + CLOSED + } + + @FunctionalInterface + public interface RuntimeFactory { + CommonCheckpointRuntime open() throws IOException; + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/core/CommonCheckpointRuntimeOwner.java b/chainbase/src/main/java/org/tron/core/db2/core/CommonCheckpointRuntimeOwner.java new file mode 100644 index 00000000000..c65ff14fef7 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/core/CommonCheckpointRuntimeOwner.java @@ -0,0 +1,201 @@ +package org.tron.core.db2.core; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.Objects; +import java.util.concurrent.locks.ReentrantReadWriteLock; + +/** Explicit lifecycle and read gate for the next-format common-checkpoint runtime. */ +public final class CommonCheckpointRuntimeOwner implements AutoCloseable { + + private final CommonCheckpointRedoCoordinator coordinator; + private final ReentrantReadWriteLock gate = new ReentrantReadWriteLock(true); + private volatile State state = State.NEW; + + public CommonCheckpointRuntimeOwner(CommonCheckpointRedoCoordinator coordinator) { + this.coordinator = Objects.requireNonNull(coordinator, "coordinator"); + } + + void requireMaterializer(CommonCheckpointMaterializer materializer) { + CommonCheckpointMaterializer admitted = Objects.requireNonNull(materializer, "materializer"); + coordinator.requireMaterializer(admitted.authority(), admitted); + } + + /** Completes any durable redo before allowing the first read lease. */ + public CommonCheckpointRedoCoordinator.RecoveryAction recoverBeforeServing() + throws IOException { + gate.writeLock().lock(); + try { + requireState(State.NEW, "common checkpoint startup recovery already attempted"); + state = State.RECOVERING; + try { + CommonCheckpointRedoCoordinator.RecoveryAction action = coordinator.recover(); + state = State.READY; + return action; + } catch (IOException | RuntimeException failure) { + state = State.FAILED; + closeAfterFailure(failure); + throw failure; + } + } finally { + gate.writeLock().unlock(); + } + } + + /** Cross-validates all published authorities before the attachment becomes externally ready. */ + void requirePublishedBeforeServing(CommonCheckpointTarget target) throws IOException { + gate.writeLock().lock(); + try { + requireState(State.READY, "common checkpoint startup recovery is not complete"); + try { + coordinator.requirePublished(Objects.requireNonNull(target, "target")); + } catch (IOException | RuntimeException failure) { + state = State.FAILED; + closeAfterFailure(failure); + throw failure; + } + } finally { + gate.writeLock().unlock(); + } + } + + /** Blocks all read leases while the durable payload and both barriers are in progress. */ + public CommonCheckpointRedoCoordinator.RecoveryAction apply(CommonCheckpointPayload payload) + throws IOException { + return apply(payload, () -> { }); + } + + /** Keeps the write gate through publication and its required in-memory completion action. */ + CommonCheckpointRedoCoordinator.RecoveryAction apply(CommonCheckpointPayload payload, + CompletionAction completion) throws IOException { + gate.writeLock().lock(); + try { + requireState(State.READY, "common checkpoint runtime is not ready to flush"); + state = State.CHECKPOINTING; + try { + CommonCheckpointRedoCoordinator.RecoveryAction action = coordinator.applyDurable( + Objects.requireNonNull(payload, "payload")); + Objects.requireNonNull(completion, "completion").run(); + state = State.READY; + coordinator.notifyCommitted(CommonCheckpointTarget.from(payload)); + return action; + } catch (IOException | RuntimeException failure) { + state = State.FAILED; + closeAfterFailure(failure); + throw failure; + } + } finally { + gate.writeLock().unlock(); + } + } + + /** Runs one query only while no startup redo or checkpoint publication can interleave. */ + public T read(ReadableOperation operation) throws IOException { + try (ReadLease ignored = acquireReadLease()) { + return Objects.requireNonNull(operation, "operation").read(); + } + } + + /** Acquires a request-thread-owned lease that blocks checkpoint publication until closed. */ + public ReadLease acquireReadLease() throws IOException { + gate.readLock().lock(); + try { + requireState(State.READY, "common checkpoint runtime is not readable"); + return new ReadLease(Thread.currentThread()); + } catch (IOException | RuntimeException failure) { + gate.readLock().unlock(); + throw failure; + } + } + + public State getState() { + return state; + } + + /** Permanently fails this owner and releases authority resources after an outer runtime error. */ + void fail(Throwable failure) { + gate.writeLock().lock(); + try { + if (state != State.CLOSED) { + state = State.FAILED; + closeAfterFailure(Objects.requireNonNull(failure, "failure")); + } + } finally { + gate.writeLock().unlock(); + } + } + + @Override + public void close() { + gate.writeLock().lock(); + try { + if (state == State.CLOSED) { + return; + } + try { + coordinator.close(); + } catch (IOException failure) { + throw new UncheckedIOException("Failed to close common checkpoint authorities", failure); + } finally { + state = State.CLOSED; + } + } finally { + gate.writeLock().unlock(); + } + } + + private void closeAfterFailure(Throwable failure) { + try { + coordinator.close(); + } catch (IOException | RuntimeException closing) { + failure.addSuppressed(closing); + } + } + + private void requireState(State expected, String message) throws IOException { + if (state != expected) { + throw new IOException(message + ": " + state); + } + } + + public enum State { + NEW, + RECOVERING, + READY, + CHECKPOINTING, + FAILED, + CLOSED + } + + @FunctionalInterface + public interface ReadableOperation { + T read() throws IOException; + } + + @FunctionalInterface + interface CompletionAction { + void run() throws IOException; + } + + /** One same-thread request lease; close it only after every pinned read resource is released. */ + public final class ReadLease implements AutoCloseable { + + private final Thread ownerThread; + private boolean closed; + + private ReadLease(Thread ownerThread) { + this.ownerThread = ownerThread; + } + + @Override + public void close() { + if (Thread.currentThread() != ownerThread) { + throw new IllegalStateException("common checkpoint read lease changed threads"); + } + if (!closed) { + closed = true; + gate.readLock().unlock(); + } + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/core/CommonCheckpointSnapshotRebaser.java b/chainbase/src/main/java/org/tron/core/db2/core/CommonCheckpointSnapshotRebaser.java new file mode 100644 index 00000000000..af0ce568b12 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/core/CommonCheckpointSnapshotRebaser.java @@ -0,0 +1,129 @@ +package org.tron.core.db2.core; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import org.tron.core.db2.archive.BlockSnapshotMeta; + +/** Removes an already-materialized Snapshot prefix without writing its mutations a second time. */ +public final class CommonCheckpointSnapshotRebaser { + + /** + * Reconnects every Store only after the complete target range has been validated everywhere. + * The caller must hold the common-checkpoint write gate and SnapshotManager monitor. + */ + public void rebase(List databases, CommonCheckpointTarget target, int count) + throws IOException { + prepare(databases, target, count).apply(); + } + + /** Fully validates every Store before returning a pointer-only rebase plan. */ + Plan prepare(List databases, CommonCheckpointTarget target, int count) + throws IOException { + CommonCheckpointTarget admittedTarget = Objects.requireNonNull(target, "target"); + if (count <= 0) { + throw new IllegalArgumentException("common checkpoint rebase count must be positive"); + } + List plans = new ArrayList<>(); + for (Chainbase database : Objects.requireNonNull(databases, "databases")) { + plans.add(validate(Objects.requireNonNull(database, "database"), admittedTarget, count)); + } + if (plans.isEmpty()) { + throw new IOException("common checkpoint rebase requires registered Stores"); + } + return new Plan(plans); + } + + private static StorePlan validate(Chainbase database, CommonCheckpointTarget target, int count) + throws IOException { + Snapshot rootSnapshot = database.getHead().getRoot(); + if (!(rootSnapshot instanceof SnapshotRoot)) { + throw new IOException("common checkpoint rebase Store has no SnapshotRoot: " + + database.getDbName()); + } + SnapshotRoot root = (SnapshotRoot) rootSnapshot; + Snapshot next = root; + BlockSnapshotMeta previous = null; + BlockSnapshotMeta first = null; + for (int index = 0; index < count; index++) { + next = next.getNext(); + if (!(next instanceof SnapshotImpl)) { + throw new IOException("common checkpoint rebase Store has too few layers: " + + database.getDbName()); + } + BlockSnapshotMeta meta = ((SnapshotImpl) next).getBlockSnapshotMeta(); + if (meta == null || previous != null && !isChild(previous, meta)) { + throw new IOException("common checkpoint rebase Store block chain differs: " + + database.getDbName()); + } + if (first == null) { + first = meta; + } + previous = meta; + } + if (!target.getFirstBlock().equals(first) || !target.getLastBlock().equals(previous)) { + throw new IOException("common checkpoint rebase Store target differs: " + + database.getDbName()); + } + Snapshot successor = next.getNext(); + Snapshot head = database.getHead(); + if (head != next && successor == null) { + throw new IOException("common checkpoint rebase Store chain is disconnected: " + + database.getDbName()); + } + return new StorePlan(database, root, next, successor, head == next); + } + + private static boolean isChild(BlockSnapshotMeta parent, BlockSnapshotMeta child) { + return child.getEpoch() == parent.getEpoch() + 1 + && child.getBlockNumber() == parent.getBlockNumber() + 1 + && Arrays.equals(child.getParentHash(), parent.getBlockHash()); + } + + static final class Plan { + + private final List stores; + + private Plan(List stores) { + this.stores = new ArrayList<>(stores); + } + + void apply() { + for (StorePlan store : stores) { + store.apply(); + } + } + } + + private static final class StorePlan { + + private final Chainbase database; + private final SnapshotRoot root; + private final Snapshot last; + private final Snapshot successor; + private final boolean consumesHead; + + private StorePlan(Chainbase database, SnapshotRoot root, Snapshot last, Snapshot successor, + boolean consumesHead) { + this.database = database; + this.root = root; + this.last = last; + this.successor = successor; + this.consumesHead = consumesHead; + } + + private void apply() { + root.resetSolidity(); + if (consumesHead) { + database.setHead(root); + root.setNext(null); + } else { + successor.setPrevious(root); + root.setNext(successor); + } + last.setNext(null); + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/core/CommonCheckpointTarget.java b/chainbase/src/main/java/org/tron/core/db2/core/CommonCheckpointTarget.java new file mode 100644 index 00000000000..e4029631258 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/core/CommonCheckpointTarget.java @@ -0,0 +1,127 @@ +package org.tron.core.db2.core; + +import java.util.Arrays; +import java.util.Objects; +import org.tron.core.db2.archive.BlockSnapshotMeta; +import org.tron.core.db2.archive.StateArchiveHotBatchDescriptor; + +/** Immutable identity every authority must reach for one common-checkpoint payload. */ +public final class CommonCheckpointTarget { + + private final byte[] formatIdentity; + private final byte[] payloadDigest; + private final BlockSnapshotMeta firstBlock; + private final BlockSnapshotMeta lastBlock; + private final byte[] parentStateRoot; + private final byte[] stateRoot; + private final StateArchiveHotBatchDescriptor archiveBinding; + + private CommonCheckpointTarget(byte[] formatIdentity, byte[] payloadDigest, + BlockSnapshotMeta firstBlock, BlockSnapshotMeta lastBlock, byte[] parentStateRoot, + byte[] stateRoot, StateArchiveHotBatchDescriptor archiveBinding) { + this.formatIdentity = copy(formatIdentity); + this.payloadDigest = copy(payloadDigest); + this.firstBlock = Objects.requireNonNull(firstBlock, "firstBlock"); + this.lastBlock = Objects.requireNonNull(lastBlock, "lastBlock"); + this.parentStateRoot = copy(parentStateRoot); + this.stateRoot = copy(stateRoot); + this.archiveBinding = archiveBinding; + } + + public static CommonCheckpointTarget from(CommonCheckpointPayload payload) { + CommonCheckpointPayload admitted = Objects.requireNonNull(payload, "payload"); + return new CommonCheckpointTarget(admitted.getFormatIdentity(), + new CommonCheckpointPayloadCodec().digest(admitted), + admitted.getBlocks().get(0).getMeta(), + admitted.getBlocks().get(admitted.getBlocks().size() - 1).getMeta(), + admitted.getParentStateRoot(), admitted.getStateRoot(), + admitted.getVersion() == CommonCheckpointPayload.COORDINATION_FORMAT_VERSION + ? admitted.getArchiveBinding() : null); + } + + /** Reconstructs a target identity from a checksummed authority publication record. */ + public static CommonCheckpointTarget restore(byte[] formatIdentity, byte[] payloadDigest, + BlockSnapshotMeta firstBlock, BlockSnapshotMeta lastBlock, byte[] parentStateRoot, + byte[] stateRoot) { + BlockSnapshotMeta first = Objects.requireNonNull(firstBlock, "firstBlock"); + BlockSnapshotMeta last = Objects.requireNonNull(lastBlock, "lastBlock"); + if (first.getEpoch() > last.getEpoch() + || first.getBlockNumber() > last.getBlockNumber()) { + throw new IllegalArgumentException("checkpoint target block range is reversed"); + } + return new CommonCheckpointTarget(requireDigest(formatIdentity, "formatIdentity"), + requireDigest(payloadDigest, "payloadDigest"), first, last, + requireDigest(parentStateRoot, "parentStateRoot"), requireDigest(stateRoot, "stateRoot"), + null); + } + + public byte[] getFormatIdentity() { + return copy(formatIdentity); + } + + public byte[] getPayloadDigest() { + return copy(payloadDigest); + } + + public BlockSnapshotMeta getFirstBlock() { + return firstBlock; + } + + public BlockSnapshotMeta getLastBlock() { + return lastBlock; + } + + public byte[] getParentStateRoot() { + return copy(parentStateRoot); + } + + public byte[] getStateRoot() { + return copy(stateRoot); + } + + public StateArchiveHotBatchDescriptor getArchiveBinding() { + if (archiveBinding == null) { + throw new IllegalStateException("checkpoint target has no Hot Archive binding"); + } + return archiveBinding; + } + + @Override + public boolean equals(Object object) { + if (this == object) { + return true; + } + if (!(object instanceof CommonCheckpointTarget)) { + return false; + } + CommonCheckpointTarget that = (CommonCheckpointTarget) object; + return firstBlock.equals(that.firstBlock) + && lastBlock.equals(that.lastBlock) + && Arrays.equals(formatIdentity, that.formatIdentity) + && Arrays.equals(payloadDigest, that.payloadDigest) + && Arrays.equals(parentStateRoot, that.parentStateRoot) + && Arrays.equals(stateRoot, that.stateRoot); + } + + @Override + public int hashCode() { + int result = Objects.hash(firstBlock, lastBlock); + result = 31 * result + Arrays.hashCode(formatIdentity); + result = 31 * result + Arrays.hashCode(payloadDigest); + result = 31 * result + Arrays.hashCode(parentStateRoot); + result = 31 * result + Arrays.hashCode(stateRoot); + return result; + } + + private static byte[] copy(byte[] value) { + return Arrays.copyOf(Objects.requireNonNull(value, "value"), value.length); + } + + private static byte[] requireDigest(byte[] value, String name) { + byte[] admitted = copy(value); + if (admitted.length != 32) { + throw new IllegalArgumentException(name + " must contain exactly 32 bytes"); + } + return admitted; + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/core/P66CoupledMutationMaterializer.java b/chainbase/src/main/java/org/tron/core/db2/core/P66CoupledMutationMaterializer.java new file mode 100644 index 00000000000..d557a4a1403 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/core/P66CoupledMutationMaterializer.java @@ -0,0 +1,120 @@ +package org.tron.core.db2.core; + +import com.google.common.primitives.Bytes; +import com.google.common.primitives.Longs; +import com.google.common.primitives.UnsignedBytes; +import com.google.protobuf.InvalidProtocolBufferException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import org.tron.core.db2.common.Key; +import org.tron.core.db2.common.Value; +import org.tron.core.db2.common.WrappedByteArray; +import org.tron.protos.Protocol.Account; + +/** Materializes coupled physical writes once, inside the active revocable block layer. */ +public final class P66CoupledMutationMaterializer { + private static final byte[] FLAG = + "ALLOW_ASSET_OPTIMIZATION".getBytes(StandardCharsets.US_ASCII); + private final Chainbase accounts; + private final Chainbase assets; + private final Chainbase properties; + + public P66CoupledMutationMaterializer(Chainbase accounts, Chainbase assets, + Chainbase properties) { + this.accounts = accounts; + this.assets = assets; + this.properties = properties; + } + + public Statistics materialize() { + if (!Snapshot.isImpl(accounts.getHead()) || !Snapshot.isImpl(assets.getHead()) + || !Snapshot.isImpl(properties.getHead())) { + throw new IllegalStateException("P66 materialization requires active Snapshot layers"); + } + Statistics stats = new Statistics(); + byte[] flag = properties.getUnchecked(FLAG); + if (flag == null || flag.length != Long.BYTES + || (Longs.fromByteArray(flag) != 0 && Longs.fromByteArray(flag) != 1)) { + throw new IllegalStateException("P66 post-state property must be 0 or 1"); + } + byte[] previous = properties.getHead().getPrevious().get(FLAG); + if (previous != null && previous.length == Long.BYTES + && Longs.fromByteArray(previous) == 1 && Longs.fromByteArray(flag) == 0) { + throw new IllegalStateException("P66 phase cannot move backwards"); + } + if (Longs.fromByteArray(flag) == 0) { + return stats; + } + // Build the complete deterministic plan before modifying either Snapshot map. + Map accountPlan = new TreeMap<>(UnsignedBytes.lexicographicalComparator()); + Map assetPlan = new TreeMap<>(UnsignedBytes.lexicographicalComparator()); + List> changed = new ArrayList<>(); + ((SnapshotImpl) accounts.getHead()).getDb().forEach(changed::add); + for (Map.Entry entry : changed) { + byte[] address = entry.getKey().getBytes(); + if (address.length != 21) { + throw new IllegalStateException("P66 Account key must be exactly 21 bytes"); + } + stats.changedAccounts++; + if (entry.getValue().getOperator() == Value.Operator.DELETE) { + stats.prefixQueries++; + Map rows = assets.prefixQuery(address); + stats.prefixRows += rows.size(); + rows.forEach((key, value) -> assetPlan.put(key.getBytes(), null)); + continue; + } + Account account; + try { + account = Account.parseFrom(entry.getValue().getBytes()); + } catch (InvalidProtocolBufferException invalid) { + throw new IllegalStateException("Invalid P66 Account bytes", invalid); + } + if (!Arrays.equals(address, account.getAddress().toByteArray())) { + throw new IllegalStateException("P66 Account key/address mismatch"); + } + if (account.getAssetV2Map().isEmpty()) { + stats.emptyAssetSkips++; + } + boolean migrated = false; + for (Map.Entry asset : account.getAssetV2Map().entrySet()) { + byte[] key = Bytes.concat(address, asset.getKey().getBytes(StandardCharsets.UTF_8)); + long value = asset.getValue(); + if (!account.getAssetOptimized() && value == 0) { + continue; + } + assetPlan.put(key, value == 0 ? null : Longs.toByteArray(value)); + migrated |= !account.getAssetOptimized() && value != 0; + } + if (migrated) { + stats.migratedAccounts++; + } + accountPlan.put(address, account.toBuilder().clearAsset().clearAssetV2() + .setAssetOptimized(true).build().toByteArray()); + } + assetPlan.forEach((key, value) -> { + if (value == null) { + assets.delete(key); + stats.assetDeletes++; + } else { + assets.put(key, value); + stats.assetPuts++; + } + }); + accountPlan.forEach(accounts::put); + return stats; + } + + public static final class Statistics { + public long changedAccounts; + public long migratedAccounts; + public long emptyAssetSkips; + public long assetPuts; + public long assetDeletes; + public long prefixQueries; + public long prefixRows; + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/core/SnapshotImpl.java b/chainbase/src/main/java/org/tron/core/db2/core/SnapshotImpl.java index bc31b406b30..cd5bcde1439 100644 --- a/chainbase/src/main/java/org/tron/core/db2/core/SnapshotImpl.java +++ b/chainbase/src/main/java/org/tron/core/db2/core/SnapshotImpl.java @@ -5,6 +5,7 @@ import com.google.common.collect.Maps; import com.google.common.collect.Streams; import com.google.common.primitives.Bytes; +import java.util.Arrays; import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; @@ -12,17 +13,29 @@ import java.util.Objects; import java.util.Set; import lombok.Getter; +import org.tron.core.db2.archive.BlockReverseDiff; +import org.tron.core.db2.archive.BlockSnapshotMeta; import org.tron.core.db2.common.HashDB; import org.tron.core.db2.common.Key; import org.tron.core.db2.common.Value; import org.tron.core.db2.common.Value.Operator; import org.tron.core.db2.common.WrappedByteArray; +import org.tron.core.db2.stateroot.PathStateSnapshotDelta; public class SnapshotImpl extends AbstractSnapshot { @Getter protected Snapshot root; + @Getter + private BlockSnapshotMeta blockSnapshotMeta; + + @Getter + private BlockReverseDiff preparedArchiveBlock; + + @Getter + private PathStateSnapshotDelta preparedPathStateDelta; + SnapshotImpl(Snapshot snapshot) { root = snapshot.getRoot(); synchronized (this) { @@ -36,6 +49,30 @@ public class SnapshotImpl extends AbstractSnapshot { } } + /** + * Publishes the immutable block identity and optional archive payload on this layer. + * + *

The caller validates the prepared payload before this ownership-transfer point. + */ + void attachArchiveBlock(BlockSnapshotMeta meta, BlockReverseDiff reverseDiff) { + attachBlockArtifacts(meta, reverseDiff, null); + } + + /** Atomically binds all prepared block-final artifacts owned by this Snapshot layer. */ + void attachBlockArtifacts(BlockSnapshotMeta meta, BlockReverseDiff reverseDiff, + PathStateSnapshotDelta pathStateDelta) { + BlockSnapshotMeta admitted = Objects.requireNonNull(meta, "meta"); + if (reverseDiff != null && !admitted.equals(reverseDiff.getMeta())) { + throw new IllegalArgumentException("archive payload differs from Snapshot block identity"); + } + if (pathStateDelta != null && !admitted.equals(pathStateDelta.getMeta())) { + throw new IllegalArgumentException("path-state delta differs from Snapshot block identity"); + } + blockSnapshotMeta = meta; + preparedArchiveBlock = reverseDiff; + preparedPathStateDelta = pathStateDelta; + } + @Override public byte[] get(byte[] key) { return get(this, key); diff --git a/chainbase/src/main/java/org/tron/core/db2/core/SnapshotManager.java b/chainbase/src/main/java/org/tron/core/db2/core/SnapshotManager.java index e20490d93c0..47aff011851 100644 --- a/chainbase/src/main/java/org/tron/core/db2/core/SnapshotManager.java +++ b/chainbase/src/main/java/org/tron/core/db2/core/SnapshotManager.java @@ -7,7 +7,12 @@ import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.ListeningExecutorService; import com.google.common.util.concurrent.MoreExecutors; +import java.io.Closeable; import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; import java.util.Arrays; @@ -15,9 +20,12 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; import java.util.concurrent.Future; import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Collectors; @@ -34,11 +42,28 @@ import org.tron.core.db.RevokingDatabase; import org.tron.core.db.TronDatabase; import org.tron.core.db2.ISession; +import org.tron.core.db2.archive.ArchivePersistenceException; +import org.tron.core.db2.archive.ArchiveRuntimeAttachment; +import org.tron.core.db2.archive.ArchiveStateBarrier.ArchiveStateAction; +import org.tron.core.db2.archive.ArchiveStoreScope; +import org.tron.core.db2.archive.ArchiveWalBinding; +import org.tron.core.db2.archive.ArchiveWalBindingCodec; +import org.tron.core.db2.archive.BlockChangeView; +import org.tron.core.db2.archive.BlockReverseDiff; +import org.tron.core.db2.archive.BlockReverseDiffSink; +import org.tron.core.db2.archive.BlockSnapshotMeta; +import org.tron.core.db2.archive.DurableBlockReverseDiffSink; +import org.tron.core.db2.archive.DurableHistoryMarkerRangeEvidence; +import org.tron.core.db2.archive.HistoryCommitMarker; +import org.tron.core.db2.archive.OldValueCollector; import org.tron.core.db2.common.DB; import org.tron.core.db2.common.IRevokingDB; import org.tron.core.db2.common.Key; import org.tron.core.db2.common.Value; import org.tron.core.db2.common.WrappedByteArray; +import org.tron.core.db2.stateroot.PathStateBlockTransition; +import org.tron.core.db2.stateroot.PathStateRuntimeAttachment; +import org.tron.core.db2.stateroot.PathStateSnapshotDelta; import org.tron.core.exception.RevokingStoreIllegalStateException; import org.tron.core.exception.TronError; import org.tron.core.store.CheckPointV2Store; @@ -48,6 +73,49 @@ public class SnapshotManager implements RevokingDatabase { public static final int DEFAULT_MIN_FLUSH_COUNT = 1; + private P66CoupledMutationMaterializer p66Materializer; + private ExecutorService artifactExecutor; + + public synchronized void installP66SnapshotLane(Chainbase assets) { + installP66SnapshotLane(assets, false); + } + + public synchronized void installP66SnapshotLane(Chainbase assets, boolean recovering) { + if (size != 0 || activeSession != 0 || p66Materializer != null + || dbs.stream().anyMatch(db -> "account-asset".equals(db.getDbName()))) { + throw new IllegalStateException("P66 Snapshot lane must be installed once before sessions"); + } + Chainbase accounts = requireDatabase("account"); + Chainbase properties = requireDatabase("properties"); + if (!Snapshot.isRoot(accounts.getHead()) || !Snapshot.isRoot(assets.getHead())) { + throw new IllegalStateException("P66 Snapshot installation requires root heads"); + } + add(assets); + if (!recovering) { + ((SnapshotRoot) accounts.getHead()).useMaterializedCoupledMutations(); + } + p66Materializer = new P66CoupledMutationMaterializer(accounts, assets, properties); + artifactExecutor = new ThreadPoolExecutor(1, 1, 0L, + TimeUnit.MILLISECONDS, new ArrayBlockingQueue<>(1), task -> { + Thread thread = new Thread(task, "block-final-archive"); + thread.setDaemon(true); + return thread; + }); + } + + public synchronized void finishP66Recovery() { + if (size != 0 || activeSession != 0 || p66Materializer == null) { + throw new IllegalStateException("P66 recovery must finish before sessions"); + } + ((SnapshotRoot) requireDatabase("account").getHead().getRoot()) + .useMaterializedCoupledMutations(); + } + + private Chainbase requireDatabase(String name) { + return dbs.stream().filter(db -> name.equals(db.getDbName())).findFirst() + .orElseThrow(() -> new IllegalStateException("Missing P66 participant: " + name)); + } + private static final int DEFAULT_STACK_MAX_SIZE = 256; private static final long ONE_MINUTE_MILLS = 60*1000L; private static final String CHECKPOINT_V2_DIR = "checkpoint"; @@ -84,6 +152,17 @@ public class SnapshotManager implements RevokingDatabase { private int checkpointVersion = 1; // default v1 + private OldValueCollector oldValueCollector; + private ArchiveRuntimeAttachment archiveRuntimeAttachment; + private PathStateRuntimeAttachment pathStateRuntimeAttachment; + private CommonCheckpointRuntimeAttachment commonCheckpointRuntimeAttachment; + private Long submittedArchiveHistoryEpoch; + private BlockReverseDiffSink blockReverseDiffSink; + @Getter + private volatile long archiveReadableEpoch = -1; + private volatile ArchiveWalBinding latestArchiveWalBinding; + private volatile ArchiveWalBinding recoveredArchiveWalBinding; + public SnapshotManager(String checkpointPath) { } @@ -167,7 +246,7 @@ private void retreat() { --size; } - public void merge() { + public synchronized void merge() { if (activeSession <= 0) { throw new RevokingStoreIllegalStateException(activeSession); } @@ -218,6 +297,342 @@ public synchronized void commit() { }); } + /** + * Commits a block session and materializes its reverse diff using the configured collector. + * Plain transaction/pending sessions must continue to use {@link #commit()} or merge/revoke. + */ + public synchronized void commit(BlockSnapshotMeta meta) { + Objects.requireNonNull(meta, "meta"); + long startedNanos = System.nanoTime(); + if (activeSession <= 0) { + throw new RevokingStoreIllegalStateException(activeSession); + } + + validateBlockMeta(meta); + for (Chainbase db : dbs) { + Snapshot head = db.getHead(); + if (!Snapshot.isImpl(head)) { + throw new IllegalStateException( + "Cannot bind block metadata to non-SnapshotImpl head: " + db.getDbName()); + } + } + + if (p66Materializer != null) { + long p66Start = System.nanoTime(); + P66CoupledMutationMaterializer.Statistics stats = p66Materializer.materialize(); + logger.info("P66 Snapshot materialized: head={}, changedAccounts={}, migratedAccounts={}, " + + "emptyAssetSkips={}, assetPuts={}, assetDeletes={}, prefixQueries={}, " + + "prefixRows={}, activationAccountScans=0, duplicateRootMigrations=0, " + + "materializeMs={}", meta.getBlockNumber(), stats.changedAccounts, + stats.migratedAccounts, stats.emptyAssetSkips, stats.assetPuts, stats.assetDeletes, + stats.prefixQueries, stats.prefixRows, elapsedMillis(p66Start, System.nanoTime())); + } + BlockChangeView changeView = null; + if (oldValueCollector != null || pathStateRuntimeAttachment != null) { + changeView = BlockChangeView.capture(meta, dbs); + } + long frozenNanos = System.nanoTime(); + BlockReverseDiff reverseDiff; + PathStateBlockTransition pathStateTransition; + long archiveNanos; + long pathNanos; + if (p66Materializer != null && oldValueCollector != null + && pathStateRuntimeAttachment != null) { + BlockChangeView frozen = changeView; + long parallelStart = System.nanoTime(); + long[] archiveElapsed = new long[1]; + Future archive = artifactExecutor.submit(() -> { + long start = System.nanoTime(); + try { + return Objects.requireNonNull(oldValueCollector.collect(frozen), + "archive collector returned null"); + } finally { + archiveElapsed[0] = System.nanoTime() - start; + } + }); + long pathStart = System.nanoTime(); + Throwable pathFailure = null; + pathStateTransition = null; + try { + pathStateTransition = Objects.requireNonNull(pathStateRuntimeAttachment.capture(frozen), + "PathState capture failed before block-final barrier"); + } catch (Throwable failure) { + pathFailure = failure; + } + long pathElapsed = System.nanoTime() - pathStart; + // Never cancel-and-revoke: the other reader must finish before releasing the Snapshot. + boolean interrupted = false; + Throwable archiveFailure = null; + reverseDiff = null; + while (true) { + try { + reverseDiff = archive.get(); + break; + } catch (InterruptedException failure) { + interrupted = true; + } catch (ExecutionException failure) { + archiveFailure = failure.getCause(); + break; + } + } + if (interrupted) { + Thread.currentThread().interrupt(); + } + if (pathFailure != null || archiveFailure != null || interrupted) { + Throwable failure = pathFailure != null ? pathFailure : archiveFailure; + IllegalStateException rejected = new IllegalStateException( + "Block-final parallel barrier failed", failure); + if (pathFailure != null && archiveFailure != null) { + rejected.addSuppressed(archiveFailure); + } + pathStateRuntimeAttachment.fail(rejected); + throw rejected; + } + archiveNanos = System.nanoTime(); + pathNanos = archiveNanos; + logger.info("Block-final parallel barrier: head={}, archiveMs={}, pathMs={}, wallMs={}, " + + "archiveP66Projection=0, pathP66Projection=0", meta.getBlockNumber(), + TimeUnit.NANOSECONDS.toMillis(archiveElapsed[0]), + TimeUnit.NANOSECONDS.toMillis(pathElapsed), elapsedMillis(parallelStart, pathNanos)); + } else { + reverseDiff = oldValueCollector == null ? null : Objects.requireNonNull( + oldValueCollector.collect(changeView), "archive collector returned null"); + archiveNanos = System.nanoTime(); + pathStateTransition = pathStateRuntimeAttachment == null ? null + : pathStateRuntimeAttachment.capture(changeView); + pathNanos = System.nanoTime(); + } + PathStateSnapshotDelta pathStateDelta = pathStateTransition == null ? null + : pathStateRuntimeAttachment.preparedSnapshotDelta(pathStateTransition); + + dbs.forEach(db -> { + if (db.getHead().isOptimized()) { + db.getHead().reloadToMem(); + } + }); + + for (Chainbase db : dbs) { + boolean stateDatabase = ArchiveStoreScope.isStateDatabase(db.getDbName()); + ((SnapshotImpl) db.getHead()).attachBlockArtifacts(meta, + stateDatabase ? reverseDiff : null, stateDatabase ? pathStateDelta : null); + } + long attachedNanos = System.nanoTime(); + if (p66Materializer == null) { + --activeSession; + } + if (pathStateRuntimeAttachment != null) { + pathStateRuntimeAttachment.publish(pathStateTransition); + if (p66Materializer != null && pathStateRuntimeAttachment.getFailure() != null) { + throw new IllegalStateException("Block-final PathState publication failed", + pathStateRuntimeAttachment.getFailure()); + } + } + if (p66Materializer != null) { + --activeSession; + } + long completedNanos = System.nanoTime(); + if (changeView != null && p66Materializer == null) { + logger.info("Block-final artifact stages: head={}, freezeMs={}, archiveMs={}, " + + "pathCaptureMs={}, attachMs={}, publishMs={}, totalMs={}", + meta.getBlockNumber(), elapsedMillis(startedNanos, frozenNanos), + elapsedMillis(frozenNanos, archiveNanos), elapsedMillis(archiveNanos, pathNanos), + elapsedMillis(pathNanos, attachedNanos), elapsedMillis(attachedNanos, completedNanos), + elapsedMillis(startedNanos, completedNanos)); + } + } + + private static long elapsedMillis(long startedNanos, long completedNanos) { + return TimeUnit.NANOSECONDS.toMillis(completedNanos - startedNanos); + } + + /** Previews optional producer metadata from the active block session without publishing it. */ + public synchronized byte[] previewPathStateRoot(BlockSnapshotMeta meta) { + if (activeSession <= 0) { + throw new RevokingStoreIllegalStateException(activeSession); + } + if (pathStateRuntimeAttachment == null) { + return null; + } + if (p66Materializer != null) { + p66Materializer.materialize(); + } + return pathStateRuntimeAttachment.preview(BlockChangeView.capture( + Objects.requireNonNull(meta, "meta"), dbs)); + } + + private void validateBlockMeta(BlockSnapshotMeta meta) { + BlockSnapshotMeta previousMeta = null; + for (Chainbase db : dbs) { + if (!ArchiveStoreScope.isStateDatabase(db.getDbName())) { + continue; + } + Snapshot head = db.getHead(); + if (!Snapshot.isImpl(head)) { + continue; + } + Snapshot previous = head.getPrevious(); + BlockSnapshotMeta candidate = Snapshot.isImpl(previous) + ? ((SnapshotImpl) previous).getBlockSnapshotMeta() : null; + if (candidate != null && previousMeta != null && !previousMeta.equals(candidate)) { + throw new IllegalStateException("Previous block metadata differs across state databases"); + } + if (candidate != null) { + previousMeta = candidate; + } + } + if (previousMeta == null) { + return; + } + if (meta.getEpoch() != previousMeta.getEpoch() + 1 + || meta.getBlockNumber() != previousMeta.getBlockNumber() + 1 + || !Arrays.equals(meta.getParentHash(), previousMeta.getBlockHash())) { + throw new IllegalStateException( + "Non-contiguous block snapshot metadata: previous=" + previousMeta + ", current=" + meta); + } + } + + /** Enables archive collection after all Chainbase stores have registered. */ + public synchronized void installArchiveCollector(OldValueCollector collector, + BlockReverseDiffSink sink) { + ArchiveStoreScope.validate(dbs); + if (archiveRuntimeAttachment != null) { + throw new IllegalStateException("Borrowed archive runtime is already attached"); + } + if (commonCheckpointRuntimeAttachment != null) { + throw new IllegalStateException("Common checkpoint runtime is already attached"); + } + oldValueCollector = Objects.requireNonNull(collector, "collector"); + blockReverseDiffSink = Objects.requireNonNull(sink, "sink"); + } + + /** Installs Archive artifact capture without any legacy per-block or flush-time sink. */ + public synchronized void installCommonCheckpointArchiveCollector(OldValueCollector collector) { + ArchiveStoreScope.validate(dbs); + if (oldValueCollector != null || blockReverseDiffSink != null + || archiveRuntimeAttachment != null || commonCheckpointRuntimeAttachment != null) { + throw new IllegalStateException("Archive collaborators are already installed"); + } + oldValueCollector = Objects.requireNonNull(collector, "collector"); + } + + /** Clears a partially installed common-checkpoint collector during startup rollback. */ + public synchronized void clearCommonCheckpointArchiveCollector() { + if (commonCheckpointRuntimeAttachment != null || archiveRuntimeAttachment != null + || blockReverseDiffSink != null) { + throw new IllegalStateException("Cannot clear an active Archive persistence runtime"); + } + oldValueCollector = null; + } + + /** Atomically installs one borrowed archive runtime bundle after store registration. */ + public synchronized void attachArchiveRuntime(ArchiveRuntimeAttachment attachment) { + ArchiveStoreScope.validate(dbs); + ArchiveRuntimeAttachment candidate = Objects.requireNonNull(attachment, "attachment"); + if (archiveRuntimeAttachment != null) { + throw new IllegalStateException("Archive runtime is already attached"); + } + if (commonCheckpointRuntimeAttachment != null) { + throw new IllegalStateException("Common checkpoint runtime is already attached"); + } + if (oldValueCollector != null || blockReverseDiffSink != null) { + throw new IllegalStateException("Legacy archive collaborators are already installed"); + } + oldValueCollector = candidate.getCollector(); + blockReverseDiffSink = candidate.getSink(); + archiveRuntimeAttachment = candidate; + } + + /** Detaches the exact borrowed bundle without closing resources owned by its runtime. */ + public synchronized ArchiveRuntimeAttachment detachArchiveRuntime( + ArchiveRuntimeAttachment expected) { + ArchiveRuntimeAttachment candidate = Objects.requireNonNull(expected, "expected"); + if (archiveRuntimeAttachment == null) { + throw new IllegalStateException("Archive runtime is not attached"); + } + if (archiveRuntimeAttachment != candidate) { + throw new IllegalStateException("Cannot detach a foreign archive runtime"); + } + archiveRuntimeAttachment = null; + oldValueCollector = null; + blockReverseDiffSink = null; + submittedArchiveHistoryEpoch = null; + archiveReadableEpoch = -1; + return candidate; + } + + /** Installs an independent non-consensus path-state block-final runtime. */ + public synchronized void attachPathStateRuntime(PathStateRuntimeAttachment attachment) { + ArchiveStoreScope.validate(dbs); + PathStateRuntimeAttachment candidate = Objects.requireNonNull(attachment, "attachment"); + if (pathStateRuntimeAttachment != null) { + throw new IllegalStateException("Path-state runtime is already attached"); + } + pathStateRuntimeAttachment = candidate; + } + + /** + * Installs the exclusive three-authority persistence owner. Archive collection and PathState + * in-memory advancement remain attached separately, but their legacy durable callbacks are + * never used while this attachment is present. + */ + public synchronized void attachCommonCheckpointRuntime( + CommonCheckpointRuntimeAttachment attachment) { + ArchiveStoreScope.validate(dbs); + CommonCheckpointRuntimeAttachment candidate = Objects.requireNonNull(attachment, + "attachment"); + if (!candidate.isEnabled()) { + throw new IllegalArgumentException("Common checkpoint runtime must be enabled"); + } + if (commonCheckpointRuntimeAttachment != null) { + throw new IllegalStateException("Common checkpoint runtime is already attached"); + } + if (archiveRuntimeAttachment != null || blockReverseDiffSink != null) { + throw new IllegalStateException( + "Common checkpoint runtime is mutually exclusive with legacy Archive persistence"); + } + if (oldValueCollector == null || pathStateRuntimeAttachment == null + || !pathStateRuntimeAttachment.isCommonCheckpointOnly()) { + throw new IllegalStateException( + "Common checkpoint requires Archive capture and a checkpoint-only PathState runtime"); + } + commonCheckpointRuntimeAttachment = candidate; + } + + /** Detaches the exact Manager-owned common-checkpoint runtime without closing it. */ + public synchronized CommonCheckpointRuntimeAttachment detachCommonCheckpointRuntime( + CommonCheckpointRuntimeAttachment expected) { + CommonCheckpointRuntimeAttachment candidate = Objects.requireNonNull(expected, "expected"); + if (commonCheckpointRuntimeAttachment != candidate) { + throw new IllegalStateException("Cannot detach a missing or foreign common runtime"); + } + commonCheckpointRuntimeAttachment = null; + oldValueCollector = null; + return candidate; + } + + /** Detaches the exact borrowed path-state runtime without closing its Manager-owned state. */ + public synchronized PathStateRuntimeAttachment detachPathStateRuntime( + PathStateRuntimeAttachment expected) { + PathStateRuntimeAttachment candidate = Objects.requireNonNull(expected, "expected"); + if (pathStateRuntimeAttachment == null) { + throw new IllegalStateException("Path-state runtime is not attached"); + } + if (pathStateRuntimeAttachment != candidate) { + throw new IllegalStateException("Cannot detach a foreign path-state runtime"); + } + pathStateRuntimeAttachment = null; + return candidate; + } + + /** Runs latest-state snapshot acquisition inside the canonical apply/flush monitor. */ + public synchronized void withArchiveStateBarrier(ArchiveStateAction action) throws IOException { + Objects.requireNonNull(action, "action").run(); + } + + public void markArchiveReadableThrough(long epoch) { + archiveReadableEpoch = epoch; + } + public synchronized void pop() { if (activeSession != 0) { throw new RevokingStoreIllegalStateException( @@ -239,7 +654,18 @@ public synchronized void pop() { } @Override - public void fastPop() { + public synchronized void fastPop() { + if (activeSession != 0) { + throw new RevokingStoreIllegalStateException( + String.format("activeSession has to be equal 0, current %d", activeSession)); + } + if (size <= 0) { + throw new RevokingStoreIllegalStateException( + String.format("there is not snapshot to be popped, current: %d", size)); + } + if (submittedArchiveHistoryEpoch != null) { + throw new IllegalStateException("Cannot pop while archive history flush is pending"); + } pop(); } @@ -267,9 +693,39 @@ public synchronized void disable() { @Override public void shutdown() { + Closeable legacyArchiveSink = prepareArchiveShutdown(); + if (artifactExecutor != null) { + ExecutorServiceManager.shutdownAndAwaitTermination(artifactExecutor, "block-final-archive"); + } ExecutorServiceManager.shutdownAndAwaitTermination(pruneCheckpointThread, pruneName); flushServices.forEach((key, value) -> ExecutorServiceManager.shutdownAndAwaitTermination(value, "flush-service-" + key)); + if (legacyArchiveSink != null) { + try { + legacyArchiveSink.close(); + } catch (IOException e) { + logger.error("Failed to close archive history sink.", e); + } + } + } + + private synchronized Closeable prepareArchiveShutdown() { + submittedArchiveHistoryEpoch = null; + if (archiveRuntimeAttachment != null) { + archiveRuntimeAttachment = null; + oldValueCollector = null; + blockReverseDiffSink = null; + archiveReadableEpoch = -1; + return null; + } + if (commonCheckpointRuntimeAttachment != null) { + commonCheckpointRuntimeAttachment = null; + oldValueCollector = null; + blockReverseDiffSink = null; + archiveReadableEpoch = -1; + return null; + } + return blockReverseDiffSink instanceof Closeable ? (Closeable) blockReverseDiffSink : null; } public void updateSolidity(int hops) { @@ -285,9 +741,28 @@ public boolean shouldBeRefreshed() { } private void refresh() { + refresh(flushCount); + } + + private void refresh(int count) { List> futures = new ArrayList<>(dbs.size()); + Chainbase properties = null; + if (oldValueCollector != null) { + properties = dbs.stream() + .filter(db -> "properties".equals(db.getDbName())) + .findFirst() + .orElse(null); + if (properties != null) { + // Account root projection reads the durable optimization flag. Make that dependency + // deterministic when archive mode projects account-asset changes at block boundaries. + refreshOne(properties, count); + } + } for (Chainbase db : dbs) { - futures.add(flushServices.get(db.getDbName()).submit(() -> refreshOne(db))); + if (db == properties) { + continue; + } + futures.add(flushServices.get(db.getDbName()).submit(() -> refreshOne(db, count))); } Future future = Futures.allAsList(futures); try { @@ -300,7 +775,7 @@ private void refresh() { } } - private void refreshOne(Chainbase db) { + private void refreshOne(Chainbase db, int count) { if (Snapshot.isRoot(db.getHead())) { return; } @@ -309,7 +784,7 @@ private void refreshOne(Chainbase db) { SnapshotRoot root = (SnapshotRoot) db.getHead().getRoot(); Snapshot next = root; - for (int i = 0; i < flushCount; ++i) { + for (int i = 0; i < count; ++i) { next = next.getNext(); snapshots.add(next); } @@ -326,20 +801,68 @@ private void refreshOne(Chainbase db) { } public void flush() { - if (unChecked) { + flush(false); + } + + @Override + public void flushPending() { + flush(true); + } + + private synchronized void flush(boolean force) { + if (unChecked || (force && flushCount == 0)) { return; } - if (shouldBeRefreshed()) { + if (force || shouldBeRefreshed()) { try { long start = System.currentTimeMillis(); + if (commonCheckpointRuntimeAttachment != null) { + if (flushCount <= 0) { + return; + } + try { + commonCheckpointRuntimeAttachment.checkpointAndRebase(flushCount); + } catch (IOException | RuntimeException failure) { + throw new TronDBException("Common checkpoint publication failed", failure); + } + flushCount = 0; + logger.info("Common checkpoint flush cost: {} ms.", + System.currentTimeMillis() - start); + return; + } + BlockSnapshotMeta pathStateFlushTarget = pathStateFlushTarget(); + ArchiveWalBinding archiveBinding = publishArchiveHistoryForFlush(); if (!isV2Open()) { deleteCheckpoint(); } - createCheckpoint(); + createCheckpoint(archiveBinding); long checkPointEnd = System.currentTimeMillis(); + if (archiveBinding != null && archiveRuntimeAttachment != null) { + try { + archiveRuntimeAttachment.publishCommittedPrefix(archiveBinding.getLast()); + } catch (IOException | RuntimeException failure) { + throw new TronDBException("Archive committed-prefix publication failed", failure); + } + } refresh(); + if (pathStateRuntimeAttachment != null && pathStateFlushTarget != null) { + pathStateRuntimeAttachment.flushBaseThrough(pathStateFlushTarget.getBlockNumber(), + pathStateFlushTarget.getBlockHash()); + } + if (archiveBinding != null && archiveRuntimeAttachment != null) { + try { + archiveRuntimeAttachment.publishReadableState(archiveBinding.getLast()); + } catch (IOException | RuntimeException failure) { + throw new TronDBException("Archive readable-state publication failed", failure); + } + } + if (archiveBinding != null) { + ((DurableBlockReverseDiffSink) blockReverseDiffSink) + .releaseThrough(archiveBinding.getLast().getEpoch()); + submittedArchiveHistoryEpoch = null; + } flushCount = 0; logger.info("Flush cost: {} ms, create checkpoint cost: {} ms, refresh cost: {} ms.", System.currentTimeMillis() - start, @@ -354,7 +877,93 @@ public void flush() { } } + private BlockSnapshotMeta pathStateFlushTarget() { + if (pathStateRuntimeAttachment == null || flushCount == 0 || dbs.isEmpty()) { + return null; + } + try { + Snapshot next = dbs.get(0).getHead().getRoot(); + BlockSnapshotMeta target = null; + for (int index = 0; index < flushCount; index++) { + next = next.getNext(); + if (!Snapshot.isImpl(next)) { + throw new IllegalStateException("Path-state flush range is missing a snapshot layer"); + } + target = ((SnapshotImpl) next).getBlockSnapshotMeta(); + if (target == null) { + throw new IllegalStateException("Path-state flush layer has no block metadata"); + } + } + return target; + } catch (RuntimeException failure) { + pathStateRuntimeAttachment.fail(failure); + return null; + } + } + + private ArchiveWalBinding publishArchiveHistoryForFlush() { + if (oldValueCollector == null) { + return null; + } + if (!(blockReverseDiffSink instanceof DurableBlockReverseDiffSink)) { + throw new TronDBException("Archive sink cannot prove durable history before checkpoint"); + } + Chainbase stateDatabase = dbs.stream() + .filter(db -> ArchiveStoreScope.isStateDatabase(db.getDbName())) + .findFirst() + .orElseThrow(() -> new TronDBException("Archive mode has no registered state database")); + Snapshot next = stateDatabase.getHead().getRoot(); + List prepared = new ArrayList<>(flushCount); + BlockSnapshotMeta last = null; + for (int i = 0; i < flushCount; i++) { + next = next.getNext(); + if (!Snapshot.isImpl(next)) { + throw new TronDBException("Archive flush range is missing a snapshot layer"); + } + BlockSnapshotMeta meta = ((SnapshotImpl) next).getBlockSnapshotMeta(); + if (meta == null) { + throw new TronDBException("Archive flush range contains a layer without block metadata"); + } + if (last != null && meta.getEpoch() != last.getEpoch() + 1) { + throw new TronDBException("Archive flush range is not epoch-contiguous"); + } + BlockReverseDiff reverseDiff = ((SnapshotImpl) next).getPreparedArchiveBlock(); + if (reverseDiff == null || !meta.equals(reverseDiff.getMeta())) { + throw new TronDBException( + "Archive flush range contains a layer without its matching prepared payload"); + } + prepared.add(reverseDiff); + last = meta; + } + if (last == null) { + return null; + } + try { + DurableBlockReverseDiffSink durableSink = + (DurableBlockReverseDiffSink) blockReverseDiffSink; + if (submittedArchiveHistoryEpoch == null) { + durableSink.acceptAll(prepared); + submittedArchiveHistoryEpoch = last.getEpoch(); + } else if (submittedArchiveHistoryEpoch.longValue() != last.getEpoch()) { + throw new ArchivePersistenceException( + "Submitted archive history target does not match the pending flush range"); + } + durableSink.awaitCommitted(last.getEpoch()); + List expectedMetas = prepared.stream() + .map(BlockReverseDiff::getMeta).collect(Collectors.toList()); + DurableHistoryMarkerRangeEvidence evidence = + durableSink.createMarkerRangeEvidence(prepared.size()); + return ArchiveWalBinding.fromMarkers(evidence.read(expectedMetas)); + } catch (RuntimeException e) { + throw new TronDBException("Archive history durability gate failed", e); + } + } + public void createCheckpoint() { + createCheckpoint(null); + } + + private void createCheckpoint(ArchiveWalBinding archiveBinding) { TronDatabase checkPointStore = null; try { Map batch = new HashMap<>(); @@ -384,6 +993,10 @@ public void createCheckpoint() { } } } + if (archiveBinding != null) { + batch.put(WrappedByteArray.of(ArchiveWalBinding.getCheckpointKey()), + WrappedByteArray.of(new ArchiveWalBindingCodec().encode(archiveBinding))); + } if (isV2Open()) { String dbName = String.valueOf(System.currentTimeMillis()); checkPointStore = getCheckpointDB(dbName); @@ -394,6 +1007,7 @@ public void createCheckpoint() { checkPointStore.updateByBatch(batch.entrySet().stream() .map(e -> Maps.immutableEntry(e.getKey().getBytes(), e.getValue().getBytes())) .collect(HashMap::new, (m, k) -> m.put(k.getKey(), k.getValue()), HashMap::putAll)); + latestArchiveWalBinding = archiveBinding; } catch (Exception e) { throw new TronDBException(e); @@ -469,6 +1083,12 @@ private void pruneCheckpoint() { // ensure run this method first after process start. @Override public void check() { + recoveredArchiveWalBinding = null; + if (hasDurableCommonCheckpointAuthority()) { + logger.info("Common checkpoint authority established, skip legacy checkpoint recovery"); + unChecked = false; + return; + } if (!isV2Open()) { List cpList = getCheckpointList(); if (cpList != null && cpList.size() != 0) { @@ -483,6 +1103,28 @@ public void check() { } } + private boolean hasDurableCommonCheckpointAuthority() { + org.tron.core.config.args.Storage storage = + CommonParameter.getInstance().getStorage(); + if (!storage.isCommonCheckpointEnabled()) { + return false; + } + Path directory = Paths.get(CommonParameter.getInstance().getOutputDirectory(), + storage.getCommonCheckpointDirectory()).normalize(); + return hasDurableCommonCheckpointAuthority(true, directory); + } + + static boolean hasDurableCommonCheckpointAuthority(boolean enabled, Path directory) { + if (!enabled) { + return false; + } + Path admitted = Objects.requireNonNull(directory, "directory"); + return Files.isRegularFile(admitted.resolve(ChainbaseCheckpointMaterializer.CURRENT_FILE), + LinkOption.NOFOLLOW_LINKS) + || Files.isRegularFile(admitted.resolve(CommonCheckpointFile.FILE_NAME), + LinkOption.NOFOLLOW_LINKS); + } + private void checkV1() { for (Chainbase db: dbs) { if (!Snapshot.isRoot(db.getHead())) { @@ -512,21 +1154,46 @@ private void checkV2() { continue; } TronDatabase checkPointV2Store = getCheckpointDB(cp); - recover(checkPointV2Store); - checkPointV2Store.close(); + try { + recover(checkPointV2Store); + } finally { + checkPointV2Store.close(); + } } logger.info("checkpoint v2 recover success"); unChecked = false; } private void recover(TronDatabase tronDatabase) { + List> entries = new ArrayList<>(); + ArchiveWalBinding recoveredBinding = null; + for (Map.Entry entry : tronDatabase.getDbSource()) { + byte[] key = Arrays.copyOf(entry.getKey(), entry.getKey().length); + byte[] value = Arrays.copyOf(entry.getValue(), entry.getValue().length); + if (ArchiveWalBinding.isCheckpointKey(key)) { + if (recoveredBinding != null) { + throw new ArchivePersistenceException( + "Checkpoint contains duplicate Archive WAL bindings"); + } + try { + recoveredBinding = new ArchiveWalBindingCodec().decode(value); + } catch (IllegalArgumentException invalid) { + throw new ArchivePersistenceException( + "Checkpoint Archive WAL binding is corrupt", invalid); + } + } + entries.add(Maps.immutableEntry(key, value)); + } Map dbMap = dbs.stream() .map(db -> Maps.immutableEntry(db.getDbName(), db)) .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); advance(); - for (Map.Entry e: tronDatabase.getDbSource()) { + for (Map.Entry e : entries) { byte[] key = e.getKey(); byte[] value = e.getValue(); + if (ArchiveWalBinding.isCheckpointKey(key)) { + continue; + } String db = simpleDecode(key); if (dbMap.get(db) == null) { continue; @@ -547,6 +1214,17 @@ private void recover(TronDatabase tronDatabase) { dbs.forEach(db -> db.getHead().getRoot().merge(db.getHead())); retreat(); + if (recoveredBinding != null) { + recoveredArchiveWalBinding = recoveredBinding; + } + } + + public ArchiveWalBinding getLatestArchiveWalBinding() { + return latestArchiveWalBinding; + } + + public ArchiveWalBinding getRecoveredArchiveWalBinding() { + return recoveredArchiveWalBinding; } private boolean isV2Open() { @@ -581,8 +1259,14 @@ public Session(SnapshotManager snapshotManager, boolean disableOnExit) { @Override public void commit() { - applySnapshot = false; snapshotManager.commit(); + applySnapshot = false; + } + + @Override + public void commit(BlockSnapshotMeta meta) { + snapshotManager.commit(meta); + applySnapshot = false; } @Override diff --git a/chainbase/src/main/java/org/tron/core/db2/core/SnapshotRoot.java b/chainbase/src/main/java/org/tron/core/db2/core/SnapshotRoot.java index f95cf68dafe..ced36906998 100644 --- a/chainbase/src/main/java/org/tron/core/db2/core/SnapshotRoot.java +++ b/chainbase/src/main/java/org/tron/core/db2/core/SnapshotRoot.java @@ -26,6 +26,11 @@ public class SnapshotRoot extends AbstractSnapshot { @Getter private Snapshot solidity; private boolean isAccountDB; + private boolean coupledMutationsMaterialized; + + void useMaterializedCoupledMutations() { + coupledMutationsMaterialized = true; + } private TronCache cache; private static final List CACHE_DBS = CommonParameter.getInstance() @@ -42,7 +47,7 @@ public SnapshotRoot(DB db) { } private boolean needOptAsset() { - return isAccountDB && ChainBaseManager.getInstance().getDynamicPropertiesStore() + return isAccountDB && !coupledMutationsMaterialized && ChainBaseManager.getInstance().getDynamicPropertiesStore() .getAllowAccountAssetOptimizationFromRoot() == 1; } @@ -121,7 +126,21 @@ public void merge(List snapshots) { } } + /** Applies a fully coalesced common-checkpoint Store batch with an explicit sync barrier. */ + void applyCheckpointMutations(Map batch) { + if (needOptAsset()) { + processAccount(batch, true); + } else { + ((Flusher) db).flushSynced(batch); + putCache(batch); + } + } + private void processAccount(Map batch) { + processAccount(batch, false); + } + + private void processAccount(Map batch, boolean synced) { AccountAssetStore assetStore = ChainBaseManager.getInstance().getAccountAssetStore(); Map accounts = new HashMap<>(); Map assets = new HashMap<>(); @@ -140,10 +159,18 @@ private void processAccount(Map batch) { accounts.put(k, WrappedByteArray.of(item.getData())); } }); - ((Flusher) db).flush(accounts); + if (synced) { + ((Flusher) db).flushSynced(accounts); + } else { + ((Flusher) db).flush(accounts); + } putCache(accounts); if (assets.size() > 0) { - assetStore.updateByBatch(AccountAssetStore.convert(assets)); + if (synced) { + assetStore.updateByBatchSynced(AccountAssetStore.convert(assets)); + } else { + assetStore.updateByBatch(AccountAssetStore.convert(assets)); + } } } @@ -220,7 +247,9 @@ public String getDbName() { @Override public Snapshot newInstance() { - return new SnapshotRoot(db.newInstance()); + SnapshotRoot replacement = new SnapshotRoot(db.newInstance()); + replacement.coupledMutationsMaterialized = coupledMutationsMaterialized; + return replacement; } @Override diff --git a/chainbase/src/main/java/org/tron/core/db2/stateroot/PathMerkleTrie.java b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathMerkleTrie.java new file mode 100644 index 00000000000..b4d14323aa9 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathMerkleTrie.java @@ -0,0 +1,1831 @@ +package org.tron.core.db2.stateroot; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.IdentityHashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.TreeMap; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicLong; +import org.tron.common.crypto.Hash; + +/** Backend-neutral secure-key MPT with path-local immutable node updates. */ +public final class PathMerkleTrie { + + public static final int SECURE_KEY_LENGTH = 32; + + private static final byte[] EMPTY_PATH = new byte[0]; + private static final byte[] EMPTY_RLP_ITEM = new byte[]{(byte) 0x80}; + private static final int PARALLEL_UPDATE_THRESHOLD = 4; + private static final Comparator UNSIGNED_KEY_COMPARATOR = (left, right) -> { + int length = Math.min(left.bytes.length, right.bytes.length); + for (int i = 0; i < length; i++) { + int compared = Integer.compare(left.bytes[i] & 0xff, right.bytes[i] & 0xff); + if (compared != 0) { + return compared; + } + } + return Integer.compare(left.bytes.length, right.bytes.length); + }; + + private final PathNodeStore nodeStore; + private static final AtomicLong NODE_CREATE_COUNT = new AtomicLong(); + private static final AtomicLong NODE_ENCODE_COUNT = new AtomicLong(); + private static final AtomicLong NODE_KECCAK_COUNT = new AtomicLong(); + private static final ThreadLocal DEFER_NODE_ENCODING = + ThreadLocal.withInitial(() -> Boolean.FALSE); + + private final boolean lazyHashReferences; + private final Map leaves = new TreeMap<>(UNSIGNED_KEY_COMPARATOR); + private final IdentityHashMap materializedNodes = new IdentityHashMap<>(); + private final IdentityHashMap resolvedMaterializedNodes = new IdentityHashMap<>(); + private Snapshot inheritedSnapshot; + private Node rootNode; + private Node materializedRoot; + private byte[] rootHash = Arrays.copyOf(Hash.EMPTY_TRIE_HASH, Hash.EMPTY_TRIE_HASH.length); + private int leafCount; + private boolean dirty; + private boolean frozen; + private int lastNodePuts; + private int lastNodeDeletes; + private long lastCommitPlanNanos; + private long lastCommitStoreNanos; + private long lastCommitFinalizeNanos; + private final AtomicLong nodeDecodeCount = new AtomicLong(); + private final AtomicLong nodeHashVerifyCount = new AtomicLong(); + private final AtomicLong hashReferenceCreateCount = new AtomicLong(); + private final AtomicLong hashReferenceResolveCount = new AtomicLong(); + + public PathMerkleTrie(PathNodeStore nodeStore) { + this(nodeStore, true); + } + + PathMerkleTrie(PathNodeStore nodeStore, boolean lazyHashReferences) { + this.nodeStore = Objects.requireNonNull(nodeStore, "nodeStore"); + this.lazyHashReferences = lazyHashReferences; + } + + public synchronized void put(byte[] secureKey, byte[] encodedValue) { + requireMutable(); + BytesKey key = secureKey(secureKey); + byte[] value = nonEmpty(encodedValue, "encodedValue"); + byte[] previous = leafValue(key); + if (Arrays.equals(previous, value)) { + return; + } + leaves.put(key, value); + if (previous == null) { + leafCount++; + } + rootNode = update(rootNode, toNibbles(key.bytes), 0, EMPTY_PATH, value); + dirty = true; + } + + public synchronized void delete(byte[] secureKey) { + requireMutable(); + BytesKey key = secureKey(secureKey); + if (leafValue(key) != null) { + leaves.put(key, null); + leafCount--; + rootNode = update(rootNode, toNibbles(key.bytes), 0, EMPTY_PATH, null); + dirty = true; + } + } + + synchronized void applyBatch(List mutations, ExecutorService executor) { + applyBatch(mutations, executor, false); + } + + synchronized void applyBatch(List mutations, ExecutorService executor, + boolean deferNodeEncoding) { + requireMutable(); + if (deferNodeEncoding) { + try (BlockNodeEncodingScope ignored = BlockNodeEncodingScope.open()) { + applyBatchInternal(mutations, executor); + } + return; + } + applyBatchInternal(mutations, executor); + } + + private void applyBatchInternal(List mutations, ExecutorService executor) { + List batch = new ArrayList<>(Objects.requireNonNull(mutations, "mutations")); + Node resolvedRoot = resolve(rootNode, EMPTY_PATH); + rootNode = resolvedRoot; + if (batch.size() < PARALLEL_UPDATE_THRESHOLD || !(resolvedRoot instanceof BranchNode)) { + applySequentialBatch(batch); + return; + } + BranchNode root = (BranchNode) resolvedRoot; + List> groups = new ArrayList<>(16); + boolean[] deletes = new boolean[16]; + for (int i = 0; i < 16; i++) { + groups.add(new ArrayList<>()); + } + for (BatchMutation mutation : batch) { + BatchMutation present = Objects.requireNonNull(mutation, "mutation"); + int nibble = (present.secureKey[0] >>> 4) & 0x0f; + groups.get(nibble).add(present); + deletes[nibble] |= present.encodedValue == null; + } + int guaranteedSurvivors = 0; + for (int i = 0; i < root.children.length; i++) { + if (root.children[i] != null && !deletes[i]) { + guaranteedSurvivors++; + } + } + if (guaranteedSurvivors < 2) { + applySequentialBatch(batch); + return; + } + List work = new ArrayList<>(); + for (int i = 0; i < groups.size(); i++) { + if (!groups.get(i).isEmpty()) { + work.add(new SubtreeWork(i, groups.get(i))); + } + } + work.sort((left, right) -> { + int compared = Integer.compare(right.mutations.size(), left.mutations.size()); + return compared != 0 ? compared : Integer.compare(left.position, right.position); + }); + List> futures = new ArrayList<>(work.size()); + boolean deferNodeEncoding = DEFER_NODE_ENCODING.get(); + for (SubtreeWork subtree : work) { + futures.add(Objects.requireNonNull(executor, "executor").submit( + () -> applySubtree(root.children[subtree.position], subtree.position, + subtree.mutations, deferNodeEncoding))); + } + Node[] children = Arrays.copyOf(root.children, root.children.length); + Map previous = new LinkedHashMap<>(); + Map changed = new LinkedHashMap<>(); + try { + for (int i = 0; i < futures.size(); i++) { + SubtreeResult result = futures.get(i).get(); + children[work.get(i).position] = result.node; + previous.putAll(result.previous); + changed.putAll(result.changed); + } + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + cancel(futures); + throw new IllegalStateException("path trie batch update interrupted", interrupted); + } catch (ExecutionException failed) { + cancel(futures); + Throwable cause = failed.getCause(); + if (cause instanceof RuntimeException) { + throw (RuntimeException) cause; + } + throw new IllegalStateException("path trie batch update failed", cause); + } + if (changed.isEmpty()) { + return; + } + for (BatchMutation mutation : batch) { + BytesKey key = new BytesKey(mutation.secureKey); + if (!changed.containsKey(key)) { + continue; + } + byte[] oldValue = previous.get(key); + leaves.put(key, mutation.encodedValue); + if (oldValue == null && mutation.encodedValue != null) { + leafCount++; + } else if (oldValue != null && mutation.encodedValue == null) { + leafCount--; + } + } + rootNode = new BranchNode(children); + dirty = true; + } + + private static final class SubtreeWork { + + private final int position; + private final List mutations; + + private SubtreeWork(int position, List mutations) { + this.position = position; + this.mutations = mutations; + } + } + + private void applySequentialBatch(List mutations) { + for (BatchMutation mutation : mutations) { + BytesKey key = new BytesKey(mutation.secureKey); + byte[] previous = mutation.previousValueKnown + ? mutation.previousEncodedValue : leafValue(key); + if (Arrays.equals(previous, mutation.encodedValue)) { + continue; + } + leaves.put(key, mutation.encodedValue); + if (previous == null && mutation.encodedValue != null) { + leafCount++; + } else if (previous != null && mutation.encodedValue == null) { + leafCount--; + } + rootNode = update(rootNode, mutation.nibbles, 0, EMPTY_PATH, + mutation.encodedValue); + dirty = true; + } + } + + private SubtreeResult applySubtree(Node initial, int nibble, + List mutations, boolean deferNodeEncoding) { + if (deferNodeEncoding) { + try (BlockNodeEncodingScope ignored = BlockNodeEncodingScope.open()) { + SubtreeResult result = applySubtreeInternal(initial, nibble, mutations); + if (result.node != null) { + nodeReference(result.node); + } + return result; + } + } + return applySubtreeInternal(initial, nibble, mutations); + } + + private SubtreeResult applySubtreeInternal(Node initial, int nibble, + List mutations) { + Node node = initial; + byte[] path = new byte[]{(byte) nibble}; + Map previous = new LinkedHashMap<>(); + Map changed = new LinkedHashMap<>(); + List effective = new ArrayList<>(); + for (BatchMutation mutation : mutations) { + BytesKey key = new BytesKey(mutation.secureKey); + byte[] oldValue; + if (mutation.previousValueKnown) { + oldValue = mutation.previousEncodedValue; + } else if (leaves.containsKey(key)) { + oldValue = leaves.get(key); + } else if (inheritedSnapshot != null && inheritedSnapshot.containsLeaf(key)) { + oldValue = inheritedSnapshot.leafValue(key); + } else { + ValueResult result = resolvedValueAt(node, mutation.nibbles, 1, path); + node = result.node; + oldValue = result.value; + } + previous.put(key, oldValue); + if (Arrays.equals(oldValue, mutation.encodedValue)) { + continue; + } + changed.put(key, mutation.encodedValue); + effective.add(mutation); + } + if (!effective.isEmpty()) { + node = updateBatch(node, effective, 1, path); + } + return new SubtreeResult(node, previous, changed); + } + + /** Applies sorted, unique edits while rebuilding every shared ancestor at most once. */ + private Node updateBatch(Node node, List mutations, int offset, byte[] path) { + if (mutations.isEmpty()) { + return node; + } + if (node == null) { + return buildMutations(mutations, offset); + } + Node present = resolve(node, path); + if (present instanceof LeafNode) { + return updateLeafBatch((LeafNode) present, mutations, offset, path); + } + if (present instanceof ExtensionNode) { + return updateExtensionBatch((ExtensionNode) present, mutations, offset, path); + } + BranchNode branch = (BranchNode) present; + Node[] children = Arrays.copyOf(branch.children, branch.children.length); + boolean changed = false; + List> groups = groupMutations(mutations, offset); + for (int nibble = 0; nibble < groups.size(); nibble++) { + if (groups.get(nibble).isEmpty()) { + continue; + } + Node previous = children[nibble]; + children[nibble] = updateBatch(previous, groups.get(nibble), offset + 1, + append(path, new byte[]{(byte) nibble})); + changed |= previous != children[nibble]; + } + return changed ? normalizeBranch(children, path) : present; + } + + private Node updateLeafBatch(LeafNode leaf, List mutations, int offset, + byte[] path) { + List entries = new ArrayList<>(mutations.size() + 1); + byte[] leafKey = append(path, leaf.path); + boolean retainLeaf = true; + for (BatchMutation mutation : mutations) { + byte[] key = mutation.nibbles; + if (Arrays.equals(key, leafKey)) { + retainLeaf = false; + } + if (mutation.encodedValue != null) { + entries.add(new Leaf(key, mutation.encodedValue)); + } + } + if (retainLeaf) { + entries.add(new Leaf(leafKey, leaf.value)); + } + if (entries.isEmpty()) { + return null; + } + entries.sort((left, right) -> compareNibbles(left.nibbles, right.nibbles)); + return build(entries, offset); + } + + private Node updateExtensionBatch(ExtensionNode extension, List mutations, + int offset, byte[] path) { + int shared = extension.path.length; + for (BatchMutation mutation : mutations) { + shared = Math.min(shared, + commonPrefix(extension.path, 0, mutation.nibbles, offset)); + } + if (shared == extension.path.length) { + Node child = updateBatch(extension.child, mutations, offset + shared, + append(path, extension.path)); + return child == extension.child ? extension + : normalizeExtension(extension.path, child, path); + } + + byte[] common = Arrays.copyOf(extension.path, shared); + byte[] branchPath = append(path, common); + Node[] children = new Node[16]; + int oldNibble = extension.path[shared]; + byte[] oldSuffix = Arrays.copyOfRange(extension.path, shared + 1, + extension.path.length); + children[oldNibble] = oldSuffix.length == 0 ? extension.child + : new ExtensionNode(oldSuffix, extension.child); + int groupOffset = offset + shared; + List> groups = groupMutations(mutations, groupOffset); + for (int nibble = 0; nibble < groups.size(); nibble++) { + if (groups.get(nibble).isEmpty()) { + continue; + } + children[nibble] = updateBatch(children[nibble], groups.get(nibble), groupOffset + 1, + append(branchPath, new byte[]{(byte) nibble})); + } + Node branch = normalizeBranch(children, branchPath); + return shared == 0 ? branch : normalizeExtension(common, branch, path); + } + + private static Node buildMutations(List mutations, int offset) { + List entries = new ArrayList<>(mutations.size()); + for (BatchMutation mutation : mutations) { + if (mutation.encodedValue != null) { + entries.add(new Leaf(mutation.nibbles, mutation.encodedValue)); + } + } + entries.sort((left, right) -> compareNibbles(left.nibbles, right.nibbles)); + return entries.isEmpty() ? null : build(entries, offset); + } + + private static List> groupMutations( + List mutations, int offset) { + List> groups = new ArrayList<>(16); + for (int nibble = 0; nibble < 16; nibble++) { + groups.add(new ArrayList<>()); + } + for (BatchMutation mutation : mutations) { + groups.get(mutation.nibbles[offset]).add(mutation); + } + return groups; + } + + private static int compareNibbles(byte[] left, byte[] right) { + for (int index = 0; index < Math.min(left.length, right.length); index++) { + int compared = Byte.compare(left[index], right[index]); + if (compared != 0) { + return compared; + } + } + return Integer.compare(left.length, right.length); + } + + private static void cancel(List> futures) { + for (Future future : futures) { + if (!future.isDone()) { + future.cancel(true); + } + } + } + + public synchronized byte[] get(byte[] secureKey) { + byte[] value = leafValue(secureKey(secureKey)); + return value == null ? null : Arrays.copyOf(value, value.length); + } + + /** Reconciles only structurally changed paths and returns the canonical root hash. */ + public synchronized byte[] rootHash() { + if (dirty) { + commit(); + } + return Arrays.copyOf(rootHash, rootHash.length); + } + + public synchronized int size() { + return leafCount; + } + + synchronized int getLastNodePuts() { + return lastNodePuts; + } + + synchronized int getLastNodeDeletes() { + return lastNodeDeletes; + } + + synchronized long getLastCommitPlanNanos() { + return lastCommitPlanNanos; + } + + synchronized long getLastCommitStoreNanos() { + return lastCommitStoreNanos; + } + + synchronized long getLastCommitFinalizeNanos() { + return lastCommitFinalizeNanos; + } + + synchronized long getNodeDecodeCount() { + return nodeDecodeCount.get(); + } + + synchronized long getNodeHashVerifyCount() { + return nodeHashVerifyCount.get(); + } + + synchronized long getHashReferenceCreateCount() { + return hashReferenceCreateCount.get(); + } + + synchronized long getHashReferenceResolveCount() { + return hashReferenceResolveCount.get(); + } + + /** Cumulative count of LeafNode/ExtensionNode/BranchNode creations (RLP encodes). */ + static long nodeCreateCountTotal() { + return NODE_CREATE_COUNT.get(); + } + + /** Cumulative count of canonical RLP encodes, excluding already encoded durable nodes. */ + static long nodeEncodeCountTotal() { + return NODE_ENCODE_COUNT.get(); + } + + /** Cumulative count of actual Keccak computations on node RLP (cache misses). */ + static long nodeKeccakCountTotal() { + return NODE_KECCAK_COUNT.get(); + } + + synchronized List leafEntries() { + Map effective = effectiveLeaves(); + List entries = new ArrayList<>(effective.size()); + for (Map.Entry entry : effective.entrySet()) { + entries.add(new LeafEntry(entry.getKey().copy(), entry.getValue())); + } + return entries; + } + + synchronized Snapshot snapshot() { + rootHash(); + frozen = true; + return new Snapshot(inheritedSnapshot, leaves, materializedNodes, rootNode, rootHash, + leafCount); + } + + static PathMerkleTrie fromSnapshot(PathNodeStore nodeStore, Snapshot snapshot) { + Snapshot parent = Objects.requireNonNull(snapshot, "snapshot"); + PathMerkleTrie trie = new PathMerkleTrie(nodeStore); + trie.inheritedSnapshot = parent; + trie.rootNode = parent.rootNode; + trie.materializedRoot = parent.rootNode; + trie.rootHash = Arrays.copyOf(parent.rootHash, parent.rootHash.length); + trie.leafCount = parent.leafCount; + return trie; + } + + /** Initializes an empty trie from canonical leaves and writes its complete path-node set. */ + synchronized void initializeLeaves(Collection entries) { + importLeaves(entries, "initialized"); + rootNode = buildTree(); + dirty = true; + rootHash(); + } + + /** Restores current leaves and verifies their complete path-node set without repairing it. */ + synchronized void restoreLeaves(Collection entries) { + importLeaves(entries, "restored"); + rootNode = buildTree(); + Map expectedNodes = collectNodes(rootNode); + for (Map.Entry entry : expectedNodes.entrySet()) { + if (!Arrays.equals(nodeStore.get(entry.getKey().bytes), entry.getValue())) { + throw new IllegalStateException("restored leaves do not match persisted path nodes"); + } + } + materializedRoot = rootNode; + indexNodes(rootNode, EMPTY_PATH, materializedNodes); + rootHash = hash(rootNode); + } + + /** Restores only the durable root node; descendants are decoded from path storage on demand. */ + synchronized void restoreRoot(byte[] expectedRoot) { + if (!leaves.isEmpty() || inheritedSnapshot != null || rootNode != null + || materializedRoot != null || dirty) { + throw new IllegalStateException("path trie is not empty before root restoration"); + } + byte[] expected = Arrays.copyOf(Objects.requireNonNull(expectedRoot, "expectedRoot"), + expectedRoot.length); + if (expected.length != SECURE_KEY_LENGTH) { + throw new IllegalArgumentException("expectedRoot must contain exactly 32 bytes"); + } + if (Arrays.equals(expected, Hash.EMPTY_TRIE_HASH)) { + rootHash = expected; + return; + } + byte[] encoded = nodeStore.get(EMPTY_PATH); + nodeHashVerifyCount.incrementAndGet(); + if (encoded == null || !Arrays.equals(Hash.sha3(encoded), expected)) { + throw new IllegalStateException("durable path trie root is missing or corrupt"); + } + installStoredRoot(encoded, expected); + } + + synchronized byte[] restoreRoot() { + byte[] encoded = nodeStore.get(EMPTY_PATH); + if (encoded == null) { + rootHash = Arrays.copyOf(Hash.EMPTY_TRIE_HASH, Hash.EMPTY_TRIE_HASH.length); + return Arrays.copyOf(rootHash, rootHash.length); + } + nodeHashVerifyCount.incrementAndGet(); + byte[] expected = Hash.sha3(encoded); + installStoredRoot(encoded, expected); + return Arrays.copyOf(expected, expected.length); + } + + private void installStoredRoot(byte[] encoded, byte[] expected) { + rootNode = new StoredNode(encoded, EMPTY_PATH, expected); + materializedRoot = rootNode; + rememberMaterialized(rootNode, EMPTY_PATH); + rootHash = Arrays.copyOf(expected, expected.length); + } + + private void importLeaves(Collection entries, String operation) { + if (!leaves.isEmpty() || inheritedSnapshot != null || rootNode != null + || materializedRoot != null || dirty) { + throw new IllegalStateException("path trie is not empty before leaf " + operation); + } + for (LeafEntry entry : Objects.requireNonNull(entries, "entries")) { + LeafEntry present = Objects.requireNonNull(entry, "entry"); + if (leaves.put(secureKey(present.secureKey), + nonEmpty(present.encodedValue, "encodedValue")) != null) { + throw new IllegalArgumentException("duplicate " + operation + " path-state leaf"); + } + leafCount++; + } + } + + /** Explicitly scans every reachable path in the materialized node set without repairing it. */ + public synchronized void verifyAllNodeStore() { + if (dirty) { + throw new IllegalStateException("cannot verify a dirty path trie"); + } + Map expectedNodes = new LinkedHashMap<>(); + collectResolvedNodes(rootNode, EMPTY_PATH, expectedNodes); + for (Map.Entry entry : expectedNodes.entrySet()) { + if (!Arrays.equals(nodeStore.get(entry.getKey().bytes), entry.getValue())) { + throw new IllegalStateException("missing or corrupt materialized path node"); + } + } + if (!Arrays.equals(rootHash, hash(rootNode))) { + throw new IllegalStateException("materialized path root does not match current leaves"); + } + } + + /** Compatibility alias for explicit rebuild, repair, and test callers. */ + public synchronized void verifyNodeStore() { + verifyAllNodeStore(); + } + + private void collectResolvedNodes(Node node, byte[] path, Map nodes) { + if (node == null) { + return; + } + Node present = resolve(node, path); + if (nodes.put(new BytesKey(path), encodedNode(present)) != null) { + throw new IllegalStateException("duplicate path-state node path"); + } + visitChildren(present, path, + (child, childPath) -> collectResolvedNodes(child, childPath, nodes)); + } + + private void commit() { + long startedNanos = System.nanoTime(); + IdentityHashMap retained = new IdentityHashMap<>(); + List additions = new ArrayList<>(); + collectAdditions(rootNode, EMPTY_PATH, retained, additions); + List removals = new ArrayList<>(); + collectRemovals(materializedRoot, retained, removals); + long plannedNanos = System.nanoTime(); + + for (NodePath removal : removals) { + nodeStore.delete(removal.path); + materializedNodes.remove(removal.node); + } + for (NodePath addition : additions) { + nodeStore.put(addition.path, encodedNode(addition.node)); + rememberMaterialized(addition.node, addition.path); + } + long storedNanos = System.nanoTime(); + synchronized (resolvedMaterializedNodes) { + for (Node resolvedReference : resolvedMaterializedNodes.keySet()) { + materializedNodes.remove(resolvedReference); + } + resolvedMaterializedNodes.clear(); + } + lastNodeDeletes = removals.size(); + lastNodePuts = additions.size(); + materializedRoot = rootNode; + rootHash = hash(rootNode); + dirty = false; + long finishedNanos = System.nanoTime(); + lastCommitPlanNanos = plannedNanos - startedNanos; + lastCommitStoreNanos = storedNanos - plannedNanos; + lastCommitFinalizeNanos = finishedNanos - storedNanos; + } + + private void collectAdditions(Node node, byte[] path, + IdentityHashMap retained, List additions) { + if (node == null) { + return; + } + BytesKey oldPath = materializedPath(node); + if (oldPath != null) { + if (!Arrays.equals(oldPath.bytes, path)) { + throw new IllegalStateException("path-local update moved an unchanged subtree"); + } + retained.put(node, Boolean.TRUE); + return; + } + additions.add(new NodePath(node, path)); + visitChildren(node, path, + (child, childPath) -> collectAdditions(child, childPath, retained, additions)); + } + + private void collectRemovals(Node node, IdentityHashMap retained, + List removals) { + if (node == null || retained.containsKey(node)) { + return; + } + Node present = resolvedMaterializedNode(node); + if (retained.containsKey(present)) { + return; + } + BytesKey path = materializedPath(present); + if (path == null) { + throw new IllegalStateException("path-local update lost a materialized node identity"); + } + removals.add(new NodePath(present, path.copy())); + visitChildren(present, path.bytes, + (child, ignored) -> collectRemovals(child, retained, removals)); + } + + private Node buildTree() { + Map effective = effectiveLeaves(); + if (effective.isEmpty()) { + return null; + } + List entries = new ArrayList<>(effective.size()); + for (Map.Entry entry : effective.entrySet()) { + entries.add(new Leaf(toNibbles(entry.getKey().bytes), entry.getValue())); + } + return build(entries, 0); + } + + private byte[] leafValue(BytesKey key) { + if (leaves.containsKey(key)) { + return leaves.get(key); + } + if (inheritedSnapshot != null && inheritedSnapshot.containsLeaf(key)) { + return inheritedSnapshot.leafValue(key); + } + return valueAt(rootNode, toNibbles(key.bytes), 0, EMPTY_PATH); + } + + private Map effectiveLeaves() { + Map effective = new TreeMap<>(UNSIGNED_KEY_COMPARATOR); + if (inheritedSnapshot != null) { + inheritedSnapshot.populateLeaves(effective); + } + applyLeaves(effective, leaves); + return effective; + } + + private BytesKey materializedPath(Node node) { + BytesKey bound = node.materializedPath(); + if (bound != null) { + return bound; + } + if (node instanceof HashRefNode) { + return node.bindMaterializedPath(new BytesKey(((HashRefNode) node).path)); + } + BytesKey path; + synchronized (materializedNodes) { + path = materializedNodes.get(node); + } + if (path == null && inheritedSnapshot != null) { + path = inheritedSnapshot.materializedPath(node); + } + return path == null ? null : node.bindMaterializedPath(path); + } + + private void rememberMaterialized(Node node, byte[] path) { + BytesKey materialized = node.bindMaterializedPath(new BytesKey(path)); + synchronized (materializedNodes) { + materializedNodes.put(node, materialized); + } + } + + private Node retainResolvedReplacement(Node original, Node replacement, byte[] path) { + BytesKey materialized = materializedPath(original); + if (materialized != null) { + if (!Arrays.equals(materialized.bytes, path)) { + throw new IllegalStateException("resolved path trie node moved from its durable path"); + } + rememberMaterialized(replacement, path); + synchronized (resolvedMaterializedNodes) { + resolvedMaterializedNodes.put(original, replacement); + } + } + return replacement; + } + + private Node resolvedMaterializedNode(Node node) { + Node present = node; + synchronized (resolvedMaterializedNodes) { + Node replacement = resolvedMaterializedNodes.get(present); + while (replacement != null && replacement != present) { + present = replacement; + replacement = resolvedMaterializedNodes.get(present); + } + } + return present; + } + + private void requireMutable() { + if (frozen) { + throw new IllegalStateException("path trie is frozen as an immutable parent snapshot"); + } + } + + private static void applyLeaves(Map target, + Map changes) { + for (Map.Entry entry : changes.entrySet()) { + if (entry.getValue() == null) { + target.remove(entry.getKey()); + } else { + target.put(entry.getKey(), entry.getValue()); + } + } + } + + private static Node build(List entries, int depth) { + if (entries.size() == 1) { + Leaf leaf = entries.get(0); + return new LeafNode(Arrays.copyOfRange(leaf.nibbles, depth, leaf.nibbles.length), + leaf.value); + } + int shared = sharedPrefix(entries, depth); + if (shared > 0) { + return new ExtensionNode(Arrays.copyOfRange(entries.get(0).nibbles, depth, + depth + shared), build(entries, depth + shared)); + } + Node[] children = new Node[16]; + int start = 0; + while (start < entries.size()) { + int nibble = entries.get(start).nibbles[depth]; + int end = start + 1; + while (end < entries.size() && entries.get(end).nibbles[depth] == nibble) { + end++; + } + children[nibble] = build(entries.subList(start, end), depth + 1); + start = end; + } + return new BranchNode(children); + } + + private Node update(Node node, byte[] key, int offset, byte[] path, byte[] value) { + if (node == null) { + return value == null ? null + : new LeafNode(Arrays.copyOfRange(key, offset, key.length), value); + } + node = resolve(node, path); + if (node instanceof LeafNode) { + return updateLeaf((LeafNode) node, key, offset, value); + } + if (node instanceof ExtensionNode) { + return updateExtension((ExtensionNode) node, key, offset, path, value); + } + BranchNode branch = (BranchNode) node; + if (offset >= key.length) { + throw new IllegalStateException("secure path ended inside a branch"); + } + int nibble = key[offset]; + Node previous = branch.children[nibble]; + Node changed = update(previous, key, offset + 1, + append(path, new byte[]{(byte) nibble}), value); + if (previous == changed) { + return branch; + } + Node[] children = Arrays.copyOf(branch.children, branch.children.length); + children[nibble] = changed; + return normalizeBranch(children, path); + } + + private static Node updateLeaf(LeafNode leaf, byte[] key, int offset, byte[] value) { + int remaining = key.length - offset; + int shared = commonPrefix(leaf.path, 0, key, offset); + if (shared == leaf.path.length && shared == remaining) { + if (value == null) { + return null; + } + return Arrays.equals(leaf.value, value) ? leaf : new LeafNode(leaf.path, value); + } + if (value == null) { + return leaf; + } + if (shared >= leaf.path.length || shared >= remaining) { + throw new IllegalStateException("fixed secure keys cannot prefix one another"); + } + Node[] children = new Node[16]; + int oldNibble = leaf.path[shared]; + children[oldNibble] = new LeafNode( + Arrays.copyOfRange(leaf.path, shared + 1, leaf.path.length), leaf.value); + int newNibble = key[offset + shared]; + children[newNibble] = new LeafNode( + Arrays.copyOfRange(key, offset + shared + 1, key.length), value); + Node branch = new BranchNode(children); + return shared == 0 ? branch + : new ExtensionNode(Arrays.copyOf(leaf.path, shared), branch); + } + + private Node updateExtension(ExtensionNode extension, byte[] key, int offset, + byte[] path, byte[] value) { + int shared = commonPrefix(extension.path, 0, key, offset); + if (shared == extension.path.length) { + Node changed = update(extension.child, key, offset + shared, + append(path, extension.path), value); + if (changed == extension.child) { + return extension; + } + return normalizeExtension(extension.path, changed, path); + } + if (value == null) { + return extension; + } + Node[] children = new Node[16]; + int oldNibble = extension.path[shared]; + byte[] oldSuffix = Arrays.copyOfRange(extension.path, shared + 1, extension.path.length); + children[oldNibble] = oldSuffix.length == 0 ? extension.child + : new ExtensionNode(oldSuffix, extension.child); + int newNibble = key[offset + shared]; + children[newNibble] = new LeafNode( + Arrays.copyOfRange(key, offset + shared + 1, key.length), value); + Node branch = new BranchNode(children); + return shared == 0 ? branch + : new ExtensionNode(Arrays.copyOf(extension.path, shared), branch); + } + + private Node normalizeBranch(Node[] children, byte[] path) { + int count = 0; + int only = -1; + for (int i = 0; i < children.length; i++) { + if (children[i] != null) { + count++; + only = i; + } + } + if (count == 0) { + return null; + } + if (count > 1) { + return new BranchNode(children); + } + Node child = resolve(children[only], append(path, new byte[]{(byte) only})); + byte[] prefix = new byte[]{(byte) only}; + if (child instanceof LeafNode) { + LeafNode leaf = (LeafNode) child; + return new LeafNode(append(prefix, leaf.path), leaf.value); + } + if (child instanceof ExtensionNode) { + ExtensionNode extension = (ExtensionNode) child; + return new ExtensionNode(append(prefix, extension.path), extension.child); + } + return new ExtensionNode(prefix, child); + } + + private Node normalizeExtension(byte[] extensionPath, Node child, byte[] parentPath) { + if (child == null) { + return null; + } + child = resolve(child, append(parentPath, extensionPath)); + if (child instanceof LeafNode) { + LeafNode leaf = (LeafNode) child; + return new LeafNode(append(extensionPath, leaf.path), leaf.value); + } + if (child instanceof ExtensionNode) { + ExtensionNode extension = (ExtensionNode) child; + return new ExtensionNode(append(extensionPath, extension.path), extension.child); + } + return new ExtensionNode(extensionPath, child); + } + + private static Map collectNodes(Node root) { + Map nodes = new LinkedHashMap<>(); + collectNodes(root, EMPTY_PATH, nodes); + return nodes; + } + + private static void collectNodes(Node node, byte[] path, Map nodes) { + if (node == null) { + return; + } + if (nodes.put(new BytesKey(path), encodedNode(node)) != null) { + throw new IllegalStateException("duplicate path-state node path"); + } + visitChildren(node, path, (child, childPath) -> collectNodes(child, childPath, nodes)); + } + + private static void indexNodes(Node node, byte[] path, + IdentityHashMap indexed) { + if (node == null) { + return; + } + indexed.put(node, node.bindMaterializedPath(new BytesKey(path))); + visitChildren(node, path, (child, childPath) -> indexNodes(child, childPath, indexed)); + } + + private static void visitChildren(Node node, byte[] path, NodeVisitor visitor) { + if (node instanceof ExtensionNode) { + ExtensionNode extension = (ExtensionNode) node; + visitor.visit(extension.child, append(path, extension.path)); + } else if (node instanceof BranchNode) { + BranchNode branch = (BranchNode) node; + for (int i = 0; i < branch.children.length; i++) { + if (branch.children[i] != null) { + visitor.visit(branch.children[i], append(path, new byte[]{(byte) i})); + } + } + } + } + + private static byte[] hash(Node node) { + return node == null ? Arrays.copyOf(Hash.EMPTY_TRIE_HASH, Hash.EMPTY_TRIE_HASH.length) + : nodeHash(node); + } + + private static int sharedPrefix(List entries, int depth) { + int shared = 0; + int keyLength = entries.get(0).nibbles.length; + while (depth + shared < keyLength) { + byte expected = entries.get(0).nibbles[depth + shared]; + for (int i = 1; i < entries.size(); i++) { + if (entries.get(i).nibbles[depth + shared] != expected) { + return shared; + } + } + shared++; + } + return shared; + } + + private static int commonPrefix(byte[] left, int leftOffset, byte[] right, int rightOffset) { + int length = Math.min(left.length - leftOffset, right.length - rightOffset); + int shared = 0; + while (shared < length && left[leftOffset + shared] == right[rightOffset + shared]) { + shared++; + } + return shared; + } + + private byte[] valueAt(Node node, byte[] key, int offset, byte[] path) { + ValueResult result = resolvedValueAt(node, key, offset, path); + if (path.length == 0) { + rootNode = result.node; + } + return result.value; + } + + private ValueResult resolvedValueAt(Node node, byte[] key, int offset, byte[] path) { + if (node == null) { + return new ValueResult(null, null); + } + Node present = resolve(node, path); + if (present instanceof LeafNode) { + LeafNode leaf = (LeafNode) present; + int remaining = key.length - offset; + byte[] value = remaining == leaf.path.length + && commonPrefix(leaf.path, 0, key, offset) == remaining + ? Arrays.copyOf(leaf.value, leaf.value.length) : null; + return new ValueResult(value, present); + } + if (present instanceof ExtensionNode) { + ExtensionNode extension = (ExtensionNode) present; + int shared = commonPrefix(extension.path, 0, key, offset); + if (shared != extension.path.length) { + return new ValueResult(null, present); + } + ValueResult child = resolvedValueAt(extension.child, key, offset + shared, + append(path, extension.path)); + if (child.node == extension.child) { + return new ValueResult(child.value, present); + } + Node replacement = retainResolvedReplacement(present, + new ExtensionNode(extension.path, child.node), path); + return new ValueResult(child.value, replacement); + } + BranchNode branch = (BranchNode) present; + if (offset >= key.length) { + return new ValueResult(null, present); + } + int nibble = key[offset]; + ValueResult child = resolvedValueAt(branch.children[nibble], key, offset + 1, + append(path, new byte[]{(byte) nibble})); + if (child.node == branch.children[nibble]) { + return new ValueResult(child.value, present); + } + Node[] children = Arrays.copyOf(branch.children, branch.children.length); + children[nibble] = child.node; + Node replacement = retainResolvedReplacement(present, new BranchNode(children), path); + return new ValueResult(child.value, replacement); + } + + private Node resolve(Node node, byte[] expectedPath) { + if (node instanceof HashRefNode) { + HashRefNode reference = (HashRefNode) node; + if (!Arrays.equals(reference.path, expectedPath)) { + throw new IllegalStateException("hashed path trie node moved from its durable path"); + } + byte[] encoded = nodeStore.get(reference.path); + nodeHashVerifyCount.incrementAndGet(); + if (encoded == null || !Arrays.equals(Hash.sha3(encoded), reference.expectedHash)) { + throw new IllegalStateException("path-state durable child is missing or corrupt"); + } + hashReferenceResolveCount.incrementAndGet(); + StoredNode stored = new StoredNode(encoded, reference.path, reference.expectedHash); + rememberMaterialized(stored, reference.path); + return retainResolvedReplacement(reference, resolve(stored, expectedPath), expectedPath); + } + if (!(node instanceof StoredNode)) { + return node; + } + StoredNode stored = (StoredNode) node; + nodeDecodeCount.incrementAndGet(); + byte[] storedEncoding = encodedNode(node); + if (!Arrays.equals(stored.path, expectedPath)) { + throw new IllegalStateException("stored path trie node moved from its durable path"); + } + List elements = decodeList(storedEncoding); + Node decoded; + if (elements.size() == 17) { + if (!elements.get(16).isEmptyString()) { + throw new IllegalStateException("path-state branch contains a value slot"); + } + Node[] children = new Node[16]; + for (int index = 0; index < children.length; index++) { + byte[] childPath = append(expectedPath, new byte[]{(byte) index}); + children[index] = storedChild(elements.get(index), childPath); + } + decoded = new BranchNode(children); + } else if (elements.size() == 2 && !elements.get(0).list) { + Compact compact = decodeCompact(elements.get(0).payload); + if (compact.leaf) { + if (elements.get(1).list) { + throw new IllegalStateException("path-state leaf value must be an RLP item"); + } + decoded = new LeafNode(compact.path, elements.get(1).payload); + } else { + if (compact.path.length == 0) { + throw new IllegalStateException("path-state extension path must not be empty"); + } + decoded = new ExtensionNode(compact.path, + requiredStoredChild(elements.get(1), append(expectedPath, compact.path))); + } + } else { + throw new IllegalStateException("path-state durable node has invalid arity"); + } + decoded.bindEncoded(storedEncoding); + if (stored.knownHash != null) { + decoded.bindCachedHash(stored.knownHash); + } + return retainResolvedReplacement(stored, decoded, expectedPath); + } + + private Node requiredStoredChild(RlpElement reference, byte[] path) { + Node child = storedChild(reference, path); + if (child == null) { + throw new IllegalStateException("path-state extension has an empty child"); + } + return child; + } + + private Node storedChild(RlpElement reference, byte[] path) { + if (reference.isEmptyString()) { + return null; + } + if (reference.list) { + if (reference.encoded.length >= SECURE_KEY_LENGTH) { + throw new IllegalStateException("path-state inline child is not canonically encoded"); + } + Node stored = new StoredNode(reference.encoded, path); + rememberMaterialized(stored, path); + return stored; + } + if (reference.payload.length != SECURE_KEY_LENGTH) { + throw new IllegalStateException("path-state child hash must contain exactly 32 bytes"); + } + if (!lazyHashReferences) { + byte[] encoded = nodeStore.get(path); + nodeHashVerifyCount.incrementAndGet(); + if (encoded == null || !Arrays.equals(Hash.sha3(encoded), reference.payload)) { + throw new IllegalStateException("path-state durable child is missing or corrupt"); + } + Node stored = new StoredNode(encoded, path, reference.payload); + rememberMaterialized(stored, path); + return stored; + } + hashReferenceCreateCount.incrementAndGet(); + return new HashRefNode(path, reference.payload); + } + + private static List decodeList(byte[] encoded) { + RlpElement root = decodeElement(encoded, 0); + if (!root.list || root.encoded.length != encoded.length) { + throw new IllegalStateException("path-state durable node must be one RLP list"); + } + List elements = new ArrayList<>(); + int offset = 0; + while (offset < root.payload.length) { + RlpElement child = decodeElement(root.payload, offset); + elements.add(child); + offset += child.encoded.length; + } + return elements; + } + + private static RlpElement decodeElement(byte[] encoded, int offset) { + if (offset < 0 || offset >= encoded.length) { + throw new IllegalStateException("path-state RLP offset is invalid"); + } + int marker = encoded[offset] & 0xff; + if (marker < 0x80) { + return new RlpElement(false, new byte[]{encoded[offset]}, new byte[]{encoded[offset]}); + } + boolean list = marker >= 0xc0; + int shortBase = list ? 0xc0 : 0x80; + int longBase = list ? 0xf7 : 0xb7; + int payloadOffset; + int payloadLength; + if (marker <= longBase) { + payloadOffset = offset + 1; + payloadLength = marker - shortBase; + if (!list && payloadLength == 1 && payloadOffset < encoded.length + && (encoded[payloadOffset] & 0xff) < 0x80) { + throw new IllegalStateException("path-state RLP item uses a non-canonical short form"); + } + } else { + int lengthBytes = marker - longBase; + if (lengthBytes > Integer.BYTES || offset + 1 + lengthBytes > encoded.length) { + throw new IllegalStateException("path-state RLP length is invalid"); + } + if (encoded[offset + 1] == 0) { + throw new IllegalStateException("path-state RLP length has leading zeroes"); + } + payloadOffset = offset + 1 + lengthBytes; + payloadLength = 0; + for (int index = offset + 1; index < payloadOffset; index++) { + payloadLength = Math.addExact(Math.multiplyExact(payloadLength, 256), + encoded[index] & 0xff); + } + if (payloadLength < 56) { + throw new IllegalStateException("path-state RLP uses a non-canonical long form"); + } + } + int end = Math.addExact(payloadOffset, payloadLength); + if (end > encoded.length) { + throw new IllegalStateException("path-state RLP payload exceeds its node"); + } + return new RlpElement(list, Arrays.copyOfRange(encoded, offset, end), + Arrays.copyOfRange(encoded, payloadOffset, end)); + } + + private static Compact decodeCompact(byte[] encoded) { + if (encoded.length == 0) { + throw new IllegalStateException("path-state compact path must not be empty"); + } + int flags = (encoded[0] >>> 4) & 0x0f; + if (flags > 3) { + throw new IllegalStateException("path-state compact path has invalid flags"); + } + boolean odd = (flags & 1) != 0; + byte[] path = new byte[encoded.length * 2 - (odd ? 1 : 2)]; + int target = 0; + if (odd) { + path[target++] = (byte) (encoded[0] & 0x0f); + } else if ((encoded[0] & 0x0f) != 0) { + throw new IllegalStateException("path-state compact path has non-zero padding"); + } + for (int index = 1; index < encoded.length; index++) { + path[target++] = (byte) ((encoded[index] >>> 4) & 0x0f); + path[target++] = (byte) (encoded[index] & 0x0f); + } + return new Compact((flags & 2) != 0, path); + } + + private static byte[] encodedNode(Node node) { + return Objects.requireNonNull(node, "node").encoded(); + } + + private static byte[] nodeHash(Node node) { + return node instanceof HashRefNode + ? Arrays.copyOf(((HashRefNode) node).expectedHash, SECURE_KEY_LENGTH) + : node.cachedHash(); + } + + private static byte[] nodeReference(Node node) { + if (node instanceof HashRefNode) { + return rlpItem(((HashRefNode) node).expectedHash); + } + byte[] encoded = encodedNode(node); + return encoded.length < SECURE_KEY_LENGTH ? encoded : rlpItem(node.cachedHash()); + } + + private static byte[] compactPath(byte[] nibbles, boolean leaf) { + int length = nibbles.length; + boolean odd = (length & 1) != 0; + byte[] compact = new byte[1 + length / 2]; + int flag = leaf ? 2 : 0; + int source = 0; + if (odd) { + compact[0] = (byte) ((flag + 1) << 4 | nibbles[source++]); + } else { + compact[0] = (byte) (flag << 4); + } + int target = 1; + while (source < nibbles.length) { + compact[target++] = (byte) (nibbles[source++] << 4 | nibbles[source++]); + } + return compact; + } + + private static byte[] toNibbles(byte[] key) { + byte[] nibbles = new byte[key.length * 2]; + for (int i = 0; i < key.length; i++) { + nibbles[i * 2] = (byte) ((key[i] >>> 4) & 0x0f); + nibbles[i * 2 + 1] = (byte) (key[i] & 0x0f); + } + return nibbles; + } + + private static byte[] append(byte[] first, byte[] second) { + byte[] result = Arrays.copyOf(first, first.length + second.length); + System.arraycopy(second, 0, result, first.length, second.length); + return result; + } + + private static BytesKey secureKey(byte[] value) { + byte[] key = Objects.requireNonNull(value, "secureKey"); + if (key.length != SECURE_KEY_LENGTH) { + throw new IllegalArgumentException("secureKey must be exactly 32 bytes"); + } + return new BytesKey(key); + } + + private static byte[] nonEmpty(byte[] value, String name) { + byte[] copy = Arrays.copyOf(Objects.requireNonNull(value, name), value.length); + if (copy.length == 0) { + throw new IllegalArgumentException(name + " must not be empty"); + } + return copy; + } + + private static byte[] rlpItem(byte[] raw) { + if (raw.length == 1 && (raw[0] & 0xff) < 0x80) { + return Arrays.copyOf(raw, raw.length); + } + return concatenate(rlpLength(raw.length, 0x80, 0xb7), raw); + } + + private static byte[] rlpList(byte[]... encodedItems) { + int payloadLength = 0; + for (byte[] item : encodedItems) { + payloadLength = Math.addExact(payloadLength, item.length); + } + byte[] prefix = rlpLength(payloadLength, 0xc0, 0xf7); + byte[] result = Arrays.copyOf(prefix, Math.addExact(prefix.length, payloadLength)); + int offset = prefix.length; + for (byte[] item : encodedItems) { + System.arraycopy(item, 0, result, offset, item.length); + offset += item.length; + } + return result; + } + + private static byte[] rlpLength(int length, int shortOffset, int longOffset) { + if (length < 56) { + return new byte[]{(byte) (shortOffset + length)}; + } + int lengthOfLength = (Integer.SIZE - Integer.numberOfLeadingZeros(length) + 7) / 8; + byte[] encoded = new byte[lengthOfLength + 1]; + encoded[0] = (byte) (longOffset + lengthOfLength); + int remaining = length; + for (int i = lengthOfLength; i > 0; i--) { + encoded[i] = (byte) remaining; + remaining >>>= Byte.SIZE; + } + return encoded; + } + + private static byte[] concatenate(byte[] first, byte[] second) { + byte[] result = Arrays.copyOf(first, first.length + second.length); + System.arraycopy(second, 0, result, first.length, second.length); + return result; + } + + private abstract static class Node { + + private volatile byte[] encoded; + private volatile byte[] hash; + private volatile BytesKey materializedPath; + + private Node(byte[] encoded) { + this.encoded = encoded; + } + + private byte[] encoded() { + byte[] cached = encoded; + if (cached == null) { + synchronized (this) { + cached = encoded; + if (cached == null) { + cached = encodeNode(); + if (cached == null) { + throw new IllegalStateException( + "unresolved path-state hash reference has no node encoding"); + } + encoded = cached; + NODE_ENCODE_COUNT.incrementAndGet(); + } + } + } + return cached; + } + + protected final void encodeEagerlyUnlessDeferred() { + if (!DEFER_NODE_ENCODING.get()) { + encoded(); + } + } + + private void bindEncoded(byte[] knownEncoding) { + byte[] known = Arrays.copyOf(Objects.requireNonNull(knownEncoding, "knownEncoding"), + knownEncoding.length); + synchronized (this) { + if (encoded == null) { + encoded = known; + } else if (!Arrays.equals(encoded, known)) { + throw new IllegalStateException("path-state durable node is not canonically encoded"); + } + } + } + + protected abstract byte[] encodeNode(); + + private byte[] cachedHash() { + byte[] cached = hash; + if (cached == null) { + synchronized (this) { + cached = hash; + if (cached == null) { + NODE_KECCAK_COUNT.incrementAndGet(); + cached = Hash.sha3(encodedNode(this)); + hash = cached; + } + } + } + return cached; + } + + private BytesKey materializedPath() { + return materializedPath; + } + + private BytesKey bindMaterializedPath(BytesKey path) { + BytesKey present = materializedPath; + if (present == null) { + synchronized (this) { + present = materializedPath; + if (present == null) { + materializedPath = path; + return path; + } + } + } + if (!Arrays.equals(present.bytes, path.bytes)) { + throw new IllegalStateException("path-local node identity moved between durable paths"); + } + return present; + } + + private void bindCachedHash(byte[] knownHash) { + byte[] known = Arrays.copyOf(Objects.requireNonNull(knownHash, "knownHash"), + knownHash.length); + if (known.length != SECURE_KEY_LENGTH) { + throw new IllegalArgumentException("knownHash must contain exactly 32 bytes"); + } + synchronized (this) { + if (hash == null) { + hash = known; + } else if (!Arrays.equals(hash, known)) { + throw new IllegalStateException("path-state node cached hash does not match storage"); + } + } + } + + } + + private static final class StoredNode extends Node { + + private final byte[] path; + private final byte[] knownHash; + + private StoredNode(byte[] encoded, byte[] path) { + this(encoded, path, null); + } + + private StoredNode(byte[] encoded, byte[] path, byte[] knownHash) { + super(Arrays.copyOf(Objects.requireNonNull(encoded, "encoded"), encoded.length)); + this.path = Arrays.copyOf(Objects.requireNonNull(path, "path"), path.length); + this.knownHash = knownHash == null ? null : Arrays.copyOf(knownHash, knownHash.length); + if (this.knownHash != null && this.knownHash.length != SECURE_KEY_LENGTH) { + throw new IllegalArgumentException("knownHash must contain exactly 32 bytes"); + } + } + + @Override + protected byte[] encodeNode() { + throw new IllegalStateException("stored path-state node lost its encoding"); + } + } + + private static final class HashRefNode extends Node { + + private final byte[] path; + private final byte[] expectedHash; + + private HashRefNode(byte[] path, byte[] expectedHash) { + super(null); + this.path = Arrays.copyOf(Objects.requireNonNull(path, "path"), path.length); + this.expectedHash = Arrays.copyOf(Objects.requireNonNull(expectedHash, "expectedHash"), + expectedHash.length); + if (this.expectedHash.length != SECURE_KEY_LENGTH) { + throw new IllegalArgumentException("expectedHash must contain exactly 32 bytes"); + } + } + + @Override + protected byte[] encodeNode() { + return null; + } + } + + private static final class LeafNode extends Node { + + private final byte[] path; + private final byte[] value; + + private LeafNode(byte[] path, byte[] value) { + super(null); + this.path = Arrays.copyOf(path, path.length); + this.value = Arrays.copyOf(value, value.length); + NODE_CREATE_COUNT.incrementAndGet(); + encodeEagerlyUnlessDeferred(); + } + + @Override + protected byte[] encodeNode() { + return rlpList(rlpItem(compactPath(path, true)), rlpItem(value)); + } + + } + + private static final class ExtensionNode extends Node { + + private final byte[] path; + private final Node child; + + private ExtensionNode(byte[] path, Node child) { + super(null); + if (path.length == 0) { + throw new IllegalArgumentException("extension path must not be empty"); + } + this.path = Arrays.copyOf(path, path.length); + this.child = Objects.requireNonNull(child, "child"); + NODE_CREATE_COUNT.incrementAndGet(); + encodeEagerlyUnlessDeferred(); + } + + @Override + protected byte[] encodeNode() { + return rlpList(rlpItem(compactPath(path, false)), nodeReference(child)); + } + } + + private static final class BranchNode extends Node { + + private final Node[] children; + + private BranchNode(Node[] children) { + super(null); + if (children.length != 16) { + throw new IllegalArgumentException("branch must contain 16 child slots"); + } + this.children = Arrays.copyOf(children, children.length); + NODE_CREATE_COUNT.incrementAndGet(); + encodeEagerlyUnlessDeferred(); + } + + @Override + protected byte[] encodeNode() { + List encodedChildren = new ArrayList<>(Collections.nCopies(17, EMPTY_RLP_ITEM)); + for (int i = 0; i < children.length; i++) { + if (children[i] != null) { + encodedChildren.set(i, nodeReference(children[i])); + } + } + return rlpList(encodedChildren.toArray(new byte[encodedChildren.size()][])); + } + } + + /** Defers node encoding while one participant folds a block's sorted mutation batch. */ + private static final class BlockNodeEncodingScope implements AutoCloseable { + + private final boolean previous; + + private BlockNodeEncodingScope(boolean previous) { + this.previous = previous; + } + + private static BlockNodeEncodingScope open() { + boolean previous = DEFER_NODE_ENCODING.get(); + DEFER_NODE_ENCODING.set(Boolean.TRUE); + return new BlockNodeEncodingScope(previous); + } + + @Override + public void close() { + if (previous) { + DEFER_NODE_ENCODING.set(Boolean.TRUE); + } else { + DEFER_NODE_ENCODING.remove(); + } + } + } + + private static final class Leaf { + + private final byte[] nibbles; + private final byte[] value; + + private Leaf(byte[] nibbles, byte[] value) { + this.nibbles = nibbles; + this.value = value; + } + } + + private static final class NodePath { + + private final Node node; + private final byte[] path; + + private NodePath(Node node, byte[] path) { + this.node = node; + this.path = Arrays.copyOf(path, path.length); + } + } + + private static final class ValueResult { + + private final byte[] value; + private final Node node; + + private ValueResult(byte[] value, Node node) { + this.value = value; + this.node = node; + } + } + + static final class BatchMutation { + + private final byte[] secureKey; + private final byte[] nibbles; + private final byte[] encodedValue; + private final boolean previousValueKnown; + private final byte[] previousEncodedValue; + + BatchMutation(byte[] secureKey, byte[] encodedValue) { + this(secureKey, encodedValue, false, null); + } + + BatchMutation(byte[] secureKey, byte[] encodedValue, boolean previousValueKnown, + byte[] previousEncodedValue) { + this.secureKey = PathMerkleTrie.secureKey(secureKey).copy(); + nibbles = toNibbles(this.secureKey); + this.encodedValue = encodedValue == null ? null + : nonEmpty(encodedValue, "encodedValue"); + this.previousValueKnown = previousValueKnown; + this.previousEncodedValue = previousEncodedValue == null ? null + : nonEmpty(previousEncodedValue, "previousEncodedValue"); + } + } + + private static final class SubtreeResult { + + private final Node node; + private final Map previous; + private final Map changed; + + private SubtreeResult(Node node, Map previous, + Map changed) { + this.node = node; + this.previous = previous; + this.changed = changed; + } + } + + private static final class RlpElement { + + private final boolean list; + private final byte[] encoded; + private final byte[] payload; + + private RlpElement(boolean list, byte[] encoded, byte[] payload) { + this.list = list; + this.encoded = encoded; + this.payload = payload; + } + + private boolean isEmptyString() { + return !list && payload.length == 0; + } + } + + private static final class Compact { + + private final boolean leaf; + private final byte[] path; + + private Compact(boolean leaf, byte[] path) { + this.leaf = leaf; + this.path = path; + } + } + + @FunctionalInterface + private interface NodeVisitor { + + void visit(Node node, byte[] path); + } + + static final class Snapshot { + + private final Snapshot parent; + private final Map leaves; + private final IdentityHashMap materializedNodes; + private final Node rootNode; + private final byte[] rootHash; + private final int leafCount; + + private Snapshot(Snapshot parent, Map leaves, + IdentityHashMap materializedNodes, Node rootNode, byte[] rootHash, + int leafCount) { + this.parent = parent; + this.leaves = leaves; + this.materializedNodes = materializedNodes; + this.rootNode = rootNode; + this.rootHash = Arrays.copyOf(rootHash, rootHash.length); + this.leafCount = leafCount; + } + + private byte[] leafValue(BytesKey key) { + if (leaves.containsKey(key)) { + return leaves.get(key); + } + return parent == null ? null : parent.leafValue(key); + } + + private boolean containsLeaf(BytesKey key) { + return leaves.containsKey(key) || parent != null && parent.containsLeaf(key); + } + + private void populateLeaves(Map target) { + if (parent != null) { + parent.populateLeaves(target); + } + applyLeaves(target, leaves); + } + + private BytesKey materializedPath(Node node) { + BytesKey bound = node.materializedPath(); + if (bound != null) { + return bound; + } + BytesKey path = materializedNodes.get(node); + if (path == null && parent != null) { + path = parent.materializedPath(node); + } + return path == null ? null : node.bindMaterializedPath(path); + } + + byte[] rootHash() { + return Arrays.copyOf(rootHash, rootHash.length); + } + + Snapshot detach() { + Map effective = new TreeMap<>(UNSIGNED_KEY_COMPARATOR); + populateLeaves(effective); + // Materialized nodes bind their durable path on creation. Re-indexing the reachable graph + // duplicates the complete resolved trie and can exhaust the heap while compacting parents. + return new Snapshot(null, effective, new IdentityHashMap<>(), rootNode, rootHash, leafCount); + } + + Snapshot reparent(Snapshot newParent) { + return new Snapshot(Objects.requireNonNull(newParent, "newParent"), leaves, + materializedNodes, rootNode, rootHash, leafCount); + } + + int depth() { + int depth = 0; + Snapshot cursor = this; + while (cursor != null) { + depth++; + cursor = cursor.parent; + } + return depth; + } + + int materializedIndexSize() { + return materializedNodes.size(); + } + } + + static final class LeafEntry { + + private final byte[] secureKey; + private final byte[] encodedValue; + + LeafEntry(byte[] secureKey, byte[] encodedValue) { + this.secureKey = Arrays.copyOf(Objects.requireNonNull(secureKey, "secureKey"), + secureKey.length); + this.encodedValue = Arrays.copyOf(Objects.requireNonNull(encodedValue, "encodedValue"), + encodedValue.length); + } + + byte[] getSecureKey() { + return Arrays.copyOf(secureKey, secureKey.length); + } + + byte[] getEncodedValue() { + return Arrays.copyOf(encodedValue, encodedValue.length); + } + } + + private static final class BytesKey { + + private final byte[] bytes; + + private BytesKey(byte[] bytes) { + this.bytes = Arrays.copyOf(bytes, bytes.length); + } + + private byte[] copy() { + return Arrays.copyOf(bytes, bytes.length); + } + + @Override + public boolean equals(Object other) { + return this == other || other instanceof BytesKey + && Arrays.equals(bytes, ((BytesKey) other).bytes); + } + + @Override + public int hashCode() { + return Arrays.hashCode(bytes); + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/stateroot/PathNodeStore.java b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathNodeStore.java new file mode 100644 index 00000000000..899c8684035 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathNodeStore.java @@ -0,0 +1,17 @@ +package org.tron.core.db2.stateroot; + +/** + * Minimal path-addressed node boundary for the TASK-016 Merkle Patricia trie. + * + *

Paths contain one byte per nibble, each in the range {@code 0..15}; the empty path identifies + * the root node. Implementations must copy mutable input and output arrays at their ownership + * boundary. + */ +public interface PathNodeStore { + + byte[] get(byte[] path); + + void put(byte[] path, byte[] encodedNode); + + void delete(byte[] path); +} diff --git a/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateAsyncPrepareHead.java b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateAsyncPrepareHead.java new file mode 100644 index 00000000000..d261998c76b --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateAsyncPrepareHead.java @@ -0,0 +1,200 @@ +package org.tron.core.db2.stateroot; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Objects; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.TimeUnit; +import lombok.extern.slf4j.Slf4j; +import org.tron.core.db2.archive.BlockSnapshotMeta; + +/** + * Benchmark-only ordered worker that removes PathState preparation from the PushBlock thread. + * + *

The queue is bounded and enqueue blocks when the worker cannot sustain the input rate. This + * owner deliberately returns no Snapshot delta, so it must not cross a Snapshot flush boundary. + */ +@Slf4j(topic = "DB") +public final class PathStateAsyncPrepareHead implements PathStateHead { + + private static final int DEFAULT_QUEUE_CAPACITY = 64; + + private final PathStateHead delegate; + private final BlockingQueue queue; + private final Thread worker; + private Work pending; + private volatile PathStateRootMetadata completed; + private volatile Throwable failure; + private volatile boolean closed; + + public PathStateAsyncPrepareHead(PathStateHead delegate) throws IOException { + this(delegate, DEFAULT_QUEUE_CAPACITY); + } + + PathStateAsyncPrepareHead(PathStateHead delegate, int queueCapacity) throws IOException { + this.delegate = Objects.requireNonNull(delegate, "delegate"); + if (queueCapacity <= 0) { + throw new IllegalArgumentException("async PathState queue capacity must be positive"); + } + queue = new ArrayBlockingQueue<>(queueCapacity); + completed = delegate.getHead(); + worker = new Thread(this::run, "path-state-async-prepare"); + worker.setDaemon(true); + worker.start(); + } + + @Override + public synchronized PathStateSnapshotDelta prepareSnapshotDelta(BlockSnapshotMeta meta, + PathStateBlockTransition transition) throws IOException { + requireHealthy(); + if (pending != null) { + throw new IOException("async PathState transition is already pending enqueue"); + } + pending = new Work(Objects.requireNonNull(meta, "meta"), + Objects.requireNonNull(transition, "transition")); + return null; + } + + @Override + public synchronized PathStateRootMetadata advance(PathStateBlockTransition transition) + throws IOException { + requireHealthy(); + if (pending == null || pending.transition != Objects.requireNonNull(transition, "transition")) { + throw new IOException("async PathState publication differs from captured transition"); + } + Work admitted = pending; + pending = null; + long startedNanos = System.nanoTime(); + try { + queue.put(admitted); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IOException("async PathState enqueue interrupted", interrupted); + } + logger.info("Path-state async enqueued: head={}, queueDepth={}, enqueueMicros={}", + transition.getBlockNumber(), queue.size(), + TimeUnit.NANOSECONDS.toMicros(System.nanoTime() - startedNanos)); + return copy(completed); + } + + @Override + public synchronized PathStateRootMetadata rewindTo(long blockNumber, byte[] blockHash) + throws IOException { + requireHealthy(); + if (pending != null || !queue.isEmpty()) { + throw new IOException("async PathState rewind requires an empty benchmark queue"); + } + completed = delegate.rewindTo(blockNumber, blockHash); + return copy(completed); + } + + @Override + public PathStateRootMetadata flushBaseThrough(long blockNumber, byte[] blockHash) + throws IOException { + awaitThrough(blockNumber, blockHash); + return delegate.flushBaseThrough(blockNumber, blockHash); + } + + @Override + public synchronized byte[] preview(PathStateBlockTransition transition) { + return null; + } + + @Override + public synchronized PathStateRootMetadata getHead() throws IOException { + requireHealthy(); + return copy(completed); + } + + private void awaitThrough(long blockNumber, byte[] blockHash) throws IOException { + byte[] expectedHash = Arrays.copyOf(Objects.requireNonNull(blockHash, "blockHash"), + blockHash.length); + long deadline = System.nanoTime() + TimeUnit.MINUTES.toNanos(5); + while (true) { + requireHealthy(); + PathStateRootMetadata current = completed; + if (current.getBlockNumber() == blockNumber + && Arrays.equals(current.getBlockHash(), expectedHash)) { + return; + } + if (current.getBlockNumber() > blockNumber || System.nanoTime() >= deadline) { + throw new IOException("async PathState worker did not reach requested flush target"); + } + try { + Thread.sleep(10L); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IOException("async PathState wait interrupted", interrupted); + } + } + } + + private void run() { + while (!closed || !queue.isEmpty()) { + try { + Work work = queue.poll(100L, TimeUnit.MILLISECONDS); + if (work == null) { + continue; + } + long startedNanos = System.nanoTime(); + delegate.prepareSnapshotDelta(work.meta, work.transition); + PathStateRootMetadata advanced = delegate.advance(work.transition); + completed = advanced; + logger.info("Path-state async prepared: head={}, queueDepth={}, serviceMs={}", + advanced.getBlockNumber(), queue.size(), + TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedNanos)); + } catch (InterruptedException interrupted) { + if (!closed) { + failure = interrupted; + Thread.currentThread().interrupt(); + } + return; + } catch (IOException | RuntimeException currentFailure) { + failure = currentFailure; + logger.error("Path-state async worker failed", currentFailure); + return; + } + } + } + + private void requireHealthy() throws IOException { + if (closed) { + throw new IOException("async PathState owner is closed"); + } + if (failure != null) { + throw new IOException("async PathState worker failed", failure); + } + } + + @Override + public void close() throws IOException { + closed = true; + try { + worker.join(TimeUnit.SECONDS.toMillis(30)); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IOException("async PathState close interrupted", interrupted); + } finally { + if (worker.isAlive()) { + worker.interrupt(); + } + delegate.close(); + } + } + + private static PathStateRootMetadata copy(PathStateRootMetadata metadata) { + return PathStateRootMetadata.decode(metadata.encode()); + } + + private static final class Work { + + private final BlockSnapshotMeta meta; + private final PathStateBlockTransition transition; + + private Work(BlockSnapshotMeta meta, PathStateBlockTransition transition) { + this.meta = meta; + this.transition = transition; + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateBaseCompaction.java b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateBaseCompaction.java new file mode 100644 index 00000000000..9bc593c9e80 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateBaseCompaction.java @@ -0,0 +1,255 @@ +package org.tron.core.db2.stateroot; + +import java.io.IOException; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; +import java.util.stream.Stream; +import org.tron.core.db2.stateroot.PathStateRootMetadata.Kind; + +/** Crash-recoverable materialization of a canonical layer prefix into the single durable BASE. */ +public final class PathStateBaseCompaction { + + public static final String INTENT_FILE = "BASE_FLUSH_INTENT"; + static final String NEXT_DIRECTORY = "base.next"; + static final String PREVIOUS_DIRECTORY = "base.previous"; + + private final PathStateStoreManifest manifest; + private final PathStateLayerLimits limits; + private final FaultHook faultHook; + private final Path root; + private final Path intentPath; + private final Path basePath; + private final Path nextPath; + private final Path previousPath; + + public PathStateBaseCompaction(PathStateStoreManifest manifest, + PathStateLayerLimits limits) { + this(manifest, limits, stage -> { }); + } + + PathStateBaseCompaction(PathStateStoreManifest manifest, PathStateLayerLimits limits, + FaultHook faultHook) { + this.manifest = Objects.requireNonNull(manifest, "manifest"); + this.limits = Objects.requireNonNull(limits, "limits"); + this.faultHook = Objects.requireNonNull(faultHook, "faultHook"); + this.root = manifest.getDirectory(); + this.intentPath = root.resolve(INTENT_FILE); + this.basePath = manifest.getBaseDirectory(); + this.nextPath = root.resolve(NEXT_DIRECTORY); + this.previousPath = root.resolve(PREVIOUS_DIRECTORY); + } + + /** Materializes canonical layers through an exact non-head target, one prefix layer at a time. */ + public synchronized PathStateRootMetadata compactThrough(long blockNumber, byte[] blockHash) + throws IOException { + byte[] targetHash = Arrays.copyOf(Objects.requireNonNull(blockHash, "blockHash"), + blockHash.length); + recover(); + while (true) { + PathStateCurrentStore currentStore = new PathStateCurrentStore(manifest); + PathStateRootMetadata base = loadBase(); + if (base.getBlockNumber() == blockNumber + && Arrays.equals(base.getBlockHash(), targetHash)) { + return base; + } + PathStateRootMetadata head = currentStore.current(); + PathStateRootMetadata target = currentStore.findAncestor(blockNumber, targetHash, limits); + if (target.getKind() != Kind.LAYER || target.getBlockNumber() >= head.getBlockNumber()) { + throw new IOException("path-state base flush must retain a newer reversible head"); + } + PathStateRootMetadata first = currentStore.firstLayerAfterBaseToward(target, limits); + PathStateRootMetadata replacement = asBase(first); + PathStateMetadataFile.publishImmutable(intentPath, replacement); + faultHook.after(Stage.AFTER_INTENT); + finish(replacement); + } + } + + /** Completes an interrupted one-layer BASE replacement and becomes a zero-action retry. */ + public synchronized RecoveryAction recover() throws IOException { + if (!Files.exists(intentPath, LinkOption.NOFOLLOW_LINKS)) { + if (Files.exists(nextPath, LinkOption.NOFOLLOW_LINKS) + || Files.exists(previousPath, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("path-state base flush has orphan replacement directories"); + } + return RecoveryAction.NONE; + } + PathStateRootMetadata replacement = requireBase(PathStateMetadataFile.load(intentPath)); + finish(replacement); + return RecoveryAction.COMPLETED_COMPACTION; + } + + private void finish(PathStateRootMetadata replacement) throws IOException { + PathStateRootMetadata installed = metadataIfPresent(basePath); + if (installed == null || !same(installed, replacement)) { + if (Files.exists(previousPath, LinkOption.NOFOLLOW_LINKS)) { + if (Files.exists(basePath, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("path-state base flush has ambiguous directory authority"); + } + verifyPreparedNext(replacement); + } else { + if (installed == null) { + throw new IOException("path-state base flush lost the previous BASE"); + } + deleteDirectory(nextPath); + buildNext(replacement); + faultHook.after(Stage.AFTER_NEXT); + moveDirectory(basePath, previousPath); + faultHook.after(Stage.AFTER_OLD_BASE); + } + moveDirectory(nextPath, basePath); + faultHook.after(Stage.AFTER_BASE); + } + verifyInstalledBase(replacement); + deleteDirectory(manifest.getLayerDirectory( + replacement.getBlockNumber(), replacement.getBlockHash())); + faultHook.after(Stage.AFTER_LAYER_RETIRE); + deleteDirectory(previousPath); + faultHook.after(Stage.AFTER_PREVIOUS_RETIRE); + PathStateMetadataFile.deleteDurable(intentPath); + faultHook.after(Stage.AFTER_RETIRE); + new PathStateCurrentStore(manifest).current(); + } + + private void buildNext(PathStateRootMetadata replacement) throws IOException { + Path layerPath = manifest.getLayerDirectory( + replacement.getBlockNumber(), replacement.getBlockHash()); + PathStateRootMetadata layer = PathStateMetadataFile.load( + layerPath.resolve(PathStateCurrentStore.METADATA_FILE)); + if (layer.getKind() != Kind.LAYER + || layer.getBlockNumber() != replacement.getBlockNumber() + || !Arrays.equals(layer.getBlockHash(), replacement.getBlockHash()) + || !Arrays.equals(layer.getStateRoot(), replacement.getStateRoot())) { + throw new IOException("path-state base flush source layer identity mismatch"); + } + Files.createDirectory(nextPath); + try (PathStateNodeStoreSet source = PathStateNodeStoreSet.openPublished(manifest, layer); + PathStateNodeStoreSet destination = PathStateNodeStoreSet.beginBaseAt(manifest, nextPath)) { + PathStateRoot sourceRoot = source.createRoot(); + sourceRoot.verifyAllNodeStores(); + List leaves = new ArrayList<>(source.leafRecords()); + PathStateRoot destinationRoot = destination.initializeBase(leaves, layer.getStateRoot()); + if (!Arrays.equals(destinationRoot.rootHash(), replacement.getStateRoot())) { + throw new IOException("path-state replacement BASE root mismatch"); + } + destination.commit(replacement); + } + PathStateMetadataFile.publishImmutable( + nextPath.resolve(PathStateCurrentStore.METADATA_FILE), replacement); + verifyPreparedNext(replacement); + } + + private void verifyPreparedNext(PathStateRootMetadata replacement) throws IOException { + PathStateMetadataFile.requireExact( + nextPath.resolve(PathStateCurrentStore.METADATA_FILE), replacement); + try (PathStateNodeStoreSet stores = PathStateNodeStoreSet.beginBaseAt(manifest, nextPath)) { + PathStateRoot restored = stores.createRoot(); + if (!Arrays.equals(restored.rootHash(), replacement.getStateRoot())) { + throw new IOException("path-state prepared BASE root mismatch"); + } + restored.verifyAllNodeStores(); + } + } + + private void verifyInstalledBase(PathStateRootMetadata replacement) throws IOException { + PathStateMetadataFile.requireExact( + basePath.resolve(PathStateCurrentStore.METADATA_FILE), replacement); + try (PathStateNodeStoreSet stores = PathStateNodeStoreSet.openBase(manifest)) { + PathStateRoot restored = stores.createRoot(); + if (!Arrays.equals(restored.rootHash(), replacement.getStateRoot())) { + throw new IOException("path-state installed BASE root mismatch"); + } + restored.verifyAllNodeStores(); + } + } + + private PathStateRootMetadata loadBase() throws IOException { + return requireBase(PathStateMetadataFile.load( + basePath.resolve(PathStateCurrentStore.METADATA_FILE))); + } + + private PathStateRootMetadata metadataIfPresent(Path directory) throws IOException { + Path metadata = directory.resolve(PathStateCurrentStore.METADATA_FILE); + return Files.exists(metadata, LinkOption.NOFOLLOW_LINKS) + ? PathStateMetadataFile.load(metadata) : null; + } + + private PathStateRootMetadata asBase(PathStateRootMetadata layer) { + return PathStateRootMetadata.base(layer.getBlockNumber(), layer.getBlockHash(), + layer.getParentHash(), layer.getTimestamp(), layer.getPhase(), + layer.getFormatDigest(), layer.getStateRoot(), layer.getPayloadDigest()); + } + + private PathStateRootMetadata requireBase(PathStateRootMetadata metadata) throws IOException { + if (metadata.getKind() != Kind.BASE + || !Arrays.equals(metadata.getFormatDigest(), manifest.getIdentityDigest())) { + throw new IOException("path-state base flush metadata identity mismatch"); + } + return metadata; + } + + private void moveDirectory(Path source, Path target) throws IOException { + try { + Files.move(source, target, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException unsupported) { + throw new IOException("path-state base flush requires atomic directory move", unsupported); + } + PathStateMetadataFile.syncDirectory(root); + } + + private void deleteDirectory(Path directory) throws IOException { + if (!Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) { + return; + } + if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS) + || Files.isSymbolicLink(directory)) { + throw new IOException("path-state base flush refuses non-directory: " + directory); + } + List entries = new ArrayList<>(); + try (Stream paths = Files.walk(directory)) { + paths.forEach(entries::add); + } + for (Path entry : entries) { + if (Files.isSymbolicLink(entry)) { + throw new IOException("path-state base flush refuses symbolic links: " + entry); + } + } + entries.sort(Comparator.reverseOrder()); + for (Path entry : entries) { + Files.deleteIfExists(entry); + } + PathStateMetadataFile.syncDirectory(root); + } + + private static boolean same(PathStateRootMetadata left, PathStateRootMetadata right) { + return Arrays.equals(left.encode(), right.encode()); + } + + public enum RecoveryAction { + NONE, + COMPLETED_COMPACTION + } + + enum Stage { + AFTER_INTENT, + AFTER_NEXT, + AFTER_OLD_BASE, + AFTER_BASE, + AFTER_LAYER_RETIRE, + AFTER_PREVIOUS_RETIRE, + AFTER_RETIRE + } + + @FunctionalInterface + interface FaultHook { + void after(Stage stage) throws IOException; + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateBasePublication.java b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateBasePublication.java new file mode 100644 index 00000000000..6c5fb06abb6 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateBasePublication.java @@ -0,0 +1,136 @@ +package org.tron.core.db2.stateroot; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Objects; +import org.tron.core.db2.stateroot.PathStateRootMetadata.Kind; + +/** Crash-recoverable publication boundary for the first durable path-state BASE. */ +public final class PathStateBasePublication { + + public static final String INTENT_FILE = "INTENT"; + + private final PathStateStoreManifest manifest; + private final PathStateCurrentStore currentStore; + private final Path baseDirectory; + private final Path intentPath; + private final Path metadataPath; + private final FaultHook faultHook; + + public PathStateBasePublication(PathStateStoreManifest manifest) { + this(manifest, stage -> { }); + } + + PathStateBasePublication(PathStateStoreManifest manifest, FaultHook faultHook) { + this.manifest = Objects.requireNonNull(manifest, "manifest"); + this.currentStore = new PathStateCurrentStore(manifest); + this.baseDirectory = manifest.getBaseDirectory(); + this.intentPath = baseDirectory.resolve(INTENT_FILE); + this.metadataPath = baseDirectory.resolve(PathStateCurrentStore.METADATA_FILE); + this.faultHook = Objects.requireNonNull(faultHook, "faultHook"); + } + + /** Publishes a first BASE through intent, native marker, metadata, CURRENT, and retire stages. */ + public synchronized PathStateRootMetadata publish(PathStateNodeStoreSet stores, + PathStateRootMetadata metadata) throws IOException { + PathStateNodeStoreSet nodeStores = Objects.requireNonNull(stores, "stores"); + Path expectedNodes = baseDirectory.resolve(PathStateNodeStoreSet.NODES_DIRECTORY) + .toAbsolutePath().normalize(); + if (!expectedNodes.equals(nodeStores.getDirectory().toAbsolutePath().normalize())) { + throw new IllegalArgumentException("path-state BASE node database directory mismatch"); + } + PathStateRootMetadata base = requireBase(metadata); + if (currentStore.isInitialized()) { + throw new IOException("path-state initial BASE is already published"); + } + PathStateMetadataFile.publishImmutable(intentPath, base); + faultHook.after(Stage.AFTER_INTENT); + nodeStores.commit(base); + faultHook.after(Stage.AFTER_NODE_PROGRESS); + PathStateMetadataFile.publishImmutable(metadataPath, base); + faultHook.after(Stage.AFTER_METADATA); + PathStateRootMetadata current = currentStore.publishBase(base); + faultHook.after(Stage.AFTER_CURRENT); + PathStateMetadataFile.deleteDurable(intentPath); + faultHook.after(Stage.AFTER_RETIRE); + return current; + } + + /** Reconciles one interrupted first-BASE publication and returns the durable action taken. */ + public synchronized RecoveryAction recover() throws IOException { + if (!Files.exists(intentPath, LinkOption.NOFOLLOW_LINKS)) { + verifySettledState(); + return RecoveryAction.NONE; + } + PathStateRootMetadata intent = requireBase(PathStateMetadataFile.load(intentPath)); + PathStateRootMetadata progress = PathStateNodeStoreSet.loadProgress(baseDirectory, manifest); + if (progress == null) { + if (Files.exists(metadataPath, LinkOption.NOFOLLOW_LINKS) || currentStore.isInitialized()) { + throw new IOException("path-state BASE authority exists without native progress"); + } + PathStateMetadataFile.deleteDurable(intentPath); + return RecoveryAction.ROLLED_BACK_INTENT; + } + requireSame(intent, progress, "path-state BASE intent and native progress differ"); + currentStore.publishBase(intent); + PathStateMetadataFile.deleteDurable(intentPath); + verifySettledState(); + return RecoveryAction.COMPLETED_PUBLICATION; + } + + private void verifySettledState() throws IOException { + PathStateRootMetadata progress = PathStateNodeStoreSet.loadProgress(baseDirectory, manifest); + boolean metadataExists = Files.exists(metadataPath, LinkOption.NOFOLLOW_LINKS); + if (!currentStore.isInitialized()) { + if (metadataExists || progress != null) { + throw new IOException("path-state BASE has orphaned authority without CURRENT"); + } + return; + } + currentStore.current(); + PathStateRootMetadata metadata = requireBase(PathStateMetadataFile.load(metadataPath)); + if (progress == null) { + throw new IOException("path-state BASE native progress is missing"); + } + requireSame(metadata, progress, "path-state BASE metadata and native progress differ"); + } + + private PathStateRootMetadata requireBase(PathStateRootMetadata metadata) throws IOException { + PathStateRootMetadata base = Objects.requireNonNull(metadata, "metadata"); + if (base.getKind() != Kind.BASE + || !Arrays.equals(base.getFormatDigest(), manifest.getIdentityDigest())) { + throw new IOException("path-state BASE publication identity mismatch"); + } + return base; + } + + private static void requireSame(PathStateRootMetadata expected, + PathStateRootMetadata actual, String error) throws IOException { + if (!Arrays.equals(expected.encode(), actual.encode())) { + throw new IOException(error); + } + } + + public enum RecoveryAction { + NONE, + ROLLED_BACK_INTENT, + COMPLETED_PUBLICATION + } + + enum Stage { + AFTER_INTENT, + AFTER_NODE_PROGRESS, + AFTER_METADATA, + AFTER_CURRENT, + AFTER_RETIRE + } + + @FunctionalInterface + interface FaultHook { + + void after(Stage stage) throws IOException; + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateBlockTransition.java b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateBlockTransition.java new file mode 100644 index 00000000000..cdb36797578 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateBlockTransition.java @@ -0,0 +1,248 @@ +package org.tron.core.db2.stateroot; + +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import org.tron.core.db2.stateroot.PathStateCanonicalizer.P66Phase; +import org.tron.core.db2.stateroot.PathStateParticipantDescriptor.StoreIdentity; + +/** + * Immutable, origin-free evidence for one block-final path-state transition. + * + *

The transition intentionally does not distinguish locally generated, normally pushed, or + * fork-reapplied blocks. Once execution reaches the metadata-aware block commit boundary, equal + * block identity, phase, and canonical mutations produce equal evidence. This type does not + * capture sessions, apply a trie, publish a root, or retain historical state. + */ +public final class PathStateBlockTransition { + + public static final int FORMAT_VERSION = 1; + public static final int HASH_LENGTH = 32; + + private static final byte DELETE_TAG = 0; + private static final byte PUT_TAG = 1; + private static final byte[] DOMAIN = + "java-tron/path-state/block-transition".getBytes(StandardCharsets.US_ASCII); + private static final Comparator MUTATION_ORDER = (left, right) -> { + int storeOrder = Integer.compare(left.store.getStoreId(), right.store.getStoreId()); + return storeOrder != 0 ? storeOrder + : compareUnsigned(left.mutation.getCanonicalKey(), right.mutation.getCanonicalKey()); + }; + + private final long blockNumber; + private final byte[] blockHash; + private final byte[] parentHash; + private final long timestamp; + private final P66Phase phase; + private final List mutations; + private final byte[] payloadDigest; + + public PathStateBlockTransition(long blockNumber, byte[] blockHash, byte[] parentHash, + long timestamp, P66Phase phase, Collection mutations) { + if (blockNumber < 0) { + throw new IllegalArgumentException("blockNumber must not be negative"); + } + this.blockNumber = blockNumber; + this.blockHash = copyHash(blockHash, "blockHash"); + this.parentHash = copyHash(parentHash, "parentHash"); + this.timestamp = timestamp; + this.phase = Objects.requireNonNull(phase, "phase"); + List prepared = prepare(mutations); + List canonical = new ArrayList<>(prepared.size()); + for (PreparedMutation mutation : prepared) { + canonical.add(mutation.mutation); + } + this.mutations = Collections.unmodifiableList(canonical); + this.payloadDigest = sha256(encode(prepared)); + } + + public long getBlockNumber() { + return blockNumber; + } + + public byte[] getBlockHash() { + return Arrays.copyOf(blockHash, blockHash.length); + } + + public byte[] getParentHash() { + return Arrays.copyOf(parentHash, parentHash.length); + } + + public long getTimestamp() { + return timestamp; + } + + public P66Phase getPhase() { + return phase; + } + + public String getScopeId() { + return PathStateParticipantDescriptor.SCOPE_ID; + } + + public List getMutations() { + return mutations; + } + + public byte[] getPayloadDigest() { + return Arrays.copyOf(payloadDigest, payloadDigest.length); + } + + private List prepare(Collection supplied) { + PathStateParticipantDescriptor descriptor = PathStateParticipantDescriptor.current(); + List prepared = new ArrayList<>(); + Set unique = new LinkedHashSet<>(); + for (PathStateMutation candidate : Objects.requireNonNull(supplied, "mutations")) { + PathStateMutation mutation = copyMutation(Objects.requireNonNull(candidate, "mutation")); + StoreIdentity store = descriptor.require(mutation.getDbName()); + byte[] key = mutation.getCanonicalKey(); + if (!unique.add(new MutationKey(store.getStoreId(), key))) { + throw new IllegalArgumentException("duplicate path-state mutation key"); + } + prepared.add(new PreparedMutation(store, mutation)); + } + prepared.sort(MUTATION_ORDER); + return prepared; + } + + private byte[] encode(List prepared) { + try { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + DataOutputStream output = new DataOutputStream(bytes); + writeBytes(output, DOMAIN); + output.writeShort(FORMAT_VERSION); + writeBytes(output, PathStateParticipantDescriptor.SCOPE_ID + .getBytes(StandardCharsets.UTF_8)); + output.writeByte(phaseTag(phase)); + output.writeLong(blockNumber); + output.write(blockHash); + output.write(parentHash); + output.writeLong(timestamp); + output.writeInt(prepared.size()); + for (PreparedMutation current : prepared) { + PathStateMutation mutation = current.mutation; + output.writeInt(current.store.getStoreId()); + writeBytes(output, current.store.getDbName().getBytes(StandardCharsets.UTF_8)); + output.writeByte(mutation.isDelete() ? DELETE_TAG : PUT_TAG); + writeSizedBytes(output, mutation.getCanonicalKey()); + byte[] value = mutation.getCanonicalValue(); + if (value == null) { + output.writeInt(-1); + } else { + writeSizedBytes(output, value); + } + } + output.flush(); + return bytes.toByteArray(); + } catch (IOException impossible) { + throw new IllegalStateException("in-memory path-state transition encoding failed", impossible); + } + } + + private static PathStateMutation copyMutation(PathStateMutation mutation) { + PathStateMutation copy = mutation.isDelete() + ? PathStateMutation.delete(mutation.getDbName(), mutation.getCanonicalKey()) + : PathStateMutation.put(mutation.getDbName(), mutation.getCanonicalKey(), + mutation.getCanonicalValue()); + return mutation.isPreviousValueKnown() + ? copy.withPreviousPhysicalValue(mutation.getPreviousPhysicalValue()) : copy; + } + + private static byte[] copyHash(byte[] value, String name) { + byte[] hash = Arrays.copyOf(Objects.requireNonNull(value, name), value.length); + if (hash.length != HASH_LENGTH) { + throw new IllegalArgumentException(name + " must be exactly " + HASH_LENGTH + " bytes"); + } + return hash; + } + + private static void writeBytes(DataOutputStream output, byte[] value) throws IOException { + if (value.length > 0xffff) { + throw new IllegalArgumentException("identity field exceeds unsigned-short length"); + } + output.writeShort(value.length); + output.write(value); + } + + private static void writeSizedBytes(DataOutputStream output, byte[] value) throws IOException { + output.writeInt(value.length); + output.write(value); + } + + private static int phaseTag(P66Phase phase) { + switch (phase) { + case P66_OFF: + return 0; + case P66_ACTIVATION: + return 1; + case P66_ON: + return 2; + default: + throw new IllegalArgumentException("unknown P66 phase: " + phase); + } + } + + private static byte[] sha256(byte[] value) { + try { + return MessageDigest.getInstance("SHA-256").digest(value); + } catch (NoSuchAlgorithmException impossible) { + throw new IllegalStateException("SHA-256 is unavailable", impossible); + } + } + + private static int compareUnsigned(byte[] left, byte[] right) { + for (int i = 0; i < Math.min(left.length, right.length); i++) { + int comparison = Integer.compare(left[i] & 0xff, right[i] & 0xff); + if (comparison != 0) { + return comparison; + } + } + return Integer.compare(left.length, right.length); + } + + private static final class PreparedMutation { + + private final StoreIdentity store; + private final PathStateMutation mutation; + + private PreparedMutation(StoreIdentity store, PathStateMutation mutation) { + this.store = store; + this.mutation = mutation; + } + } + + private static final class MutationKey { + + private final int storeId; + private final byte[] key; + + private MutationKey(int storeId, byte[] key) { + this.storeId = storeId; + this.key = Arrays.copyOf(key, key.length); + } + + @Override + public boolean equals(Object other) { + return this == other || other instanceof MutationKey + && storeId == ((MutationKey) other).storeId + && Arrays.equals(key, ((MutationKey) other).key); + } + + @Override + public int hashCode() { + return 31 * storeId + Arrays.hashCode(key); + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateCanonicalizer.java b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateCanonicalizer.java new file mode 100644 index 00000000000..6d564a52ed4 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateCanonicalizer.java @@ -0,0 +1,350 @@ +package org.tron.core.db2.stateroot; + +import com.google.protobuf.InvalidProtocolBufferException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import org.tron.protos.Protocol.Account; +import org.tron.protos.contract.AssetIssueContractOuterClass.AssetIssueContract; +import org.tron.protos.contract.SmartContractOuterClass.SmartContract.ABI; + +/** Canonical key/value boundary for the current path-state root. */ +public final class PathStateCanonicalizer { + + public static final String PHYSICAL_RAW = "physical-raw/v1"; + public static final String P66_ACCOUNT = "p66-account/v1"; + public static final String P66_ACCOUNT_ASSET = "p66-account-asset/v1"; + public static final String STORAGE_ROW = "storage-physical-row/v1"; + public static final String ABI_PROTOBUF = "abi-protobuf/v1"; + public static final String LEGACY_ASSET_PROTOBUF = "asset-name-protobuf/v1"; + public static final String ASSET_V2_PROTOBUF = "asset-id-protobuf/v1"; + + private static final int ADDRESS_LENGTH = 21; + private static final int STORAGE_KEY_LENGTH = 32; + private static final int STORAGE_VALUE_LENGTH = 32; + private static final int BALANCE_LENGTH = Long.BYTES; + private static final int STORE_FORMAT_VERSION = 1; + + private final PathStateParticipantDescriptor descriptor; + private final Map formats; + + public PathStateCanonicalizer() { + descriptor = PathStateParticipantDescriptor.current(); + LinkedHashMap configured = new LinkedHashMap<>(); + for (PathStateParticipantDescriptor.StoreIdentity identity : descriptor.getStores()) { + configured.put(identity.getDbName(), new StoreFormat(identity.getDbName(), PHYSICAL_RAW)); + } + configure(configured, "account", P66_ACCOUNT); + configure(configured, "account-asset", P66_ACCOUNT_ASSET); + configure(configured, "storage-row", STORAGE_ROW); + configure(configured, "abi", ABI_PROTOBUF); + configure(configured, "asset-issue", LEGACY_ASSET_PROTOBUF); + configure(configured, "asset-issue-v2", ASSET_V2_PROTOBUF); + formats = Collections.unmodifiableMap(configured); + } + + public StoreFormat requireFormat(String dbName) { + StoreFormat format = formats.get(Objects.requireNonNull(dbName, "dbName")); + if (format == null) { + throw new IllegalArgumentException("unknown path-state database: " + dbName); + } + return format; + } + + /** Creates the approved exact-27 scope after every Store has an explicit format identity. */ + public PathStateParticipantScope participantScope() { + List participants = new ArrayList<>(); + for (PathStateParticipantDescriptor.StoreIdentity identity : descriptor.getStores()) { + StoreFormat format = requireFormat(identity.getDbName()); + participants.add(new PathStateParticipant(identity.getStoreId(), identity.getDbName(), + format.getStoreFormatVersion())); + } + return new PathStateParticipantScope(participants); + } + + /** Canonicalizes one physical PRESENT value for the target P66 phase. */ + public PathStateMutation put(P66Phase phase, String dbName, byte[] physicalKey, + byte[] rawValue) { + P66Phase target = Objects.requireNonNull(phase, "phase"); + String name = requireFormat(dbName).getDbName(); + byte[] key = canonicalKey(target, name, physicalKey); + byte[] value = canonicalValue(target, name, key, + Objects.requireNonNull(rawValue, "rawValue")); + return PathStateMutation.put(name, key, value); + } + + /** Canonicalizes one physical delete; no PRESENT value is synthesized. */ + public PathStateMutation delete(P66Phase phase, String dbName, byte[] physicalKey) { + P66Phase target = Objects.requireNonNull(phase, "phase"); + String name = requireFormat(dbName).getDbName(); + return PathStateMutation.delete(name, canonicalKey(target, name, physicalKey)); + } + + /** Encodes a P66 direct balance; zero is represented only by a delete mutation. */ + public PathStateMutation accountAsset(P66Phase phase, byte[] address, String tokenId, + long balance) { + P66Phase target = Objects.requireNonNull(phase, "phase"); + if (!target.directAssetsEnabled()) { + throw new IllegalArgumentException("P66-off state must not contain account-asset rows"); + } + byte[] key = accountAssetKey(address, tokenId); + return balance == 0 ? PathStateMutation.delete("account-asset", key) + : PathStateMutation.put("account-asset", key, + ByteBuffer.allocate(BALANCE_LENGTH).putLong(balance).array()); + } + + /** Requires the physical Account representation admitted for a rebuild target phase. */ + public void requireSnapshotAccountLayout(P66Phase phase, byte[] physicalKey, + byte[] rawValue) { + P66Phase target = Objects.requireNonNull(phase, "phase"); + byte[] key = copy(physicalKey, "physicalKey"); + requireLength(key, ADDRESS_LENGTH, "account key"); + Account account = parseAccount(key, rawValue); + if (target.directAssetsEnabled()) { + if (account.getAssetOptimized() && (!account.getAssetMap().isEmpty() + || !account.getAssetV2Map().isEmpty())) { + throw new IllegalArgumentException("P66-on snapshot Account layout is mixed"); + } + } else if (account.getAssetOptimized()) { + throw new IllegalArgumentException("P66-off snapshot Account layout is mixed"); + } + } + + /** Projects a lazily migrated P66 Account's embedded V2 balances into canonical direct rows. */ + public List projectSnapshotAccountAssets(P66Phase phase, + byte[] physicalKey, byte[] rawValue) { + P66Phase target = Objects.requireNonNull(phase, "phase"); + byte[] key = copy(physicalKey, "physicalKey"); + requireLength(key, ADDRESS_LENGTH, "account key"); + Account account = parseAccount(key, rawValue); + if (!target.directAssetsEnabled() || account.getAssetOptimized()) { + return Collections.emptyList(); + } + List> balances = new ArrayList<>( + account.getAssetV2Map().entrySet()); + balances.sort(Map.Entry.comparingByKey()); + List projected = new ArrayList<>(balances.size()); + for (Map.Entry balance : balances) { + projected.add(accountAsset(target, key, balance.getKey(), balance.getValue())); + } + return Collections.unmodifiableList(projected); + } + + private static void configure(Map formats, String dbName, String codecId) { + if (formats.replace(dbName, new StoreFormat(dbName, codecId)) == null) { + throw new IllegalStateException("missing path-state format participant: " + dbName); + } + } + + private static byte[] canonicalKey(P66Phase phase, String dbName, byte[] physicalKey) { + byte[] key = copy(physicalKey, "physicalKey"); + switch (dbName) { + case "account": + case "abi": + requireLength(key, ADDRESS_LENGTH, dbName + " key"); + break; + case "account-asset": + if (!phase.directAssetsEnabled()) { + throw new IllegalArgumentException("P66-off state must not contain account-asset rows"); + } + decodeAccountAssetKey(key); + break; + case "asset-issue-v2": + requireCanonicalDecimal(key, "asset-issue-v2 key"); + break; + case "storage-row": + requireLength(key, STORAGE_KEY_LENGTH, "storage-row key"); + break; + default: + break; + } + return key; + } + + private static byte[] canonicalValue(P66Phase phase, String dbName, byte[] physicalKey, + byte[] rawValue) { + byte[] value = Arrays.copyOf(rawValue, rawValue.length); + switch (dbName) { + case "account": + return canonicalAccount(phase, physicalKey, value); + case "account-asset": + requireLength(value, BALANCE_LENGTH, "account-asset value"); + if (ByteBuffer.wrap(value).getLong() == 0) { + throw new IllegalArgumentException("account-asset zero balance must be ABSENT"); + } + return value; + case "storage-row": + requireLength(value, STORAGE_VALUE_LENGTH, "storage-row value"); + if (isZero(value)) { + throw new IllegalArgumentException("storage-row zero word must be ABSENT"); + } + return value; + case "abi": + parseAbi(value); + return value; + case "asset-issue": + requireAssetKey(physicalKey, value, false); + return value; + case "asset-issue-v2": + requireAssetKey(physicalKey, value, true); + return value; + default: + return value; + } + } + + private static byte[] canonicalAccount(P66Phase phase, byte[] physicalKey, byte[] rawValue) { + Account account = parseAccount(physicalKey, rawValue); + if (!phase.directAssetsEnabled()) { + if (account.getAssetOptimized()) { + throw new IllegalArgumentException("P66-off Account must not use direct asset layout"); + } + return rawValue; + } + return account.toBuilder() + .setAssetOptimized(true) + .clearAsset() + .clearAssetV2() + .build() + .toByteArray(); + } + + private static Account parseAccount(byte[] physicalKey, byte[] rawValue) { + Account account; + try { + account = Account.parseFrom(Objects.requireNonNull(rawValue, "rawValue")); + } catch (InvalidProtocolBufferException invalid) { + throw new IllegalArgumentException("account value is not valid protobuf", invalid); + } + if (!Arrays.equals(physicalKey, account.getAddress().toByteArray())) { + throw new IllegalArgumentException("account protobuf address does not match physical key"); + } + return account; + } + + private static void parseAbi(byte[] value) { + try { + ABI.parseFrom(value); + } catch (InvalidProtocolBufferException invalid) { + throw new IllegalArgumentException("abi value is not valid protobuf", invalid); + } + } + + private static void requireAssetKey(byte[] physicalKey, byte[] value, boolean v2) { + AssetIssueContract asset; + try { + asset = AssetIssueContract.parseFrom(value); + } catch (InvalidProtocolBufferException invalid) { + throw new IllegalArgumentException("asset value is not valid protobuf", invalid); + } + byte[] expected = v2 ? asset.getId().getBytes(StandardCharsets.US_ASCII) + : asset.getName().toByteArray(); + if (v2) { + requireCanonicalDecimal(expected, "asset protobuf ID"); + } + if (!Arrays.equals(physicalKey, expected)) { + throw new IllegalArgumentException("asset protobuf identity does not match physical key"); + } + } + + private static byte[] accountAssetKey(byte[] address, String tokenId) { + byte[] canonicalAddress = Arrays.copyOf(Objects.requireNonNull(address, "address"), + address.length); + requireLength(canonicalAddress, ADDRESS_LENGTH, "account-asset address"); + byte[] token = Objects.requireNonNull(tokenId, "tokenId").getBytes(StandardCharsets.US_ASCII); + if (!tokenId.equals(new String(token, StandardCharsets.US_ASCII))) { + throw new IllegalArgumentException("account-asset token ID must be ASCII decimal"); + } + requireCanonicalDecimal(token, "account-asset token ID"); + return ByteBuffer.allocate(canonicalAddress.length + token.length) + .put(canonicalAddress) + .put(token) + .array(); + } + + private static void decodeAccountAssetKey(byte[] key) { + if (key.length <= ADDRESS_LENGTH) { + throw new IllegalArgumentException("account-asset key is too short"); + } + requireCanonicalDecimal(Arrays.copyOfRange(key, ADDRESS_LENGTH, key.length), + "account-asset token ID"); + } + + private static void requireCanonicalDecimal(byte[] value, String name) { + if (value.length == 0 || value.length > 1 && value[0] == '0') { + throw new IllegalArgumentException(name + " is not canonical decimal"); + } + for (byte digit : value) { + if (digit < '0' || digit > '9') { + throw new IllegalArgumentException(name + " is not canonical decimal"); + } + } + } + + private static byte[] copyNonEmpty(byte[] value, String name) { + byte[] copy = Arrays.copyOf(Objects.requireNonNull(value, name), value.length); + if (copy.length == 0) { + throw new IllegalArgumentException(name + " must not be empty"); + } + return copy; + } + + private static byte[] copy(byte[] value, String name) { + return Arrays.copyOf(Objects.requireNonNull(value, name), value.length); + } + + private static void requireLength(byte[] value, int length, String name) { + if (value.length != length) { + throw new IllegalArgumentException(name + " must be exactly " + length + " bytes"); + } + } + + private static boolean isZero(byte[] value) { + for (byte current : value) { + if (current != 0) { + return false; + } + } + return true; + } + + public enum P66Phase { + P66_OFF, + P66_ACTIVATION, + P66_ON; + + private boolean directAssetsEnabled() { + return this != P66_OFF; + } + } + + /** Immutable codec identity committed through a participant's Store format version. */ + public static final class StoreFormat { + + private final String dbName; + private final String codecId; + + private StoreFormat(String dbName, String codecId) { + this.dbName = dbName; + this.codecId = codecId; + } + + public String getDbName() { + return dbName; + } + + public String getCodecId() { + return codecId; + } + + public int getStoreFormatVersion() { + return STORE_FORMAT_VERSION; + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateCatchUpQueue.java b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateCatchUpQueue.java new file mode 100644 index 00000000000..02cfa78d178 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateCatchUpQueue.java @@ -0,0 +1,270 @@ +package org.tron.core.db2.stateroot; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayDeque; +import java.util.Arrays; +import java.util.Deque; +import java.util.Objects; +import org.tron.core.db2.stateroot.PathStateRebuildCoordinator.SnapshotIdentity; +import org.tron.core.db2.stateroot.PathStateRootMetadata.Kind; + +/** + * Bounded handoff from a fixed rebuild snapshot to normal block-final path-state processing. + * + *

Transitions captured while BASE(P0) is rebuilding remain in memory and must form one exact + * block/hash chain above P0. BASE publication and the switch to draining share one synchronized + * gate, so capture cannot pass through that boundary unnoticed. Once the queue becomes READY, + * callers must send later transitions through the normal direct-apply path. + */ +public final class PathStateCatchUpQueue { + + private final SnapshotIdentity snapshot; + private final int maxTransitions; + private final long maxMutations; + private final long maxBytes; + private final DrainHook drainHook; + private final Deque transitions = new ArrayDeque<>(); + private State state = State.CAPTURING; + private long queuedMutations; + private long queuedBytes; + private PathStateRootMetadata readyHead; + + public PathStateCatchUpQueue(SnapshotIdentity snapshot, int maxTransitions, + long maxMutations, long maxBytes) { + this(snapshot, maxTransitions, maxMutations, maxBytes, transition -> { }); + } + + PathStateCatchUpQueue(SnapshotIdentity snapshot, int maxTransitions, + long maxMutations, long maxBytes, DrainHook drainHook) { + this.snapshot = Objects.requireNonNull(snapshot, "snapshot"); + if (maxTransitions <= 0 || maxMutations <= 0 || maxBytes <= 0) { + throw new IllegalArgumentException("path-state catch-up limits must be positive"); + } + this.maxTransitions = maxTransitions; + this.maxMutations = maxMutations; + this.maxBytes = maxBytes; + this.drainHook = Objects.requireNonNull(drainHook, "drainHook"); + } + + /** Queues a rebuilding transition, or explicitly returns it to the post-READY direct path. */ + public synchronized CaptureDisposition capture(PathStateBlockTransition transition) + throws IOException { + PathStateBlockTransition admitted = Objects.requireNonNull(transition, "transition"); + if (state == State.FAILED) { + throw new IOException("path-state catch-up queue has failed"); + } + if (state == State.READY) { + return CaptureDisposition.DIRECT_APPLY; + } + requireNext(admitted); + long nextMutations; + long nextBytes; + try { + nextMutations = Math.addExact(queuedMutations, admitted.getMutations().size()); + nextBytes = Math.addExact(queuedBytes, logicalBytes(admitted)); + } catch (ArithmeticException overflow) { + return failOverflow(overflow); + } + if (transitions.size() >= maxTransitions || nextMutations > maxMutations + || nextBytes > maxBytes) { + return failOverflow(null); + } + transitions.addLast(admitted); + queuedMutations = nextMutations; + queuedBytes = nextBytes; + return CaptureDisposition.QUEUED; + } + + public synchronized State getState() { + return state; + } + + public synchronized int getQueuedTransitions() { + return transitions.size(); + } + + public synchronized long getQueuedMutations() { + return queuedMutations; + } + + public synchronized long getQueuedBytes() { + return queuedBytes; + } + + public synchronized PathStateRootMetadata getReadyHead() { + return readyHead; + } + + synchronized void admitSnapshot(SnapshotIdentity identity) throws IOException { + requireCapturing(); + if (!snapshot.sameAs(Objects.requireNonNull(identity, "identity"))) { + state = State.FAILED; + throw new IOException("path-state catch-up snapshot identity mismatch"); + } + } + + synchronized PathStateRootMetadata publishBase(SnapshotIdentity identity, + PathStateRootMetadata base, BasePublisher publisher) throws IOException { + requireCapturing(); + requireBase(identity, base); + try { + PathStateRootMetadata published = Objects.requireNonNull( + publisher.publish(), "published BASE"); + if (!Arrays.equals(base.encode(), published.encode())) { + throw new IOException("path-state catch-up published BASE identity mismatch"); + } + state = State.DRAINING; + readyHead = published; + return published; + } catch (IOException | RuntimeException failure) { + state = State.FAILED; + throw failure; + } + } + + PathStateRootMetadata drain(PathStateStoreManifest manifest, PathStateLayerLimits limits) + throws IOException { + PathStateStoreManifest admittedManifest = Objects.requireNonNull(manifest, "manifest"); + PathStateLayerLimits admittedLimits = Objects.requireNonNull(limits, "limits"); + while (true) { + PathStateBlockTransition transition; + PathStateRootMetadata parent; + synchronized (this) { + if (state == State.FAILED) { + throw new IOException("path-state catch-up queue has failed"); + } + if (state == State.READY) { + return readyHead; + } + if (state != State.DRAINING) { + throw new IOException("path-state catch-up BASE is not published"); + } + transition = transitions.peekFirst(); + if (transition == null) { + state = State.READY; + return readyHead; + } + parent = readyHead; + } + try { + PathStateRootMetadata committed; + try (PathStateLayer layer = PathStateLayer.begin(admittedManifest, parent, + transition.getBlockNumber(), transition.getBlockHash(), transition.getParentHash(), + transition.getTimestamp(), transition.getPhase(), transition.getPayloadDigest(), + admittedLimits)) { + layer.apply(transition.getMutations()); + committed = layer.commit(); + } + synchronized (this) { + if (transitions.peekFirst() != transition || state != State.DRAINING) { + throw new IOException("path-state catch-up queue changed during drain"); + } + transitions.removeFirst(); + queuedMutations -= transition.getMutations().size(); + queuedBytes -= logicalBytes(transition); + readyHead = committed; + } + drainHook.afterCommit(transition); + } catch (IOException | RuntimeException failure) { + synchronized (this) { + state = State.FAILED; + } + throw failure; + } + } + } + + private void requireNext(PathStateBlockTransition transition) throws IOException { + PathStateBlockTransition tail = transitions.peekLast(); + long parentNumber; + byte[] parentHash; + if (tail != null) { + parentNumber = tail.getBlockNumber(); + parentHash = tail.getBlockHash(); + } else if (state == State.DRAINING) { + parentNumber = readyHead.getBlockNumber(); + parentHash = readyHead.getBlockHash(); + } else { + parentNumber = snapshot.getBlockNumber(); + parentHash = snapshot.getBlockHash(); + } + if (transition.getBlockNumber() != parentNumber + 1 + || !Arrays.equals(transition.getParentHash(), parentHash)) { + state = State.FAILED; + throw new IOException("path-state catch-up transition is not block/hash continuous"); + } + } + + private void requireCapturing() throws IOException { + if (state != State.CAPTURING) { + throw new IOException("path-state catch-up BASE publication is not admissible"); + } + } + + private void requireBase(SnapshotIdentity identity, PathStateRootMetadata base) + throws IOException { + SnapshotIdentity admittedIdentity = Objects.requireNonNull(identity, "identity"); + PathStateRootMetadata admittedBase = Objects.requireNonNull(base, "base"); + if (!snapshot.sameAs(admittedIdentity) + || admittedBase.getKind() != Kind.BASE + || admittedBase.getBlockNumber() != snapshot.getBlockNumber() + || admittedBase.getTimestamp() != snapshot.getTimestamp() + || admittedBase.getPhase() != snapshot.getPhase() + || !Arrays.equals(admittedBase.getBlockHash(), snapshot.getBlockHash()) + || !Arrays.equals(admittedBase.getParentHash(), snapshot.getParentHash())) { + state = State.FAILED; + throw new IOException("path-state catch-up snapshot and BASE identity mismatch"); + } + } + + private CaptureDisposition failOverflow(ArithmeticException cause) throws IOException { + state = State.FAILED; + IOException failure = new IOException("path-state catch-up queue limit exceeded"); + if (cause != null) { + failure.initCause(cause); + } + throw failure; + } + + private static long logicalBytes(PathStateBlockTransition transition) { + long bytes = Long.BYTES * 2L + PathStateBlockTransition.HASH_LENGTH * 2L + + Integer.BYTES; + for (PathStateMutation mutation : transition.getMutations()) { + bytes = Math.addExact(bytes, + mutation.getDbName().getBytes(StandardCharsets.UTF_8).length); + bytes = Math.addExact(bytes, mutation.getCanonicalKey().length); + byte[] value = mutation.getCanonicalValue(); + if (value != null) { + bytes = Math.addExact(bytes, value.length); + } + bytes = Math.addExact(bytes, Integer.BYTES * 3L + 1L); + } + return bytes; + } + + public enum CaptureDisposition { + QUEUED, + DIRECT_APPLY + } + + public enum State { + CAPTURING, + DRAINING, + READY, + FAILED + } + + @FunctionalInterface + interface BasePublisher { + + PathStateRootMetadata publish() throws IOException; + } + + @FunctionalInterface + interface DrainHook { + + void afterCommit(PathStateBlockTransition transition) throws IOException; + } + +} diff --git a/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateCheckpointMaterializer.java b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateCheckpointMaterializer.java new file mode 100644 index 00000000000..90542575f7d --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateCheckpointMaterializer.java @@ -0,0 +1,474 @@ +package org.tron.core.db2.stateroot; + +import com.google.common.hash.Hashing; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.EOFException; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Objects; +import java.util.Set; +import org.tron.core.db2.archive.BlockSnapshotMeta; +import org.tron.core.db2.core.CommonCheckpointBaseline; +import org.tron.core.db2.core.CommonCheckpointMaterializedStore; +import org.tron.core.db2.core.CommonCheckpointMaterializer; +import org.tron.core.db2.core.CommonCheckpointPayload; +import org.tron.core.db2.core.CommonCheckpointTarget; +import org.tron.core.db2.stateroot.PathStateStoreManifest.Engine; + +/** Next-format PathState participant for the common-checkpoint two-barrier protocol. */ +public final class PathStateCheckpointMaterializer implements CommonCheckpointMaterializer { + + public static final String CURRENT_FILE = "CURRENT"; + static final String LEGACY_BASELINE_FILE = "LEGACY_BASELINE"; + public static final String COMMON_MODE_FILE = "COMMON_CHECKPOINT_MODE"; + public static final String COMMON_BASELINE_HEAD_FILE = "COMMON_BASELINE_HEAD"; + static final String MATERIALIZED_DIRECTORY = "checkpoint-materialized"; + private static final int MAGIC = 0x50534354; // PSCT + private static final short VERSION = 1; + private static final int DIGEST_LENGTH = 32; + private static final int RECORD_LENGTH = Integer.BYTES + Short.BYTES + Short.BYTES + + 4 * DIGEST_LENGTH + 2 * Long.BYTES + DIGEST_LENGTH; + + private final PathStatePhysicalStoreSet stores; + private final PathStateParticipantScope scope; + private final Path directory; + private final byte[] formatIdentity; + private final FaultHook faultHook; + private final CommonCheckpointBaseline baseline; + private final CommonCheckpointMaterializedStore materializedStore; + + public PathStateCheckpointMaterializer(PathStatePhysicalStoreSet stores, + PathStateParticipantScope scope, byte[] formatIdentity) { + this(stores, scope, formatIdentity, null, null, (stage, storeId) -> { }); + } + + public PathStateCheckpointMaterializer(PathStatePhysicalStoreSet stores, + PathStateParticipantScope scope, byte[] formatIdentity, CommonCheckpointBaseline baseline) { + this(stores, scope, formatIdentity, baseline, null, (stage, storeId) -> { }); + } + + public PathStateCheckpointMaterializer(PathStatePhysicalStoreSet stores, + PathStateParticipantScope scope, byte[] formatIdentity, CommonCheckpointBaseline baseline, + CommonCheckpointMaterializedStore materializedStore) { + this(stores, scope, formatIdentity, baseline, materializedStore, (stage, storeId) -> { }); + } + + PathStateCheckpointMaterializer(PathStatePhysicalStoreSet stores, + PathStateParticipantScope scope, byte[] formatIdentity, FaultHook faultHook) { + this(stores, scope, formatIdentity, null, null, faultHook); + } + + PathStateCheckpointMaterializer(PathStatePhysicalStoreSet stores, + PathStateParticipantScope scope, byte[] formatIdentity, CommonCheckpointBaseline baseline, + CommonCheckpointMaterializedStore materializedStore, FaultHook faultHook) { + this.stores = Objects.requireNonNull(stores, "stores"); + this.scope = Objects.requireNonNull(scope, "scope"); + this.directory = stores.getDirectory(); + this.formatIdentity = digest(formatIdentity, "formatIdentity"); + this.faultHook = Objects.requireNonNull(faultHook, "faultHook"); + this.baseline = baseline; + this.materializedStore = materializedStore; + } + + /** + * Opens physical stores for WAL redo without first trusting the possibly stale published + * PathState CURRENT. The session must be closed before the normal overlay is opened. + */ + public static RecoverySession openRecovery(Path directory, Engine engine, + long residentNodeCacheBytes, byte[] formatIdentity, CommonCheckpointBaseline baseline, + CommonCheckpointMaterializedStore materializedStore) throws IOException { + PathStateParticipantScope scope = new PathStateCanonicalizer().participantScope(); + PathStatePhysicalStoreSet stores = PathStatePhysicalStoreSet.openExisting(directory, scope, + engine, residentNodeCacheBytes); + try { + return new RecoverySession(stores, new PathStateCheckpointMaterializer(stores, scope, + formatIdentity, baseline, materializedStore)); + } catch (RuntimeException failure) { + try { + stores.close(); + } catch (IOException closeFailure) { + failure.addSuppressed(closeFailure); + } + throw failure; + } + } + + @Override + public Authority authority() { + return Authority.PATH_STATE; + } + + public static boolean isCommonModeAdmitted(Path directory, byte[] formatIdentity) + throws IOException { + Path mode = Objects.requireNonNull(directory, "directory").resolve(COMMON_MODE_FILE); + if (!Files.exists(mode, LinkOption.NOFOLLOW_LINKS)) { + return false; + } + if (!Files.isRegularFile(mode, LinkOption.NOFOLLOW_LINKS) + || !Arrays.equals(Files.readAllBytes(mode), + digest(formatIdentity, "formatIdentity"))) { + throw new IOException("PathState common-mode marker differs"); + } + return true; + } + + /** Converts a newly rebuilt legacy pointer into a read-only baseline admission record. */ + public static void admitFreshBaseline(PathStatePhysicalStoreSet stores, + PathStateRootMetadata metadata, CommonCheckpointBaseline baseline) throws IOException { + Path directory = Objects.requireNonNull(stores, "stores").getDirectory(); + Path current = directory.resolve(CURRENT_FILE); + Path legacy = directory.resolve(LEGACY_BASELINE_FILE); + Path mode = directory.resolve(COMMON_MODE_FILE); + if (Files.exists(mode, LinkOption.NOFOLLOW_LINKS)) { + if (!Files.isRegularFile(mode, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("PathState common-mode marker is not a regular file"); + } + return; + } + if (!Files.isRegularFile(current, LinkOption.NOFOLLOW_LINKS)) { + if (Files.isRegularFile(legacy, LinkOption.NOFOLLOW_LINKS) + && Files.isRegularFile(directory.resolve(COMMON_BASELINE_HEAD_FILE), + LinkOption.NOFOLLOW_LINKS)) { + PathStateMetadataFile.publishImmutableBytes(mode, baseline.getFormatIdentity()); + return; + } + throw new IOException("PathState fresh common baseline requires one legacy CURRENT"); + } + if (Files.exists(legacy, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("PathState fresh common baseline has conflicting pointers"); + } + if (metadata.getBlockNumber() != baseline.getHead().getBlockNumber() + || !Arrays.equals(metadata.getBlockHash(), baseline.getHead().getBlockHash()) + || !Arrays.equals(metadata.getStateRoot(), baseline.getStateRoot())) { + throw new IOException("PathState fresh baseline identity differs"); + } + PathStateMetadataFile.publishImmutableBytes(directory.resolve(COMMON_BASELINE_HEAD_FILE), + metadata.encode()); + try { + Files.move(current, legacy, java.nio.file.StandardCopyOption.ATOMIC_MOVE); + } catch (java.nio.file.AtomicMoveNotSupportedException unsupported) { + throw new IOException("PathState common baseline requires atomic rename", unsupported); + } + PathStateMetadataFile.publishImmutableBytes(mode, baseline.getFormatIdentity()); + } + + /** Loads the exact next-format head currently published by PathState CURRENT. */ + public static PublishedHead loadPublishedHead(Path directory, + byte[] expectedFormatIdentity) throws IOException { + Marker marker = load(Objects.requireNonNull(directory, "directory").resolve(CURRENT_FILE)); + if (!Arrays.equals(marker.formatIdentity, + digest(expectedFormatIdentity, "expectedFormatIdentity"))) { + throw new IOException("PathState published target format identity differs"); + } + return new PublishedHead(marker.lastEpoch, marker.lastBlockNumber, marker.lastBlockHash, + marker.stateRoot, marker.payloadDigest); + } + + @Override + public synchronized Status inspect(CommonCheckpointTarget target) throws IOException { + CommonCheckpointTarget admitted = requireTarget(target); + byte[] expected = encode(admitted); + Path currentPath = directory.resolve(CURRENT_FILE); + if (Files.exists(currentPath, LinkOption.NOFOLLOW_LINKS)) { + Marker current = load(currentPath); + if (Arrays.equals(current.encoded, expected)) { + requireMaterialized(admitted, expected); + return Status.PUBLISHED; + } + requireParent(current, admitted); + } else if (baseline != null) { + baseline.requireParent(admitted, "PathState"); + } + if (!isMaterialized(admitted, expected)) { + return Status.NEEDS_MATERIALIZATION; + } + return Status.MATERIALIZED; + } + + @Override + public synchronized void materialize(CommonCheckpointPayload payload, + CommonCheckpointTarget target) throws IOException { + CommonCheckpointPayload admittedPayload = Objects.requireNonNull(payload, "payload"); + CommonCheckpointTarget admittedTarget = requireTarget(target); + if (!admittedTarget.equals(CommonCheckpointTarget.from(admittedPayload))) { + throw new IOException("PathState checkpoint payload and target differ"); + } + Status status = inspect(admittedTarget); + if (status == Status.PUBLISHED || status == Status.MATERIALIZED) { + return; + } + byte[] marker = marker(admittedTarget); + Set seen = new HashSet<>(); + for (CommonCheckpointPayload.PathStoreTarget pathStore + : admittedPayload.getPathStores()) { + PathStateParticipant participant = scope.require(pathStore.getDbName()); + if (participant.getStoreId() != pathStore.getStoreId() + || !seen.add(pathStore.getStoreId())) { + throw new IOException("PathState checkpoint participant identity differs"); + } + PathStatePhysicalStoreSet.PhysicalStore store = stores.participant(pathStore.getDbName()); + if (!Arrays.equals(marker, store.checkpointTargetMarker())) { + store.applyCheckpointParticipant(pathStore, marker); + faultHook.after(Stage.AFTER_PARTICIPANT_BATCH, pathStore.getStoreId()); + } + } + PathStatePhysicalStoreSet.PhysicalStore superStore = stores.superStore(); + if (!Arrays.equals(marker, superStore.checkpointTargetMarker())) { + superStore.applyCheckpointSuper(admittedPayload.getSuperNodeMutations(), marker); + faultHook.after(Stage.AFTER_SUPER_BATCH, 0); + } + byte[] encoded = encode(admittedTarget); + recordMaterialized(admittedTarget, encoded); + faultHook.after(Stage.AFTER_MATERIALIZED_TARGET, 0); + } + + @Override + public synchronized void publish(CommonCheckpointTarget target) throws IOException { + CommonCheckpointTarget admitted = requireTarget(target); + Status status = inspect(admitted); + if (status == Status.PUBLISHED) { + return; + } + if (status != Status.MATERIALIZED) { + throw new IOException("PathState checkpoint target is not fully materialized"); + } + PathStateMetadataFile.replaceCurrentBytes(directory.resolve(CURRENT_FILE), encode(admitted)); + faultHook.after(Stage.AFTER_CURRENT, 0); + } + + private CommonCheckpointTarget requireTarget(CommonCheckpointTarget target) throws IOException { + CommonCheckpointTarget admitted = Objects.requireNonNull(target, "target"); + if (!Arrays.equals(formatIdentity, admitted.getFormatIdentity())) { + throw new IOException("PathState checkpoint format identity differs"); + } + return admitted; + } + + private void requireMaterialized(CommonCheckpointTarget target, byte[] expected) + throws IOException { + if (!isMaterialized(target, expected)) { + throw new IOException("PathState materialized checkpoint target is missing"); + } + } + + private boolean isMaterialized(CommonCheckpointTarget target, byte[] expected) + throws IOException { + if (materializedStore != null && materializedStore.exists(Authority.PATH_STATE)) { + return materializedStore.matches(Authority.PATH_STATE, expected); + } + Path legacy = materializedPath(target); + if (!Files.exists(legacy, LinkOption.NOFOLLOW_LINKS)) { + return false; + } + requireExact(legacy, expected); + return true; + } + + private void recordMaterialized(CommonCheckpointTarget target, byte[] encoded) + throws IOException { + if (materializedStore == null) { + PathStateMetadataFile.publishImmutableBytes(materializedPath(target), encoded); + } else { + materializedStore.replace(Authority.PATH_STATE, encoded); + } + } + + private void requireParent(Marker current, CommonCheckpointTarget target) throws IOException { + BlockSnapshotMeta first = target.getFirstBlock(); + if (!Arrays.equals(current.formatIdentity, target.getFormatIdentity()) + || current.lastEpoch + 1 != first.getEpoch() + || current.lastBlockNumber + 1 != first.getBlockNumber() + || !Arrays.equals(current.lastBlockHash, first.getParentHash()) + || !Arrays.equals(current.stateRoot, target.getParentStateRoot())) { + throw new IOException("PathState CURRENT is not the checkpoint parent target"); + } + } + + private Path materializedPath(CommonCheckpointTarget target) { + return directory.resolve(MATERIALIZED_DIRECTORY).resolve(hex(target.getPayloadDigest())); + } + + private static byte[] marker(CommonCheckpointTarget target) { + byte[] marker = new byte[2 * DIGEST_LENGTH]; + System.arraycopy(target.getPayloadDigest(), 0, marker, 0, DIGEST_LENGTH); + System.arraycopy(target.getStateRoot(), 0, marker, DIGEST_LENGTH, DIGEST_LENGTH); + return marker; + } + + private static byte[] encode(CommonCheckpointTarget target) { + try { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(RECORD_LENGTH); + DataOutputStream output = new DataOutputStream(bytes); + output.writeInt(MAGIC); + output.writeShort(VERSION); + output.writeShort(0); + output.write(target.getFormatIdentity()); + output.write(target.getPayloadDigest()); + output.writeLong(target.getLastBlock().getEpoch()); + output.writeLong(target.getLastBlock().getBlockNumber()); + output.write(target.getLastBlock().getBlockHash()); + output.write(target.getStateRoot()); + output.flush(); + byte[] body = bytes.toByteArray(); + output.write(Hashing.sha256().hashBytes(body).asBytes()); + output.flush(); + return bytes.toByteArray(); + } catch (IOException impossible) { + throw new IllegalStateException("in-memory PathState target encoding failed", impossible); + } + } + + private static Marker load(Path path) throws IOException { + byte[] encoded = PathStateMetadataFile.loadImmutableBytes(path, RECORD_LENGTH); + if (encoded.length != RECORD_LENGTH) { + throw new IOException("PathState checkpoint target length is invalid"); + } + int bodyLength = encoded.length - DIGEST_LENGTH; + byte[] body = Arrays.copyOf(encoded, bodyLength); + byte[] checksum = Arrays.copyOfRange(encoded, bodyLength, encoded.length); + if (!Arrays.equals(checksum, Hashing.sha256().hashBytes(body).asBytes())) { + throw new IOException("PathState checkpoint target checksum differs"); + } + try (DataInputStream input = new DataInputStream(new ByteArrayInputStream(body))) { + if (input.readInt() != MAGIC || input.readShort() != VERSION || input.readShort() != 0) { + throw new IOException("PathState checkpoint target format is unsupported"); + } + return new Marker(encoded, readDigest(input), readDigest(input), input.readLong(), + input.readLong(), readDigest(input), readDigest(input)); + } catch (EOFException truncated) { + throw new IOException("PathState checkpoint target is truncated", truncated); + } + } + + private static void requireExact(Path path, byte[] expected) throws IOException { + Marker actual = load(path); + if (!Arrays.equals(actual.encoded, expected)) { + throw new IOException("PathState checkpoint target identity differs"); + } + } + + private static byte[] readDigest(DataInputStream input) throws IOException { + byte[] value = new byte[DIGEST_LENGTH]; + input.readFully(value); + return value; + } + + private static byte[] digest(byte[] value, String name) { + byte[] copy = Arrays.copyOf(Objects.requireNonNull(value, name), value.length); + if (copy.length != DIGEST_LENGTH) { + throw new IllegalArgumentException(name + " must contain exactly 32 bytes"); + } + return copy; + } + + private static String hex(byte[] value) { + StringBuilder encoded = new StringBuilder(value.length * 2); + for (byte current : value) { + encoded.append(Character.forDigit(current >>> 4 & 0xf, 16)); + encoded.append(Character.forDigit(current & 0xf, 16)); + } + return encoded.toString(); + } + + enum Stage { + AFTER_PARTICIPANT_BATCH, + AFTER_SUPER_BATCH, + AFTER_MATERIALIZED_TARGET, + AFTER_CURRENT + } + + @FunctionalInterface + interface FaultHook { + void after(Stage stage, int storeId) throws IOException; + } + + /** Short-lived owner used only while replaying a durable common-checkpoint WAL. */ + public static final class RecoverySession implements AutoCloseable { + + private final PathStatePhysicalStoreSet stores; + private final PathStateCheckpointMaterializer materializer; + + private RecoverySession(PathStatePhysicalStoreSet stores, + PathStateCheckpointMaterializer materializer) { + this.stores = stores; + this.materializer = materializer; + } + + public PathStateCheckpointMaterializer getMaterializer() { + return materializer; + } + + @Override + public void close() throws IOException { + stores.close(); + } + } + + private static final class Marker { + + private final byte[] encoded; + private final byte[] formatIdentity; + private final byte[] payloadDigest; + private final long lastEpoch; + private final long lastBlockNumber; + private final byte[] lastBlockHash; + private final byte[] stateRoot; + + private Marker(byte[] encoded, byte[] formatIdentity, byte[] payloadDigest, long lastEpoch, + long lastBlockNumber, byte[] lastBlockHash, byte[] stateRoot) { + this.encoded = encoded; + this.formatIdentity = formatIdentity; + this.payloadDigest = payloadDigest; + this.lastEpoch = lastEpoch; + this.lastBlockNumber = lastBlockNumber; + this.lastBlockHash = lastBlockHash; + this.stateRoot = stateRoot; + } + } + + /** Minimal restart identity retained by the compact next-format CURRENT record. */ + public static final class PublishedHead { + + private final long epoch; + private final long blockNumber; + private final byte[] blockHash; + private final byte[] stateRoot; + private final byte[] payloadDigest; + + private PublishedHead(long epoch, long blockNumber, byte[] blockHash, byte[] stateRoot, + byte[] payloadDigest) { + this.epoch = epoch; + this.blockNumber = blockNumber; + this.blockHash = Arrays.copyOf(blockHash, blockHash.length); + this.stateRoot = Arrays.copyOf(stateRoot, stateRoot.length); + this.payloadDigest = Arrays.copyOf(payloadDigest, payloadDigest.length); + } + + public long getEpoch() { + return epoch; + } + + public long getBlockNumber() { + return blockNumber; + } + + public byte[] getBlockHash() { + return Arrays.copyOf(blockHash, blockHash.length); + } + + public byte[] getStateRoot() { + return Arrays.copyOf(stateRoot, stateRoot.length); + } + + public byte[] getPayloadDigest() { + return Arrays.copyOf(payloadDigest, payloadDigest.length); + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateCommitmentCodec.java b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateCommitmentCodec.java new file mode 100644 index 00000000000..d839e0af41d --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateCommitmentCodec.java @@ -0,0 +1,145 @@ +package org.tron.core.db2.stateroot; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Objects; +import org.tron.common.crypto.Hash; + +/** + * Experimental byte contract for the TASK-016 current path-state commitment. + * + *

This codec is independent from the existing account trie and State Archive formats. Its + * output is not a persistent compatibility promise until the H1-L1 gate approves the root domain + * and golden vectors. + */ +public final class PathStateCommitmentCodec { + + public static final int FORMAT_VERSION = 1; + public static final int ROOT_LENGTH = 32; + + private static final byte PRESENT_TAG = 1; + private static final byte[] STORE_LEAF_KEY_DOMAIN = + "java-tron/path-state/store-leaf-key".getBytes(StandardCharsets.US_ASCII); + private static final byte[] SUPER_LEAF_KEY_DOMAIN = + "java-tron/path-state/super-leaf-key".getBytes(StandardCharsets.US_ASCII); + private static final int RLP_SHORT_LIMIT = 56; + private static final int RLP_SHORT_ITEM_OFFSET = 0x80; + private static final int RLP_LONG_ITEM_OFFSET = 0xb7; + private static final int RLP_SHORT_LIST_OFFSET = 0xc0; + private static final int RLP_LONG_LIST_OFFSET = 0xf7; + + private PathStateCommitmentCodec() { + } + + /** Returns the secure per-Store trie key for one exact physical raw key. */ + public static byte[] storeLeafKey(int stableStoreId, byte[] physicalRawKey) { + requireStoreId(stableStoreId); + byte[] key = copy(physicalRawKey, "physicalRawKey"); + ByteBuffer material = ByteBuffer.allocate(Short.BYTES + STORE_LEAF_KEY_DOMAIN.length + + Short.BYTES + Integer.BYTES + Integer.BYTES + key.length); + putDomain(material, STORE_LEAF_KEY_DOMAIN); + material.putShort((short) FORMAT_VERSION); + material.putInt(stableStoreId); + material.putInt(key.length); + material.put(key); + return Hash.sha3(material.array()); + } + + /** Encodes PRESENT(empty) distinctly from PRESENT(0x00); ABSENT has no leaf. */ + public static byte[] presentLeafValue(byte[] physicalRawValue) { + byte[] value = Arrays.copyOf(Objects.requireNonNull(physicalRawValue, "physicalRawValue"), + physicalRawValue.length); + return rlpList(new byte[]{PRESENT_TAG}, value); + } + + /** Returns the secure super-trie key for one stable Store identity. */ + public static byte[] superLeafKey(int stableStoreId) { + requireStoreId(stableStoreId); + ByteBuffer material = ByteBuffer.allocate(Short.BYTES + SUPER_LEAF_KEY_DOMAIN.length + + Short.BYTES + Integer.BYTES); + putDomain(material, SUPER_LEAF_KEY_DOMAIN); + material.putShort((short) FORMAT_VERSION); + material.putInt(stableStoreId); + return Hash.sha3(material.array()); + } + + /** Encodes a Store identity and current Store root as one super-trie leaf value. */ + public static byte[] superLeafValue(int stableStoreId, String dbName, int storeFormatVersion, + byte[] storeRoot) { + requireStoreId(stableStoreId); + if (storeFormatVersion <= 0) { + throw new IllegalArgumentException("storeFormatVersion must be positive"); + } + String name = Objects.requireNonNull(dbName, "dbName"); + byte[] encodedName = name.getBytes(StandardCharsets.UTF_8); + if (encodedName.length == 0 || encodedName.length > 128) { + throw new IllegalArgumentException("dbName must encode to 1..128 bytes"); + } + byte[] root = Objects.requireNonNull(storeRoot, "storeRoot"); + if (root.length != ROOT_LENGTH) { + throw new IllegalArgumentException("storeRoot must be exactly 32 bytes"); + } + return rlpList(intBytes(stableStoreId), encodedName, intBytes(storeFormatVersion), root); + } + + private static void putDomain(ByteBuffer target, byte[] domain) { + target.putShort((short) domain.length); + target.put(domain); + } + + private static byte[] intBytes(int value) { + return ByteBuffer.allocate(Integer.BYTES).putInt(value).array(); + } + + private static void requireStoreId(int stableStoreId) { + if (stableStoreId <= 0) { + throw new IllegalArgumentException("stableStoreId must be positive"); + } + } + + private static byte[] copy(byte[] value, String name) { + return Arrays.copyOf(Objects.requireNonNull(value, name), value.length); + } + + private static byte[] rlpList(byte[]... rawItems) { + byte[][] encoded = new byte[rawItems.length][]; + int payloadLength = 0; + for (int i = 0; i < rawItems.length; i++) { + encoded[i] = rlpItem(Objects.requireNonNull(rawItems[i], "raw RLP item")); + payloadLength = Math.addExact(payloadLength, encoded[i].length); + } + byte[] prefix = rlpLength(payloadLength, RLP_SHORT_LIST_OFFSET, RLP_LONG_LIST_OFFSET); + ByteBuffer result = ByteBuffer.allocate(Math.addExact(prefix.length, payloadLength)); + result.put(prefix); + for (byte[] item : encoded) { + result.put(item); + } + return result.array(); + } + + private static byte[] rlpItem(byte[] raw) { + if (raw.length == 1 && (raw[0] & 0xff) < RLP_SHORT_ITEM_OFFSET) { + return Arrays.copyOf(raw, raw.length); + } + byte[] prefix = rlpLength(raw.length, RLP_SHORT_ITEM_OFFSET, RLP_LONG_ITEM_OFFSET); + ByteBuffer result = ByteBuffer.allocate(Math.addExact(prefix.length, raw.length)); + result.put(prefix); + result.put(raw); + return result.array(); + } + + private static byte[] rlpLength(int length, int shortOffset, int longOffset) { + if (length < RLP_SHORT_LIMIT) { + return new byte[]{(byte) (shortOffset + length)}; + } + int lengthOfLength = (Integer.SIZE - Integer.numberOfLeadingZeros(length) + 7) / 8; + byte[] encoded = new byte[lengthOfLength + 1]; + encoded[0] = (byte) (longOffset + lengthOfLength); + for (int i = lengthOfLength; i > 0; i--) { + encoded[i] = (byte) length; + length >>>= Byte.SIZE; + } + return encoded; + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateCurrentStore.java b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateCurrentStore.java new file mode 100644 index 00000000000..4515e634980 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateCurrentStore.java @@ -0,0 +1,291 @@ +package org.tron.core.db2.stateroot; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import org.tron.core.db2.stateroot.PathStateRootMetadata.Kind; + +/** + * Current-head authority over one durable base and its immutable reversible metadata chain. + * + *

This component intentionally exposes only the verified current record. Traversal of older + * layers is an internal startup integrity check, not a historical-root query API. + */ +public final class PathStateCurrentStore { + + public static final String METADATA_FILE = "METADATA"; + public static final String CURRENT_FILE = "CURRENT"; + + static final int MAX_VALIDATION_LAYERS = 65_536; + + private final PathStateStoreManifest manifest; + private final Path currentPath; + + public PathStateCurrentStore(PathStateStoreManifest manifest) { + this.manifest = Objects.requireNonNull(manifest, "manifest"); + this.currentPath = manifest.getDirectory().resolve(CURRENT_FILE); + } + + public boolean isInitialized() { + return Files.exists(currentPath, LinkOption.NOFOLLOW_LINKS); + } + + /** Publishes the first durable base and then atomically makes it current. */ + public synchronized PathStateRootMetadata publishBase(PathStateRootMetadata base) + throws IOException { + PathStateRootMetadata metadata = requireKind(base, Kind.BASE); + requireFormat(metadata); + if (isInitialized()) { + PathStateRootMetadata current = current(); + if (!Arrays.equals(current.encode(), metadata.encode())) { + throw new IOException("path-state CURRENT already identifies another root"); + } + return current; + } + PathStateMetadataFile.publishImmutable(basePath(), metadata); + PathStateMetadataFile.replaceCurrent(currentPath, metadata); + return current(); + } + + /** Publishes one immutable child layer before atomically advancing CURRENT. */ + public synchronized PathStateRootMetadata appendLayer(PathStateRootMetadata layer) + throws IOException { + PathStateRootMetadata child = requireKind(layer, Kind.LAYER); + requireFormat(child); + PathStateRootMetadata parent = current(); + if (same(parent, child)) { + return parent; + } + requireChild(parent, child); + PathStateMetadataFile.publishImmutable(layerPath(child), child); + PathStateMetadataFile.replaceCurrent(currentPath, child); + return current(); + } + + /** Atomically switches CURRENT to an exact durable ancestor inside the reversible window. */ + synchronized PathStateRootMetadata switchToAncestor(PathStateRootMetadata target, + PathStateLayerLimits limits) throws IOException { + return switchToAncestor(target, limits, temporary -> { }); + } + + synchronized PathStateRootMetadata switchToAncestor(PathStateRootMetadata target, + PathStateLayerLimits limits, PathStateMetadataFile.FaultHook faultHook) throws IOException { + List suffix = layersAboveAncestor(target, limits); + PathStateRootMetadata admittedTarget = Objects.requireNonNull(target, "target"); + if (suffix.isEmpty()) { + return current(); + } + PathStateMetadataFile.replaceCurrent(currentPath, admittedTarget, + Objects.requireNonNull(faultHook, "faultHook")); + return current(); + } + + synchronized List layersAboveAncestor(PathStateRootMetadata target, + PathStateLayerLimits limits) throws IOException { + PathStateRootMetadata admittedTarget = Objects.requireNonNull(target, "target"); + PathStateLayerLimits admittedLimits = Objects.requireNonNull(limits, "limits"); + requireFormat(admittedTarget); + PathStateRootMetadata head = current(); + if (same(head, admittedTarget)) { + verifyTargetState(admittedTarget); + return Collections.emptyList(); + } + if (admittedTarget.getBlockNumber() >= head.getBlockNumber()) { + throw new IOException("path-state canonical switch target is not an ancestor"); + } + + PathStateRootMetadata base = requireKind(PathStateMetadataFile.load(basePath()), Kind.BASE); + requireFormat(base); + List suffix = new ArrayList<>(); + PathStateRootMetadata cursor = head; + for (int depth = 1; depth <= admittedLimits.getMaxLayers(); depth++) { + suffix.add(cursor); + cursor = parentOf(cursor, base); + if (same(cursor, admittedTarget)) { + verifyTargetState(admittedTarget); + return Collections.unmodifiableList(suffix); + } + if (cursor.getKind() == Kind.BASE) { + break; + } + } + throw new IOException("path-state canonical switch exceeds the reversible window"); + } + + /** Resolves an exact number/hash ancestor without exposing historical-root lookup. */ + synchronized PathStateRootMetadata findAncestor(long blockNumber, byte[] blockHash, + PathStateLayerLimits limits) throws IOException { + byte[] admittedHash = Arrays.copyOf(Objects.requireNonNull(blockHash, "blockHash"), + blockHash.length); + PathStateLayerLimits admittedLimits = Objects.requireNonNull(limits, "limits"); + PathStateRootMetadata base = requireKind(PathStateMetadataFile.load(basePath()), Kind.BASE); + requireFormat(base); + PathStateRootMetadata cursor = current(); + if (matchesBlock(cursor, blockNumber, admittedHash)) { + verifyTargetState(cursor); + return cursor; + } + if (blockNumber >= cursor.getBlockNumber()) { + throw new IOException("path-state canonical target is not an ancestor"); + } + for (int depth = 1; depth <= admittedLimits.getMaxLayers(); depth++) { + cursor = parentOf(cursor, base); + if (matchesBlock(cursor, blockNumber, admittedHash)) { + verifyTargetState(cursor); + return cursor; + } + if (cursor.getKind() == Kind.BASE) { + break; + } + } + throw new IOException("path-state canonical target exceeds the reversible window"); + } + + synchronized PathStateRootMetadata firstLayerAfterBaseToward( + PathStateRootMetadata target, PathStateLayerLimits limits) throws IOException { + PathStateRootMetadata admittedTarget = Objects.requireNonNull(target, "target"); + PathStateLayerLimits admittedLimits = Objects.requireNonNull(limits, "limits"); + PathStateRootMetadata base = requireKind(PathStateMetadataFile.load(basePath()), Kind.BASE); + requireFormat(base); + if (admittedTarget.getKind() != Kind.LAYER) { + throw new IOException("path-state base flush target is not a layer"); + } + PathStateRootMetadata cursor = admittedTarget; + for (int depth = 1; depth <= admittedLimits.getMaxLayers(); depth++) { + PathStateRootMetadata parent = parentOf(cursor, base); + if (same(parent, base)) { + return cursor; + } + cursor = parent; + } + throw new IOException("path-state base flush target exceeds the reversible window"); + } + + /** Loads CURRENT and verifies that every referenced layer reaches the single durable base. */ + public synchronized PathStateRootMetadata current() throws IOException { + PathStateRootMetadata base = requireKind(PathStateMetadataFile.load(basePath()), Kind.BASE); + requireFormat(base); + PathStateRootMetadata head = PathStateMetadataFile.load(currentPath); + requireFormat(head); + PathStateRootMetadata storedHead = PathStateMetadataFile.load(metadataPath(head)); + requireFormat(storedHead); + requireSame(head, storedHead, "CURRENT metadata differs from its immutable record"); + + PathStateRootMetadata child = head; + int layers = 0; + while (child.getKind() == Kind.LAYER) { + if (++layers > MAX_VALIDATION_LAYERS) { + throw new IOException("path-state layer chain exceeds the validation safety limit"); + } + if (child.getBlockNumber() == 0) { + throw new IOException("path-state layer zero cannot have a parent"); + } + PathStateRootMetadata parent; + if (base.getBlockNumber() == child.getBlockNumber() - 1) { + parent = base; + } else { + parent = requireKind(PathStateMetadataFile.load( + layerPath(child.getBlockNumber() - 1, child.getParentHash())), Kind.LAYER); + requireFormat(parent); + } + requireChild(parent, child); + child = parent; + } + requireSame(base, child, "path-state layer chain does not terminate at the durable base"); + return head; + } + + private Path metadataPath(PathStateRootMetadata metadata) { + return metadata.getKind() == Kind.BASE ? basePath() : layerPath(metadata); + } + + private Path basePath() { + return manifest.getBaseDirectory().resolve(METADATA_FILE); + } + + private Path layerPath(PathStateRootMetadata metadata) { + return layerPath(metadata.getBlockNumber(), metadata.getBlockHash()); + } + + private Path layerPath(long blockNumber, byte[] blockHash) { + return manifest.getLayerDirectory(blockNumber, blockHash).resolve(METADATA_FILE); + } + + private PathStateRootMetadata parentOf(PathStateRootMetadata child, + PathStateRootMetadata base) throws IOException { + if (child.getKind() != Kind.LAYER || child.getBlockNumber() == 0) { + throw new IOException("path-state canonical switch reached an invalid parent boundary"); + } + PathStateRootMetadata parent; + if (base.getBlockNumber() == child.getBlockNumber() - 1) { + parent = base; + } else { + parent = requireKind(PathStateMetadataFile.load( + layerPath(child.getBlockNumber() - 1, child.getParentHash())), Kind.LAYER); + requireFormat(parent); + } + requireChild(parent, child); + return parent; + } + + private void verifyTargetState(PathStateRootMetadata target) throws IOException { + Path owner = target.getKind() == Kind.BASE ? manifest.getBaseDirectory() + : manifest.getLayerDirectory(target.getBlockNumber(), target.getBlockHash()); + PathStateRootMetadata progress = PathStateNodeStoreSet.loadProgress(owner, manifest); + if (progress == null || !same(target, progress)) { + throw new IOException("path-state canonical switch target has invalid native progress"); + } + try (PathStateNodeStoreSet stores = PathStateNodeStoreSet.openPublished(manifest, target)) { + stores.createRoot(); + } catch (IllegalArgumentException | IllegalStateException e) { + throw new IOException("path-state canonical switch target is corrupt", e); + } + } + + private static PathStateRootMetadata requireKind(PathStateRootMetadata metadata, Kind kind) { + PathStateRootMetadata present = Objects.requireNonNull(metadata, "metadata"); + if (present.getKind() != kind) { + throw new IllegalArgumentException("expected path-state " + kind + " metadata"); + } + return present; + } + + private void requireFormat(PathStateRootMetadata metadata) throws IOException { + if (!Arrays.equals(metadata.getFormatDigest(), manifest.getIdentityDigest())) { + throw new IOException("path-state metadata manifest identity mismatch"); + } + } + + private static void requireChild(PathStateRootMetadata parent, PathStateRootMetadata child) + throws IOException { + if (child.getBlockNumber() != parent.getBlockNumber() + 1 + || !Arrays.equals(child.getParentHash(), parent.getBlockHash()) + || !Arrays.equals(child.getParentStateRoot(), parent.getStateRoot())) { + throw new IOException("path-state layer does not extend CURRENT"); + } + } + + private static void requireSame(PathStateRootMetadata expected, + PathStateRootMetadata actual, String error) throws IOException { + if (!same(expected, actual)) { + throw new IOException(error); + } + } + + private static boolean same(PathStateRootMetadata left, PathStateRootMetadata right) { + return Arrays.equals(left.encode(), right.encode()); + } + + private static boolean matchesBlock(PathStateRootMetadata metadata, long blockNumber, + byte[] blockHash) { + return metadata.getBlockNumber() == blockNumber + && Arrays.equals(metadata.getBlockHash(), blockHash); + } + +} diff --git a/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateFlushTarget.java b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateFlushTarget.java new file mode 100644 index 00000000000..3036a3d25d9 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateFlushTarget.java @@ -0,0 +1,283 @@ +package org.tron.core.db2.stateroot; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import org.tron.core.db2.archive.BlockSnapshotMeta; + +/** Immutable deterministic coalescing target for one consecutive Snapshot flush range. */ +public final class PathStateFlushTarget { + + private static final Comparator MUTATION_ORDER = + (left, right) -> compareUnsigned(left.getKey(), right.getKey()); + + private final List blocks; + private final byte[] parentStateRoot; + private final byte[] stateRoot; + private final List stores; + private final List superNodeMutations; + private final long mutationBytes; + + private PathStateFlushTarget(List blocks, byte[] parentStateRoot, + byte[] stateRoot, List stores, + List superNodeMutations) { + this.blocks = Collections.unmodifiableList(new ArrayList<>(blocks)); + this.parentStateRoot = copy(parentStateRoot); + this.stateRoot = copy(stateRoot); + this.stores = Collections.unmodifiableList(new ArrayList<>(stores)); + this.superNodeMutations = immutableMutations(superNodeMutations); + this.mutationBytes = mutationBytes(this.stores, this.superNodeMutations); + } + + /** Validates and coalesces a non-empty oldest-to-newest path-state Snapshot delta chain. */ + public static PathStateFlushTarget coalesce(List supplied) { + List deltas = new ArrayList<>(Objects.requireNonNull(supplied, + "deltas")); + if (deltas.isEmpty()) { + throw new IllegalArgumentException("path-state flush delta chain must not be empty"); + } + + List blocks = new ArrayList<>(); + Map stores = new LinkedHashMap<>(); + Map storeNames = new LinkedHashMap<>(); + Map superNodes = new LinkedHashMap<>(); + PathStateSnapshotDelta previous = null; + for (PathStateSnapshotDelta candidate : deltas) { + PathStateSnapshotDelta delta = Objects.requireNonNull(candidate, "delta"); + if (previous != null) { + requireChild(previous, delta); + } + blocks.add(new BlockBinding(delta)); + for (PathStateSnapshotDelta.StoreDelta store : delta.getStores()) { + Integer priorId = storeNames.putIfAbsent(store.getDbName(), store.getStoreId()); + if (priorId != null && priorId != store.getStoreId()) { + throw new IllegalArgumentException("path-state flush Store name changes identity"); + } + StoreAccumulator accumulator = stores.computeIfAbsent(store.getStoreId(), ignored -> + new StoreAccumulator(store.getStoreId(), store.getDbName())); + accumulator.add(store); + } + putAll(superNodes, delta.getSuperNodeMutations()); + previous = delta; + } + + List storeTargets = new ArrayList<>(); + stores.values().stream().sorted(Comparator.comparingInt(store -> store.storeId)) + .forEach(store -> storeTargets.add(store.freeze())); + PathStateSnapshotDelta first = deltas.get(0); + PathStateSnapshotDelta last = deltas.get(deltas.size() - 1); + return new PathStateFlushTarget(blocks, first.getParentStateRoot(), last.getStateRoot(), + storeTargets, new ArrayList<>(superNodes.values())); + } + + public List getBlocks() { + return blocks; + } + + public byte[] getParentStateRoot() { + return copy(parentStateRoot); + } + + public byte[] getStateRoot() { + return copy(stateRoot); + } + + public List getStores() { + return stores; + } + + public List getSuperNodeMutations() { + return superNodeMutations; + } + + public long getMutationBytes() { + return mutationBytes; + } + + private static void requireChild(PathStateSnapshotDelta parent, + PathStateSnapshotDelta child) { + BlockSnapshotMeta parentMeta = parent.getMeta(); + BlockSnapshotMeta childMeta = child.getMeta(); + if (childMeta.getEpoch() != parentMeta.getEpoch() + 1 + || childMeta.getBlockNumber() != parentMeta.getBlockNumber() + 1 + || !Arrays.equals(childMeta.getParentHash(), parentMeta.getBlockHash()) + || !Arrays.equals(child.getParentStateRoot(), parent.getStateRoot())) { + throw new IllegalArgumentException("path-state flush delta chain is not consecutive"); + } + } + + private static void putAll(Map target, + List mutations) { + for (PathStateSnapshotDelta.Mutation mutation : mutations) { + byte[] key = mutation.getKey(); + target.put(new BytesKey(key), new PathStateSnapshotDelta.Mutation(key, + mutation.getValue())); + } + } + + private static List immutableMutations( + List mutations) { + List copy = new ArrayList<>(mutations); + copy.sort(MUTATION_ORDER); + return Collections.unmodifiableList(copy); + } + + private static long mutationBytes(List stores, + List superNodes) { + long bytes = mutationsBytes(superNodes); + for (StoreTarget store : stores) { + bytes = Math.addExact(bytes, mutationsBytes(store.flatMutations)); + bytes = Math.addExact(bytes, mutationsBytes(store.nodeMutations)); + } + return bytes; + } + + private static long mutationsBytes(List mutations) { + long bytes = 0; + for (PathStateSnapshotDelta.Mutation mutation : mutations) { + bytes = Math.addExact(bytes, mutation.getKey().length); + byte[] value = mutation.getValue(); + if (value != null) { + bytes = Math.addExact(bytes, value.length); + } + } + return bytes; + } + + private static byte[] copy(byte[] value) { + return Arrays.copyOf(Objects.requireNonNull(value, "value"), value.length); + } + + private static int compareUnsigned(byte[] left, byte[] right) { + for (int index = 0; index < Math.min(left.length, right.length); index++) { + int compared = Integer.compare(left[index] & 0xff, right[index] & 0xff); + if (compared != 0) { + return compared; + } + } + return Integer.compare(left.length, right.length); + } + + /** Per-block chain and payload identity retained even though forward mutations are coalesced. */ + public static final class BlockBinding { + + private final BlockSnapshotMeta meta; + private final byte[] parentStateRoot; + private final byte[] stateRoot; + private final byte[] transitionPayloadDigest; + + private BlockBinding(PathStateSnapshotDelta delta) { + this.meta = delta.getMeta(); + this.parentStateRoot = delta.getParentStateRoot(); + this.stateRoot = delta.getStateRoot(); + this.transitionPayloadDigest = delta.getTransitionPayloadDigest(); + } + + public BlockSnapshotMeta getMeta() { + return meta; + } + + public byte[] getParentStateRoot() { + return copy(parentStateRoot); + } + + public byte[] getStateRoot() { + return copy(stateRoot); + } + + public byte[] getTransitionPayloadDigest() { + return copy(transitionPayloadDigest); + } + + } + + /** Final target for one participant changed anywhere in the coalesced range. */ + public static final class StoreTarget { + + private final int storeId; + private final String dbName; + private final byte[] storeRoot; + private final List flatMutations; + private final List nodeMutations; + + private StoreTarget(StoreAccumulator accumulator) { + this.storeId = accumulator.storeId; + this.dbName = accumulator.dbName; + this.storeRoot = copy(accumulator.storeRoot); + this.flatMutations = immutableMutations(new ArrayList<>(accumulator.flat.values())); + this.nodeMutations = immutableMutations(new ArrayList<>(accumulator.nodes.values())); + } + + public int getStoreId() { + return storeId; + } + + public String getDbName() { + return dbName; + } + + public byte[] getStoreRoot() { + return copy(storeRoot); + } + + public List getFlatMutations() { + return flatMutations; + } + + public List getNodeMutations() { + return nodeMutations; + } + } + + private static final class StoreAccumulator { + + private final int storeId; + private final String dbName; + private final Map flat = new LinkedHashMap<>(); + private final Map nodes = new LinkedHashMap<>(); + private byte[] storeRoot; + + private StoreAccumulator(int storeId, String dbName) { + this.storeId = storeId; + this.dbName = dbName; + } + + private void add(PathStateSnapshotDelta.StoreDelta store) { + if (!dbName.equals(store.getDbName())) { + throw new IllegalArgumentException("path-state flush Store ID changes identity"); + } + putAll(flat, store.getFlatMutations()); + putAll(nodes, store.getNodeMutations()); + storeRoot = store.getStoreRoot(); + } + + private StoreTarget freeze() { + return new StoreTarget(this); + } + } + + private static final class BytesKey { + + private final byte[] bytes; + + private BytesKey(byte[] bytes) { + this.bytes = copy(bytes); + } + + @Override + public boolean equals(Object other) { + return this == other || other instanceof BytesKey + && Arrays.equals(bytes, ((BytesKey) other).bytes); + } + + @Override + public int hashCode() { + return Arrays.hashCode(bytes); + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateHead.java b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateHead.java new file mode 100644 index 00000000000..7c81ddd2add --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateHead.java @@ -0,0 +1,29 @@ +package org.tron.core.db2.stateroot; + +import java.io.Closeable; +import java.io.IOException; +import org.tron.core.db2.archive.BlockSnapshotMeta; + +/** Runtime-owned current path-state authority used by Manager lifecycle integration. */ +public interface PathStateHead extends Closeable { + + PathStateRootMetadata advance(PathStateBlockTransition transition) throws IOException; + + PathStateRootMetadata rewindTo(long blockNumber, byte[] blockHash) throws IOException; + + PathStateRootMetadata flushBaseThrough(long blockNumber, byte[] blockHash) throws IOException; + + /** Computes the child state root without publishing or adopting it. */ + byte[] preview(PathStateBlockTransition transition) throws IOException; + + /** Prepares an optional Snapshot-owned forward delta without publishing durable state. */ + default PathStateSnapshotDelta prepareSnapshotDelta(BlockSnapshotMeta meta, + PathStateBlockTransition transition) throws IOException { + return null; + } + + PathStateRootMetadata getHead() throws IOException; + + @Override + void close() throws IOException; +} diff --git a/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateLayer.java b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateLayer.java new file mode 100644 index 00000000000..0ad8f2081fb --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateLayer.java @@ -0,0 +1,241 @@ +package org.tron.core.db2.stateroot; + +import java.io.Closeable; +import java.io.IOException; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Collection; +import java.util.Objects; +import org.tron.core.db2.stateroot.PathStateCanonicalizer.P66Phase; + +/** One writable, current-only path-state layer derived from the published canonical parent. */ +public final class PathStateLayer implements Closeable { + + private final PathStateStoreManifest manifest; + private final PathStateLayerPublication publication; + private final PathStateNodeStoreSet stores; + private final PathStateRoot root; + private final PathStateRootMetadata parent; + private final long blockNumber; + private final byte[] blockHash; + private final byte[] parentHash; + private final long timestamp; + private final P66Phase phase; + private final byte[] transitionDigest; + private PathStateRootMetadata prepared; + private PathStateRootMetadata committed; + private PathStateRoot.Snapshot preparedSnapshot; + + private PathStateLayer(PathStateStoreManifest manifest, PathStateLayerPublication publication, + PathStateNodeStoreSet stores, PathStateRoot root, PathStateRootMetadata parent, + long blockNumber, byte[] blockHash, byte[] parentHash, long timestamp, P66Phase phase, + byte[] transitionDigest) { + this.manifest = manifest; + this.publication = publication; + this.stores = stores; + this.root = root; + this.parent = parent; + this.blockNumber = blockNumber; + this.blockHash = copy32(blockHash, "blockHash"); + this.parentHash = copy32(parentHash, "parentHash"); + this.timestamp = timestamp; + this.phase = Objects.requireNonNull(phase, "phase"); + this.transitionDigest = copy32(transitionDigest, "transitionDigest"); + } + + /** Begins a child layer only when the supplied parent is the exact verified CURRENT record. */ + public static PathStateLayer begin(PathStateStoreManifest manifest, + PathStateRootMetadata parent, long blockNumber, byte[] blockHash, byte[] parentHash, + long timestamp, P66Phase phase, byte[] transitionDigest) throws IOException { + return begin(manifest, parent, blockNumber, blockHash, parentHash, timestamp, phase, + transitionDigest, PathStateLayerLimits.defaults()); + } + + public static PathStateLayer begin(PathStateStoreManifest manifest, + PathStateRootMetadata parent, long blockNumber, byte[] blockHash, byte[] parentHash, + long timestamp, P66Phase phase, byte[] transitionDigest, PathStateLayerLimits limits) + throws IOException { + return begin(manifest, parent, blockNumber, blockHash, parentHash, timestamp, phase, + transitionDigest, limits, stage -> { }); + } + + /** Begins a child from an explicitly retained, immutable in-process parent trie snapshot. */ + public static PathStateLayer beginFromSnapshot(PathStateStoreManifest manifest, + PathStateRootMetadata parent, PathStateRoot.Snapshot parentSnapshot, long blockNumber, + byte[] blockHash, byte[] parentHash, long timestamp, P66Phase phase, + byte[] transitionDigest) throws IOException { + return beginFromSnapshot(manifest, parent, parentSnapshot, blockNumber, blockHash, parentHash, + timestamp, phase, transitionDigest, PathStateLayerLimits.defaults()); + } + + public static PathStateLayer beginFromSnapshot(PathStateStoreManifest manifest, + PathStateRootMetadata parent, PathStateRoot.Snapshot parentSnapshot, long blockNumber, + byte[] blockHash, byte[] parentHash, long timestamp, P66Phase phase, + byte[] transitionDigest, PathStateLayerLimits limits) throws IOException { + return begin(manifest, parent, blockNumber, blockHash, parentHash, timestamp, phase, + transitionDigest, Objects.requireNonNull(limits, "limits"), stage -> { }, + Objects.requireNonNull(parentSnapshot, "parentSnapshot")); + } + + /** Begins durable publication from an immutable candidate computed without native I/O. */ + public static PathStateLayer beginPrepared(PathStateStoreManifest manifest, + PathStateRootMetadata parent, PreparedPathStateTransition prepared, + PathStateLayerLimits limits) throws IOException { + PreparedPathStateTransition candidate = Objects.requireNonNull(prepared, "prepared"); + if (!candidate.extendsParent(Objects.requireNonNull(parent, "parent"))) { + throw new IOException("path-state prepared transition parent is not CURRENT candidate"); + } + PathStateBlockTransition transition = candidate.getTransition(); + return begin(manifest, parent, transition.getBlockNumber(), transition.getBlockHash(), + transition.getParentHash(), transition.getTimestamp(), transition.getPhase(), + transition.getPayloadDigest(), Objects.requireNonNull(limits, "limits"), stage -> { }, + null, candidate); + } + + static PathStateLayer begin(PathStateStoreManifest manifest, + PathStateRootMetadata parent, long blockNumber, byte[] blockHash, byte[] parentHash, + long timestamp, P66Phase phase, byte[] transitionDigest, + PathStateLayerPublication.FaultHook faultHook) throws IOException { + return begin(manifest, parent, blockNumber, blockHash, parentHash, timestamp, phase, + transitionDigest, PathStateLayerLimits.defaults(), faultHook, null); + } + + static PathStateLayer begin(PathStateStoreManifest manifest, + PathStateRootMetadata parent, long blockNumber, byte[] blockHash, byte[] parentHash, + long timestamp, P66Phase phase, byte[] transitionDigest, PathStateLayerLimits limits, + PathStateLayerPublication.FaultHook faultHook) throws IOException { + return begin(manifest, parent, blockNumber, blockHash, parentHash, timestamp, phase, + transitionDigest, limits, faultHook, null); + } + + private static PathStateLayer begin(PathStateStoreManifest manifest, + PathStateRootMetadata parent, long blockNumber, byte[] blockHash, byte[] parentHash, + long timestamp, P66Phase phase, byte[] transitionDigest, PathStateLayerLimits limits, + PathStateLayerPublication.FaultHook faultHook, PathStateRoot.Snapshot parentSnapshot) + throws IOException { + return begin(manifest, parent, blockNumber, blockHash, parentHash, timestamp, phase, + transitionDigest, limits, faultHook, parentSnapshot, null); + } + + private static PathStateLayer begin(PathStateStoreManifest manifest, + PathStateRootMetadata parent, long blockNumber, byte[] blockHash, byte[] parentHash, + long timestamp, P66Phase phase, byte[] transitionDigest, PathStateLayerLimits limits, + PathStateLayerPublication.FaultHook faultHook, PathStateRoot.Snapshot parentSnapshot, + PreparedPathStateTransition preparedTransition) throws IOException { + PathStateStoreManifest admitted = Objects.requireNonNull(manifest, "manifest"); + PathStateLayerLimits admittedLimits = Objects.requireNonNull(limits, "limits"); + PathStateRootMetadata admittedParent = Objects.requireNonNull(parent, "parent"); + PathStateCurrentStore currentStore = new PathStateCurrentStore(admitted); + requireSame(admittedParent, currentStore.current(), + "path-state layer parent is not CURRENT"); + if (blockNumber != admittedParent.getBlockNumber() + 1 + || !Arrays.equals(parentHash, admittedParent.getBlockHash())) { + throw new IOException("path-state layer identity does not extend CURRENT"); + } + + PathStateRootMetadata identity = PathStateRootMetadata.layer(blockNumber, blockHash, + parentHash, timestamp, phase, admitted.getIdentityDigest(), + admittedParent.getStateRoot(), admittedParent.getStateRoot(), transitionDigest); + Path layerDirectory = admitted.getLayerDirectory(blockNumber, blockHash); + admittedLimits.verifyCanBegin(admitted, layerDirectory); + PathStateNodeStoreSet parentStores = + PathStateNodeStoreSet.openPublished(admitted, admittedParent); + PathStateNodeStoreSet childStores = null; + try { + PathStateRoot parentRoot = parentSnapshot == null && preparedTransition == null + ? parentStores.createRoot() : null; + childStores = PathStateNodeStoreSet.beginLayer(admitted, identity, parentStores); + PathStateRoot childRoot = preparedTransition != null + ? childStores.createRootFrom(preparedTransition) + : parentSnapshot == null + ? childStores.createRootFrom(parentRoot.snapshot(), parentRoot.rootHash()) + : childStores.createRootFrom(parentSnapshot, admittedParent.getStateRoot()); + return new PathStateLayer(admitted, + new PathStateLayerPublication(admitted, admittedLimits, faultHook), + childStores, childRoot, + admittedParent, blockNumber, blockHash, parentHash, timestamp, phase, + transitionDigest); + } catch (RuntimeException | IOException failure) { + PathStateNodeStoreSet owned = childStores == null ? parentStores : childStores; + try { + owned.close(); + } catch (IOException closeFailure) { + failure.addSuppressed(closeFailure); + } + throw failure; + } + } + + public synchronized void apply(Collection mutations) { + requireUncommitted(); + root.apply(mutations); + } + + /** Persists this layer's nodes/leaves/progress before publishing metadata and CURRENT. */ + public synchronized PathStateRootMetadata commit() throws IOException { + if (committed != null) { + return committed; + } + if (prepared == null) { + prepared = PathStateRootMetadata.layer(blockNumber, blockHash, parentHash, timestamp, phase, + manifest.getIdentityDigest(), parent.getStateRoot(), root.rootHash(), transitionDigest); + } + committed = publication.publish(stores, prepared); + return committed; + } + + public synchronized byte[] rootHash() { + return root.rootHash(); + } + + /** Returns a detached immutable trie snapshot only after this layer is durably CURRENT. */ + public synchronized PathStateRoot.Snapshot snapshot() { + if (committed == null) { + throw new IllegalStateException("path-state layer is not committed"); + } + return preparedSnapshot == null ? root.snapshot() : preparedSnapshot; + } + + synchronized PathStateRoot.Snapshot prepareSnapshot() { + if (committed != null) { + return snapshot(); + } + if (prepared == null) { + prepared = PathStateRootMetadata.layer(blockNumber, blockHash, parentHash, timestamp, phase, + manifest.getIdentityDigest(), parent.getStateRoot(), root.rootHash(), transitionDigest); + } + if (preparedSnapshot == null) { + preparedSnapshot = root.snapshot(); + } + if (!Arrays.equals(prepared.getStateRoot(), preparedSnapshot.getStateRoot())) { + throw new IllegalStateException("path-state prepared snapshot root mismatch"); + } + return preparedSnapshot; + } + + @Override + public synchronized void close() throws IOException { + stores.close(); + } + + private void requireUncommitted() { + if (prepared != null) { + throw new IllegalStateException("path-state layer is already frozen for commit"); + } + } + + private static void requireSame(PathStateRootMetadata expected, + PathStateRootMetadata actual, String error) throws IOException { + if (!Arrays.equals(expected.encode(), actual.encode())) { + throw new IOException(error); + } + } + + private static byte[] copy32(byte[] value, String name) { + byte[] copy = Arrays.copyOf(Objects.requireNonNull(value, name), value.length); + if (copy.length != PathStateRootMetadata.DIGEST_LENGTH) { + throw new IllegalArgumentException(name + " must be exactly 32 bytes"); + } + return copy; + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateLayerLimits.java b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateLayerLimits.java new file mode 100644 index 00000000000..7ca64d37daf --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateLayerLimits.java @@ -0,0 +1,154 @@ +package org.tron.core.db2.stateroot; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Objects; +import java.util.stream.Stream; +import org.tron.core.db2.stateroot.PathStateRootMetadata.Kind; + +/** Explicit count and logical-byte admission limits for current-only reversible layers. */ +public final class PathStateLayerLimits { + + public static final int DEFAULT_MAX_LAYERS = 128; + public static final long DEFAULT_MAX_LOGICAL_BYTES = 1L << 40; + + private final int maxLayers; + private final long maxLogicalBytes; + + public PathStateLayerLimits(int maxLayers, long maxLogicalBytes) { + if (maxLayers <= 0 || maxLogicalBytes <= 0) { + throw new IllegalArgumentException("path-state layer limits must be positive"); + } + this.maxLayers = maxLayers; + this.maxLogicalBytes = maxLogicalBytes; + } + + public static PathStateLayerLimits defaults() { + return new PathStateLayerLimits(DEFAULT_MAX_LAYERS, DEFAULT_MAX_LOGICAL_BYTES); + } + + void verifyCanBegin(PathStateStoreManifest manifest, Path candidate) throws IOException { + Usage usage = usageExcluding(manifest, candidate); + try { + requireWithin(Math.addExact(usage.layers, 1), usage.logicalBytes); + } catch (ArithmeticException overflow) { + throw new IOException("path-state layer count overflow", overflow); + } + } + + void verifyAdmission(PathStateStoreManifest manifest, Path candidate, + PathStateRootMetadata metadata, long nativeLogicalBytes) throws IOException { + Usage usage = usageExcluding(manifest, candidate); + long candidateBytes; + try { + candidateBytes = Math.addExact(nativeLogicalBytes, metadata.encode().length); + requireWithin(Math.addExact(usage.layers, 1), + Math.addExact(usage.logicalBytes, candidateBytes)); + } catch (ArithmeticException overflow) { + throw new IOException("path-state layer limit accounting overflow", overflow); + } + } + + void verifyExisting(PathStateStoreManifest manifest) throws IOException { + Usage usage = usageExcluding(manifest, null); + requireWithin(usage.layers, usage.logicalBytes); + } + + public int getMaxLayers() { + return maxLayers; + } + + public long getMaxLogicalBytes() { + return maxLogicalBytes; + } + + private Usage usageExcluding(PathStateStoreManifest manifest, Path excluded) + throws IOException { + PathStateStoreManifest admitted = Objects.requireNonNull(manifest, "manifest"); + Path omitted = excluded == null ? null : excluded.toAbsolutePath().normalize(); + int layers = 0; + long logicalBytes = 0; + try (Stream paths = Files.list(admitted.getLayersDirectory())) { + for (Path entry : (Iterable) paths::iterator) { + if (!Files.isDirectory(entry, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(entry)) { + throw new IOException("path-state layer entry is not a direct directory: " + entry); + } + if (omitted != null && omitted.equals(entry.toAbsolutePath().normalize())) { + continue; + } + Path metadataPath = entry.resolve(PathStateCurrentStore.METADATA_FILE); + Path intentPath = entry.resolve(PathStateLayerPublication.INTENT_FILE); + PathStateRootMetadata metadata = Files.exists(metadataPath, LinkOption.NOFOLLOW_LINKS) + ? requireLayer(admitted, entry, PathStateMetadataFile.load(metadataPath)) : null; + PathStateRootMetadata intent = Files.exists(intentPath, LinkOption.NOFOLLOW_LINKS) + ? requireLayer(admitted, entry, PathStateMetadataFile.load(intentPath)) : null; + PathStateRootMetadata progress = PathStateNodeStoreSet.loadProgress(entry, admitted); + Long nativeBytes = PathStateNodeStoreSet.loadLogicalBytes(entry, admitted); + if ((progress == null) != (nativeBytes == null)) { + throw new IOException("path-state layer progress and logical bytes marker differ"); + } + if (metadata != null) { + requireSame(metadata, progress, + "path-state layer metadata and native progress differ"); + layers = Math.addExact(layers, 1); + logicalBytes = Math.addExact(logicalBytes, + Math.addExact(nativeBytes, metadata.encode().length)); + } else if (intent != null && progress != null) { + requireSame(intent, progress, + "path-state layer intent and native progress differ"); + layers = Math.addExact(layers, 1); + logicalBytes = Math.addExact(logicalBytes, + Math.addExact(nativeBytes, intent.encode().length)); + } else if (progress != null) { + throw new IOException("path-state layer has orphaned native progress"); + } + } + } catch (ArithmeticException overflow) { + throw new IOException("path-state layer limit accounting overflow", overflow); + } + return new Usage(layers, logicalBytes); + } + + private void requireWithin(int layers, long logicalBytes) throws IOException { + if (layers > maxLayers) { + throw new IOException("path-state layer count limit exceeded: " + layers + " > " + + maxLayers); + } + if (logicalBytes > maxLogicalBytes) { + throw new IOException("path-state layer logical bytes limit exceeded: " + logicalBytes + + " > " + maxLogicalBytes); + } + } + + private static PathStateRootMetadata requireLayer(PathStateStoreManifest manifest, + Path directory, PathStateRootMetadata metadata) throws IOException { + if (metadata.getKind() != Kind.LAYER + || !Arrays.equals(metadata.getFormatDigest(), manifest.getIdentityDigest()) + || !directory.equals(manifest.getLayerDirectory( + metadata.getBlockNumber(), metadata.getBlockHash()))) { + throw new IOException("path-state layer limit record identity mismatch"); + } + return metadata; + } + + private static void requireSame(PathStateRootMetadata expected, + PathStateRootMetadata actual, String error) throws IOException { + if (actual == null || !Arrays.equals(expected.encode(), actual.encode())) { + throw new IOException(error); + } + } + + private static final class Usage { + + private final int layers; + private final long logicalBytes; + + private Usage(int layers, long logicalBytes) { + this.layers = layers; + this.logicalBytes = logicalBytes; + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateLayerPublication.java b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateLayerPublication.java new file mode 100644 index 00000000000..b0938c810d2 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateLayerPublication.java @@ -0,0 +1,252 @@ +package org.tron.core.db2.stateroot; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; +import java.util.stream.Stream; +import org.tron.core.db2.stateroot.PathStateRootMetadata.Kind; + +/** Crash-recoverable publication boundary for one immutable reversible path-state layer. */ +public final class PathStateLayerPublication { + + public static final String INTENT_FILE = "INTENT"; + + private final PathStateStoreManifest manifest; + private final PathStateCurrentStore currentStore; + private final PathStateLayerLimits limits; + private final FaultHook faultHook; + + public PathStateLayerPublication(PathStateStoreManifest manifest) { + this(manifest, PathStateLayerLimits.defaults()); + } + + public PathStateLayerPublication(PathStateStoreManifest manifest, + PathStateLayerLimits limits) { + this(manifest, limits, stage -> { }); + } + + PathStateLayerPublication(PathStateStoreManifest manifest, FaultHook faultHook) { + this(manifest, PathStateLayerLimits.defaults(), faultHook); + } + + PathStateLayerPublication(PathStateStoreManifest manifest, PathStateLayerLimits limits, + FaultHook faultHook) { + this.manifest = Objects.requireNonNull(manifest, "manifest"); + this.currentStore = new PathStateCurrentStore(manifest); + this.limits = Objects.requireNonNull(limits, "limits"); + this.faultHook = Objects.requireNonNull(faultHook, "faultHook"); + } + + /** Publishes one layer through intent, native progress, metadata, CURRENT, and retire stages. */ + public synchronized PathStateRootMetadata publish(PathStateNodeStoreSet stores, + PathStateRootMetadata metadata) throws IOException { + PathStateRootMetadata layer = requireLayer(metadata); + Path directory = layerDirectory(layer); + Path expectedNodes = directory.resolve(PathStateNodeStoreSet.NODES_DIRECTORY) + .toAbsolutePath().normalize(); + PathStateNodeStoreSet nodeStores = Objects.requireNonNull(stores, "stores"); + if (!expectedNodes.equals(nodeStores.getDirectory().toAbsolutePath().normalize())) { + throw new IllegalArgumentException("path-state LAYER node database directory mismatch"); + } + requireCurrentParentOrChild(layer); + nodeStores.resolvePendingLeafValues(); + nodeStores.releaseParentReadHandles(); + limits.verifyAdmission(manifest, directory, layer, + nodeStores.projectedLogicalBytes(layer)); + + Path intent = directory.resolve(INTENT_FILE); + PathStateMetadataFile.publishImmutable(intent, layer); + faultHook.after(Stage.AFTER_INTENT); + nodeStores.commit(layer); + faultHook.after(Stage.AFTER_NODE_PROGRESS); + PathStateMetadataFile.publishImmutable( + directory.resolve(PathStateCurrentStore.METADATA_FILE), layer); + faultHook.after(Stage.AFTER_METADATA); + PathStateRootMetadata current = currentStore.current(); + if (!same(current, layer)) { + current = currentStore.appendLayer(layer); + } + faultHook.after(Stage.AFTER_CURRENT); + PathStateMetadataFile.deleteDurable(intent); + faultHook.after(Stage.AFTER_RETIRE); + return current; + } + + /** Reconciles the sole unfinished layer intent and verifies all settled layer authorities. */ + public synchronized RecoveryAction recover() throws IOException { + limits.verifyExisting(manifest); + List layers = scanLayers(); + LayerState pending = null; + for (LayerState layer : layers) { + layer.verifySettledOrIntent(); + if (layer.intent != null) { + if (pending != null) { + throw new IOException("multiple unfinished path-state layer intents"); + } + pending = layer; + } + } + if (pending == null) { + verifyCurrentProgress(); + return RecoveryAction.NONE; + } + + PathStateRootMetadata intent = pending.intent; + PathStateRootMetadata current = currentStore.current(); + if (!same(current, intent) && !isParent(current, intent)) { + throw new IOException("path-state layer intent no longer extends CURRENT"); + } + if (pending.progress == null) { + if (same(current, intent)) { + throw new IOException("path-state CURRENT layer exists without native progress"); + } + PathStateMetadataFile.deleteDurable(pending.intentPath); + verifyCurrentProgress(); + limits.verifyExisting(manifest); + return RecoveryAction.ROLLED_BACK_INTENT; + } + + requireSame(intent, pending.progress, + "path-state layer intent and native progress differ"); + if (!same(current, intent)) { + currentStore.appendLayer(intent); + } + PathStateMetadataFile.deleteDurable(pending.intentPath); + verifyCurrentProgress(); + limits.verifyExisting(manifest); + return RecoveryAction.COMPLETED_PUBLICATION; + } + + private List scanLayers() throws IOException { + List entries = new ArrayList<>(); + try (Stream paths = Files.list(manifest.getLayersDirectory())) { + paths.sorted(Comparator.comparing(path -> path.getFileName().toString())) + .forEach(entries::add); + } + List layers = new ArrayList<>(entries.size()); + for (Path entry : entries) { + if (!Files.isDirectory(entry, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(entry)) { + throw new IOException("path-state layer entry is not a direct directory: " + entry); + } + layers.add(new LayerState(entry)); + } + return layers; + } + + private void verifyCurrentProgress() throws IOException { + PathStateRootMetadata current = currentStore.current(); + Path owner = current.getKind() == Kind.BASE ? manifest.getBaseDirectory() + : layerDirectory(current); + PathStateRootMetadata progress = PathStateNodeStoreSet.loadProgress(owner, manifest); + if (progress == null) { + throw new IOException("path-state CURRENT has no native progress"); + } + requireSame(current, progress, "path-state CURRENT and native progress differ"); + try (PathStateNodeStoreSet stores = PathStateNodeStoreSet.openPublished(manifest, current)) { + stores.createRoot(); + } + } + + private void requireCurrentParentOrChild(PathStateRootMetadata layer) throws IOException { + PathStateRootMetadata current = currentStore.current(); + if (!same(current, layer) && !isParent(current, layer)) { + throw new IOException("path-state LAYER publication does not extend CURRENT"); + } + } + + private PathStateRootMetadata requireLayer(PathStateRootMetadata metadata) throws IOException { + PathStateRootMetadata layer = Objects.requireNonNull(metadata, "metadata"); + if (layer.getKind() != Kind.LAYER + || !Arrays.equals(layer.getFormatDigest(), manifest.getIdentityDigest())) { + throw new IOException("path-state LAYER publication identity mismatch"); + } + return layer; + } + + private Path layerDirectory(PathStateRootMetadata metadata) { + return manifest.getLayerDirectory(metadata.getBlockNumber(), metadata.getBlockHash()); + } + + private static boolean isParent(PathStateRootMetadata parent, PathStateRootMetadata child) { + return child.getBlockNumber() == parent.getBlockNumber() + 1 + && Arrays.equals(child.getParentHash(), parent.getBlockHash()) + && Arrays.equals(child.getParentStateRoot(), parent.getStateRoot()); + } + + private static boolean same(PathStateRootMetadata left, PathStateRootMetadata right) { + return Arrays.equals(left.encode(), right.encode()); + } + + private static void requireSame(PathStateRootMetadata expected, + PathStateRootMetadata actual, String error) throws IOException { + if (!same(expected, actual)) { + throw new IOException(error); + } + } + + public enum RecoveryAction { + NONE, + ROLLED_BACK_INTENT, + COMPLETED_PUBLICATION + } + + enum Stage { + AFTER_INTENT, + AFTER_NODE_PROGRESS, + AFTER_METADATA, + AFTER_CURRENT, + AFTER_RETIRE + } + + @FunctionalInterface + interface FaultHook { + + void after(Stage stage) throws IOException; + } + + private final class LayerState { + + private final Path directory; + private final Path intentPath; + private final PathStateRootMetadata intent; + private final PathStateRootMetadata metadata; + private final PathStateRootMetadata progress; + + private LayerState(Path directory) throws IOException { + this.directory = directory; + this.intentPath = directory.resolve(INTENT_FILE); + Path metadataPath = directory.resolve(PathStateCurrentStore.METADATA_FILE); + this.intent = Files.exists(intentPath, LinkOption.NOFOLLOW_LINKS) + ? requireLayer(PathStateMetadataFile.load(intentPath)) : null; + this.metadata = Files.exists(metadataPath, LinkOption.NOFOLLOW_LINKS) + ? requireLayer(PathStateMetadataFile.load(metadataPath)) : null; + this.progress = PathStateNodeStoreSet.loadProgress(directory, manifest); + } + + private void verifySettledOrIntent() throws IOException { + PathStateRootMetadata identity = intent != null ? intent : metadata; + if (identity != null && !directory.equals(layerDirectory(identity))) { + throw new IOException("path-state layer record is in a noncanonical directory"); + } + if (metadata != null) { + if (progress == null) { + throw new IOException("path-state layer metadata exists without native progress"); + } + requireSame(metadata, progress, + "path-state layer metadata and native progress differ"); + } else if (progress != null && intent == null) { + throw new IOException("path-state layer has orphaned native progress"); + } + if (intent != null && metadata != null) { + requireSame(intent, metadata, + "path-state layer intent and metadata differ"); + } + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateLayerRetirement.java b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateLayerRetirement.java new file mode 100644 index 00000000000..3b7822c24c5 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateLayerRetirement.java @@ -0,0 +1,281 @@ +package org.tron.core.db2.stateroot; + +import com.google.common.hash.Hashing; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; +import java.util.stream.Stream; +import org.tron.core.db2.stateroot.PathStateRootMetadata.Kind; + +/** Crash-recoverable removal of the old canonical suffix after an ancestor switch. */ +public final class PathStateLayerRetirement { + + public static final String INTENT_FILE = "RETIRE_INTENT"; + + private static final int MAGIC = 0x50535254; // PSRT + private static final short VERSION = 1; + private static final int MAX_PLAN_LENGTH = 32 * 1024 * 1024; + + private final PathStateStoreManifest manifest; + private final PathStateCurrentStore currentStore; + private final PathStateLayerLimits limits; + private final FaultHook faultHook; + private final Path intentPath; + + public PathStateLayerRetirement(PathStateStoreManifest manifest, + PathStateLayerLimits limits) { + this(manifest, limits, stage -> { }); + } + + PathStateLayerRetirement(PathStateStoreManifest manifest, PathStateLayerLimits limits, + FaultHook faultHook) { + this.manifest = Objects.requireNonNull(manifest, "manifest"); + this.currentStore = new PathStateCurrentStore(manifest); + this.limits = Objects.requireNonNull(limits, "limits"); + this.faultHook = Objects.requireNonNull(faultHook, "faultHook"); + this.intentPath = manifest.getDirectory().resolve(INTENT_FILE); + } + + /** Switches to an exact ancestor and durably removes only the old canonical suffix. */ + public synchronized PathStateRootMetadata switchToAncestor(PathStateRootMetadata target) + throws IOException { + if (Files.exists(intentPath, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("unfinished path-state layer retirement requires recovery"); + } + List victims = currentStore.layersAboveAncestor(target, limits); + if (victims.isEmpty()) { + return currentStore.current(); + } + RetirementPlan plan = new RetirementPlan(target, victims); + plan.verify(manifest); + PathStateMetadataFile.publishImmutableBytes(intentPath, plan.encode()); + faultHook.after(Stage.AFTER_INTENT); + PathStateRootMetadata current = currentStore.switchToAncestor(target, limits); + faultHook.after(Stage.AFTER_CURRENT); + retire(plan); + PathStateMetadataFile.deleteDurable(intentPath); + faultHook.after(Stage.AFTER_RETIRE); + limits.verifyExisting(manifest); + return current; + } + + /** Completes one durable switch/retire plan and becomes a zero-action retry. */ + public synchronized RecoveryAction recover() throws IOException { + if (!Files.exists(intentPath, LinkOption.NOFOLLOW_LINKS)) { + limits.verifyExisting(manifest); + return RecoveryAction.NONE; + } + RetirementPlan plan = RetirementPlan.decode( + PathStateMetadataFile.loadImmutableBytes(intentPath, MAX_PLAN_LENGTH)); + plan.verify(manifest); + PathStateRootMetadata current = currentStore.current(); + if (same(current, plan.victims.get(0))) { + currentStore.switchToAncestor(plan.target, limits); + } else if (!same(current, plan.target)) { + throw new IOException("path-state retirement plan does not own CURRENT"); + } + retire(plan); + PathStateMetadataFile.deleteDurable(intentPath); + limits.verifyExisting(manifest); + return RecoveryAction.COMPLETED_RETIREMENT; + } + + private void retire(RetirementPlan plan) throws IOException { + for (PathStateRootMetadata victim : plan.victims) { + deleteLayer(victim); + faultHook.after(Stage.AFTER_LAYER_RETIRE); + } + } + + private void deleteLayer(PathStateRootMetadata victim) throws IOException { + Path directory = manifest.getLayerDirectory(victim.getBlockNumber(), victim.getBlockHash()); + if (!Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) { + return; + } + if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS) + || Files.isSymbolicLink(directory)) { + throw new IOException("path-state retirement target is not a direct directory"); + } + Path metadata = directory.resolve(PathStateCurrentStore.METADATA_FILE); + if (Files.exists(metadata, LinkOption.NOFOLLOW_LINKS)) { + PathStateMetadataFile.requireExact(metadata, victim); + } + List entries = new ArrayList<>(); + try (Stream paths = Files.walk(directory)) { + paths.forEach(entries::add); + } + for (Path entry : entries) { + if (Files.isSymbolicLink(entry)) { + throw new IOException("path-state retirement refuses symbolic links: " + entry); + } + } + entries.sort(Comparator.reverseOrder()); + for (Path entry : entries) { + Files.deleteIfExists(entry); + faultHook.after(Stage.AFTER_LAYER_ENTRY_DELETE); + } + PathStateMetadataFile.syncDirectory(manifest.getLayersDirectory()); + } + + public enum RecoveryAction { + NONE, + COMPLETED_RETIREMENT + } + + enum Stage { + AFTER_INTENT, + AFTER_CURRENT, + AFTER_LAYER_ENTRY_DELETE, + AFTER_LAYER_RETIRE, + AFTER_RETIRE + } + + @FunctionalInterface + interface FaultHook { + + void after(Stage stage) throws IOException; + } + + private static final class RetirementPlan { + + private final PathStateRootMetadata target; + private final List victims; + + private RetirementPlan(PathStateRootMetadata target, + List victims) { + this.target = Objects.requireNonNull(target, "target"); + this.victims = new ArrayList<>(Objects.requireNonNull(victims, "victims")); + if (this.victims.isEmpty()) { + throw new IllegalArgumentException("path-state retirement plan requires victims"); + } + } + + private void verify(PathStateStoreManifest manifest) throws IOException { + requireFormat(target, manifest); + PathStateRootMetadata child = null; + for (PathStateRootMetadata victim : victims) { + requireFormat(victim, manifest); + if (victim.getKind() != Kind.LAYER) { + throw new IOException("path-state retirement victim is not a layer"); + } + if (child != null && !isParent(victim, child)) { + throw new IOException("path-state retirement suffix is not contiguous"); + } + child = victim; + } + if (!isParent(target, victims.get(victims.size() - 1))) { + throw new IOException("path-state retirement target does not precede its suffix"); + } + } + + private byte[] encode() { + try { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + DataOutputStream output = new DataOutputStream(bytes); + output.writeInt(MAGIC); + output.writeShort(VERSION); + output.writeShort(0); + output.writeInt(0); + output.writeInt(victims.size()); + writeMetadata(output, target); + for (PathStateRootMetadata victim : victims) { + writeMetadata(output, victim); + } + output.flush(); + byte[] payload = bytes.toByteArray(); + ByteBuffer.wrap(payload).putInt(8, payload.length + Integer.BYTES); + bytes.reset(); + output = new DataOutputStream(bytes); + output.write(payload); + output.writeInt(Hashing.crc32c().hashBytes(payload).asInt()); + output.flush(); + byte[] encoded = bytes.toByteArray(); + if (encoded.length > MAX_PLAN_LENGTH) { + throw new IllegalArgumentException("path-state retirement plan is too large"); + } + return encoded; + } catch (IOException impossible) { + throw new IllegalStateException("in-memory retirement plan encoding failed", impossible); + } + } + + private static RetirementPlan decode(byte[] encoded) throws IOException { + byte[] value = Arrays.copyOf(Objects.requireNonNull(encoded, "encoded"), encoded.length); + if (value.length <= Integer.BYTES || value.length > MAX_PLAN_LENGTH) { + throw new IOException("path-state retirement plan length is invalid"); + } + byte[] payload = Arrays.copyOf(value, value.length - Integer.BYTES); + int checksum = ByteBuffer.wrap(value, payload.length, Integer.BYTES).getInt(); + if (checksum != Hashing.crc32c().hashBytes(payload).asInt()) { + throw new IOException("path-state retirement plan checksum mismatch"); + } + try (DataInputStream input = new DataInputStream(new ByteArrayInputStream(value))) { + if (input.readInt() != MAGIC || input.readShort() != VERSION || input.readShort() != 0 + || input.readInt() != value.length) { + throw new IOException("unsupported path-state retirement plan header"); + } + int count = input.readInt(); + if (count <= 0 || count > PathStateCurrentStore.MAX_VALIDATION_LAYERS) { + throw new IOException("path-state retirement plan count is invalid"); + } + PathStateRootMetadata target = readMetadata(input); + List victims = new ArrayList<>(count); + for (int index = 0; index < count; index++) { + victims.add(readMetadata(input)); + } + if (input.available() != Integer.BYTES) { + throw new IOException("path-state retirement plan payload mismatch"); + } + return new RetirementPlan(target, victims); + } catch (IllegalArgumentException invalid) { + throw new IOException("path-state retirement plan metadata is corrupt", invalid); + } + } + + private static void writeMetadata(DataOutputStream output, PathStateRootMetadata metadata) + throws IOException { + byte[] encoded = metadata.encode(); + output.writeInt(encoded.length); + output.write(encoded); + } + + private static PathStateRootMetadata readMetadata(DataInputStream input) throws IOException { + int length = input.readInt(); + if (length <= 0 || length > input.available() - Integer.BYTES) { + throw new IOException("path-state retirement metadata length is invalid"); + } + byte[] encoded = new byte[length]; + input.readFully(encoded); + return PathStateRootMetadata.decode(encoded); + } + + private static void requireFormat(PathStateRootMetadata metadata, + PathStateStoreManifest manifest) throws IOException { + if (!Arrays.equals(metadata.getFormatDigest(), manifest.getIdentityDigest())) { + throw new IOException("path-state retirement metadata identity mismatch"); + } + } + + private static boolean isParent(PathStateRootMetadata parent, + PathStateRootMetadata child) { + return child.getBlockNumber() == parent.getBlockNumber() + 1 + && Arrays.equals(child.getParentHash(), parent.getBlockHash()) + && Arrays.equals(child.getParentStateRoot(), parent.getStateRoot()); + } + } + + private static boolean same(PathStateRootMetadata left, PathStateRootMetadata right) { + return Arrays.equals(left.encode(), right.encode()); + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateMetadataFile.java b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateMetadataFile.java new file mode 100644 index 00000000000..bc86e3d83cb --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateMetadataFile.java @@ -0,0 +1,173 @@ +package org.tron.core.db2.stateroot; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.util.Arrays; +import java.util.Objects; +import java.util.UUID; + +/** Atomic-file boundary for immutable BASE/LAYER metadata and the replaceable CURRENT authority. */ +final class PathStateMetadataFile { + + private PathStateMetadataFile() { + } + + static PathStateRootMetadata load(Path path) throws IOException { + Path target = Objects.requireNonNull(path, "path"); + if (!Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("path-state metadata is missing or not a regular file: " + target); + } + try { + return PathStateRootMetadata.decode(Files.readAllBytes(target)); + } catch (IllegalArgumentException invalid) { + throw new IOException("path-state metadata is corrupt: " + target, invalid); + } + } + + static byte[] loadImmutableBytes(Path path, int maxLength) throws IOException { + Path target = Objects.requireNonNull(path, "path"); + if (!Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("path-state immutable record is missing or not a regular file: " + + target); + } + long length = Files.size(target); + if (length <= 0 || length > maxLength) { + throw new IOException("path-state immutable record length is invalid: " + target); + } + return Files.readAllBytes(target); + } + + /** Publishes once; an exact existing record is an idempotent retry, not a rewrite. */ + static void publishImmutable(Path path, PathStateRootMetadata metadata) throws IOException { + Path target = Objects.requireNonNull(path, "path"); + byte[] encoded = Objects.requireNonNull(metadata, "metadata").encode(); + if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) { + requireExact(target, encoded); + return; + } + publish(target, encoded, false, temporary -> { }); + } + + static void publishImmutableBytes(Path path, byte[] encoded) throws IOException { + Path target = Objects.requireNonNull(path, "path"); + byte[] value = Arrays.copyOf(Objects.requireNonNull(encoded, "encoded"), encoded.length); + if (value.length == 0) { + throw new IllegalArgumentException("path-state immutable record must not be empty"); + } + if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) { + requireExactBytes(target, value); + return; + } + publish(target, value, false, temporary -> { }); + } + + static void replaceCurrent(Path path, PathStateRootMetadata metadata) throws IOException { + replaceCurrent(path, metadata, temporary -> { }); + } + + static void replaceCurrentBytes(Path path, byte[] encoded) throws IOException { + byte[] value = Arrays.copyOf(Objects.requireNonNull(encoded, "encoded"), encoded.length); + if (value.length == 0) { + throw new IllegalArgumentException("path-state CURRENT record must not be empty"); + } + publish(Objects.requireNonNull(path, "path"), value, true, temporary -> { }); + } + + static void replaceCurrent(Path path, PathStateRootMetadata metadata, FaultHook faultHook) + throws IOException { + publish(Objects.requireNonNull(path, "path"), + Objects.requireNonNull(metadata, "metadata").encode(), true, + Objects.requireNonNull(faultHook, "faultHook")); + } + + static void requireExact(Path path, PathStateRootMetadata metadata) throws IOException { + requireExact(path, Objects.requireNonNull(metadata, "metadata").encode()); + } + + static void deleteDurable(Path path) throws IOException { + Path target = Objects.requireNonNull(path, "path"); + Path directory = Objects.requireNonNull(target.getParent(), "metadata directory"); + if (Files.deleteIfExists(target)) { + syncDirectory(directory); + } + } + + private static void requireExact(Path path, byte[] expected) throws IOException { + if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("path-state metadata is not a regular file: " + path); + } + byte[] actual = Files.readAllBytes(path); + try { + PathStateRootMetadata.decode(actual); + } catch (IllegalArgumentException invalid) { + throw new IOException("path-state metadata is corrupt: " + path, invalid); + } + if (!Arrays.equals(expected, actual)) { + throw new IOException("immutable path-state metadata identity mismatch: " + path); + } + } + + private static void requireExactBytes(Path path, byte[] expected) throws IOException { + if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS) + || !Arrays.equals(expected, Files.readAllBytes(path))) { + throw new IOException("immutable path-state record identity mismatch: " + path); + } + } + + private static void publish(Path target, byte[] encoded, boolean replace, FaultHook faultHook) + throws IOException { + Path directory = Objects.requireNonNull(target.getParent(), "metadata directory"); + Files.createDirectories(directory); + if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS) + || Files.isSymbolicLink(directory)) { + throw new IOException("path-state metadata parent is not a direct directory: " + directory); + } + Path temporary = directory.resolve("." + target.getFileName() + "-" + UUID.randomUUID()); + try { + try (FileChannel channel = FileChannel.open(temporary, StandardOpenOption.CREATE_NEW, + StandardOpenOption.WRITE)) { + writeFully(channel, ByteBuffer.wrap(encoded)); + channel.force(true); + } + faultHook.afterTemporaryForce(temporary); + try { + if (replace) { + Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING); + } else { + Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE); + } + } catch (AtomicMoveNotSupportedException unsupported) { + throw new IOException("path-state metadata requires atomic publication", unsupported); + } + syncDirectory(directory); + } finally { + Files.deleteIfExists(temporary); + } + } + + private static void writeFully(FileChannel channel, ByteBuffer buffer) throws IOException { + while (buffer.hasRemaining()) { + channel.write(buffer); + } + } + + static void syncDirectory(Path directory) throws IOException { + try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) { + channel.force(true); + } + } + + @FunctionalInterface + interface FaultHook { + + void afterTemporaryForce(Path temporary) throws IOException; + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateMutation.java b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateMutation.java new file mode 100644 index 00000000000..b92dc148502 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateMutation.java @@ -0,0 +1,92 @@ +package org.tron.core.db2.stateroot; + +import java.util.Arrays; +import java.util.Objects; + +/** Immutable current-state mutation consumed by {@link PathStateRoot}. */ +public final class PathStateMutation { + + private final String dbName; + private final byte[] physicalKey; + private final byte[] physicalValue; + private final boolean previousValueKnown; + private final byte[] previousPhysicalValue; + + private PathStateMutation(String dbName, byte[] physicalKey, byte[] physicalValue) { + this(dbName, physicalKey, physicalValue, false, null); + } + + private PathStateMutation(String dbName, byte[] physicalKey, byte[] physicalValue, + boolean previousValueKnown, byte[] previousPhysicalValue) { + this.dbName = Objects.requireNonNull(dbName, "dbName"); + this.physicalKey = copy(physicalKey, "physicalKey"); + this.physicalValue = physicalValue == null ? null + : Arrays.copyOf(physicalValue, physicalValue.length); + this.previousValueKnown = previousValueKnown; + this.previousPhysicalValue = previousPhysicalValue == null ? null + : Arrays.copyOf(previousPhysicalValue, previousPhysicalValue.length); + } + + public static PathStateMutation put(String dbName, byte[] physicalKey, byte[] physicalValue) { + return new PathStateMutation(dbName, physicalKey, + Objects.requireNonNull(physicalValue, "physicalValue")); + } + + public static PathStateMutation delete(String dbName, byte[] physicalKey) { + return new PathStateMutation(dbName, physicalKey, null); + } + + /** Returns an immutable copy carrying the block pre-state value, or known absence. */ + public PathStateMutation withPreviousPhysicalValue(byte[] previousValue) { + return new PathStateMutation(dbName, physicalKey, physicalValue, true, previousValue); + } + + public String getDbName() { + return dbName; + } + + /** Exact physical key bytes supplied by the Chainbase mutation/source boundary. */ + public byte[] getPhysicalKey() { + return Arrays.copyOf(physicalKey, physicalKey.length); + } + + /** Exact physical value bytes, or {@code null} for an absent/delete mutation. */ + public byte[] getPhysicalValue() { + return physicalValue == null ? null : Arrays.copyOf(physicalValue, physicalValue.length); + } + + /** @deprecated Use {@link #getPhysicalKey()}; this alias is retained for old-format callers. */ + @Deprecated + public byte[] getCanonicalKey() { + return getPhysicalKey(); + } + + public boolean isDelete() { + return physicalValue == null; + } + + /** Whether the block snapshot supplied an authoritative pre-state value for this key. */ + public boolean isPreviousValueKnown() { + return previousValueKnown; + } + + /** Exact pre-state bytes, or {@code null} when the authoritative pre-state is absent. */ + public byte[] getPreviousPhysicalValue() { + if (!previousValueKnown) { + throw new IllegalStateException("previous physical value is unknown"); + } + return previousPhysicalValue == null ? null + : Arrays.copyOf(previousPhysicalValue, previousPhysicalValue.length); + } + + /** @deprecated Use {@link #getPhysicalValue()}; this alias is retained for old-format callers. */ + @Deprecated + public byte[] getCanonicalValue() { + return getPhysicalValue(); + } + + private static byte[] copy(byte[] value, String name) { + byte[] source = Objects.requireNonNull(value, name); + return Arrays.copyOf(source, source.length); + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateNativeNodeStore.java b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateNativeNodeStore.java new file mode 100644 index 00000000000..238249b4dc8 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateNativeNodeStore.java @@ -0,0 +1,447 @@ +package org.tron.core.db2.stateroot; + +import static org.fusesource.leveldbjni.JniDBFactory.factory; + +import java.io.Closeable; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import org.iq80.leveldb.DB; +import org.iq80.leveldb.WriteOptions; +import org.rocksdb.BlockBasedTableConfig; +import org.rocksdb.BloomFilter; +import org.rocksdb.ChecksumType; +import org.rocksdb.CompressionType; +import org.rocksdb.LRUCache; +import org.rocksdb.RocksDBException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.tron.core.config.args.StorageConfig.NativeDbConfig; +import org.tron.core.db2.stateroot.PathStateStoreManifest.Engine; + +/** Package-owned LevelDB/RocksDB key/value engine shared by namespaced path-node views. */ +final class PathStateNativeNodeStore implements Closeable { + + private static final Logger logger = LoggerFactory.getLogger("DB"); + + static { + org.rocksdb.RocksDB.loadLibrary(); + } + + private final Path directory; + private final Engine engine; + private final String storageProfile; + private final Delegate delegate; + private long writeBatchCalls; + private long writeBatchMutations; + private long syncedWriteBatchCalls; + private long unsyncedWriteBatchCalls; + private volatile boolean closed; + + private PathStateNativeNodeStore(Path directory, Engine engine, String storageProfile, + Delegate delegate) { + this.directory = directory; + this.engine = engine; + this.storageProfile = storageProfile; + this.delegate = delegate; + } + + /** Opens one independent node database; callers choose the WAL sync boundary per batch. */ + static PathStateNativeNodeStore open(Path directory, Engine engine) throws IOException { + return open(directory, engine, "small", NativeDbConfig.small()); + } + + /** Opens one independent database with an explicit validated resource profile. */ + static PathStateNativeNodeStore open(Path directory, Engine engine, String storageProfile, + NativeDbConfig config) throws IOException { + Path path = Objects.requireNonNull(directory, "directory").toAbsolutePath().normalize(); + Engine selected = Objects.requireNonNull(engine, "engine"); + String profile = Objects.requireNonNull(storageProfile, "storageProfile"); + NativeDbConfig settings = Objects.requireNonNull(config, "config"); + if (Files.isSymbolicLink(path)) { + throw new IOException("path-state node database must not be a symbolic link: " + path); + } + Files.createDirectories(path); + if (!Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("path-state node database is not a directory: " + path); + } + Delegate opened = selected == Engine.LEVELDB ? new LevelDelegate(path, settings) + : new RocksDelegate(path, settings); + logger.info("Path-state database opened: directory={}, engine={}, profile={}, blockBytes={}, " + + "writeBufferBytes={}, cacheBytes={}, maxOpenFiles={}", path, selected, profile, + settings.getBlockSize(), settings.getWriteBufferSize(), settings.getCacheSize(), + settings.getMaxOpenFiles()); + return new PathStateNativeNodeStore(path, selected, profile, opened); + } + + byte[] get(byte[] key) { + requireOpen(); + byte[] ownedKey = nonEmpty(key, "key"); + byte[] value = delegate.get(ownedKey); + return value == null ? null : Arrays.copyOf(value, value.length); + } + + synchronized void put(byte[] key, byte[] value) { + writeBatch(Collections.singletonList(BatchMutation.put(key, value))); + } + + synchronized void delete(byte[] key) { + writeBatch(Collections.singletonList(BatchMutation.delete(key))); + } + + synchronized void writeBatch(List mutations) { + writeBatch(mutations, true); + } + + synchronized void writeBatchUnsynced(List mutations) { + writeBatch(mutations, false); + } + + private void writeBatch(List mutations, boolean sync) { + requireOpen(); + List supplied = Objects.requireNonNull(mutations, "mutations"); + if (supplied.isEmpty()) { + throw new IllegalArgumentException("path-state native batch must not be empty"); + } + for (BatchMutation mutation : supplied) { + Objects.requireNonNull(mutation, "mutation"); + } + delegate.writeBatch(supplied, sync); + writeBatchCalls = Math.addExact(writeBatchCalls, 1L); + writeBatchMutations = Math.addExact(writeBatchMutations, supplied.size()); + if (sync) { + syncedWriteBatchCalls = Math.addExact(syncedWriteBatchCalls, 1L); + } else { + unsyncedWriteBatchCalls = Math.addExact(unsyncedWriteBatchCalls, 1L); + } + } + + synchronized long getWriteBatchCalls() { + return writeBatchCalls; + } + + synchronized long getWriteBatchMutations() { + return writeBatchMutations; + } + + synchronized long getSyncedWriteBatchCalls() { + return syncedWriteBatchCalls; + } + + synchronized long getUnsyncedWriteBatchCalls() { + return unsyncedWriteBatchCalls; + } + + synchronized List scanPrefix(byte[] prefix) throws IOException { + List entries = new ArrayList<>(); + scanPrefix(prefix, entries::add); + return entries; + } + + synchronized List scanAll() throws IOException { + List entries = new ArrayList<>(); + scanAll(entries::add); + return entries; + } + + synchronized void scanPrefix(byte[] prefix, EntryConsumer consumer) throws IOException { + requireOpen(); + delegate.scanPrefix(nonEmpty(prefix, "prefix"), + Objects.requireNonNull(consumer, "consumer")); + } + + synchronized void scanAll(EntryConsumer consumer) throws IOException { + requireOpen(); + delegate.scanAll(Objects.requireNonNull(consumer, "consumer")); + } + + Path getDirectory() { + return directory; + } + + Engine getEngine() { + return engine; + } + + String getStorageProfile() { + return storageProfile; + } + + @Override + public synchronized void close() throws IOException { + if (!closed) { + closed = true; + delegate.close(); + } + } + + private void requireOpen() { + if (closed) { + throw new IllegalStateException("path-state node database is closed: " + directory); + } + } + + private static byte[] nonEmpty(byte[] value, String name) { + byte[] copy = Arrays.copyOf(Objects.requireNonNull(value, name), value.length); + if (copy.length == 0) { + throw new IllegalArgumentException(name + " must not be empty"); + } + return copy; + } + + private interface Delegate extends Closeable { + + byte[] get(byte[] key); + + void writeBatch(List mutations, boolean sync); + + void scanPrefix(byte[] prefix, EntryConsumer consumer) throws IOException; + + void scanAll(EntryConsumer consumer) throws IOException; + } + + private static final class LevelDelegate implements Delegate { + + private final org.iq80.leveldb.Options options; + private final WriteOptions syncWrites = new WriteOptions().sync(true); + private final WriteOptions unsyncedWrites = new WriteOptions().sync(false); + private final DB database; + + private LevelDelegate(Path directory, NativeDbConfig config) throws IOException { + options = new org.iq80.leveldb.Options() + .createIfMissing(true) + .paranoidChecks(true) + .verifyChecksums(true) + .compressionType(org.iq80.leveldb.CompressionType.SNAPPY) + .blockSize(config.getBlockSize()) + .writeBufferSize(config.getWriteBufferSize()) + .cacheSize(config.getCacheSize()) + .maxOpenFiles(config.getMaxOpenFiles()); + database = factory.open(directory.toFile(), options); + } + + @Override + public byte[] get(byte[] key) { + return database.get(key); + } + + @Override + public void writeBatch(List mutations, boolean sync) { + try (org.iq80.leveldb.WriteBatch batch = database.createWriteBatch()) { + for (BatchMutation mutation : mutations) { + if (mutation.value == null) { + batch.delete(mutation.key); + } else { + batch.put(mutation.key, mutation.value); + } + } + database.write(batch, sync ? syncWrites : unsyncedWrites); + } catch (IOException failure) { + throw new IllegalStateException("failed to apply path-state LevelDB node batch", failure); + } + } + + @Override + public void scanPrefix(byte[] prefix, EntryConsumer consumer) throws IOException { + try (org.iq80.leveldb.DBIterator iterator = database.iterator()) { + iterator.seek(prefix); + while (iterator.hasNext()) { + Map.Entry entry = iterator.next(); + if (!startsWith(entry.getKey(), prefix)) { + break; + } + consumer.accept(new KeyValue(entry.getKey(), entry.getValue())); + } + } + } + + @Override + public void scanAll(EntryConsumer consumer) throws IOException { + try (org.iq80.leveldb.DBIterator iterator = database.iterator()) { + iterator.seekToFirst(); + while (iterator.hasNext()) { + Map.Entry entry = iterator.next(); + consumer.accept(new KeyValue(entry.getKey(), entry.getValue())); + } + } + } + + @Override + public void close() throws IOException { + database.close(); + } + } + + private static final class RocksDelegate implements Delegate { + + private final LRUCache blockCache; + private final BloomFilter bloomFilter; + private final org.rocksdb.Options options; + private final org.rocksdb.WriteOptions syncWrites = + new org.rocksdb.WriteOptions().setSync(true); + private final org.rocksdb.WriteOptions unsyncedWrites = + new org.rocksdb.WriteOptions().setSync(false); + private final org.rocksdb.RocksDB database; + + private RocksDelegate(Path directory, NativeDbConfig config) throws IOException { + blockCache = new LRUCache(config.getCacheSize()); + bloomFilter = new BloomFilter(config.getBloomBitsPerKey(), false); + BlockBasedTableConfig table = new BlockBasedTableConfig() + .setBlockSize(config.getBlockSize()) + .setChecksumType(ChecksumType.kCRC32c) + .setBlockCache(blockCache) + .setCacheIndexAndFilterBlocks(true) + .setPinL0FilterAndIndexBlocksInCache(false) + .setWholeKeyFiltering(true) + .setFilter(bloomFilter); + options = new org.rocksdb.Options() + .setCreateIfMissing(true) + .setParanoidChecks(true) + .setCompressionType(CompressionType.SNAPPY_COMPRESSION) + .setWriteBufferSize(config.getWriteBufferSize()) + .setMaxWriteBufferNumber(config.getMaxWriteBufferNumber()) + .setMinWriteBufferNumberToMerge(1) + .setMaxOpenFiles(config.getMaxOpenFiles()) + .setNumLevels(config.getLevelNumber()) + .setLevelCompactionDynamicLevelBytes(true) + .setLevel0FileNumCompactionTrigger(config.getLevel0FileNumCompactionTrigger()) + .setLevel0SlowdownWritesTrigger(config.getLevel0SlowdownWritesTrigger()) + .setLevel0StopWritesTrigger(config.getLevel0StopWritesTrigger()) + .setMaxBackgroundCompactions(config.getBackgroundCompactions()) + .setMaxBackgroundFlushes(config.getBackgroundFlushes()) + .setTargetFileSizeBase(config.getTargetFileSizeBase()) + .setMaxBytesForLevelBase(config.getMaxBytesForLevelBase()) + .setMaxBytesForLevelMultiplier(config.getMaxBytesForLevelMultiplier()) + .setTableFormatConfig(table); + try { + database = org.rocksdb.RocksDB.open(options, directory.toString()); + } catch (RocksDBException failure) { + unsyncedWrites.close(); + syncWrites.close(); + options.close(); + bloomFilter.close(); + blockCache.close(); + throw new IOException("failed to open path-state RocksDB node database", failure); + } + } + + @Override + public byte[] get(byte[] key) { + try { + return database.get(key); + } catch (RocksDBException failure) { + throw new IllegalStateException("failed to read path-state RocksDB node", failure); + } + } + + @Override + public void writeBatch(List mutations, boolean sync) { + try (org.rocksdb.WriteBatch batch = new org.rocksdb.WriteBatch()) { + for (BatchMutation mutation : mutations) { + if (mutation.value == null) { + batch.delete(mutation.key); + } else { + batch.put(mutation.key, mutation.value); + } + } + database.write(sync ? syncWrites : unsyncedWrites, batch); + } catch (RocksDBException failure) { + throw new IllegalStateException("failed to apply path-state RocksDB node batch", failure); + } + } + + @Override + public void scanPrefix(byte[] prefix, EntryConsumer consumer) throws IOException { + try (org.rocksdb.RocksIterator iterator = database.newIterator()) { + iterator.seek(prefix); + while (iterator.isValid() && startsWith(iterator.key(), prefix)) { + consumer.accept(new KeyValue(iterator.key(), iterator.value())); + iterator.next(); + } + iterator.status(); + } catch (RocksDBException failure) { + throw new IllegalStateException("failed to scan path-state RocksDB nodes", failure); + } + } + + @Override + public void scanAll(EntryConsumer consumer) throws IOException { + try (org.rocksdb.RocksIterator iterator = database.newIterator()) { + iterator.seekToFirst(); + while (iterator.isValid()) { + consumer.accept(new KeyValue(iterator.key(), iterator.value())); + iterator.next(); + } + iterator.status(); + } catch (RocksDBException failure) { + throw new IllegalStateException("failed to scan all path-state RocksDB nodes", failure); + } + } + + @Override + public void close() { + database.close(); + unsyncedWrites.close(); + syncWrites.close(); + options.close(); + bloomFilter.close(); + blockCache.close(); + } + } + + static final class BatchMutation { + + private final byte[] key; + private final byte[] value; + + private BatchMutation(byte[] key, byte[] value) { + this.key = nonEmpty(key, "key"); + this.value = value == null ? null : nonEmpty(value, "value"); + } + + static BatchMutation put(byte[] key, byte[] value) { + return new BatchMutation(key, Objects.requireNonNull(value, "value")); + } + + static BatchMutation delete(byte[] key) { + return new BatchMutation(key, null); + } + } + + static final class KeyValue { + + private final byte[] key; + private final byte[] value; + + private KeyValue(byte[] key, byte[] value) { + this.key = nonEmpty(key, "key"); + this.value = nonEmpty(value, "value"); + } + + byte[] getKey() { + return Arrays.copyOf(key, key.length); + } + + byte[] getValue() { + return Arrays.copyOf(value, value.length); + } + } + + @FunctionalInterface + interface EntryConsumer { + + void accept(KeyValue entry) throws IOException; + } + + private static boolean startsWith(byte[] value, byte[] prefix) { + return value.length >= prefix.length + && Arrays.equals(Arrays.copyOf(value, prefix.length), prefix); + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateNativeSnapshotSource.java b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateNativeSnapshotSource.java new file mode 100644 index 00000000000..12bb4df016c --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateNativeSnapshotSource.java @@ -0,0 +1,360 @@ +package org.tron.core.db2.stateroot; + +import com.google.common.hash.Hasher; +import com.google.common.hash.Hashing; +import java.io.Closeable; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import org.tron.core.capsule.utils.MarketUtils; +import org.tron.core.db2.archive.LatestStateGenerationAdapter.SnapshotCapableStore; +import org.tron.core.db2.archive.LatestStateGenerationAdapter.StoreSnapshot; +import org.tron.core.db2.common.DB; +import org.tron.core.db2.core.Chainbase; +import org.tron.core.db2.core.Snapshot; +import org.tron.core.db2.core.SnapshotManager; +import org.tron.core.db2.core.SnapshotRoot; +import org.tron.core.db2.stateroot.PathStateRebuildCoordinator.EntryConsumer; +import org.tron.core.db2.stateroot.PathStateRebuildCoordinator.SnapshotIdentity; + +/** Caller-owned exact-27 native snapshot lease used only by current-state rebuild. */ +public final class PathStateNativeSnapshotSource + implements PathStateRebuildCoordinator.SnapshotSource, Closeable { + + private final PathStateParticipantDescriptor descriptor; + private final SnapshotIdentity identity; + private final Map stores; + private final Map sourceIdentities; + private final byte[] sourceIdentityDigest; + private final Map snapshots; + private final int pageSize; + private final int marketEntryLimit; + private volatile boolean closed; + + private PathStateNativeSnapshotSource(PathStateParticipantDescriptor descriptor, + SnapshotIdentity identity, Map stores, + Map sourceIdentities, Map snapshots, + int pageSize, int marketEntryLimit) { + this.descriptor = descriptor; + this.identity = identity; + this.stores = Collections.unmodifiableMap(new LinkedHashMap<>(stores)); + this.sourceIdentities = Collections.unmodifiableMap(new LinkedHashMap<>(sourceIdentities)); + this.sourceIdentityDigest = sourceIdentityDigest(this.sourceIdentities); + this.snapshots = Collections.unmodifiableMap(new LinkedHashMap<>(snapshots)); + this.pageSize = pageSize; + this.marketEntryLimit = marketEntryLimit; + } + + /** + * Resolves and pins every participant while holding the canonical apply/flush barrier. + * Supplemental Stores are accepted only for exact participants absent from SnapshotManager. + */ + public static PathStateNativeSnapshotSource acquire(SnapshotManager manager, + Map supplementalStores, IdentityReader identityReader, + int pageSize, int marketEntryLimit) throws IOException { + Objects.requireNonNull(manager, "manager"); + Objects.requireNonNull(supplementalStores, "supplementalStores"); + Objects.requireNonNull(identityReader, "identityReader"); + if (pageSize <= 0 || marketEntryLimit <= 0) { + throw new IllegalArgumentException("path-state snapshot scan limits must be positive"); + } + PathStateNativeSnapshotSource[] acquired = new PathStateNativeSnapshotSource[1]; + manager.withArchiveStateBarrier(() -> acquired[0] = acquireInsideBarrier(manager, + supplementalStores, identityReader, pageSize, marketEntryLimit)); + return acquired[0]; + } + + private static PathStateNativeSnapshotSource acquireInsideBarrier(SnapshotManager manager, + Map supplementalStores, IdentityReader identityReader, + int pageSize, int marketEntryLimit) throws IOException { + PathStateParticipantDescriptor descriptor = PathStateParticipantDescriptor.current(); + LinkedHashMap stores = resolveStores(manager, supplementalStores, + descriptor); + SnapshotIdentity before = Objects.requireNonNull(identityReader.read(), "snapshot identity"); + LinkedHashMap identities = new LinkedHashMap<>(); + LinkedHashMap snapshots = new LinkedHashMap<>(); + try { + for (PathStateParticipantDescriptor.StoreIdentity participant : descriptor.getStores()) { + String dbName = participant.getDbName(); + SnapshotCapableStore store = stores.get(dbName); + String sourceIdentity = requireSourceIdentity(dbName, store.getSourceIdentity()); + StoreSnapshot snapshot = Objects.requireNonNull( + store.pin(before.getBlockNumber(), before.getBlockHash()), "pinned Store snapshot"); + identities.put(dbName, sourceIdentity); + snapshots.put(dbName, snapshot); + validateSnapshot(dbName, sourceIdentity, before, snapshot); + } + SnapshotIdentity after = Objects.requireNonNull(identityReader.read(), "snapshot identity"); + if (!before.sameAs(after)) { + throw new IOException("path-state canonical identity drifted during snapshot acquisition"); + } + return new PathStateNativeSnapshotSource(descriptor, before, stores, identities, snapshots, + pageSize, marketEntryLimit); + } catch (IOException | RuntimeException failure) { + closeAfterFailure(snapshots, failure); + throw failure; + } + } + + private static LinkedHashMap resolveStores(SnapshotManager manager, + Map supplementalStores, + PathStateParticipantDescriptor descriptor) throws IOException { + LinkedHashMap found = new LinkedHashMap<>(); + for (Chainbase database : new ArrayList<>(manager.getDbs())) { + String dbName = database.getDbName(); + try { + descriptor.require(dbName); + } catch (IllegalArgumentException outsideScope) { + continue; + } + if (found.containsKey(dbName)) { + throw new IOException("duplicate path-state SnapshotManager Store: " + dbName); + } + Snapshot head = database.getHead(); + if (!Snapshot.isRoot(head)) { + throw new IOException("path-state native snapshot requires a flushed Store: " + dbName); + } + Snapshot root = head.getRoot(); + DB engine = ((SnapshotRoot) root).getDb(); + if (!(engine instanceof SnapshotCapableStore) + || !dbName.equals(engine.getDbName())) { + throw new IOException("path-state Store lacks a native snapshot engine: " + dbName); + } + found.put(dbName, (SnapshotCapableStore) engine); + } + for (Map.Entry entry : supplementalStores.entrySet()) { + String dbName = entry.getKey(); + SnapshotCapableStore store = Objects.requireNonNull(entry.getValue(), "supplemental Store"); + descriptor.require(dbName); + if (!dbName.equals(store.getDbName()) || found.putIfAbsent(dbName, store) != null) { + throw new IOException("duplicate or mismatched supplemental path-state Store: " + dbName); + } + } + descriptor.requireExactDatabases(found.keySet()); + LinkedHashMap ordered = new LinkedHashMap<>(); + for (PathStateParticipantDescriptor.StoreIdentity participant : descriptor.getStores()) { + ordered.put(participant.getDbName(), found.get(participant.getDbName())); + } + return ordered; + } + + @Override + public synchronized SnapshotIdentity identity() { + ensureOpen(); + return identity; + } + + @Override + public Collection databases() { + return stores.keySet(); + } + + @Override + public byte[] sourceIdentityDigest() { + return Arrays.copyOf(sourceIdentityDigest, sourceIdentityDigest.length); + } + + @Override + public void scan(String dbName, EntryConsumer consumer) throws IOException { + scanAfter(dbName, null, consumer); + } + + @Override + public void scanAfter(String dbName, byte[] exclusivePhysicalCursor, EntryConsumer consumer) + throws IOException { + ensureOpen(); + Objects.requireNonNull(consumer, "consumer"); + PathStateParticipantDescriptor.StoreIdentity participant = descriptor.require(dbName); + StoreSnapshot snapshot = snapshots.get(dbName); + if (snapshot == null) { + throw new IOException("database is outside pinned path-state snapshot: " + dbName); + } + if (PathStateParticipantDescriptor.MARKET_PRICE_COMPARATOR.equals( + participant.getComparatorId())) { + if (exclusivePhysicalCursor != null) { + throw new IOException("market-price snapshot scan does not support physical cursor resume"); + } + List> entries = new ArrayList<>(); + scanLexical(snapshot, (key, value) -> { + if (entries.size() >= marketEntryLimit) { + throw new IOException("path-state market snapshot entry limit exceeded"); + } + entries.add(new java.util.AbstractMap.SimpleImmutableEntry<>(key, value)); + }); + entries.sort((left, right) -> MarketUtils.comparePriceKey(left.getKey(), right.getKey())); + for (Map.Entry entry : entries) { + consumer.accept(entry.getKey(), entry.getValue()); + } + return; + } + scanLexical(snapshot, consumer, exclusivePhysicalCursor); + } + + private void scanLexical(StoreSnapshot snapshot, EntryConsumer consumer) throws IOException { + scanLexical(snapshot, consumer, null); + } + + private void scanLexical(StoreSnapshot snapshot, EntryConsumer consumer, byte[] cursor) + throws IOException { + byte[] lower = cursor == null ? new byte[0] : Arrays.copyOf(cursor, cursor.length); + byte[] previous = cursor == null ? null : Arrays.copyOf(cursor, cursor.length); + while (true) { + List> page; + try { + page = snapshot.range(lower, null, pageSize + 1); + } catch (UnsupportedOperationException unsupported) { + throw new IOException("pinned Store does not support range scan: " + + snapshot.getDbName(), unsupported); + } + if (page == null || page.size() > pageSize + 1) { + throw new IOException("pinned Store returned an invalid range page: " + + snapshot.getDbName()); + } + for (Map.Entry entry : page) { + byte[] key = copy(entry.getKey(), "snapshot key"); + byte[] value = copy(entry.getValue(), "snapshot value"); + if (previous != null && compareUnsigned(previous, key) > 0) { + throw new IOException("pinned Store range is not strictly lexical: " + + snapshot.getDbName()); + } + if (previous != null && Arrays.equals(previous, key)) { + continue; + } + consumer.accept(key, value); + previous = key; + } + if (page.size() < pageSize + 1) { + return; + } + if (previous == null) { + throw new IOException("pinned Store returned a full empty range page: " + + snapshot.getDbName()); + } + lower = Arrays.copyOf(previous, previous.length); + } + } + + @Override + public synchronized void verifyIdentity(SnapshotIdentity expected) throws IOException { + ensureOpen(); + if (!identity.sameAs(expected)) { + throw new IOException("path-state snapshot block identity mismatch"); + } + for (Map.Entry entry : snapshots.entrySet()) { + String dbName = entry.getKey(); + String expectedSource = sourceIdentities.get(dbName); + if (!expectedSource.equals(stores.get(dbName).getSourceIdentity())) { + throw new IOException("path-state Store source was replaced: " + dbName); + } + validateSnapshot(dbName, expectedSource, identity, entry.getValue()); + } + } + + @Override + public synchronized void close() throws IOException { + if (closed) { + return; + } + closed = true; + IOException failure = null; + List reverse = new ArrayList<>(snapshots.values()); + Collections.reverse(reverse); + for (StoreSnapshot snapshot : reverse) { + try { + snapshot.close(); + } catch (IOException closeFailure) { + if (failure == null) { + failure = closeFailure; + } else { + failure.addSuppressed(closeFailure); + } + } + } + if (failure != null) { + throw failure; + } + } + + private static void validateSnapshot(String dbName, String sourceIdentity, + SnapshotIdentity identity, StoreSnapshot snapshot) throws IOException { + if (!dbName.equals(snapshot.getDbName()) + || !sourceIdentity.equals(snapshot.getSourceIdentity()) + || snapshot.getBlockNumber() != identity.getBlockNumber() + || !Arrays.equals(snapshot.getBlockHash(), identity.getBlockHash())) { + throw new IOException("pinned path-state Store identity mismatch: " + dbName); + } + } + + private static String requireSourceIdentity(String dbName, String sourceIdentity) + throws IOException { + if (sourceIdentity == null || sourceIdentity.isEmpty()) { + throw new IOException("path-state Store source identity is invalid: " + dbName); + } + return sourceIdentity; + } + + private static void closeAfterFailure(Map snapshots, + Exception failure) { + List reverse = new ArrayList<>(snapshots.values()); + Collections.reverse(reverse); + for (StoreSnapshot snapshot : reverse) { + try { + snapshot.close(); + } catch (IOException closeFailure) { + failure.addSuppressed(closeFailure); + } + } + } + + private static byte[] copy(byte[] value, String name) throws IOException { + if (value == null) { + throw new IOException(name + " must not be null"); + } + return Arrays.copyOf(value, value.length); + } + + private static int compareUnsigned(byte[] left, byte[] right) { + for (int index = 0; index < Math.min(left.length, right.length); index++) { + int compared = Integer.compare(left[index] & 0xff, right[index] & 0xff); + if (compared != 0) { + return compared; + } + } + return Integer.compare(left.length, right.length); + } + + private static byte[] sourceIdentityDigest(Map identities) { + Hasher hasher = Hashing.sha256().newHasher(); + hasher.putBytes(ByteBuffer.allocate(Integer.BYTES).putInt(identities.size()).array()); + for (Map.Entry entry : identities.entrySet()) { + putString(hasher, entry.getKey()); + putString(hasher, entry.getValue()); + } + return hasher.hash().asBytes(); + } + + private static void putString(Hasher hasher, String value) { + byte[] encoded = value.getBytes(StandardCharsets.UTF_8); + hasher.putBytes(ByteBuffer.allocate(Integer.BYTES).putInt(encoded.length).array()); + hasher.putBytes(encoded); + } + + private void ensureOpen() { + if (closed) { + throw new IllegalStateException("path-state native snapshot source is closed"); + } + } + + @FunctionalInterface + public interface IdentityReader { + SnapshotIdentity read() throws IOException; + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateNodeStoreSet.java b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateNodeStoreSet.java new file mode 100644 index 00000000000..347ec037e5b --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateNodeStoreSet.java @@ -0,0 +1,1311 @@ +package org.tron.core.db2.stateroot; + +import java.io.Closeable; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; +import org.tron.core.db2.stateroot.PathStateRootMetadata.Kind; + +/** Exact-27 participant and super-trie namespace views over one BASE or LAYER native database. */ +public final class PathStateNodeStoreSet implements Closeable { + + public static final String NODES_DIRECTORY = "nodes"; + private static final byte[] PROGRESS_KEY = new byte[]{ + (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff, + 'p', 'r', 'o', 'g', 'r', 'e', 's', 's'}; + private static final byte[] LOGICAL_BYTES_KEY = new byte[]{ + (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff, + 'l', 'o', 'g', 'i', 'c', 'a', 'l', '-', 'b', 'y', 't', 'e', 's'}; + private static final byte[] REBUILD_CHECKPOINT_KEY = new byte[]{ + (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff, + 'r', 'e', 'b', 'u', 'i', 'l', 'd'}; + private static final byte[] STREAMED_REBUILD_KEY = new byte[]{ + (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff, + 's', 't', 'r', 'e', 'a', 'm', 'e', 'd'}; + private static final byte[] STREAMED_REBUILD_VALUE = new byte[]{1}; + private static final byte[] LEAF_OVERLAY_KEY = new byte[]{ + (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff, + 'l', 'e', 'a', 'f', '-', 'o', 'v', 'e', 'r', 'l', 'a', 'y'}; + private static final byte[] LEAF_OVERLAY_VALUE = new byte[]{1}; + private static final int LEAF_DOMAIN = -2; + private static final int NODE_TOMBSTONE_DOMAIN = -3; + private static final int LEAF_TOMBSTONE_DOMAIN = -4; + private static final byte[] NODE_TOMBSTONE_PREFIX = ByteBuffer.allocate(Integer.BYTES) + .putInt(NODE_TOMBSTONE_DOMAIN).array(); + private static final byte[] TOMBSTONE_VALUE = new byte[]{1}; + private static final byte[] LEAF_PREFIX = ByteBuffer.allocate(Integer.BYTES) + .putInt(LEAF_DOMAIN).array(); + private static final int LEAF_KEY_LENGTH = Integer.BYTES * 2 + PathMerkleTrie.SECURE_KEY_LENGTH; + private static final byte[] LEAF_TOMBSTONE_PREFIX = ByteBuffer.allocate(Integer.BYTES) + .putInt(LEAF_TOMBSTONE_DOMAIN).array(); + private static final int REBUILD_WRITE_BATCH_ENTRIES = 4096; + + private final Path directory; + private final PathStateParticipantScope scope; + private final Map participantStores = new LinkedHashMap<>(); + private final Map> pending = new LinkedHashMap<>(); + private final Map localLeaves = new LinkedHashMap<>(); + private final Map persistedLeaves = new LinkedHashMap<>(); + private final Map resolvedLeafValues = new LinkedHashMap<>(); + private final Set leafTombstones = new LinkedHashSet<>(); + private final PathStateNativeNodeStore nativeStore; + private final PathStateNodeStoreSet parentStores; + private final PathNodeStore superStore; + private final byte[] manifestDigest; + private final AtomicLong nodeStoreGetCalls = new AtomicLong(); + private final Kind kind; + private final PathStateRootMetadata expectedMetadata; + private final boolean sealed; + private PathStateRootMetadata progress; + private PathStateRebuildCheckpoint rebuildCheckpoint; + private Long logicalBytes; + private boolean leafOverlay; + private boolean streamedRebuild; + private boolean lazyRoots; + private PathStateRoot root; + private boolean rootClaimed; + private boolean closed; + + private PathStateNodeStoreSet(Path directory, PathStateStoreManifest manifest, Kind kind, + PathStateRootMetadata expectedMetadata, PathStateNodeStoreSet parentStores) + throws IOException { + this.directory = directory.resolve(NODES_DIRECTORY); + this.scope = new PathStateCanonicalizer().participantScope(); + this.manifestDigest = manifest.getIdentityDigest(); + this.kind = kind; + this.expectedMetadata = expectedMetadata; + this.parentStores = parentStores; + this.sealed = Files.exists(directory.resolve(PathStateCurrentStore.METADATA_FILE), + LinkOption.NOFOLLOW_LINKS); + nativeStore = PathStateNativeNodeStore.open(this.directory, manifest.getEngine()); + try { + progress = decodeProgress(nativeStore.get(PROGRESS_KEY)); + logicalBytes = decodeLogicalBytes(nativeStore.get(LOGICAL_BYTES_KEY)); + rebuildCheckpoint = decodeRebuildCheckpoint(nativeStore.get(REBUILD_CHECKPOINT_KEY)); + byte[] streamedValue = nativeStore.get(STREAMED_REBUILD_KEY); + if (streamedValue != null && (kind != Kind.BASE + || !Arrays.equals(streamedValue, STREAMED_REBUILD_VALUE))) { + throw new IOException("path-state streamed rebuild marker is invalid"); + } + streamedRebuild = streamedValue != null; + lazyRoots = kind == Kind.BASE ? streamedRebuild + || rebuildCheckpoint != null && rebuildCheckpoint.hasIndependentStores() + : parentStores != null && parentStores.lazyRoots; + byte[] leafOverlayValue = nativeStore.get(LEAF_OVERLAY_KEY); + if ((progress == null) != (logicalBytes == null)) { + throw new IOException("path-state native progress and logical bytes marker differ"); + } + if (progress != null && rebuildCheckpoint != null) { + throw new IOException("path-state native progress conflicts with rebuild checkpoint"); + } + if (progress != null) { + requireProgressIdentity(progress); + } else if (kind == Kind.BASE && expectedMetadata != null) { + throw new IOException("path-state BASE metadata exists without native progress"); + } + if (rebuildCheckpoint != null) { + requireRebuildCheckpointIdentity(rebuildCheckpoint); + } + if (leafOverlayValue != null + && (kind != Kind.LAYER || progress == null + || !Arrays.equals(leafOverlayValue, LEAF_OVERLAY_VALUE))) { + throw new IOException("path-state leaf overlay marker is invalid"); + } + leafOverlay = leafOverlayValue != null + || kind == Kind.LAYER && progress == null && parentStores != null; + if (!lazyRoots) { + inheritParentLeaves(); + loadPersistedLeaves(); + loadLeafTombstones(); + } + validateNodeTombstones(); + boolean hasUnexpectedLeaves = kind == Kind.BASE + ? !persistedLeaves.isEmpty() : !localLeaves.isEmpty(); + if (progress == null && rebuildCheckpoint == null && !streamedRebuild + && (hasUnexpectedLeaves || !leafTombstones.isEmpty())) { + throw new IOException("path-state leaf inventory exists without native progress"); + } + for (PathStateParticipant participant : scope.getParticipants()) { + pending.put(participant.getStoreId(), new LinkedHashMap<>()); + participantStores.put(participant.getDbName(), + new NamespacedNodeStore(this, participant.getStoreId())); + } + pending.put(0, new LinkedHashMap<>()); + superStore = new NamespacedNodeStore(this, 0); + } catch (RuntimeException | IOException failure) { + nativeStore.close(); + throw failure; + } + } + + public static PathStateNodeStoreSet openBase(PathStateStoreManifest manifest) + throws IOException { + PathStateStoreManifest admitted = Objects.requireNonNull(manifest, "manifest"); + Path metadataPath = admitted.getBaseDirectory().resolve(PathStateCurrentStore.METADATA_FILE); + PathStateRootMetadata metadata = Files.exists(metadataPath, LinkOption.NOFOLLOW_LINKS) + ? PathStateMetadataFile.load(metadataPath) : null; + return new PathStateNodeStoreSet(admitted.getBaseDirectory(), admitted, Kind.BASE, metadata, + null); + } + + static PathStateNodeStoreSet beginBaseAt(PathStateStoreManifest manifest, Path directory) + throws IOException { + return new PathStateNodeStoreSet(Objects.requireNonNull(directory, "directory"), + Objects.requireNonNull(manifest, "manifest"), Kind.BASE, null, null); + } + + public static PathStateNodeStoreSet openLayer(PathStateStoreManifest manifest, + PathStateRootMetadata metadata) throws IOException { + PathStateStoreManifest admitted = Objects.requireNonNull(manifest, "manifest"); + PathStateRootMetadata layer = Objects.requireNonNull(metadata, "metadata"); + if (layer.getKind() != Kind.LAYER) { + throw new IllegalArgumentException("path-state layer node set requires LAYER metadata"); + } + if (!Arrays.equals(layer.getFormatDigest(), admitted.getIdentityDigest())) { + throw new IOException("path-state layer node set manifest identity mismatch"); + } + Path layerDirectory = admitted.getLayerDirectory(layer.getBlockNumber(), layer.getBlockHash()); + requireUnsealed(layerDirectory); + return new PathStateNodeStoreSet(layerDirectory, admitted, Kind.LAYER, layer, null); + } + + /** Opens the node database referenced by the verified current authority. */ + public static PathStateNodeStoreSet openCurrent(PathStateStoreManifest manifest) + throws IOException { + PathStateStoreManifest admitted = Objects.requireNonNull(manifest, "manifest"); + PathStateRootMetadata current = new PathStateCurrentStore(admitted).current(); + return openPublished(admitted, current); + } + + static PathStateNodeStoreSet beginLayer(PathStateStoreManifest manifest, + PathStateRootMetadata identity, PathStateNodeStoreSet parentStores) throws IOException { + PathStateStoreManifest admitted = Objects.requireNonNull(manifest, "manifest"); + PathStateRootMetadata layer = Objects.requireNonNull(identity, "identity"); + if (layer.getKind() != Kind.LAYER + || !Arrays.equals(layer.getFormatDigest(), admitted.getIdentityDigest())) { + throw new IOException("path-state layer node set identity mismatch"); + } + Path directory = admitted.getLayerDirectory(layer.getBlockNumber(), layer.getBlockHash()); + requireUnsealed(directory); + return new PathStateNodeStoreSet(directory, admitted, Kind.LAYER, null, + Objects.requireNonNull(parentStores, "parentStores")); + } + + static PathStateNodeStoreSet openPublished(PathStateStoreManifest manifest, + PathStateRootMetadata metadata) throws IOException { + PathStateStoreManifest admitted = Objects.requireNonNull(manifest, "manifest"); + PathStateRootMetadata published = Objects.requireNonNull(metadata, "metadata"); + Path owner = published.getKind() == Kind.BASE ? admitted.getBaseDirectory() + : admitted.getLayerDirectory(published.getBlockNumber(), published.getBlockHash()); + PathStateRootMetadata stored = PathStateMetadataFile.load( + owner.resolve(PathStateCurrentStore.METADATA_FILE)); + if (!Arrays.equals(stored.encode(), published.encode())) { + throw new IOException("path-state published metadata differs from authority"); + } + PathStateNodeStoreSet parent = published.getKind() == Kind.BASE ? null + : openPublished(admitted, loadParent(admitted, published)); + try { + return new PathStateNodeStoreSet(owner, admitted, published.getKind(), published, parent); + } catch (IOException | RuntimeException failure) { + closeAfterFailure(parent, failure); + throw failure; + } + } + + /** Claims this database and restores its durable leaves when progress already exists. */ + public synchronized PathStateRoot createRoot() { + requireOpen(); + if (rootClaimed) { + throw new IllegalStateException("path-state node database set already has a trie owner"); + } + PathStateRoot candidate = new PathStateRoot(scope, + participant -> participantStores.get(participant.getDbName()), + superStore); + if (progress != null || rebuildCheckpoint != null) { + if (progress == null && rebuildCheckpoint.hasIndependentStores()) { + candidate.restoreRebuildParticipants(rebuildCheckpoint); + } else if (progress != null && lazyRoots) { + candidate.restoreStoredRoots(progress.getStateRoot()); + } else { + byte[] expectedRoot = progress == null ? rebuildCheckpoint.getPartialRoot() + : progress.getStateRoot(); + candidate.restoreLeaves(restoredLeafRecords(), expectedRoot); + } + if (hasPending()) { + throw new IllegalStateException("path-state leaf restoration attempted to repair nodes"); + } + } + root = candidate; + rootClaimed = true; + return root; + } + + synchronized PathStateRoot createRootFrom(List parentLeaves, + byte[] parentRoot) { + requireOpen(); + if (rootClaimed) { + throw new IllegalStateException("path-state node database set already has a trie owner"); + } + if (progress != null || !localLeaves.isEmpty() || !leafTombstones.isEmpty()) { + throw new IllegalStateException("path-state layer already contains durable state"); + } + PathStateRoot candidate = new PathStateRoot(scope, + participant -> participantStores.get(participant.getDbName()), superStore); + if (parentStores == null) { + throw new IllegalStateException("path-state layer has no parent node overlay"); + } + candidate.restoreLeaves(parentLeaves, parentRoot); + if (hasPending()) { + throw new IllegalStateException("path-state parent restore attempted to copy nodes"); + } + root = candidate; + rootClaimed = true; + return root; + } + + synchronized PathStateRoot createRootFrom(PathStateRoot.Snapshot snapshot, + byte[] parentRoot) { + requireOpen(); + if (rootClaimed) { + throw new IllegalStateException("path-state node database set already has a trie owner"); + } + if (progress != null || !localLeaves.isEmpty() || !leafTombstones.isEmpty()) { + throw new IllegalStateException("path-state layer already contains durable state"); + } + if (parentStores == null + || !Arrays.equals(snapshot.getStateRoot(), Objects.requireNonNull(parentRoot, + "parentRoot"))) { + throw new IllegalArgumentException("path-state parent snapshot root mismatch"); + } + PathStateRoot candidate = PathStateRoot.fromSnapshot(scope, + participant -> participantStores.get(participant.getDbName()), superStore, snapshot); + if (hasPending()) { + throw new IllegalStateException("path-state snapshot fork attempted to copy nodes"); + } + root = candidate; + rootClaimed = true; + return root; + } + + synchronized PathStateRoot createRootFrom(PreparedPathStateTransition prepared) { + requireOpen(); + if (rootClaimed) { + throw new IllegalStateException("path-state node database set already has a trie owner"); + } + if (progress != null || !localLeaves.isEmpty() || !leafTombstones.isEmpty()) { + throw new IllegalStateException("path-state layer already contains durable state"); + } + if (parentStores == null) { + throw new IllegalStateException("path-state layer has no parent node overlay"); + } + PreparedPathStateTransition candidate = Objects.requireNonNull(prepared, "prepared"); + candidate.validatePreparedTransition(scope); + PathStateRoot next = PathStateRoot.fromSnapshot(scope, + participant -> participantStores.get(participant.getDbName()), superStore, + candidate.getSnapshot()); + if (!candidate.getTransition().getMutations().isEmpty()) { + next.recordPendingLeafMutations(candidate.getTransition().getMutations()); + } + for (PreparedPathStateTransition.NodeMutation mutation : candidate.getNodeMutations()) { + PathNodeStore store = nodeStore(mutation.getStoreId()); + byte[] encoded = mutation.getEncodedNode(); + if (encoded == null) { + store.delete(mutation.getPath()); + } else { + store.put(mutation.getPath(), encoded); + } + } + root = next; + rootClaimed = true; + return root; + } + + long nodeStoreGetCalls() { + return nodeStoreGetCalls.get(); + } + + synchronized List leafRecords() { + requireOpen(); + if (root == null) { + throw new IllegalStateException("path-state node database set has no trie owner"); + } + return root.leafRecords(); + } + + synchronized PathStateRoot initializeBase(List leaves, + byte[] expectedRoot) { + requireOpen(); + if (kind != Kind.BASE || rootClaimed || progress != null || rebuildCheckpoint != null) { + throw new IllegalStateException("path-state replacement BASE is not empty"); + } + PathStateRoot candidate = new PathStateRoot(scope, + participant -> participantStores.get(participant.getDbName()), superStore); + List admittedLeaves = Objects.requireNonNull(leaves, "leaves"); + candidate.initializeLeaves(admittedLeaves, expectedRoot); + candidate.recordPendingLeafRecords(admittedLeaves); + root = candidate; + rootClaimed = true; + return candidate; + } + + /** Atomically persists all pending path nodes and their exact root progress. */ + public synchronized void commit(PathStateRootMetadata metadata) throws IOException { + requireOpen(); + if (sealed) { + throw new IOException("path-state node database set is sealed by immutable metadata"); + } + if (root == null) { + throw new IllegalStateException("path-state node database set has no trie owner"); + } + PathStateRootMetadata next = Objects.requireNonNull(metadata, "metadata"); + long nextLogicalBytes = projectedLogicalBytes(next); + List leafMutations = root.pendingLeafMutations(); + List mutations = + new ArrayList<>(pendingSize() + leafMutations.size() + 4); + appendPendingMutations(mutations); + appendLeafMutations(mutations, leafMutations); + mutations.add(PathStateNativeNodeStore.BatchMutation.put(PROGRESS_KEY, next.encode())); + if (leafOverlay) { + mutations.add(PathStateNativeNodeStore.BatchMutation.put( + LEAF_OVERLAY_KEY, LEAF_OVERLAY_VALUE)); + } + mutations.add(PathStateNativeNodeStore.BatchMutation.put(LOGICAL_BYTES_KEY, + ByteBuffer.allocate(Long.BYTES).putLong(nextLogicalBytes).array())); + if (rebuildCheckpoint != null) { + mutations.add(PathStateNativeNodeStore.BatchMutation.delete(REBUILD_CHECKPOINT_KEY)); + } + nativeStore.writeBatch(mutations); + clearPending(); + recordCommittedLeaves(leafMutations); + root.clearPendingLeafMutations(); + progress = next; + rebuildCheckpoint = null; + logicalBytes = nextLogicalBytes; + } + + /** Persists one more completed rebuild Store without creating BASE authority. */ + synchronized void checkpointRebuild(PathStateRebuildCheckpoint checkpoint, + Collection participantStoreIds) throws IOException { + requireOpen(); + if (kind != Kind.BASE || sealed || progress != null || root == null) { + throw new IOException("path-state rebuild checkpoint is not admissible"); + } + PathStateRebuildCheckpoint next = Objects.requireNonNull(checkpoint, "checkpoint"); + requireRebuildCheckpointIdentity(next); + int previousCount = rebuildCheckpoint == null ? 0 + : rebuildCheckpoint.getCompletedStores().size(); + if (next.getCompletedStores().size() != previousCount + 1) { + throw new IOException("path-state rebuild checkpoint must advance one Store"); + } + if (rebuildCheckpoint != null) { + for (PathStateRebuildCoordinator.StoreResult previous + : rebuildCheckpoint.getCompletedStores()) { + boolean retained = false; + for (PathStateRebuildCoordinator.StoreResult advanced : next.getCompletedStores()) { + if (sameStoreResult(previous, advanced)) { + retained = true; + break; + } + } + if (!retained) { + throw new IOException("path-state rebuild checkpoint rewrites completed Store"); + } + } + } + Set storeIds = new LinkedHashSet<>(Objects.requireNonNull(participantStoreIds, + "participantStoreIds")); + if (storeIds.isEmpty() || storeIds.contains(0)) { + throw new IOException("path-state rebuild checkpoint Store ownership is invalid"); + } + List leafMutations = new ArrayList<>(); + for (Integer storeId : storeIds) { + requireParticipant(storeId); + leafMutations.addAll(root.pendingLeafMutations(storeId)); + } + List mutations = + durableStateMutations(next.encode(), leafMutations, storeIds); + nativeStore.writeBatch(mutations); + for (Integer storeId : storeIds) { + clearPending(storeId); + root.clearPendingLeafMutations(storeId); + } + recordCommittedLeaves(leafMutations); + rebuildCheckpoint = next; + } + + PathStateRebuildCheckpoint getRebuildCheckpoint() { + requireOpen(); + return rebuildCheckpoint; + } + + /** Opens a bounded direct writer for one not-yet-checkpointed rebuild participant. */ + synchronized RebuildWriter openRebuildWriter(int storeId) throws IOException { + requireOpen(); + if (kind != Kind.BASE || sealed || progress != null || root == null) { + throw new IOException("path-state direct rebuild writer is not admissible"); + } + requireParticipant(storeId); + if (!streamedRebuild) { + nativeStore.writeBatch(Collections.singletonList( + PathStateNativeNodeStore.BatchMutation.put( + STREAMED_REBUILD_KEY, STREAMED_REBUILD_VALUE))); + streamedRebuild = true; + } + clearRebuildPrefix(ByteBuffer.allocate(Integer.BYTES).putInt(storeId).array()); + clearRebuildPrefix(ByteBuffer.allocate(Integer.BYTES * 2) + .putInt(LEAF_DOMAIN).putInt(storeId).array()); + return new RebuildWriter(storeId); + } + + private void clearRebuildPrefix(byte[] prefix) throws IOException { + List deletes = + new ArrayList<>(REBUILD_WRITE_BATCH_ENTRIES); + nativeStore.scanPrefix(prefix, entry -> { + deletes.add(PathStateNativeNodeStore.BatchMutation.delete(entry.getKey())); + if (deletes.size() >= REBUILD_WRITE_BATCH_ENTRIES) { + nativeStore.writeBatch(new ArrayList<>(deletes)); + deletes.clear(); + } + }); + if (!deletes.isEmpty()) { + nativeStore.writeBatch(deletes); + } + } + + /** Publishes only the Store-completion marker after its streamed nodes and leaves are durable. */ + synchronized void checkpointStreamedRebuild(PathStateRebuildCheckpoint checkpoint) + throws IOException { + requireOpen(); + if (kind != Kind.BASE || sealed || progress != null || root == null) { + throw new IOException("path-state streamed rebuild checkpoint is not admissible"); + } + PathStateRebuildCheckpoint next = Objects.requireNonNull(checkpoint, "checkpoint"); + requireRebuildCheckpointIdentity(next); + int previousCount = rebuildCheckpoint == null ? 0 + : rebuildCheckpoint.getCompletedStores().size(); + if (next.getCompletedStores().size() != previousCount + 1) { + throw new IOException("path-state streamed rebuild checkpoint must advance one Store"); + } + if (rebuildCheckpoint != null) { + for (PathStateRebuildCoordinator.StoreResult previous + : rebuildCheckpoint.getCompletedStores()) { + boolean retained = false; + for (PathStateRebuildCoordinator.StoreResult advanced : next.getCompletedStores()) { + if (sameStoreResult(previous, advanced)) { + retained = true; + break; + } + } + if (!retained) { + throw new IOException("path-state streamed rebuild rewrites completed Store"); + } + } + } + nativeStore.writeBatch(Collections.singletonList( + PathStateNativeNodeStore.BatchMutation.put(REBUILD_CHECKPOINT_KEY, next.encode()))); + rebuildCheckpoint = next; + } + + synchronized long projectedLogicalBytes(PathStateRootMetadata metadata) throws IOException { + requireOpen(); + if (root == null) { + throw new IllegalStateException("path-state node database set has no trie owner"); + } + PathStateRootMetadata next = Objects.requireNonNull(metadata, "metadata"); + requireProgressIdentity(next); + if (!Arrays.equals(root.rootHash(), next.getStateRoot())) { + throw new IllegalArgumentException("path-state progress root does not match trie root"); + } + long total = rebuildCheckpoint == null ? (logicalBytes == null ? 0 : logicalBytes) + : rebuildLogicalBytes(); + total = projectedPendingBytes(total); + total = projectedLeafBytes(total, root.pendingLeafMutations()); + if (leafOverlay) { + total = replaceLogicalEntry(total, LEAF_OVERLAY_KEY, + nativeStore.get(LEAF_OVERLAY_KEY), LEAF_OVERLAY_VALUE); + } + total = replaceLogicalEntry(total, PROGRESS_KEY, + progress == null ? null : progress.encode(), next.encode()); + return rebuildCheckpoint == null ? total : replaceLogicalEntry(total, + REBUILD_CHECKPOINT_KEY, rebuildCheckpoint.encode(), null); + } + + public synchronized PathStateRootMetadata getProgress() { + requireOpen(); + return progress; + } + + static PathStateRootMetadata loadProgress(Path ownerDirectory, + PathStateStoreManifest manifest) throws IOException { + Path nodes = Objects.requireNonNull(ownerDirectory, "ownerDirectory").resolve(NODES_DIRECTORY); + if (!Files.exists(nodes, LinkOption.NOFOLLOW_LINKS)) { + return null; + } + if (!Files.isDirectory(nodes, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(nodes)) { + throw new IOException("path-state node database is not a direct directory: " + nodes); + } + try (PathStateNativeNodeStore store = PathStateNativeNodeStore.open(nodes, + Objects.requireNonNull(manifest, "manifest").getEngine())) { + return decodeProgress(store.get(PROGRESS_KEY)); + } + } + + static Long loadLogicalBytes(Path ownerDirectory, PathStateStoreManifest manifest) + throws IOException { + Path nodes = Objects.requireNonNull(ownerDirectory, "ownerDirectory").resolve(NODES_DIRECTORY); + if (!Files.exists(nodes, LinkOption.NOFOLLOW_LINKS)) { + return null; + } + if (!Files.isDirectory(nodes, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(nodes)) { + throw new IOException("path-state node database is not a direct directory: " + nodes); + } + try (PathStateNativeNodeStore store = PathStateNativeNodeStore.open(nodes, + Objects.requireNonNull(manifest, "manifest").getEngine())) { + Long expected = decodeLogicalBytes(store.get(LOGICAL_BYTES_KEY)); + if (expected == null) { + return null; + } + long[] actual = new long[]{0}; + try { + store.scanAll(entry -> { + byte[] key = entry.getKey(); + if (!Arrays.equals(key, LOGICAL_BYTES_KEY)) { + actual[0] = Math.addExact(actual[0], + Math.addExact(key.length, entry.getValue().length)); + } + }); + } catch (ArithmeticException overflow) { + throw new IOException("path-state logical bytes verification overflow", overflow); + } + if (actual[0] != expected) { + throw new IOException("path-state logical bytes marker does not match native entries"); + } + return expected; + } + } + + public Path getDirectory() { + return directory; + } + + /** Releases inherited read handles after the child root has been frozen for publication. */ + synchronized void releaseParentReadHandles() throws IOException { + requireOpen(); + if (parentStores != null) { + parentStores.close(); + } + } + + /** Resolves only this block's changed leaf baselines before inherited database handles close. */ + synchronized void resolvePendingLeafValues() { + requireOpen(); + if (root == null) { + throw new IllegalStateException("path-state node database set has no trie owner"); + } + for (PathStateRoot.LeafMutationRecord mutation : root.pendingLeafMutations()) { + byte[] key = leafKey(mutation); + BytesKey owned = new BytesKey(key); + if (!resolvedLeafValues.containsKey(owned)) { + resolvedLeafValues.put(owned, persistedLeafValue(key)); + } + } + } + + @Override + public synchronized void close() throws IOException { + if (closed) { + return; + } + closed = true; + IOException failure = null; + try { + nativeStore.close(); + } catch (IOException closeFailure) { + failure = closeFailure; + } + if (parentStores != null) { + try { + parentStores.close(); + } catch (IOException closeFailure) { + if (failure == null) { + failure = closeFailure; + } else { + failure.addSuppressed(closeFailure); + } + } + } + if (failure != null) { + throw failure; + } + } + + private void requireOpen() { + if (closed) { + throw new IllegalStateException("path-state node database set is closed: " + directory); + } + } + + private byte[] get(byte[] key) { + BytesKey ownedKey = new BytesKey(key); + Map participantPending = pending(key); + synchronized (participantPending) { + if (participantPending.containsKey(ownedKey)) { + byte[] value = participantPending.get(ownedKey); + return value == null ? null : Arrays.copyOf(value, value.length); + } + } + byte[] owned = ownedKey.copy(); + byte[] local = nativeStore.get(owned); + if (local != null) { + return local; + } + if (kind == Kind.LAYER && nativeStore.get(tombstoneKey(owned)) != null) { + return null; + } + return parentStores == null ? null : parentStores.get(owned); + } + + private void put(byte[] key, byte[] value) { + Map participantPending = pending(key); + synchronized (participantPending) { + participantPending.put(new BytesKey(key), Arrays.copyOf(value, value.length)); + } + } + + private void delete(byte[] key) { + Map participantPending = pending(key); + synchronized (participantPending) { + participantPending.put(new BytesKey(key), null); + } + } + + private PathNodeStore nodeStore(int storeId) { + if (storeId == 0) { + return superStore; + } + for (PathStateParticipant participant : scope.getParticipants()) { + if (participant.getStoreId() == storeId) { + return participantStores.get(participant.getDbName()); + } + } + throw new IllegalArgumentException("unknown prepared path-state Store ID: " + storeId); + } + + private void requireProgressIdentity(PathStateRootMetadata metadata) throws IOException { + if (metadata.getKind() != kind + || !Arrays.equals(metadata.getFormatDigest(), manifestDigest)) { + throw new IOException("path-state native progress identity mismatch"); + } + if (expectedMetadata != null + && !Arrays.equals(metadata.encode(), expectedMetadata.encode())) { + throw new IOException("path-state native progress differs from immutable metadata"); + } + } + + private void loadPersistedLeaves() throws IOException { + nativeStore.scanPrefix(LEAF_PREFIX, entry -> { + byte[] key = entry.getKey(); + if (key.length != LEAF_KEY_LENGTH || ByteBuffer.wrap(key).getInt() != LEAF_DOMAIN) { + throw new IOException("path-state durable leaf key is malformed"); + } + int storeId = ByteBuffer.wrap(key, Integer.BYTES, Integer.BYTES).getInt(); + requireParticipant(storeId); + BytesKey leafKey = new BytesKey(key); + byte[] value = entry.getValue(); + if (kind == Kind.LAYER) { + if (localLeaves.put(leafKey, value) != null) { + throw new IOException("duplicate path-state durable leaf key"); + } + persistedLeaves.put(leafKey, value); + } else if (persistedLeaves.put(leafKey, value) != null) { + throw new IOException("duplicate path-state durable leaf key"); + } + }); + } + + private void inheritParentLeaves() { + if (!leafOverlay || parentStores == null) { + return; + } + for (Map.Entry entry : parentStores.persistedLeaves.entrySet()) { + persistedLeaves.put(entry.getKey(), Arrays.copyOf(entry.getValue(), entry.getValue().length)); + } + } + + private void loadLeafTombstones() throws IOException { + nativeStore.scanPrefix(LEAF_TOMBSTONE_PREFIX, entry -> { + if (!leafOverlay || kind != Kind.LAYER || progress == null) { + throw new IOException("path-state leaf tombstones require durable LAYER progress"); + } + byte[] key = entry.getKey(); + if (key.length != LEAF_KEY_LENGTH + || ByteBuffer.wrap(key).getInt() != LEAF_TOMBSTONE_DOMAIN + || !Arrays.equals(entry.getValue(), TOMBSTONE_VALUE)) { + throw new IOException("path-state leaf tombstone is malformed"); + } + int storeId = ByteBuffer.wrap(key, Integer.BYTES, Integer.BYTES).getInt(); + requireParticipant(storeId); + BytesKey leafKey = new BytesKey(leafKeyFromTombstone(key)); + if (localLeaves.containsKey(leafKey)) { + throw new IOException("path-state leaf and tombstone coexist"); + } + if (parentStores == null || parentStores.persistedLeafValue(leafKey.copy()) == null) { + throw new IOException("path-state leaf tombstone does not mask a parent leaf"); + } + leafTombstones.add(leafKey); + persistedLeaves.remove(leafKey); + }); + } + + private void validateNodeTombstones() throws IOException { + nativeStore.scanPrefix(NODE_TOMBSTONE_PREFIX, entry -> { + if (kind != Kind.LAYER || progress == null) { + throw new IOException("path-state node tombstones require durable LAYER progress"); + } + byte[] key = entry.getKey(); + if (key.length < Integer.BYTES * 2 + || ByteBuffer.wrap(key).getInt() != NODE_TOMBSTONE_DOMAIN + || !Arrays.equals(entry.getValue(), TOMBSTONE_VALUE)) { + throw new IOException("path-state node tombstone is malformed"); + } + byte[] nodeKey = Arrays.copyOfRange(key, Integer.BYTES, key.length); + int storeId = ByteBuffer.wrap(nodeKey).getInt(); + if (storeId < 0 || storeId > scope.getParticipants().size()) { + throw new IOException("path-state node tombstone has an unknown Store ID"); + } + for (int index = Integer.BYTES; index < nodeKey.length; index++) { + if (nodeKey[index] < 0 || nodeKey[index] > 15) { + throw new IOException("path-state node tombstone contains a non-nibble path"); + } + } + if (nativeStore.get(nodeKey) != null) { + throw new IOException("path-state node and tombstone coexist"); + } + }); + } + + private List durableStateMutations( + byte[] rebuildValue, List leafMutations, + Collection storeIds) { + List mutations = + new ArrayList<>(pendingSize(storeIds) + leafMutations.size() + 1); + appendPendingMutations(mutations, storeIds); + appendLeafMutations(mutations, leafMutations); + mutations.add(PathStateNativeNodeStore.BatchMutation.put(REBUILD_CHECKPOINT_KEY, + rebuildValue)); + return mutations; + } + + private long rebuildLogicalBytes() throws IOException { + long[] total = new long[]{0}; + try { + nativeStore.scanAll(entry -> { + byte[] key = entry.getKey(); + if (!Arrays.equals(key, LOGICAL_BYTES_KEY)) { + total[0] = Math.addExact(total[0], + Math.addExact(key.length, entry.getValue().length)); + } + }); + return total[0]; + } catch (ArithmeticException overflow) { + throw new IOException("path-state rebuild logical bytes overflow", overflow); + } + } + + private void requireRebuildCheckpointIdentity(PathStateRebuildCheckpoint checkpoint) + throws IOException { + if (kind != Kind.BASE || expectedMetadata != null + || !Arrays.equals(checkpoint.getManifestDigest(), manifestDigest)) { + throw new IOException("path-state rebuild checkpoint identity mismatch"); + } + } + + private static boolean sameStoreResult(PathStateRebuildCoordinator.StoreResult left, + PathStateRebuildCoordinator.StoreResult right) { + return left.getStoreId() == right.getStoreId() + && left.getDbName().equals(right.getDbName()) + && left.getEntryCount() == right.getEntryCount() + && Arrays.equals(left.getInputDigest(), right.getInputDigest()) + && Arrays.equals(left.getStoreRoot(), right.getStoreRoot()); + } + + private List restoredLeafRecords() { + List records = new ArrayList<>(persistedLeaves.size()); + for (Map.Entry entry : persistedLeaves.entrySet()) { + byte[] key = entry.getKey().copy(); + int storeId = ByteBuffer.wrap(key, Integer.BYTES, Integer.BYTES).getInt(); + byte[] secureKey = Arrays.copyOfRange(key, Integer.BYTES * 2, key.length); + records.add(new PathStateRoot.LeafRecord(storeId, secureKey, entry.getValue())); + } + return records; + } + + private PathStateParticipant requireParticipant(int storeId) { + for (PathStateParticipant participant : scope.getParticipants()) { + if (participant.getStoreId() == storeId) { + return participant; + } + } + throw new IllegalArgumentException("unknown path-state durable leaf Store ID: " + storeId); + } + + private static PathStateRootMetadata decodeProgress(byte[] encoded) throws IOException { + if (encoded == null) { + return null; + } + try { + return PathStateRootMetadata.decode(encoded); + } catch (IllegalArgumentException invalid) { + throw new IOException("path-state native progress is corrupt", invalid); + } + } + + private static Long decodeLogicalBytes(byte[] encoded) throws IOException { + if (encoded == null) { + return null; + } + if (encoded.length != Long.BYTES) { + throw new IOException("path-state logical bytes marker is corrupt"); + } + long value = ByteBuffer.wrap(encoded).getLong(); + if (value < 0) { + throw new IOException("path-state logical bytes marker is negative"); + } + return value; + } + + private static PathStateRebuildCheckpoint decodeRebuildCheckpoint(byte[] encoded) + throws IOException { + return encoded == null ? null : PathStateRebuildCheckpoint.decode(encoded); + } + + private static long replaceLogicalEntry(long total, byte[] key, byte[] previous, byte[] next) + throws IOException { + try { + long adjusted = previous == null ? total + : Math.subtractExact(total, Math.addExact(key.length, previous.length)); + return next == null ? adjusted + : Math.addExact(adjusted, Math.addExact(key.length, next.length)); + } catch (ArithmeticException overflow) { + throw new IOException("path-state logical bytes overflow", overflow); + } + } + + private void appendPendingMutations( + List mutations) { + appendPendingMutations(mutations, pending.keySet()); + } + + private void appendPendingMutations(List mutations, + Collection storeIds) { + for (Integer storeId : storeIds) { + Map participantPending = pending.get(storeId); + synchronized (participantPending) { + appendPendingMutations(mutations, participantPending); + } + } + } + + private void appendPendingMutations(List mutations, + Map participantPending) { + for (Map.Entry entry : participantPending.entrySet()) { + byte[] key = entry.getKey().copy(); + byte[] value = entry.getValue(); + if (kind == Kind.LAYER) { + mutations.add(value == null + ? PathStateNativeNodeStore.BatchMutation.delete(key) + : PathStateNativeNodeStore.BatchMutation.put(key, value)); + byte[] tombstone = tombstoneKey(key); + mutations.add(value == null + ? PathStateNativeNodeStore.BatchMutation.put(tombstone, TOMBSTONE_VALUE) + : PathStateNativeNodeStore.BatchMutation.delete(tombstone)); + } else { + mutations.add(value == null + ? PathStateNativeNodeStore.BatchMutation.delete(key) + : PathStateNativeNodeStore.BatchMutation.put(key, value)); + } + } + } + + private void appendLeafMutations(List mutations, + List leafMutations) { + for (PathStateRoot.LeafMutationRecord mutation : leafMutations) { + byte[] key = leafKey(mutation); + byte[] value = mutation.getEncodedValue(); + if (!Arrays.equals(persistedLeafValue(key), value)) { + appendLeafMutation(mutations, key, value); + } + } + } + + private void appendLeafMutation(List mutations, + byte[] key, byte[] value) { + mutations.add(value == null + ? PathStateNativeNodeStore.BatchMutation.delete(key) + : PathStateNativeNodeStore.BatchMutation.put(key, value)); + if (leafOverlay) { + byte[] tombstone = leafTombstoneKey(key); + mutations.add(value == null + ? PathStateNativeNodeStore.BatchMutation.put(tombstone, TOMBSTONE_VALUE) + : PathStateNativeNodeStore.BatchMutation.delete(tombstone)); + } + } + + private long projectedLeafBytes(long total, + List leafMutations) + throws IOException { + long projected = total; + for (PathStateRoot.LeafMutationRecord mutation : leafMutations) { + byte[] key = leafKey(mutation); + byte[] value = mutation.getEncodedValue(); + if (!Arrays.equals(persistedLeafValue(key), value)) { + projected = projectedLeafMutation(projected, key, value); + } + } + return projected; + } + + private long projectedLeafMutation(long total, byte[] key, byte[] value) throws IOException { + long projected = replaceLogicalEntry(total, key, nativeStore.get(key), value); + if (leafOverlay) { + byte[] tombstone = leafTombstoneKey(key); + projected = replaceLogicalEntry(projected, tombstone, nativeStore.get(tombstone), + value == null ? TOMBSTONE_VALUE : null); + } + return projected; + } + + private void recordCommittedLeaves(List leafMutations) { + for (PathStateRoot.LeafMutationRecord mutation : leafMutations) { + BytesKey key = new BytesKey(leafKey(mutation)); + byte[] value = mutation.getEncodedValue(); + if (Arrays.equals(persistedLeaves.get(key), value)) { + continue; + } + if (value == null) { + persistedLeaves.remove(key); + if (kind == Kind.LAYER) { + localLeaves.remove(key); + } + if (leafOverlay) { + leafTombstones.add(key); + } + } else if (!Arrays.equals(persistedLeaves.get(key), value)) { + byte[] owned = Arrays.copyOf(value, value.length); + persistedLeaves.put(key, owned); + if (kind == Kind.LAYER) { + localLeaves.put(key, owned); + } + leafTombstones.remove(key); + } + } + } + + private byte[] persistedLeafValue(byte[] key) { + BytesKey leafKey = new BytesKey(key); + if (resolvedLeafValues.containsKey(leafKey)) { + return resolvedLeafValues.get(leafKey); + } + if (persistedLeaves.containsKey(leafKey)) { + return persistedLeaves.get(leafKey); + } + byte[] local = nativeStore.get(key); + if (local != null) { + return local; + } + if (leafOverlay && nativeStore.get(leafTombstoneKey(key)) != null) { + return null; + } + return parentStores == null ? null : parentStores.persistedLeafValue(key); + } + + private static byte[] leafKey(PathStateRoot.LeafMutationRecord mutation) { + return ByteBuffer.allocate(LEAF_KEY_LENGTH) + .putInt(LEAF_DOMAIN) + .putInt(mutation.getStoreId()) + .put(mutation.getSecureKey()) + .array(); + } + + private static byte[] rebuildLeafKey(int storeId, byte[] secureKey) { + byte[] key = Arrays.copyOf(Objects.requireNonNull(secureKey, "secureKey"), secureKey.length); + if (key.length != PathMerkleTrie.SECURE_KEY_LENGTH) { + throw new IllegalArgumentException("secureKey must contain exactly 32 bytes"); + } + return ByteBuffer.allocate(LEAF_KEY_LENGTH) + .putInt(LEAF_DOMAIN) + .putInt(storeId) + .put(key) + .array(); + } + + private long projectedPendingBytes(long total) throws IOException { + long projected = total; + for (Map participantPending : pending.values()) { + synchronized (participantPending) { + projected = projectedPendingBytes(projected, participantPending); + } + } + return projected; + } + + private long projectedPendingBytes(long total, Map participantPending) + throws IOException { + long projected = total; + for (Map.Entry entry : participantPending.entrySet()) { + byte[] key = entry.getKey().copy(); + byte[] value = entry.getValue(); + projected = replaceLogicalEntry(projected, key, nativeStore.get(key), value); + if (kind == Kind.LAYER) { + byte[] tombstone = tombstoneKey(key); + projected = replaceLogicalEntry(projected, tombstone, nativeStore.get(tombstone), + value == null ? TOMBSTONE_VALUE : null); + } + } + return projected; + } + + private Map pending(byte[] key) { + if (key.length < Integer.BYTES) { + throw new IllegalArgumentException("path-state namespaced key is too short"); + } + int storeId = ByteBuffer.wrap(key).getInt(); + Map participantPending = pending.get(storeId); + if (participantPending == null) { + throw new IllegalArgumentException("unknown path-state node Store ID: " + storeId); + } + return participantPending; + } + + private boolean hasPending() { + return pendingSize() != 0; + } + + private int pendingSize() { + return pendingSize(pending.keySet()); + } + + private int pendingSize(Collection storeIds) { + int size = 0; + for (Integer storeId : storeIds) { + Map participantPending = pending.get(storeId); + synchronized (participantPending) { + size = Math.addExact(size, participantPending.size()); + } + } + return size; + } + + private void clearPending() { + for (Integer storeId : pending.keySet()) { + clearPending(storeId); + } + } + + private void clearPending(int storeId) { + Map participantPending = pending.get(storeId); + synchronized (participantPending) { + participantPending.clear(); + } + } + + private static byte[] tombstoneKey(byte[] nodeKey) { + return ByteBuffer.allocate(Integer.BYTES + nodeKey.length) + .putInt(NODE_TOMBSTONE_DOMAIN) + .put(nodeKey) + .array(); + } + + private static byte[] leafTombstoneKey(byte[] leafKey) { + byte[] tombstone = Arrays.copyOf(leafKey, leafKey.length); + ByteBuffer.wrap(tombstone).putInt(LEAF_TOMBSTONE_DOMAIN); + return tombstone; + } + + private static byte[] leafKeyFromTombstone(byte[] tombstoneKey) { + byte[] leafKey = Arrays.copyOf(tombstoneKey, tombstoneKey.length); + ByteBuffer.wrap(leafKey).putInt(LEAF_DOMAIN); + return leafKey; + } + + private static PathStateRootMetadata loadParent(PathStateStoreManifest manifest, + PathStateRootMetadata child) throws IOException { + if (child.getKind() != Kind.LAYER || child.getBlockNumber() == 0) { + throw new IOException("path-state node overlay has an invalid child identity"); + } + PathStateRootMetadata base = PathStateMetadataFile.load( + manifest.getBaseDirectory().resolve(PathStateCurrentStore.METADATA_FILE)); + PathStateRootMetadata parent = base.getBlockNumber() == child.getBlockNumber() - 1 + ? base : PathStateMetadataFile.load(manifest.getLayerDirectory( + child.getBlockNumber() - 1, child.getParentHash()) + .resolve(PathStateCurrentStore.METADATA_FILE)); + if (child.getBlockNumber() != parent.getBlockNumber() + 1 + || !Arrays.equals(child.getParentHash(), parent.getBlockHash()) + || !Arrays.equals(child.getParentStateRoot(), parent.getStateRoot()) + || !Arrays.equals(parent.getFormatDigest(), manifest.getIdentityDigest())) { + throw new IOException("path-state node overlay parent identity mismatch"); + } + return parent; + } + + private static void closeAfterFailure(PathStateNodeStoreSet stores, Throwable failure) { + if (stores == null) { + return; + } + try { + stores.close(); + } catch (IOException closeFailure) { + failure.addSuppressed(closeFailure); + } + } + + private static void requireUnsealed(Path directory) throws IOException { + Path metadata = directory.resolve(PathStateCurrentStore.METADATA_FILE); + if (Files.exists(metadata, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("path-state node database set is sealed by immutable metadata: " + + directory); + } + } + + private static final class NamespacedNodeStore implements PathNodeStore { + + private final PathStateNodeStoreSet owner; + private final int storeId; + + private NamespacedNodeStore(PathStateNodeStoreSet owner, int storeId) { + this.owner = owner; + this.storeId = storeId; + } + + @Override + public byte[] get(byte[] path) { + owner.nodeStoreGetCalls.incrementAndGet(); + return owner.get(key(path)); + } + + @Override + public void put(byte[] path, byte[] encodedNode) { + byte[] value = Arrays.copyOf(Objects.requireNonNull(encodedNode, "encodedNode"), + encodedNode.length); + if (value.length == 0) { + throw new IllegalArgumentException("encodedNode must not be empty"); + } + owner.put(key(path), value); + } + + @Override + public void delete(byte[] path) { + owner.delete(key(path)); + } + + private byte[] key(byte[] path) { + byte[] ownedPath = Arrays.copyOf(Objects.requireNonNull(path, "path"), path.length); + for (byte nibble : ownedPath) { + if (nibble < 0 || nibble > 15) { + throw new IllegalArgumentException("path-state node path contains a non-nibble byte"); + } + } + return ByteBuffer.allocate(Integer.BYTES + ownedPath.length) + .putInt(storeId) + .put(ownedPath) + .array(); + } + } + + final class RebuildWriter implements Closeable { + + private final int storeId; + private final NamespacedNodeStore namespace; + private final List mutations = + new ArrayList<>(REBUILD_WRITE_BATCH_ENTRIES); + private long nodeEntries; + private long leafEntries; + private boolean writerClosed; + + private RebuildWriter(int storeId) { + this.storeId = storeId; + namespace = new NamespacedNodeStore(PathStateNodeStoreSet.this, storeId); + } + + void putNode(byte[] path, byte[] encodedNode) { + add(PathStateNativeNodeStore.BatchMutation.put(namespace.key(path), encodedNode)); + nodeEntries = Math.addExact(nodeEntries, 1L); + } + + void putLeaf(byte[] secureKey, byte[] encodedValue) { + add(PathStateNativeNodeStore.BatchMutation.put( + rebuildLeafKey(storeId, secureKey), encodedValue)); + leafEntries = Math.addExact(leafEntries, 1L); + } + + long getNodeEntries() { + return nodeEntries; + } + + long getLeafEntries() { + return leafEntries; + } + + private void add(PathStateNativeNodeStore.BatchMutation mutation) { + if (writerClosed) { + throw new IllegalStateException("path-state rebuild writer is closed"); + } + mutations.add(mutation); + if (mutations.size() >= REBUILD_WRITE_BATCH_ENTRIES) { + flush(); + } + } + + void flush() { + if (!mutations.isEmpty()) { + nativeStore.writeBatch(new ArrayList<>(mutations)); + mutations.clear(); + } + } + + @Override + public void close() { + if (!writerClosed) { + flush(); + writerClosed = true; + } + } + } + + private static final class BytesKey { + + private final byte[] bytes; + + private BytesKey(byte[] bytes) { + this.bytes = Arrays.copyOf(Objects.requireNonNull(bytes, "bytes"), bytes.length); + } + + private byte[] copy() { + return Arrays.copyOf(bytes, bytes.length); + } + + @Override + public boolean equals(Object other) { + return this == other || other instanceof BytesKey + && Arrays.equals(bytes, ((BytesKey) other).bytes); + } + + @Override + public int hashCode() { + return Arrays.hashCode(bytes); + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateParticipant.java b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateParticipant.java new file mode 100644 index 00000000000..ebf74a49639 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateParticipant.java @@ -0,0 +1,50 @@ +package org.tron.core.db2.stateroot; + +import java.nio.charset.StandardCharsets; +import java.util.Objects; + +/** Immutable identity and byte-format version for one path-state participant. */ +public final class PathStateParticipant implements Comparable { + + private final int storeId; + private final String dbName; + private final int storeFormatVersion; + + public PathStateParticipant(int storeId, String dbName, int storeFormatVersion) { + if (storeId <= 0) { + throw new IllegalArgumentException("storeId must be positive"); + } + this.dbName = Objects.requireNonNull(dbName, "dbName"); + int encodedNameLength = dbName.getBytes(StandardCharsets.UTF_8).length; + if (encodedNameLength == 0 || encodedNameLength > 128) { + throw new IllegalArgumentException("dbName must encode to 1..128 bytes"); + } + if (storeFormatVersion <= 0) { + throw new IllegalArgumentException("storeFormatVersion must be positive"); + } + this.storeId = storeId; + this.storeFormatVersion = storeFormatVersion; + } + + public int getStoreId() { + return storeId; + } + + public String getDbName() { + return dbName; + } + + public int getStoreFormatVersion() { + return storeFormatVersion; + } + + @Override + public int compareTo(PathStateParticipant other) { + return Integer.compare(storeId, other.storeId); + } + + @Override + public String toString() { + return storeId + ":" + dbName + ":v" + storeFormatVersion; + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateParticipantDescriptor.java b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateParticipantDescriptor.java new file mode 100644 index 00000000000..d2351e5250f --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateParticipantDescriptor.java @@ -0,0 +1,144 @@ +package org.tron.core.db2.stateroot; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** Stable participant and comparator identity for the current path-state root domain. */ +public final class PathStateParticipantDescriptor { + + public static final String SCOPE_ID = "path-state-root/exact-27/v1"; + public static final String UNSIGNED_RAW_COMPARATOR = "unsigned-raw/v1"; + public static final String MARKET_PRICE_COMPARATOR = "market-pair-price/v1"; + public static final String MARKET_PRICE_DATABASE = "market_pair_price_to_order"; + + private static final PathStateParticipantDescriptor CURRENT = + new PathStateParticipantDescriptor(); + + private final List stores; + private final Map storesByName; + + private PathStateParticipantDescriptor() { + LinkedHashMap names = new LinkedHashMap<>(); + names.put(1, "abi"); + names.put(2, "accountid-index"); + names.put(3, "account-index"); + names.put(4, "account"); + names.put(5, "account-asset"); + names.put(6, "asset-issue"); + names.put(7, "asset-issue-v2"); + names.put(8, "code"); + names.put(9, "contract-state"); + names.put(10, "contract"); + names.put(11, "DelegatedResourceAccountIndex"); + names.put(12, "DelegatedResource"); + names.put(13, "delegation"); + names.put(14, "properties"); + names.put(15, "exchange"); + names.put(16, "exchange-v2"); + names.put(17, "market_account"); + names.put(18, "market_order"); + names.put(19, MARKET_PRICE_DATABASE); + names.put(20, "market_pair_to_price"); + names.put(21, "proposal"); + names.put(22, "storage-row"); + names.put(23, "votes"); + names.put(24, "witness_schedule"); + names.put(25, "witness"); + names.put(26, "nullifier"); + names.put(27, "IncrementalMerkleTree"); + + List ordered = new ArrayList<>(); + LinkedHashMap byName = new LinkedHashMap<>(); + for (Map.Entry entry : names.entrySet()) { + String comparator = MARKET_PRICE_DATABASE.equals(entry.getValue()) + ? MARKET_PRICE_COMPARATOR : UNSIGNED_RAW_COMPARATOR; + StoreIdentity identity = new StoreIdentity(entry.getKey(), entry.getValue(), comparator); + ordered.add(identity); + if (byName.put(identity.getDbName(), identity) != null) { + throw new IllegalStateException("duplicate path-state database: " + identity.getDbName()); + } + } + if (ordered.size() != 27) { + throw new IllegalStateException("path-state participant descriptor must contain exact-27"); + } + stores = Collections.unmodifiableList(ordered); + storesByName = Collections.unmodifiableMap(byName); + } + + public static PathStateParticipantDescriptor current() { + return CURRENT; + } + + public List getStores() { + return stores; + } + + public StoreIdentity require(String dbName) { + StoreIdentity identity = storesByName.get(Objects.requireNonNull(dbName, "dbName")); + if (identity == null) { + throw new IllegalArgumentException("unknown path-state database: " + dbName); + } + return identity; + } + + /** Requires exact membership while allowing callers to enumerate databases in any order. */ + public void requireExactDatabases(Collection dbNames) { + Collection supplied = Objects.requireNonNull(dbNames, "dbNames"); + LinkedHashSet unique = new LinkedHashSet<>(); + for (String dbName : supplied) { + if (dbName == null) { + throw new IllegalArgumentException("path-state database must not be null"); + } + if (!unique.add(dbName)) { + throw new IllegalArgumentException("duplicate path-state database: " + dbName); + } + } + Set expected = storesByName.keySet(); + if (!expected.equals(unique)) { + LinkedHashSet missing = new LinkedHashSet<>(expected); + missing.removeAll(unique); + LinkedHashSet unexpected = new LinkedHashSet<>(unique); + unexpected.removeAll(expected); + throw new IllegalArgumentException( + "path-state exact-27 mismatch, missing=" + missing + ", unexpected=" + unexpected); + } + } + + /** Immutable Store identity; canonical key/value format is approved in a separate gate. */ + public static final class StoreIdentity { + + private final int storeId; + private final String dbName; + private final String comparatorId; + + private StoreIdentity(int storeId, String dbName, String comparatorId) { + this.storeId = storeId; + this.dbName = dbName; + this.comparatorId = comparatorId; + } + + public int getStoreId() { + return storeId; + } + + public String getDbName() { + return dbName; + } + + public String getComparatorId() { + return comparatorId; + } + + @Override + public String toString() { + return storeId + ":" + dbName + ":" + comparatorId; + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateParticipantScope.java b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateParticipantScope.java new file mode 100644 index 00000000000..cc3a0fba9f4 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateParticipantScope.java @@ -0,0 +1,74 @@ +package org.tron.core.db2.stateroot; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * Fail-closed participant registry for one path-state root format. + * + *

The current exact-set is defined by {@link PathStateParticipantDescriptor}. This lower-level + * class still accepts a supplied immutable scope so standalone trie tests do not imply an approved + * per-Store canonical value format before that separate gate is complete. + */ +public final class PathStateParticipantScope { + + public static final String ABI_DB = "abi"; + public static final String ASSET_ISSUE_DB = "asset-issue"; + public static final String ASSET_ISSUE_V2_DB = "asset-issue-v2"; + + private final List participants; + private final Map participantsByName; + + public PathStateParticipantScope(Collection participants) { + List sorted = new ArrayList<>( + Objects.requireNonNull(participants, "participants")); + if (sorted.isEmpty()) { + throw new IllegalArgumentException("participant scope must not be empty"); + } + Collections.sort(sorted); + + Set storeIds = new LinkedHashSet<>(); + Map byName = new LinkedHashMap<>(); + for (PathStateParticipant participant : sorted) { + PathStateParticipant present = Objects.requireNonNull(participant, "participant"); + if (!storeIds.add(present.getStoreId())) { + throw new IllegalArgumentException("duplicate Store ID: " + present.getStoreId()); + } + if (byName.put(present.getDbName(), present) != null) { + throw new IllegalArgumentException("duplicate database: " + present.getDbName()); + } + } + requireMandatory(byName, ABI_DB); + requireMandatory(byName, ASSET_ISSUE_DB); + requireMandatory(byName, ASSET_ISSUE_V2_DB); + this.participants = Collections.unmodifiableList(sorted); + this.participantsByName = Collections.unmodifiableMap(byName); + } + + public List getParticipants() { + return participants; + } + + public PathStateParticipant require(String dbName) { + PathStateParticipant participant = participantsByName.get( + Objects.requireNonNull(dbName, "dbName")); + if (participant == null) { + throw new IllegalArgumentException("unknown path-state participant: " + dbName); + } + return participant; + } + + private static void requireMandatory(Map participants, + String dbName) { + if (!participants.containsKey(dbName)) { + throw new IllegalArgumentException("missing mandatory path-state participant: " + dbName); + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStatePhysicalGlobalIntent.java b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStatePhysicalGlobalIntent.java new file mode 100644 index 00000000000..4391d91ecb9 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStatePhysicalGlobalIntent.java @@ -0,0 +1,182 @@ +package org.tron.core.db2.stateroot; + +import com.google.common.hash.Hashing; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** Exact 27-participant plus super target used by TASK-018 INTENT and CURRENT. */ +final class PathStatePhysicalGlobalIntent { + + static final int DIGEST_LENGTH = 32; + static final int MAX_ENCODED_LENGTH = 32 * 1024; + private static final int PARTICIPANT_COUNT = 27; + + private static final int MAGIC = 0x50534749; // PSGI + private static final short VERSION = 1; + private static final int ENTRY_LENGTH = Integer.BYTES + 3 * DIGEST_LENGTH; + private static final int FIXED_LENGTH_WITHOUT_METADATA = Integer.BYTES + Short.BYTES + + DIGEST_LENGTH + Integer.BYTES + Integer.BYTES + 2 * DIGEST_LENGTH + DIGEST_LENGTH; + + private final byte[] formatDigest; + private final PathStateRootMetadata metadata; + private final List participants; + private final byte[] superGeneration; + private final byte[] superRoot; + + PathStatePhysicalGlobalIntent(byte[] formatDigest, PathStateRootMetadata metadata, + List participants, byte[] superGeneration, byte[] superRoot) { + this.formatDigest = digest(formatDigest, "formatDigest"); + this.metadata = Objects.requireNonNull(metadata, "metadata"); + if (!Arrays.equals(this.formatDigest, metadata.getFormatDigest())) { + throw new IllegalArgumentException("physical global intent metadata format differs"); + } + List supplied = new ArrayList<>( + Objects.requireNonNull(participants, "participants")); + if (supplied.size() != PARTICIPANT_COUNT) { + throw new IllegalArgumentException("physical global intent must contain exact-27 participants"); + } + int previousStoreId = 0; + for (ParticipantTarget target : supplied) { + ParticipantTarget present = Objects.requireNonNull(target, "participant target"); + if (present.storeId <= previousStoreId) { + throw new IllegalArgumentException( + "physical global intent Store IDs must be strictly ascending"); + } + previousStoreId = present.storeId; + } + this.participants = Collections.unmodifiableList(supplied); + this.superGeneration = digest(superGeneration, "superGeneration"); + this.superRoot = digest(superRoot, "superRoot"); + if (!Arrays.equals(this.superRoot, metadata.getStateRoot())) { + throw new IllegalArgumentException("physical global intent metadata root differs"); + } + } + + byte[] encode() { + byte[] encodedMetadata = metadata.encode(); + int payloadLength = FIXED_LENGTH_WITHOUT_METADATA - DIGEST_LENGTH + + encodedMetadata.length + participants.size() * ENTRY_LENGTH; + ByteBuffer payload = ByteBuffer.allocate(payloadLength); + payload.putInt(MAGIC).putShort(VERSION).put(formatDigest) + .putInt(encodedMetadata.length).put(encodedMetadata).putInt(participants.size()); + for (ParticipantTarget participant : participants) { + payload.putInt(participant.storeId).put(participant.generation) + .put(participant.flatDigest).put(participant.storeRoot); + } + payload.put(superGeneration).put(superRoot); + byte[] body = payload.array(); + return ByteBuffer.allocate(body.length + DIGEST_LENGTH).put(body) + .put(Hashing.sha256().hashBytes(body).asBytes()).array(); + } + + static PathStatePhysicalGlobalIntent decode(byte[] encoded) { + byte[] supplied = Arrays.copyOf(Objects.requireNonNull(encoded, "encoded"), encoded.length); + if (supplied.length < FIXED_LENGTH_WITHOUT_METADATA || supplied.length > MAX_ENCODED_LENGTH) { + throw new IllegalArgumentException("physical global intent length is invalid"); + } + int payloadLength = supplied.length - DIGEST_LENGTH; + byte[] body = Arrays.copyOf(supplied, payloadLength); + byte[] checksum = Arrays.copyOfRange(supplied, payloadLength, supplied.length); + if (!Arrays.equals(checksum, Hashing.sha256().hashBytes(body).asBytes())) { + throw new IllegalArgumentException("physical global intent checksum differs"); + } + ByteBuffer input = ByteBuffer.wrap(body); + if (input.getInt() != MAGIC || input.getShort() != VERSION) { + throw new IllegalArgumentException("physical global intent format is unsupported"); + } + byte[] formatDigest = readDigest(input); + int metadataLength = input.getInt(); + if (metadataLength <= 0 || metadataLength > input.remaining() - Integer.BYTES + - 2 * DIGEST_LENGTH) { + throw new IllegalArgumentException("physical global intent metadata length is invalid"); + } + byte[] encodedMetadata = new byte[metadataLength]; + input.get(encodedMetadata); + PathStateRootMetadata metadata = PathStateRootMetadata.decode(encodedMetadata); + int count = input.getInt(); + if (count != PARTICIPANT_COUNT + || body.length != FIXED_LENGTH_WITHOUT_METADATA - DIGEST_LENGTH + + metadataLength + count * ENTRY_LENGTH) { + throw new IllegalArgumentException("physical global intent participant count is invalid"); + } + List participants = new ArrayList<>(count); + for (int index = 0; index < count; index++) { + participants.add(new ParticipantTarget(input.getInt(), readDigest(input), + readDigest(input), readDigest(input))); + } + return new PathStatePhysicalGlobalIntent(formatDigest, metadata, participants, + readDigest(input), readDigest(input)); + } + + byte[] getFormatDigest() { + return Arrays.copyOf(formatDigest, formatDigest.length); + } + + List getParticipants() { + return participants; + } + + PathStateRootMetadata getMetadata() { + return PathStateRootMetadata.decode(metadata.encode()); + } + + byte[] getSuperGeneration() { + return Arrays.copyOf(superGeneration, superGeneration.length); + } + + byte[] getSuperRoot() { + return Arrays.copyOf(superRoot, superRoot.length); + } + + private static byte[] readDigest(ByteBuffer input) { + byte[] value = new byte[DIGEST_LENGTH]; + input.get(value); + return value; + } + + private static byte[] digest(byte[] value, String name) { + byte[] supplied = Arrays.copyOf(Objects.requireNonNull(value, name), value.length); + if (supplied.length != DIGEST_LENGTH) { + throw new IllegalArgumentException(name + " must contain exactly 32 bytes"); + } + return supplied; + } + + static final class ParticipantTarget { + + private final int storeId; + private final byte[] generation; + private final byte[] flatDigest; + private final byte[] storeRoot; + + ParticipantTarget(int storeId, byte[] generation, byte[] flatDigest, byte[] storeRoot) { + if (storeId <= 0) { + throw new IllegalArgumentException("storeId must be positive"); + } + this.storeId = storeId; + this.generation = digest(generation, "generation"); + this.flatDigest = digest(flatDigest, "flatDigest"); + this.storeRoot = digest(storeRoot, "storeRoot"); + } + + int getStoreId() { + return storeId; + } + + byte[] getGeneration() { + return Arrays.copyOf(generation, generation.length); + } + + byte[] getFlatDigest() { + return Arrays.copyOf(flatDigest, flatDigest.length); + } + + byte[] getStoreRoot() { + return Arrays.copyOf(storeRoot, storeRoot.length); + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStatePhysicalIngestCheckpoint.java b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStatePhysicalIngestCheckpoint.java new file mode 100644 index 00000000000..a9e383dc2c6 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStatePhysicalIngestCheckpoint.java @@ -0,0 +1,64 @@ +package org.tron.core.db2.stateroot; + +import java.nio.ByteBuffer; +import java.util.Arrays; +import java.util.Objects; + +/** Durable per-Store F-ingest cursor bound to one pinned physical source identity. */ +final class PathStatePhysicalIngestCheckpoint { + + private static final int VERSION = 1; + private final byte[] sourceIdentity; + private final byte[] cursor; + private final long rows; + private final long bytes; + + PathStatePhysicalIngestCheckpoint(byte[] sourceIdentity, byte[] cursor, long rows, long bytes) { + this.sourceIdentity = copy32(sourceIdentity, "sourceIdentity"); + this.cursor = Arrays.copyOf(Objects.requireNonNull(cursor, "cursor"), cursor.length); + if (rows < 0 || bytes < 0) { + throw new IllegalArgumentException("checkpoint rows and bytes must not be negative"); + } + this.rows = rows; + this.bytes = bytes; + } + + byte[] encode() { + return ByteBuffer.allocate(Integer.BYTES + sourceIdentity.length + Integer.BYTES + cursor.length + + Long.BYTES * 2).putInt(VERSION).put(sourceIdentity).putInt(cursor.length).put(cursor) + .putLong(rows).putLong(bytes).array(); + } + + static PathStatePhysicalIngestCheckpoint decode(byte[] encoded) { + ByteBuffer input = ByteBuffer.wrap(Objects.requireNonNull(encoded, "encoded")); + if (input.remaining() < Integer.BYTES + 32 + Integer.BYTES + Long.BYTES * 2 + || input.getInt() != VERSION) { + throw new IllegalArgumentException("physical ingest checkpoint is invalid"); + } + byte[] identity = new byte[32]; + input.get(identity); + int cursorLength = input.getInt(); + if (cursorLength < 0 || input.remaining() != cursorLength + Long.BYTES * 2) { + throw new IllegalArgumentException("physical ingest checkpoint cursor is invalid"); + } + byte[] cursor = new byte[cursorLength]; + input.get(cursor); + return new PathStatePhysicalIngestCheckpoint(identity, cursor, input.getLong(), input.getLong()); + } + + byte[] getSourceIdentity() { return Arrays.copyOf(sourceIdentity, sourceIdentity.length); } + + byte[] getCursor() { return Arrays.copyOf(cursor, cursor.length); } + + long getRows() { return rows; } + + long getBytes() { return bytes; } + + private static byte[] copy32(byte[] value, String name) { + byte[] copy = Arrays.copyOf(Objects.requireNonNull(value, name), value.length); + if (copy.length != 32) { + throw new IllegalArgumentException(name + " must contain exactly 32 bytes"); + } + return copy; + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStatePhysicalOracleWindow.java b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStatePhysicalOracleWindow.java new file mode 100644 index 00000000000..5a33735b4f5 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStatePhysicalOracleWindow.java @@ -0,0 +1,86 @@ +package org.tron.core.db2.stateroot; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** + * Validated, read-only input for one fixed physical-store oracle window. + * + *

The window is ordered from the current child toward its oldest retained parent. It does not + * expose a historical root lookup API and does not mutate {@code F/N/M}, {@code INTENT}, + * {@code CURRENT}, or reverse journals. A caller must open an offline coherent checkpoint before + * loading the window; this class only proves that the supplied CURRENT and journals form one exact + * direct-parent chain. + */ +final class PathStatePhysicalOracleWindow { + + private final byte[] currentTarget; + private final byte[] oldestTarget; + private final List encodedJournals; + + PathStatePhysicalOracleWindow(byte[] currentTarget, + List journals) { + this.currentTarget = owned(currentTarget, "currentTarget"); + List supplied = new ArrayList<>( + Objects.requireNonNull(journals, "journals")); + if (supplied.isEmpty()) { + throw new IllegalArgumentException("physical oracle window must contain at least one block"); + } + PathStatePhysicalGlobalIntent cursor = PathStatePhysicalGlobalIntent.decode( + this.currentTarget); + List encoded = new ArrayList<>(supplied.size()); + for (PathStatePhysicalReverseJournal journal : supplied) { + PathStatePhysicalReverseJournal present = Objects.requireNonNull(journal, "journal"); + if (!Arrays.equals(cursor.encode(), present.getChildTarget())) { + throw new IllegalArgumentException( + "physical oracle window journal does not extend its current child"); + } + byte[] journalBytes = present.encode(); + encoded.add(Arrays.copyOf(journalBytes, journalBytes.length)); + cursor = PathStatePhysicalGlobalIntent.decode(present.getParentTarget()); + } + this.oldestTarget = cursor.encode(); + this.encodedJournals = Collections.unmodifiableList(encoded); + } + + int getBlockCount() { + return encodedJournals.size(); + } + + PathStateRootMetadata getCurrentMetadata() { + return PathStatePhysicalGlobalIntent.decode(currentTarget).getMetadata(); + } + + PathStateRootMetadata getOldestMetadata() { + return PathStatePhysicalGlobalIntent.decode(oldestTarget).getMetadata(); + } + + List journals() { + List copies = new ArrayList<>(encodedJournals.size()); + for (byte[] encoded : encodedJournals) { + copies.add(PathStatePhysicalReverseJournal.decode(encoded)); + } + return Collections.unmodifiableList(copies); + } + + List targets() { + List targets = new ArrayList<>(encodedJournals.size() + 1); + PathStatePhysicalGlobalIntent cursor = PathStatePhysicalGlobalIntent.decode(currentTarget); + targets.add(cursor); + for (byte[] encoded : encodedJournals) { + PathStatePhysicalReverseJournal journal = PathStatePhysicalReverseJournal.decode(encoded); + cursor = PathStatePhysicalGlobalIntent.decode(journal.getParentTarget()); + targets.add(cursor); + } + return Collections.unmodifiableList(targets); + } + + private static byte[] owned(byte[] value, String name) { + byte[] supplied = Arrays.copyOf(Objects.requireNonNull(value, name), value.length); + PathStatePhysicalGlobalIntent.decode(supplied); + return supplied; + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStatePhysicalOverlayHead.java b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStatePhysicalOverlayHead.java new file mode 100644 index 00000000000..bc3517f9b9c --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStatePhysicalOverlayHead.java @@ -0,0 +1,889 @@ +package org.tron.core.db2.stateroot; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import lombok.extern.slf4j.Slf4j; +import org.tron.core.db2.archive.BlockSnapshotMeta; +import org.tron.core.db2.core.CommonCheckpointBaseline; +import org.tron.core.db2.core.CommonCheckpointMemoryRebaser; +import org.tron.core.db2.core.CommonCheckpointMaterializedStore; +import org.tron.core.db2.core.CommonCheckpointTarget; +import org.tron.core.db2.stateroot.PathStateCanonicalizer.P66Phase; +import org.tron.core.db2.stateroot.PathStateStoreManifest.Engine; + +/** + * PathState head that reads one durable physical checkpoint and advances in memory. + * + *

No transition method writes F/N/M, INTENT, CURRENT, or a reverse journal. The durable base is + * changed only by the common-checkpoint materializer. The same implementation remains available + * to the explicitly configured volatile benchmark mode. + */ +@Slf4j(topic = "DB") +public final class PathStatePhysicalOverlayHead implements PathStateHead { + + private static final byte[] ABSENT = new byte[0]; + static final int DEFAULT_PARTICIPANT_THREADS = 4; + static final int DEFAULT_BRANCH_THREADS = 8; + + private final PathStatePhysicalStoreSet stores; + private final PathStateParticipantScope scope; + private final byte[] formatDigest; + private final int maxHistory; + private final ExecutorService participantExecutor; + private final ExecutorService branchExecutor; + private final List history = new ArrayList<>(); + private PathStateRootMetadata head; + private PathStateRoot.Snapshot snapshot; + private PreparedOverlay pending; + private boolean failed; + private boolean closed; + + private PathStatePhysicalOverlayHead(PathStatePhysicalStoreSet stores, + PathStateRootMetadata head, PathStateRoot.Snapshot snapshot, int maxHistory, + int participantThreads, int branchThreads) { + this.stores = Objects.requireNonNull(stores, "stores"); + this.scope = stores.participantScope(); + this.formatDigest = stores.getFormatDigest(); + this.head = Objects.requireNonNull(head, "head"); + this.snapshot = Objects.requireNonNull(snapshot, "snapshot"); + this.maxHistory = maxHistory; + participantExecutor = newExecutor(participantThreads, "participant"); + branchExecutor = newExecutor(branchThreads, "branch"); + } + + /** Opens the physical CURRENT as a read-only base for a volatile benchmark overlay. */ + public static PathStatePhysicalOverlayHead open(Path directory, Engine engine, + PathStateLayerLimits limits) throws IOException { + return open(directory, engine, limits, PathStatePhysicalStoreSet.STEADY_NODE_CACHE_BYTES); + } + + /** Opens a next-format published target without invoking legacy journal/CURRENT recovery. */ + public static PathStatePhysicalOverlayHead openCommonCheckpoint(Path directory, Engine engine, + PathStateLayerLimits limits, long residentNodeCacheBytes, int participantThreads, + int branchThreads, byte[] formatIdentity, BlockSnapshotMeta canonicalHead, P66Phase phase) + throws IOException { + requireThreadCount(participantThreads, "participantThreads"); + requireThreadCount(branchThreads, "branchThreads"); + PathStatePhysicalStoreSet opened = PathStatePhysicalStoreSet.openExisting(directory, + new PathStateCanonicalizer().participantScope(), engine, residentNodeCacheBytes); + try { + PathStateCheckpointMaterializer.PublishedHead current = + PathStateCheckpointMaterializer.loadPublishedHead(directory, formatIdentity); + PathStateRoot root = opened.createRoot(); + root.restoreStoredRoots(current.getStateRoot()); + PathStateRoot.Snapshot restored = root.snapshot(); + PathStateRootMetadata metadata = PathStateRootMetadata.base(current.getBlockNumber(), + current.getBlockHash(), canonicalHead.getParentHash(), canonicalHead.getTimestamp(), + phase, opened.getFormatDigest(), current.getStateRoot(), current.getPayloadDigest()); + return new PathStatePhysicalOverlayHead(opened, metadata, restored, + Objects.requireNonNull(limits, "limits").getMaxLayers(), participantThreads, + branchThreads); + } catch (IOException | RuntimeException failure) { + try { + opened.close(); + } catch (IOException closeFailure) { + failure.addSuppressed(closeFailure); + } + throw failure; + } + } + + /** Opens the immutable fresh baseline after its legacy pointer has been retired. */ + public static PathStatePhysicalOverlayHead openCommonBaseline(Path directory, Engine engine, + PathStateLayerLimits limits, long residentNodeCacheBytes, int participantThreads, + int branchThreads) throws IOException { + requireThreadCount(participantThreads, "participantThreads"); + requireThreadCount(branchThreads, "branchThreads"); + PathStatePhysicalStoreSet opened = PathStatePhysicalStoreSet.openExisting(directory, + new PathStateCanonicalizer().participantScope(), engine, residentNodeCacheBytes); + try { + PathStateRootMetadata current = PathStateRootMetadata.decode(Files.readAllBytes( + directory.resolve(PathStateCheckpointMaterializer.COMMON_BASELINE_HEAD_FILE))); + PathStateRoot root = opened.createRoot(); + root.restoreStoredRoots(current.getStateRoot()); + return new PathStatePhysicalOverlayHead(opened, current, root.snapshot(), + Objects.requireNonNull(limits, "limits").getMaxLayers(), participantThreads, + branchThreads); + } catch (IOException | RuntimeException failure) { + try { + opened.close(); + } catch (IOException closeFailure) { + failure.addSuppressed(closeFailure); + } + throw failure; + } + } + + /** Refreshes the in-memory head after startup redo has published a newer common target. */ + public synchronized void synchronizePublishedCheckpoint(byte[] formatIdentity, + BlockSnapshotMeta canonicalHead, P66Phase phase) throws IOException { + requireHealthy(); + PathStateCheckpointMaterializer.PublishedHead current = + PathStateCheckpointMaterializer.loadPublishedHead(stores.getDirectory(), formatIdentity); + if (current.getEpoch() != canonicalHead.getEpoch() + || current.getBlockNumber() != canonicalHead.getBlockNumber() + || !Arrays.equals(current.getBlockHash(), canonicalHead.getBlockHash())) { + throw new IOException("PathState common CURRENT differs after startup redo"); + } + PathStateRoot restored = new PathStateRoot(scope, + participant -> stores.participant(participant.getDbName()).nodeStore(), + stores.superStore().nodeStore()); + restored.restoreStoredRoots(current.getStateRoot()); + snapshot = restored.snapshot(); + head = PathStateRootMetadata.base(current.getBlockNumber(), current.getBlockHash(), + canonicalHead.getParentHash(), canonicalHead.getTimestamp(), phase, formatDigest, + current.getStateRoot(), current.getPayloadDigest()); + history.clear(); + pending = null; + } + + /** Creates the PathState authority over the same stores used by this in-memory head. */ + public synchronized PathStateCheckpointMaterializer checkpointMaterializer( + byte[] formatIdentity, CommonCheckpointBaseline baseline) throws IOException { + return checkpointMaterializer(formatIdentity, baseline, null); + } + + public synchronized PathStateCheckpointMaterializer checkpointMaterializer( + byte[] formatIdentity, CommonCheckpointBaseline baseline, + CommonCheckpointMaterializedStore materializedStore) throws IOException { + requireHealthy(); + return new PathStateCheckpointMaterializer(stores, scope, formatIdentity, baseline, + materializedStore); + } + + /** Builds a fully validated parentless-target plus reversible-suffix memory rebase. */ + public synchronized CommonCheckpointMemoryRebaser.RebasePlan prepareCommonCheckpointRebase( + CommonCheckpointTarget target) throws IOException { + requireHealthy(); + CommonCheckpointTarget admitted = Objects.requireNonNull(target, "target"); + if (pending != null) { + throw new IOException("path-state checkpoint cannot rebase a pending transition"); + } + PathStateCheckpointMaterializer.PublishedHead published = + PathStateCheckpointMaterializer.loadPublishedHead(stores.getDirectory(), + admitted.getFormatIdentity()); + BlockSnapshotMeta targetBlock = admitted.getLastBlock(); + if (published.getEpoch() != targetBlock.getEpoch() + || published.getBlockNumber() != targetBlock.getBlockNumber() + || !Arrays.equals(published.getBlockHash(), targetBlock.getBlockHash()) + || !Arrays.equals(published.getStateRoot(), admitted.getStateRoot()) + || !Arrays.equals(published.getPayloadDigest(), admitted.getPayloadDigest())) { + throw new IOException("PathState published CURRENT differs from common checkpoint target"); + } + + TargetPosition position = targetPosition(admitted); + PathStateRoot baseline = new PathStateRoot(scope, + participant -> stores.participant(participant.getDbName()).nodeStore(), + stores.superStore().nodeStore()); + baseline.restoreStoredRoots(admitted.getStateRoot()); + PathStateRoot.Snapshot candidateSnapshot = baseline.snapshot(); + PathStateRootMetadata candidateHead = PathStateRootMetadata.base( + targetBlock.getBlockNumber(), targetBlock.getBlockHash(), targetBlock.getParentHash(), + targetBlock.getTimestamp(), position.metadata.getPhase(), formatDigest, + admitted.getStateRoot(), admitted.getPayloadDigest()); + List candidateHistory = new ArrayList<>(); + BlockSnapshotMeta replayParent = targetBlock; + + for (int index = position.historyIndex; index < history.size(); index++) { + HeadState retained = history.get(index); + PathStateRootMetadata child = index + 1 < history.size() + ? history.get(index + 1).metadata : head; + validateReplayStep(candidateHead, replayParent, retained.deltaToChild, child); + candidateHistory.add(new HeadState(candidateHead, candidateSnapshot, + retained.deltaToChild)); + candidateSnapshot = replay(candidateSnapshot, retained.deltaToChild); + candidateHead = copy(child); + replayParent = retained.deltaToChild.getMeta(); + } + if (!Arrays.equals(candidateSnapshot.getStateRoot(), head.getStateRoot()) + || candidateHead.getBlockNumber() != head.getBlockNumber() + || !Arrays.equals(candidateHead.getBlockHash(), head.getBlockHash())) { + throw new IOException("path-state checkpoint rebase candidate differs from live head"); + } + + PathStateRootMetadata installedHead = candidateHead; + PathStateRoot.Snapshot installedSnapshot = candidateSnapshot; + List installedHistory = new ArrayList<>(candidateHistory); + return () -> installRebase(installedHead, installedSnapshot, installedHistory); + } + + /** Retires this freshly rebuilt legacy pointer before the first common checkpoint is enabled. */ + public synchronized void admitFreshCommonBaseline(CommonCheckpointBaseline baseline) + throws IOException { + requireHealthy(); + PathStateCheckpointMaterializer.admitFreshBaseline(stores, head, baseline); + } + + /** Opens a benchmark overlay with an explicit shared resident-node cache budget. */ + public static PathStatePhysicalOverlayHead open(Path directory, Engine engine, + PathStateLayerLimits limits, long residentNodeCacheBytes) throws IOException { + return open(directory, engine, limits, residentNodeCacheBytes, + DEFAULT_PARTICIPANT_THREADS, DEFAULT_BRANCH_THREADS); + } + + /** Opens a benchmark overlay with explicit cache and bounded prepare worker budgets. */ + public static PathStatePhysicalOverlayHead open(Path directory, Engine engine, + PathStateLayerLimits limits, long residentNodeCacheBytes, int participantThreads, + int branchThreads) throws IOException { + requireThreadCount(participantThreads, "participantThreads"); + requireThreadCount(branchThreads, "branchThreads"); + PathStatePhysicalStoreSet opened = PathStatePhysicalStoreSet.openExisting(directory, + new PathStateCanonicalizer().participantScope(), engine, residentNodeCacheBytes); + try { + opened.recoverPublication(); + PathStateRootMetadata current = opened.currentMetadata(); + PathStateRoot root = opened.createRoot(); + root.restoreStoredRoots(current.getStateRoot()); + PathStateRoot.Snapshot restored = root.snapshot(); + if (!Arrays.equals(restored.getStateRoot(), current.getStateRoot())) { + throw new IOException("path-state overlay root mismatch"); + } + return new PathStatePhysicalOverlayHead(opened, current, restored, + Objects.requireNonNull(limits, "limits").getMaxLayers(), participantThreads, + branchThreads); + } catch (IOException | RuntimeException failure) { + try { + opened.close(); + } catch (IOException closeFailure) { + failure.addSuppressed(closeFailure); + } + throw failure; + } + } + + @Override + public synchronized PathStateRootMetadata advance(PathStateBlockTransition transition) + throws IOException { + requireHealthy(); + PathStateBlockTransition admitted = Objects.requireNonNull(transition, "transition"); + if (pending == null || pending.transition != admitted) { + throw new IOException("path-state overlay publication differs from prepared transition"); + } + history.add(new HeadState(head, snapshot, pending.delta)); + while (history.size() > maxHistory) { + history.remove(0); + } + head = pending.metadata; + snapshot = pending.snapshot; + compactRetainedSnapshotsIfNeeded(); + logger.info("Path-state volatile overlay advanced: head={}, mutations={}, " + + "nodeMutations={}, nativeNodeReads={}, cacheBytes={}, cacheEntries={}, " + + "cacheEvictions={}, changedParticipants={}, maxParticipantMutations={}, " + + "authoritativePreviousValues={}, " + + "maxParticipantStoreId={}, maxParticipantMs={}, participantWorkMs={}, " + + "participantWallMs={}, prepareMs={}, trieMs={}, artifactMs={}, " + + "nodePlanWorkMs={}, nodeStoreWorkMs={}, nodeFinalizeWorkMs={}, " + + "nodePuts={}, nodeDeletes={}, nodeRlpBytes={}, nodeRlpFinalBytes={}, " + + "uniqueNodePaths={}, overwriteWrites={}, nodeCreates={}, nodeEncodes={}, " + + "avoidedNodeEncodes={}, unencodedCreatesAfterTopLevelDecodes={}, nodeKeccaks={}, " + + "nodeDecodes={}, nodeHashVerifies={}, hashRefsCreated={}, hashRefsResolved={}, " + + "durableWrites=0, journal=0", + head.getBlockNumber(), admitted.getMutations().size(), pending.nodeMutations, + pending.nativeNodeReads, stores.residentNodeCacheBytes(), + stores.residentNodeCacheEntries(), stores.residentNodeCacheEvictions(), + pending.changedParticipants, pending.maxParticipantMutations, + pending.authoritativePreviousValues, + pending.maxParticipantStoreId, pending.maxParticipantMillis, + pending.participantWorkMillis, pending.participantWallMillis, + pending.prepareMillis, pending.trieMillis, pending.artifactMillis, + pending.nodePlanWorkMillis, pending.nodeStoreWorkMillis, + pending.nodeFinalizeWorkMillis, + pending.stats.nodePuts, pending.stats.nodeDeletes, pending.stats.nodeRlpBytes, + pending.stats.nodeRlpFinalBytes, pending.stats.uniqueNodePaths, + pending.stats.overwriteWrites(), pending.nodeCreates, pending.nodeEncodes, + Math.max(0L, pending.nodeCreates - pending.nodeEncodes), + Math.max(0L, pending.nodeCreates - pending.nodeEncodes - pending.nodeDecodes), + pending.nodeKeccaks, + pending.nodeDecodes, pending.nodeHashVerifies, pending.hashRefsCreated, + pending.hashRefsResolved); + logger.info("Path-state artifact stores: head={}, perStore={}", + head.getBlockNumber(), pending.stats.perStore); + pending = null; + return copy(head); + } + + private void compactRetainedSnapshotsIfNeeded() throws IOException { + int previousDepth = snapshot.maxTrieDepth(); + if (history.isEmpty() || previousDepth <= (long) maxHistory * 2) { + return; + } + HeadState first = history.get(0); + PathStateRootMetadata candidateHead = first.metadata; + PathStateRoot.Snapshot candidateSnapshot = first.snapshot.detach(); + List candidateHistory = new ArrayList<>(history.size()); + BlockSnapshotMeta replayParent = BlockSnapshotMeta.forBlock( + candidateHead.getBlockNumber(), candidateHead.getBlockHash(), + candidateHead.getParentHash(), candidateHead.getTimestamp()); + for (int index = 0; index < history.size(); index++) { + HeadState retained = history.get(index); + PathStateRootMetadata child = index + 1 < history.size() + ? history.get(index + 1).metadata : head; + validateReplayStep(candidateHead, replayParent, retained.deltaToChild, child); + candidateHistory.add(new HeadState(candidateHead, candidateSnapshot, + retained.deltaToChild)); + PathStateRoot.Snapshot childSnapshot = index + 1 < history.size() + ? history.get(index + 1).snapshot : snapshot; + candidateSnapshot = childSnapshot.reparent(candidateSnapshot); + candidateHead = copy(child); + replayParent = retained.deltaToChild.getMeta(); + } + if (!Arrays.equals(candidateSnapshot.getStateRoot(), snapshot.getStateRoot()) + || candidateHead.getBlockNumber() != head.getBlockNumber() + || !Arrays.equals(candidateHead.getBlockHash(), head.getBlockHash())) { + throw new IOException("path-state retained snapshot compaction differs from live head"); + } + snapshot = candidateSnapshot; + history.clear(); + history.addAll(candidateHistory); + logger.info("Path-state volatile snapshot parents compacted: head={}, suffixBlocks={}, " + + "previousDepth={}, retainedDepth={}", head.getBlockNumber(), history.size(), + previousDepth, snapshot.maxTrieDepth()); + } + + @Override + public synchronized PathStateRootMetadata rewindTo(long blockNumber, byte[] blockHash) + throws IOException { + requireHealthy(); + byte[] admittedHash = Arrays.copyOf(Objects.requireNonNull(blockHash, "blockHash"), + blockHash.length); + if (matches(head, blockNumber, admittedHash)) { + pending = null; + return copy(head); + } + for (int index = history.size() - 1; index >= 0; index--) { + HeadState candidate = history.get(index); + if (matches(candidate.metadata, blockNumber, admittedHash)) { + head = candidate.metadata; + snapshot = candidate.snapshot; + history.subList(index, history.size()).clear(); + pending = null; + return copy(head); + } + } + throw new IOException("path-state overlay ancestor is outside memory history"); + } + + @Override + public synchronized PathStateRootMetadata flushBaseThrough(long blockNumber, byte[] blockHash) + throws IOException { + requireHealthy(); + logger.info("Path-state volatile overlay skipped durable base flush: target={}, " + + "benchmarkOnly=true", blockNumber); + return copy(head); + } + + @Override + public synchronized byte[] preview(PathStateBlockTransition transition) throws IOException { + requireHealthy(); + return prepare(null, Objects.requireNonNull(transition, "transition")).metadata.getStateRoot(); + } + + @Override + public synchronized PathStateSnapshotDelta prepareSnapshotDelta(BlockSnapshotMeta meta, + PathStateBlockTransition transition) throws IOException { + requireHealthy(); + if (pending != null) { + throw new IOException("path-state overlay transition is already prepared"); + } + pending = prepare(Objects.requireNonNull(meta, "meta"), + Objects.requireNonNull(transition, "transition")); + return pending.delta; + } + + @Override + public synchronized PathStateRootMetadata getHead() throws IOException { + requireHealthy(); + return copy(head); + } + + long durableWriteBatchCalls() { + long calls = stores.superStore().getWriteBatchCalls(); + for (PathStateParticipant participant : scope.getParticipants()) { + calls += stores.participant(participant.getDbName()).getWriteBatchCalls(); + } + return calls; + } + + synchronized RetentionCensus retentionCensus() throws IOException { + requireHealthy(); + return new RetentionCensus(head.getBlockNumber(), history.size(), snapshot.trieCount(), + snapshot.maxTrieDepth()); + } + + @Override + public synchronized void close() throws IOException { + if (closed) { + return; + } + closed = true; + participantExecutor.shutdownNow(); + branchExecutor.shutdownNow(); + stores.close(); + } + + private PreparedOverlay prepare(BlockSnapshotMeta meta, PathStateBlockTransition transition) + throws IOException { + requireChild(transition); + long startedNanos = System.nanoTime(); + long nodeCreatesBefore = PathMerkleTrie.nodeCreateCountTotal(); + long nodeEncodesBefore = PathMerkleTrie.nodeEncodeCountTotal(); + long nodeKeccaksBefore = PathMerkleTrie.nodeKeccakCountTotal(); + Map recordings = new LinkedHashMap<>(); + PathStateRoot candidate = PathStateRoot.fromSnapshot(scope, + participant -> recordings.computeIfAbsent(participant.getStoreId(), ignored -> + new RecordingStore(stores.participant(participant.getDbName()).nodeStore())), + recordings.computeIfAbsent(0, ignored -> + new RecordingStore(stores.superStore().nodeStore())), snapshot); + PathStateRoot.ParallelApplyStats parallelStats = transition.getMutations().isEmpty() ? null + : candidate.applyParallel(transition.getMutations(), participantExecutor, branchExecutor); + PathStateRoot.Snapshot nextSnapshot = candidate.snapshot(); + long trieNanos = System.nanoTime(); + Map> flatByStore = new LinkedHashMap<>(); + for (PathStateMutation mutation : transition.getMutations()) { + PathStateParticipant participant = scope.require(mutation.getDbName()); + byte[] secureKey = PathStateCommitmentCodec.storeLeafKey(participant.getStoreId(), + mutation.getPhysicalKey()); + byte[] encodedValue = mutation.isDelete() ? null + : PathStateCommitmentCodec.presentLeafValue(mutation.getPhysicalValue()); + flatByStore.computeIfAbsent(participant.getStoreId(), ignored -> new ArrayList<>()) + .add(new PathStateSnapshotDelta.Mutation(secureKey, encodedValue)); + } + + List deltas = new ArrayList<>(); + int nodeMutations = 0; + for (PathStateParticipant participant : scope.getParticipants()) { + List flats = flatByStore.get(participant.getStoreId()); + if (flats == null) { + continue; + } + List nodes = recordings.get(participant.getStoreId()) + .mutations(); + nodeMutations += nodes.size(); + deltas.add(new PathStateSnapshotDelta.StoreDelta(participant, + nextSnapshot.participantRoot(participant.getDbName()), flats, nodes)); + } + List superNodes = recordings.get(0).mutations(); + nodeMutations += superNodes.size(); + PathStateRootMetadata metadata = PathStateRootMetadata.layer(transition.getBlockNumber(), + transition.getBlockHash(), transition.getParentHash(), transition.getTimestamp(), + transition.getPhase(), formatDigest, head.getStateRoot(), nextSnapshot.getStateRoot(), + transition.getPayloadDigest()); + PathStateSnapshotDelta delta = meta == null ? null : PathStateSnapshotDelta.fromPhysical(meta, + head, transition, nextSnapshot, deltas, superNodes); + long nativeReads = recordings.values().stream().mapToLong(RecordingStore::nativeReads).sum(); + RecordingStats stats = RecordingStats.collect(recordings); + long finishedNanos = System.nanoTime(); + long prepareMillis = TimeUnit.NANOSECONDS.toMillis(finishedNanos - startedNanos); + return new PreparedOverlay(transition, metadata, nextSnapshot, delta, nodeMutations, + nativeReads, parallelStats, prepareMillis, + TimeUnit.NANOSECONDS.toMillis(trieNanos - startedNanos), + TimeUnit.NANOSECONDS.toMillis(finishedNanos - trieNanos), + TimeUnit.NANOSECONDS.toMillis(candidate.nodeCommitPlanNanos()), + TimeUnit.NANOSECONDS.toMillis(candidate.nodeCommitStoreNanos()), + TimeUnit.NANOSECONDS.toMillis(candidate.nodeCommitFinalizeNanos()), + PathMerkleTrie.nodeCreateCountTotal() - nodeCreatesBefore, + PathMerkleTrie.nodeEncodeCountTotal() - nodeEncodesBefore, + PathMerkleTrie.nodeKeccakCountTotal() - nodeKeccaksBefore, + candidate.nodeDecodeCount(), candidate.nodeHashVerifyCount(), + candidate.hashReferenceCreateCount(), candidate.hashReferenceResolveCount(), stats); + } + + private TargetPosition targetPosition(CommonCheckpointTarget target) throws IOException { + if (matches(head, target.getLastBlock().getBlockNumber(), + target.getLastBlock().getBlockHash()) + && Arrays.equals(head.getStateRoot(), target.getStateRoot())) { + return new TargetPosition(head, history.size()); + } + for (int index = history.size() - 1; index >= 0; index--) { + HeadState candidate = history.get(index); + if (matches(candidate.metadata, target.getLastBlock().getBlockNumber(), + target.getLastBlock().getBlockHash()) + && Arrays.equals(candidate.metadata.getStateRoot(), target.getStateRoot())) { + return new TargetPosition(candidate.metadata, index); + } + } + throw new IOException("PathState checkpoint target is outside reversible history"); + } + + private PathStateRoot.Snapshot replay(PathStateRoot.Snapshot parent, + PathStateSnapshotDelta delta) throws IOException { + Map recordings = new LinkedHashMap<>(); + PathStateRoot candidate = PathStateRoot.fromSnapshot(scope, + participant -> recordings.computeIfAbsent(participant.getStoreId(), ignored -> + new RecordingStore(stores.participant(participant.getDbName()).nodeStore())), + recordings.computeIfAbsent(0, ignored -> + new RecordingStore(stores.superStore().nodeStore())), parent); + try { + candidate.replaySnapshotDelta(delta); + return candidate.snapshot(); + } catch (IllegalArgumentException | IllegalStateException failure) { + throw new IOException("path-state checkpoint suffix replay failed", failure); + } + } + + private void validateReplayStep(PathStateRootMetadata parent, BlockSnapshotMeta parentBlock, + PathStateSnapshotDelta delta, PathStateRootMetadata child) throws IOException { + if (delta == null || child.getKind() != PathStateRootMetadata.Kind.LAYER + || delta.getMeta().getEpoch() != parentBlock.getEpoch() + 1 + || delta.getMeta().getBlockNumber() != child.getBlockNumber() + || !Arrays.equals(delta.getMeta().getBlockHash(), child.getBlockHash()) + || !Arrays.equals(delta.getMeta().getParentHash(), child.getParentHash()) + || delta.getMeta().getTimestamp() != child.getTimestamp() + || child.getBlockNumber() != parent.getBlockNumber() + 1 + || !Arrays.equals(child.getParentHash(), parent.getBlockHash()) + || !Arrays.equals(delta.getParentStateRoot(), parent.getStateRoot()) + || !Arrays.equals(child.getParentStateRoot(), parent.getStateRoot()) + || !Arrays.equals(delta.getStateRoot(), child.getStateRoot()) + || !Arrays.equals(delta.getTransitionPayloadDigest(), child.getPayloadDigest()) + || !Arrays.equals(child.getFormatDigest(), formatDigest)) { + throw new IOException("path-state checkpoint suffix identity mismatch"); + } + } + + private synchronized void installRebase(PathStateRootMetadata installedHead, + PathStateRoot.Snapshot installedSnapshot, List installedHistory) { + head = installedHead; + snapshot = installedSnapshot; + history.clear(); + history.addAll(installedHistory); + } + + private void requireChild(PathStateBlockTransition transition) throws IOException { + if (transition.getBlockNumber() != head.getBlockNumber() + 1 + || !Arrays.equals(transition.getParentHash(), head.getBlockHash())) { + throw new IOException("path-state benchmark transition does not extend volatile head"); + } + } + + private void requireHealthy() throws IOException { + if (closed) { + throw new IOException("path-state overlay is closed"); + } + if (failed) { + throw new IOException("path-state overlay failed closed"); + } + } + + private static ExecutorService newExecutor(int threads, String role) { + return Executors.newFixedThreadPool(threads, task -> { + Thread thread = new Thread(task, "path-state-overlay-" + role); + thread.setDaemon(true); + return thread; + }); + } + + private static void requireThreadCount(int threads, String label) { + if (threads <= 0 || threads > 64) { + throw new IllegalArgumentException(label + " must be in [1, 64]"); + } + } + + private static boolean matches(PathStateRootMetadata metadata, long blockNumber, + byte[] blockHash) { + return metadata.getBlockNumber() == blockNumber + && Arrays.equals(metadata.getBlockHash(), blockHash); + } + + private static PathStateRootMetadata copy(PathStateRootMetadata metadata) { + return PathStateRootMetadata.decode(metadata.encode()); + } + + private static final class HeadState { + + private final PathStateRootMetadata metadata; + private final PathStateRoot.Snapshot snapshot; + private final PathStateSnapshotDelta deltaToChild; + + private HeadState(PathStateRootMetadata metadata, PathStateRoot.Snapshot snapshot, + PathStateSnapshotDelta deltaToChild) { + this.metadata = metadata; + this.snapshot = snapshot; + this.deltaToChild = deltaToChild; + } + } + + private static final class TargetPosition { + + private final PathStateRootMetadata metadata; + private final int historyIndex; + + private TargetPosition(PathStateRootMetadata metadata, int historyIndex) { + this.metadata = metadata; + this.historyIndex = historyIndex; + } + } + + static final class RetentionCensus { + + private final long headBlockNumber; + private final int suffixBlocks; + private final int trieCount; + private final int maxSnapshotDepth; + + private RetentionCensus(long headBlockNumber, int suffixBlocks, int trieCount, + int maxSnapshotDepth) { + this.headBlockNumber = headBlockNumber; + this.suffixBlocks = suffixBlocks; + this.trieCount = trieCount; + this.maxSnapshotDepth = maxSnapshotDepth; + } + + long getHeadBlockNumber() { + return headBlockNumber; + } + + int getSuffixBlocks() { + return suffixBlocks; + } + + int getTrieCount() { + return trieCount; + } + + int getMaxSnapshotDepth() { + return maxSnapshotDepth; + } + } + + private static final class PreparedOverlay { + + private final PathStateBlockTransition transition; + private final PathStateRootMetadata metadata; + private final PathStateRoot.Snapshot snapshot; + private final PathStateSnapshotDelta delta; + private final int nodeMutations; + private final long nativeNodeReads; + private final int changedParticipants; + private final int maxParticipantMutations; + private final int authoritativePreviousValues; + private final int maxParticipantStoreId; + private final long maxParticipantMillis; + private final long participantWorkMillis; + private final long participantWallMillis; + private final long prepareMillis; + private final long trieMillis; + private final long artifactMillis; + private final long nodePlanWorkMillis; + private final long nodeStoreWorkMillis; + private final long nodeFinalizeWorkMillis; + private final long nodeCreates; + private final long nodeEncodes; + private final long nodeKeccaks; + private final long nodeDecodes; + private final long nodeHashVerifies; + private final long hashRefsCreated; + private final long hashRefsResolved; + private final RecordingStats stats; + + private PreparedOverlay(PathStateBlockTransition transition, PathStateRootMetadata metadata, + PathStateRoot.Snapshot snapshot, PathStateSnapshotDelta delta, int nodeMutations, + long nativeNodeReads, PathStateRoot.ParallelApplyStats parallelStats, + long prepareMillis, long trieMillis, long artifactMillis, long nodePlanWorkMillis, + long nodeStoreWorkMillis, long nodeFinalizeWorkMillis, long nodeCreates, + long nodeEncodes, long nodeKeccaks, long nodeDecodes, long nodeHashVerifies, + long hashRefsCreated, + long hashRefsResolved, RecordingStats stats) { + this.transition = transition; + this.metadata = metadata; + this.snapshot = snapshot; + this.delta = delta; + this.nodeMutations = nodeMutations; + this.nativeNodeReads = nativeNodeReads; + changedParticipants = parallelStats == null ? 0 : parallelStats.participantCount(); + maxParticipantMutations = parallelStats == null + ? 0 : parallelStats.maxParticipantMutations(); + authoritativePreviousValues = parallelStats == null + ? 0 : parallelStats.authoritativePreviousValues(); + maxParticipantStoreId = parallelStats == null + ? 0 : parallelStats.maxParticipantStoreId(); + maxParticipantMillis = parallelStats == null ? 0 : parallelStats.maxParticipantMillis(); + participantWorkMillis = parallelStats == null ? 0 : parallelStats.participantWorkMillis(); + participantWallMillis = parallelStats == null ? 0 : parallelStats.wallMillis(); + this.prepareMillis = prepareMillis; + this.trieMillis = trieMillis; + this.artifactMillis = artifactMillis; + this.nodePlanWorkMillis = nodePlanWorkMillis; + this.nodeStoreWorkMillis = nodeStoreWorkMillis; + this.nodeFinalizeWorkMillis = nodeFinalizeWorkMillis; + this.nodeCreates = nodeCreates; + this.nodeEncodes = nodeEncodes; + this.nodeKeccaks = nodeKeccaks; + this.nodeDecodes = nodeDecodes; + this.nodeHashVerifies = nodeHashVerifies; + this.hashRefsCreated = hashRefsCreated; + this.hashRefsResolved = hashRefsResolved; + this.stats = stats; + } + } + + /** Aggregated per-block node-artifact counters across all recording stores. */ + private static final class RecordingStats { + + private final long nodePuts; + private final long nodeDeletes; + private final long nodeRlpBytes; + private final long nodeRlpFinalBytes; + private final long uniqueNodePaths; + private final String perStore; + + private RecordingStats(long nodePuts, long nodeDeletes, long nodeRlpBytes, + long nodeRlpFinalBytes, long uniqueNodePaths, String perStore) { + this.nodePuts = nodePuts; + this.nodeDeletes = nodeDeletes; + this.nodeRlpBytes = nodeRlpBytes; + this.nodeRlpFinalBytes = nodeRlpFinalBytes; + this.uniqueNodePaths = uniqueNodePaths; + this.perStore = perStore; + } + + private long overwriteWrites() { + return nodePuts + nodeDeletes - uniqueNodePaths; + } + + private static RecordingStats collect(Map recordings) { + long puts = 0; + long deletes = 0; + long bytes = 0; + long finalBytes = 0; + long unique = 0; + StringBuilder detail = new StringBuilder(); + for (Map.Entry entry : recordings.entrySet()) { + RecordingStore store = entry.getValue(); + if (store.putCalls() + store.deleteCalls() == 0) { + continue; + } + puts += store.putCalls(); + deletes += store.deleteCalls(); + bytes += store.putBytes(); + finalBytes += store.finalBytes(); + unique += store.uniquePaths(); + detail.append(entry.getKey()).append(':').append(store.putCalls()).append('/') + .append(store.deleteCalls()).append('/').append(store.putBytes()).append('/') + .append(store.uniquePaths()).append(';'); + } + return new RecordingStats(puts, deletes, bytes, finalBytes, unique, detail.toString()); + } + } + + private static final class RecordingStore implements PathNodeStore { + + private final PathNodeStore base; + private final Map changes = new ConcurrentHashMap<>(); + private final Map reads = new ConcurrentHashMap<>(); + private final long initialNativeReads; + private final AtomicLong directReads = new AtomicLong(); + private final AtomicLong putCalls = new AtomicLong(); + private final AtomicLong deleteCalls = new AtomicLong(); + private final AtomicLong putBytes = new AtomicLong(); + + private RecordingStore(PathNodeStore base) { + this.base = Objects.requireNonNull(base, "base"); + initialNativeReads = base instanceof PathStatePhysicalStoreSet.ResidentNodeStore + ? ((PathStatePhysicalStoreSet.ResidentNodeStore) base).getNativeReads() : 0; + } + + @Override + public byte[] get(byte[] path) { + BytesKey key = new BytesKey(path); + byte[] changed = changes.get(key); + if (changed != null) { + return changed == ABSENT ? null : Arrays.copyOf(changed, changed.length); + } + byte[] value = reads.computeIfAbsent(key, ignored -> { + directReads.incrementAndGet(); + byte[] loaded = base.get(path); + return loaded == null ? ABSENT : Arrays.copyOf(loaded, loaded.length); + }); + return value == ABSENT ? null : Arrays.copyOf(value, value.length); + } + + @Override + public void put(byte[] path, byte[] encodedNode) { + byte[] present = Objects.requireNonNull(encodedNode, "encodedNode"); + putCalls.incrementAndGet(); + putBytes.addAndGet(present.length); + changes.put(new BytesKey(path), Arrays.copyOf(present, present.length)); + } + + @Override + public void delete(byte[] path) { + deleteCalls.incrementAndGet(); + changes.put(new BytesKey(path), ABSENT); + } + + private long putCalls() { + return putCalls.get(); + } + + private long deleteCalls() { + return deleteCalls.get(); + } + + private long putBytes() { + return putBytes.get(); + } + + private int uniquePaths() { + return changes.size(); + } + + private long finalBytes() { + long bytes = 0; + for (byte[] value : changes.values()) { + if (value != ABSENT) { + bytes += value.length; + } + } + return bytes; + } + + private List mutations() { + List result = new ArrayList<>(changes.size()); + for (Map.Entry entry : changes.entrySet()) { + byte[] value = entry.getValue(); + result.add(new PathStateSnapshotDelta.Mutation(entry.getKey().bytes, + value == ABSENT ? null : value)); + } + return result; + } + + private long nativeReads() { + return base instanceof PathStatePhysicalStoreSet.ResidentNodeStore + ? ((PathStatePhysicalStoreSet.ResidentNodeStore) base).getNativeReads() + - initialNativeReads : directReads.get(); + } + + } + + private static final class BytesKey { + + private final byte[] bytes; + + private BytesKey(byte[] bytes) { + this.bytes = Arrays.copyOf(Objects.requireNonNull(bytes, "path"), bytes.length); + } + + @Override + public boolean equals(Object other) { + return this == other || other instanceof BytesKey + && Arrays.equals(bytes, ((BytesKey) other).bytes); + } + + @Override + public int hashCode() { + return Arrays.hashCode(bytes); + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStatePhysicalReverseJournal.java b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStatePhysicalReverseJournal.java new file mode 100644 index 00000000000..578d5b9300c --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStatePhysicalReverseJournal.java @@ -0,0 +1,267 @@ +package org.tron.core.db2.stateroot; + +import com.google.common.hash.Hashing; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** Immutable reverse delta for one committed physical 27+1 child. */ +final class PathStatePhysicalReverseJournal { + + static final int MAX_ENCODED_LENGTH = 256 * 1024 * 1024; + private static final int MAGIC = 0x5053524a; // PSRJ + private static final short VERSION = 1; + private static final int CHECKSUM_LENGTH = 32; + private static final int MAX_STORES = 27; + private static final int MAX_MUTATIONS = 1_000_000; + private static final int MAX_VALUE_LENGTH = 16 * 1024 * 1024; + private static final int MAX_PATH_LENGTH = 64; + + private final byte[] childTarget; + private final byte[] parentTarget; + private final List stores; + private final List superNodes; + + PathStatePhysicalReverseJournal(byte[] childTarget, byte[] parentTarget, + List stores, List superNodes) { + this.childTarget = target(childTarget, "childTarget"); + this.parentTarget = target(parentTarget, "parentTarget"); + PathStatePhysicalGlobalIntent child = PathStatePhysicalGlobalIntent.decode(this.childTarget); + PathStatePhysicalGlobalIntent parent = PathStatePhysicalGlobalIntent.decode(this.parentTarget); + if (child.getMetadata().getBlockNumber() != parent.getMetadata().getBlockNumber() + 1 + || !Arrays.equals(child.getMetadata().getParentHash(), + parent.getMetadata().getBlockHash())) { + throw new IllegalArgumentException("physical reverse journal is not a direct child"); + } + List supplied = new ArrayList<>(Objects.requireNonNull(stores, "stores")); + if (supplied.size() > MAX_STORES) { + throw new IllegalArgumentException("physical reverse journal has too many Stores"); + } + int previousStoreId = 0; + for (StoreReverse store : supplied) { + if (store.storeId <= previousStoreId) { + throw new IllegalArgumentException("physical reverse Store IDs are not ascending"); + } + previousStoreId = store.storeId; + } + this.stores = Collections.unmodifiableList(supplied); + this.superNodes = immutableEntries(superNodes, false); + } + + byte[] encode() { + try { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + DataOutputStream output = new DataOutputStream(bytes); + output.writeInt(MAGIC); + output.writeShort(VERSION); + writeBytes(output, childTarget); + writeBytes(output, parentTarget); + output.writeInt(stores.size()); + for (StoreReverse store : stores) { + output.writeInt(store.storeId); + writeEntries(output, store.flatEntries); + writeEntries(output, store.nodeEntries); + } + writeEntries(output, superNodes); + output.flush(); + byte[] body = bytes.toByteArray(); + if (body.length > MAX_ENCODED_LENGTH - CHECKSUM_LENGTH) { + throw new IllegalArgumentException("physical reverse journal exceeds byte limit"); + } + return ByteBuffer.allocate(body.length + CHECKSUM_LENGTH).put(body) + .put(Hashing.sha256().hashBytes(body).asBytes()).array(); + } catch (IOException impossible) { + throw new IllegalStateException("in-memory reverse journal encoding failed", impossible); + } + } + + static PathStatePhysicalReverseJournal decode(byte[] encoded) { + byte[] supplied = Arrays.copyOf(Objects.requireNonNull(encoded, "encoded"), encoded.length); + if (supplied.length <= CHECKSUM_LENGTH || supplied.length > MAX_ENCODED_LENGTH) { + throw new IllegalArgumentException("physical reverse journal length is invalid"); + } + byte[] body = Arrays.copyOf(supplied, supplied.length - CHECKSUM_LENGTH); + byte[] checksum = Arrays.copyOfRange(supplied, body.length, supplied.length); + if (!Arrays.equals(checksum, Hashing.sha256().hashBytes(body).asBytes())) { + throw new IllegalArgumentException("physical reverse journal checksum differs"); + } + try (DataInputStream input = new DataInputStream(new ByteArrayInputStream(body))) { + if (input.readInt() != MAGIC || input.readShort() != VERSION) { + throw new IllegalArgumentException("physical reverse journal format is unsupported"); + } + byte[] child = readBytes(input, PathStatePhysicalGlobalIntent.MAX_ENCODED_LENGTH); + byte[] parent = readBytes(input, PathStatePhysicalGlobalIntent.MAX_ENCODED_LENGTH); + int storeCount = input.readInt(); + if (storeCount < 0 || storeCount > MAX_STORES) { + throw new IllegalArgumentException("physical reverse journal Store count is invalid"); + } + List stores = new ArrayList<>(); + for (int index = 0; index < storeCount; index++) { + stores.add(new StoreReverse(input.readInt(), readEntries(input, true), + readEntries(input, false))); + } + List superNodes = readEntries(input, false); + if (input.available() != 0) { + throw new IllegalArgumentException("physical reverse journal has trailing bytes"); + } + return new PathStatePhysicalReverseJournal(child, parent, stores, superNodes); + } catch (IOException truncated) { + throw new IllegalArgumentException("physical reverse journal is truncated", truncated); + } + } + + byte[] getChildTarget() { + return Arrays.copyOf(childTarget, childTarget.length); + } + + byte[] getParentTarget() { + return Arrays.copyOf(parentTarget, parentTarget.length); + } + + List getStores() { + return stores; + } + + List getSuperNodes() { + return superNodes; + } + + static final class StoreReverse { + + private final int storeId; + private final List flatEntries; + private final List nodeEntries; + + StoreReverse(int storeId, List flatEntries, List nodeEntries) { + if (storeId <= 0) { + throw new IllegalArgumentException("physical reverse Store ID must be positive"); + } + this.storeId = storeId; + this.flatEntries = immutableEntries(flatEntries, true); + this.nodeEntries = immutableEntries(nodeEntries, false); + } + + int getStoreId() { + return storeId; + } + + List getFlatEntries() { + return flatEntries; + } + + List getNodeEntries() { + return nodeEntries; + } + } + + static final class Entry { + + private final byte[] key; + private final byte[] oldValue; + + Entry(byte[] key, byte[] oldValue) { + this.key = Arrays.copyOf(Objects.requireNonNull(key, "key"), key.length); + this.oldValue = oldValue == null ? null : Arrays.copyOf(oldValue, oldValue.length); + } + + byte[] getKey() { + return Arrays.copyOf(key, key.length); + } + + byte[] getOldValue() { + return oldValue == null ? null : Arrays.copyOf(oldValue, oldValue.length); + } + } + + private static List immutableEntries(List entries, boolean flat) { + List supplied = new ArrayList<>(Objects.requireNonNull(entries, "entries")); + if (supplied.size() > MAX_MUTATIONS) { + throw new IllegalArgumentException("physical reverse mutation count exceeds limit"); + } + List copies = new ArrayList<>(supplied.size()); + for (Entry entry : supplied) { + Entry present = Objects.requireNonNull(entry, "entry"); + int expected = flat ? PathStateCommitmentCodec.ROOT_LENGTH : -1; + if (flat && present.key.length != expected) { + throw new IllegalArgumentException("physical reverse flat key length is invalid"); + } + if (!flat && present.key.length > MAX_PATH_LENGTH) { + throw new IllegalArgumentException("physical reverse node path length is invalid"); + } + if (present.oldValue != null + && (present.oldValue.length == 0 || present.oldValue.length > MAX_VALUE_LENGTH)) { + throw new IllegalArgumentException("physical reverse value length is invalid"); + } + copies.add(new Entry(present.key, present.oldValue)); + } + return Collections.unmodifiableList(copies); + } + + private static void writeEntries(DataOutputStream output, List entries) + throws IOException { + output.writeInt(entries.size()); + for (Entry entry : entries) { + writeBytes(output, entry.key); + if (entry.oldValue == null) { + output.writeInt(-1); + } else { + writeBytes(output, entry.oldValue); + } + } + } + + private static List readEntries(DataInputStream input, boolean flat) + throws IOException { + int count = input.readInt(); + if (count < 0 || count > MAX_MUTATIONS) { + throw new IllegalArgumentException("physical reverse mutation count is invalid"); + } + List entries = new ArrayList<>(count); + for (int index = 0; index < count; index++) { + byte[] key = readBytes(input, flat ? PathStateCommitmentCodec.ROOT_LENGTH + : MAX_PATH_LENGTH); + int valueLength = input.readInt(); + byte[] value = null; + if (valueLength != -1) { + if (valueLength <= 0 || valueLength > MAX_VALUE_LENGTH) { + throw new IllegalArgumentException("physical reverse value length is invalid"); + } + value = new byte[valueLength]; + input.readFully(value); + } + entries.add(new Entry(key, value)); + } + return entries; + } + + private static void writeBytes(DataOutputStream output, byte[] value) throws IOException { + output.writeInt(value.length); + output.write(value); + } + + private static byte[] readBytes(DataInputStream input, int maxLength) throws IOException { + int length = input.readInt(); + if (length < 0 || length > maxLength) { + throw new IllegalArgumentException("physical reverse field length is invalid"); + } + byte[] value = new byte[length]; + input.readFully(value); + return value; + } + + private static byte[] target(byte[] encoded, String name) { + byte[] value = Arrays.copyOf(Objects.requireNonNull(encoded, name), encoded.length); + if (value.length == 0 || value.length > PathStatePhysicalGlobalIntent.MAX_ENCODED_LENGTH) { + throw new IllegalArgumentException(name + " length is invalid"); + } + return value; + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStatePhysicalRuntimeAdmission.java b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStatePhysicalRuntimeAdmission.java new file mode 100644 index 00000000000..1d40ab16c7e --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStatePhysicalRuntimeAdmission.java @@ -0,0 +1,56 @@ +package org.tron.core.db2.stateroot; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.util.Objects; +import org.tron.core.db2.stateroot.PathStateStoreManifest.Engine; + +/** Non-creating startup gate for the fresh physical 27+1 format. */ +public final class PathStatePhysicalRuntimeAdmission { + + private PathStatePhysicalRuntimeAdmission() { + } + + public static Result inspect(boolean enabled, Path directory, Engine engine) throws IOException { + if (!enabled) { + return new Result(Status.DISABLED, null); + } + Path root = Objects.requireNonNull(directory, "directory").toAbsolutePath().normalize(); + Engine selected = Objects.requireNonNull(engine, "engine"); + if (!Files.exists(root, LinkOption.NOFOLLOW_LINKS)) { + return new Result(Status.REBUILD_REQUIRED, null); + } + PathStatePhysicalStoreManifest manifest = + PathStatePhysicalStoreManifest.validateExisting(root, selected); + Status status = Files.isRegularFile(root.resolve(PathStatePhysicalStoreSet.CURRENT_FILE), + LinkOption.NOFOLLOW_LINKS) ? Status.CURRENT_CANDIDATE : Status.REBUILD_REQUIRED; + return new Result(status, manifest); + } + + public enum Status { + DISABLED, + REBUILD_REQUIRED, + CURRENT_CANDIDATE + } + + public static final class Result { + + private final Status status; + private final PathStatePhysicalStoreManifest manifest; + + private Result(Status status, PathStatePhysicalStoreManifest manifest) { + this.status = status; + this.manifest = manifest; + } + + public Status getStatus() { + return status; + } + + public PathStatePhysicalStoreManifest getManifest() { + return manifest; + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStatePhysicalSnapshotHead.java b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStatePhysicalSnapshotHead.java new file mode 100644 index 00000000000..78d486539a8 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStatePhysicalSnapshotHead.java @@ -0,0 +1,168 @@ +package org.tron.core.db2.stateroot; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.Objects; +import org.tron.core.db2.archive.BlockSnapshotMeta; +import org.tron.core.db2.stateroot.PathStateStoreManifest.Engine; + +/** Runtime owner for a block-bound physical 27+1 CURRENT. */ +public final class PathStatePhysicalSnapshotHead implements PathStateHead { + + private final PathStatePhysicalStoreSet stores; + private final PathStateLayerLimits limits; + private PathStateRootMetadata head; + private PathStatePhysicalStoreSet.PreparedPhysicalTransition pending; + private boolean failed; + private boolean closed; + + private PathStatePhysicalSnapshotHead(PathStatePhysicalStoreSet stores, + PathStateRootMetadata head, PathStateLayerLimits limits) { + this.stores = stores; + this.head = head; + this.limits = limits; + } + + /** Opens and verifies the exact 28-database target before exposing its block identity. */ + public static PathStatePhysicalSnapshotHead open(Path directory, Engine engine) + throws IOException { + return open(directory, engine, PathStateLayerLimits.defaults()); + } + + /** Opens with explicit bounded reverse-journal count and logical-byte limits. */ + public static PathStatePhysicalSnapshotHead open(Path directory, Engine engine, + PathStateLayerLimits limits) throws IOException { + PathStatePhysicalStoreSet opened = PathStatePhysicalStoreSet.openExisting(directory, + new PathStateCanonicalizer().participantScope(), engine); + try { + opened.recoverPublication(); + PathStateLayerLimits admitted = Objects.requireNonNull(limits, "limits"); + opened.verifyReverseJournals(admitted); + return new PathStatePhysicalSnapshotHead(opened, opened.currentMetadata(), admitted); + } catch (IOException | RuntimeException failure) { + try { + opened.close(); + } catch (IOException closeFailure) { + failure.addSuppressed(closeFailure); + } + throw failure; + } + } + + @Override + public synchronized PathStateRootMetadata advance(PathStateBlockTransition transition) + throws IOException { + requireHealthy(); + if (pending != null && !pending.matches(transition)) { + throw new IOException("physical path-state publication differs from prepared transition"); + } + PathStateRootMetadata previous = head; + try { + PathStatePhysicalStoreSet.PreparedPhysicalTransition prepared = pending; + PathStateRootMetadata committed = prepared != null && prepared.matches(transition) + ? stores.applyAndPublish(prepared, limits) + : stores.applyAndPublish(transition, limits); + if (!same(committed, stores.currentMetadata())) { + failed = true; + throw new IOException("physical path-state committed CURRENT identity mismatch"); + } + head = committed; + pending = null; + return PathStateRootMetadata.decode(committed.encode()); + } catch (IOException | RuntimeException failure) { + try { + if (!same(previous, stores.currentMetadata())) { + this.failed = true; + } + } catch (IOException | RuntimeException verificationFailure) { + this.failed = true; + failure.addSuppressed(verificationFailure); + } + throw failure; + } + } + + @Override + public synchronized PathStateRootMetadata rewindTo(long blockNumber, byte[] blockHash) + throws IOException { + requireHealthy(); + pending = null; + PathStateRootMetadata previous = head; + try { + PathStateRootMetadata rewound = stores.rewindTo(blockNumber, blockHash, limits); + if (!same(rewound, stores.currentMetadata())) { + failed = true; + throw new IOException("physical path-state rewound CURRENT identity mismatch"); + } + head = rewound; + return PathStateRootMetadata.decode(rewound.encode()); + } catch (IOException | RuntimeException failure) { + try { + if (!same(previous, stores.currentMetadata())) { + this.failed = true; + } + } catch (IOException | RuntimeException verificationFailure) { + this.failed = true; + failure.addSuppressed(verificationFailure); + } + throw failure; + } + } + + @Override + public synchronized PathStateRootMetadata flushBaseThrough(long blockNumber, byte[] blockHash) + throws IOException { + requireHealthy(); + return head; + } + + @Override + public synchronized byte[] preview(PathStateBlockTransition transition) throws IOException { + requireHealthy(); + return stores.previewTransition(Objects.requireNonNull(transition, "transition")) + .getStateRoot(); + } + + @Override + public synchronized PathStateSnapshotDelta prepareSnapshotDelta(BlockSnapshotMeta meta, + PathStateBlockTransition transition) throws IOException { + requireHealthy(); + if (pending != null) { + throw new IOException("physical path-state transition is already prepared"); + } + pending = stores.prepareSnapshotDelta(Objects.requireNonNull(meta, "meta"), + Objects.requireNonNull(transition, "transition")); + return pending.getSnapshotDelta(); + } + + @Override + public synchronized PathStateRootMetadata getHead() throws IOException { + requireHealthy(); + return PathStateRootMetadata.decode(head.encode()); + } + + @Override + public synchronized void close() throws IOException { + if (!closed) { + closed = true; + stores.close(); + } + } + + private void requireOpen() throws IOException { + if (closed) { + throw new IOException("physical path-state head is closed"); + } + } + + private void requireHealthy() throws IOException { + requireOpen(); + if (failed) { + throw new IOException("physical path-state head failed closed"); + } + } + + private static boolean same(PathStateRootMetadata left, PathStateRootMetadata right) { + return java.util.Arrays.equals(left.encode(), right.encode()); + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStatePhysicalStoreManifest.java b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStatePhysicalStoreManifest.java new file mode 100644 index 00000000000..169c33a19d6 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStatePhysicalStoreManifest.java @@ -0,0 +1,124 @@ +package org.tron.core.db2.stateroot; + +import com.google.common.hash.Hashing; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.util.Arrays; +import java.util.Objects; +import java.util.UUID; +import org.tron.core.db2.stateroot.PathStateStoreManifest.Engine; + +/** Immutable format identity for the TASK-018 physical 27+1 layout. */ +public final class PathStatePhysicalStoreManifest { + + private static final String FILE = "MANIFEST"; + private static final int MAGIC = 0x50535046; // PSPF + private static final short VERSION = 1; + private static final int HEADER_LENGTH = Integer.BYTES + Short.BYTES + Short.BYTES + + Short.BYTES + Integer.BYTES; + + private final Path directory; + private final Engine engine; + private final byte[] identityDigest; + + private PathStatePhysicalStoreManifest(Path directory, Engine engine, byte[] encoded) { + this.directory = directory; + this.engine = engine; + identityDigest = Hashing.sha256().hashBytes(encoded).asBytes(); + } + + /** Creates a fresh manifest or requires byte-for-byte identity with the existing manifest. */ + public static PathStatePhysicalStoreManifest createOrOpen(Path directory, Engine engine) + throws IOException { + Path root = Objects.requireNonNull(directory, "directory").toAbsolutePath().normalize(); + Engine selected = Objects.requireNonNull(engine, "engine"); + if (Files.isSymbolicLink(root)) { + throw new IOException("path-state physical root must not be a symbolic link: " + root); + } + Files.createDirectories(root); + if (!Files.isDirectory(root, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("path-state physical root is not a directory: " + root); + } + byte[] expected = encode(selected); + Path manifest = root.resolve(FILE); + if (Files.exists(manifest, LinkOption.NOFOLLOW_LINKS)) { + byte[] actual = Files.readAllBytes(manifest); + if (!Arrays.equals(expected, actual)) { + throw new IOException("TASK-018 physical manifest identity mismatch"); + } + } else { + publish(manifest, expected); + } + return new PathStatePhysicalStoreManifest(root, selected, expected); + } + + /** Validates a previously created physical manifest without creating any path. */ + public static PathStatePhysicalStoreManifest validateExisting(Path directory, Engine engine) + throws IOException { + Path root = Objects.requireNonNull(directory, "directory").toAbsolutePath().normalize(); + Engine selected = Objects.requireNonNull(engine, "engine"); + if (Files.isSymbolicLink(root) + || !Files.isDirectory(root, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("path-state physical root is missing or invalid: " + root); + } + Path manifest = root.resolve(FILE); + if (!Files.isRegularFile(manifest, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("path-state physical manifest is missing"); + } + byte[] expected = encode(selected); + byte[] actual = Files.readAllBytes(manifest); + if (!Arrays.equals(expected, actual)) { + throw new IOException("TASK-018 physical manifest identity mismatch"); + } + return new PathStatePhysicalStoreManifest(root, selected, expected); + } + + public Path getDirectory() { + return directory; + } + + public Engine getEngine() { + return engine; + } + + public byte[] getIdentityDigest() { + return Arrays.copyOf(identityDigest, identityDigest.length); + } + + private static byte[] encode(Engine engine) { + PathStateParticipantDescriptor descriptor = PathStateParticipantDescriptor.current(); + ByteBuffer encoded = ByteBuffer.allocate(HEADER_LENGTH + descriptor.getStores().size() + * Integer.BYTES); + encoded.putInt(MAGIC).putShort(VERSION).putShort((short) PathStateCommitmentCodec.FORMAT_VERSION) + .putShort((short) engine.ordinal()).putInt(descriptor.getStores().size()); + for (PathStateParticipantDescriptor.StoreIdentity store : descriptor.getStores()) { + encoded.putInt(store.getStoreId()); + } + return encoded.array(); + } + + private static void publish(Path manifest, byte[] encoded) throws IOException { + Path temporary = manifest.getParent().resolve(".MANIFEST-" + UUID.randomUUID()); + try { + try (FileChannel channel = FileChannel.open(temporary, StandardOpenOption.CREATE_NEW, + StandardOpenOption.WRITE)) { + channel.write(ByteBuffer.wrap(encoded)); + channel.force(true); + } + try { + Files.move(temporary, manifest, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException failure) { + throw new IOException("TASK-018 manifest requires atomic publication", failure); + } + } finally { + Files.deleteIfExists(temporary); + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStatePhysicalStoreSet.java b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStatePhysicalStoreSet.java new file mode 100644 index 00000000000..d77ba43dd05 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStatePhysicalStoreSet.java @@ -0,0 +1,2592 @@ +package org.tron.core.db2.stateroot; + +import com.google.common.hash.Hasher; +import com.google.common.hash.Hashing; +import java.io.Closeable; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Stream; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.tron.common.crypto.Hash; +import org.tron.common.parameter.CommonParameter; +import org.tron.core.config.args.StorageConfig.NativeDbConfig; +import org.tron.core.config.args.StorageConfig.PathStateDbSettingsConfig; +import org.tron.core.db2.archive.BlockSnapshotMeta; +import org.tron.core.db2.core.CommonCheckpointPayload; +import org.tron.core.db2.stateroot.PathStateStoreManifest.Engine; + +/** + * Fresh-format physical owner for the TASK-018 27 participant databases and one super database. + * + *

Each native database owns three disjoint domains: {@code F|secureKey -> encodedLeaf}, + * {@code N|path -> nodeRlp}, and {@code M|name -> metadata}. This class deliberately does not + * provide an upgrade path from the old shared {@code base/nodes} layout. + */ +public final class PathStatePhysicalStoreSet implements Closeable { + + private static final Logger logger = LoggerFactory.getLogger("DB"); + + static final long DEFAULT_CHECKPOINT_ROWS = 1_000_000L; + static final long DEFAULT_CHECKPOINT_BYTES = 256L * 1024 * 1024; + static final int BOOTSTRAP_WRITE_BATCH_ENTRIES = 4096; + static final long BOOTSTRAP_WRITE_BATCH_BYTES = 8L * 1024 * 1024; + static final long STEADY_NODE_CACHE_BYTES = 256L * 1024 * 1024; + private static final int MAX_PARALLEL_PARTICIPANT_WRITES = 4; + private static final int MAX_PARALLEL_PARTICIPANT_PREPARES = 4; + private static final int MAX_PARALLEL_TRIE_BRANCHES = 8; + private static final Set LARGE_BOOTSTRAP_STORES = java.util.Collections.unmodifiableSet( + new HashSet<>(Arrays.asList( + "account", "account-asset", "delegation", "storage-row"))); + private static final Set GIANT_NATIVE_STORES = java.util.Collections.unmodifiableSet( + new HashSet<>(Arrays.asList("account", "account-asset", "storage-row"))); + private static final Set LARGE_NATIVE_STORES = java.util.Collections.unmodifiableSet( + new HashSet<>(Arrays.asList("code", "contract", "delegation"))); + + private static final String STORES_DIRECTORY = "stores"; + private static final String SUPER_DIRECTORY = "super"; + private static final String NODES_DIRECTORY = "nodes"; + private static final String REVERSE_DIRECTORY = "reverse"; + static final String INTENT_FILE = "INTENT"; + static final String CURRENT_FILE = "CURRENT"; + // Stable persisted keyspace tags. The descriptive names carry semantics; byte values keep ABI. + private static final byte FLAT_STATE_PREFIX = 'F'; + private static final byte TRIE_NODE_PREFIX = 'N'; + private static final byte METADATA_PREFIX = 'M'; + private static final byte[] FLAT_ROOT_METADATA = new byte[]{'f', 'l', 'a', 't', '-', 'r', 'o', + 'o', 't'}; + private static final byte[] FLAT_COMPLETE_METADATA = new byte[]{'f', 'l', 'a', 't', '-', 'c', + 'o', 'm', 'p', 'l', 'e', 't', 'e'}; + private static final byte[] FLAT_INGEST_CHECKPOINT = new byte[]{'f', 'l', 'a', 't', '-', 'i', + 'n', 'g', 'e', 's', 't'}; + private static final byte[] FLAT_INGEST_COMPLETE = new byte[]{'f', 'l', 'a', 't', '-', 'i', 'n', + 'g', 'e', 's', 't', '-', 'c', 'o', 'm', 'p', 'l', 'e', 't', 'e'}; + private static final byte[] FLAT_DIGEST_METADATA = new byte[]{'f', 'l', 'a', 't', '-', 'd', 'i', + 'g', 'e', 's', 't'}; + private static final byte[] STORE_GENERATION_METADATA = new byte[]{'s', 't', 'o', 'r', 'e', '-', + 'g', 'e', 'n', 'e', 'r', 'a', 't', 'i', 'o', 'n'}; + private static final byte[] SUPER_GENERATION_METADATA = new byte[]{'s', 'u', 'p', 'e', 'r', '-', + 'g', 'e', 'n', 'e', 'r', 'a', 't', 'i', 'o', 'n'}; + private static final byte[] CHECKPOINT_TARGET_METADATA = new byte[]{'c', 'h', 'e', 'c', 'k', + 'p', 'o', 'i', 'n', 't', '-', 't', 'a', 'r', 'g', 'e', 't', '-', 'v', '1'}; + + private final Path directory; + private final PathStatePhysicalStoreManifest manifest; + private final PathStateParticipantScope scope; + private final Map participants = new LinkedHashMap<>(); + private final PhysicalStore superStore; + private final ExecutorService participantWriteExecutor; + private final ExecutorService participantPrepareExecutor; + private final ExecutorService trieBranchExecutor; + private final ResidentNodeCache residentNodeCache; + private Map reverseJournalIndex; + private byte[] cachedTrieTarget; + private PathStateRoot.Snapshot cachedTrieSnapshot; + private boolean rootClaimed; + private boolean closed; + + private PathStatePhysicalStoreSet(PathStatePhysicalStoreManifest manifest, + PathStateParticipantScope scope, long residentNodeCacheBytes, + PathStateDbSettingsConfig dbSettings) + throws IOException { + this.manifest = manifest; + this.directory = manifest.getDirectory(); + this.scope = requireExactScope(scope); + this.participantWriteExecutor = newParticipantWriteExecutor(); + this.participantPrepareExecutor = newTrieExecutor("participant-prepare", + MAX_PARALLEL_PARTICIPANT_PREPARES); + this.trieBranchExecutor = newTrieExecutor("branch-prepare", MAX_PARALLEL_TRIE_BRANCHES); + this.residentNodeCache = new ResidentNodeCache(residentNodeCacheBytes); + try { + for (PathStateParticipant participant : scope.getParticipants()) { + Path participantDirectory = directory.resolve(STORES_DIRECTORY).resolve(String.format( + "%02d-%s", participant.getStoreId(), participant.getDbName())).resolve(NODES_DIRECTORY); + participants.put(participant.getDbName(), new PhysicalStore(participantDirectory, + manifest.getEngine(), participant.getStoreId(), residentNodeCache, + storageProfileNameFor(participant.getDbName()), + storageProfileFor(participant.getDbName(), dbSettings))); + } + superStore = new PhysicalStore(directory.resolve(SUPER_DIRECTORY).resolve(NODES_DIRECTORY), + manifest.getEngine(), 0, residentNodeCache, "small", dbSettings.getSmall()); + } catch (IOException | RuntimeException failure) { + closeAfterFailure(failure); + throw failure; + } + } + + /** Creates or opens only the TASK-018 fresh physical layout. */ + public static PathStatePhysicalStoreSet open(Path directory, PathStateParticipantScope scope, + Engine engine) throws IOException { + Path root = Objects.requireNonNull(directory, "directory").toAbsolutePath().normalize(); + if (Files.isSymbolicLink(root)) { + throw new IOException("path-state physical root must not be a symbolic link: " + root); + } + rejectLegacySharedNodes(root); + PathStatePhysicalStoreManifest manifest = PathStatePhysicalStoreManifest.createOrOpen(root, + Objects.requireNonNull(engine, "engine")); + return new PathStatePhysicalStoreSet(manifest, Objects.requireNonNull(scope, "scope"), + STEADY_NODE_CACHE_BYTES, configuredDbSettings()); + } + + /** Opens only a fully materialized physical layout; missing child databases fail closed. */ + public static PathStatePhysicalStoreSet openExisting(Path directory, + PathStateParticipantScope scope, Engine engine) throws IOException { + return openExisting(directory, scope, engine, STEADY_NODE_CACHE_BYTES); + } + + /** Opens a complete physical layout with an explicit shared resident-node cache budget. */ + public static PathStatePhysicalStoreSet openExisting(Path directory, + PathStateParticipantScope scope, Engine engine, long residentNodeCacheBytes) + throws IOException { + Path root = Objects.requireNonNull(directory, "directory").toAbsolutePath().normalize(); + rejectLegacySharedNodes(root); + PathStatePhysicalStoreManifest manifest = PathStatePhysicalStoreManifest.validateExisting( + root, Objects.requireNonNull(engine, "engine")); + PathStateParticipantScope admittedScope = requireExactScope(scope); + for (PathStateParticipant participant : admittedScope.getParticipants()) { + requireStoreDirectory(root.resolve(STORES_DIRECTORY).resolve(String.format( + "%02d-%s", participant.getStoreId(), participant.getDbName())) + .resolve(NODES_DIRECTORY)); + } + requireStoreDirectory(root.resolve(SUPER_DIRECTORY).resolve(NODES_DIRECTORY)); + return new PathStatePhysicalStoreSet(manifest, admittedScope, residentNodeCacheBytes, + configuredDbSettings()); + } + + static String storageProfileNameFor(String dbName) { + String supplied = Objects.requireNonNull(dbName, "dbName"); + if (GIANT_NATIVE_STORES.contains(supplied)) { + return "giant"; + } + if (LARGE_NATIVE_STORES.contains(supplied)) { + return "large"; + } + return "small"; + } + + private static NativeDbConfig storageProfileFor(String dbName, + PathStateDbSettingsConfig settings) { + String profile = storageProfileNameFor(dbName); + if ("giant".equals(profile)) { + return settings.getGiant(); + } + if ("large".equals(profile)) { + return settings.getLarge(); + } + return settings.getSmall(); + } + + private static PathStateDbSettingsConfig configuredDbSettings() { + org.tron.core.config.args.Storage storage = CommonParameter.getInstance().getStorage(); + PathStateDbSettingsConfig settings = storage == null ? null + : storage.getPathStateRootDbSettings(); + return settings == null ? new PathStateDbSettingsConfig() : settings; + } + + public synchronized PhysicalStore participant(String dbName) { + requireOpen(); + PhysicalStore store = participants.get(Objects.requireNonNull(dbName, "dbName")); + if (store == null) { + throw new IllegalArgumentException("unknown path-state participant: " + dbName); + } + return store; + } + + public synchronized PhysicalStore superStore() { + requireOpen(); + return superStore; + } + + /** Creates the one in-memory root owner backed by this set's physically separate node stores. */ + public synchronized PathStateRoot createRoot() { + requireOpen(); + if (rootClaimed) { + throw new IllegalStateException("path-state physical store set already has a root owner"); + } + rootClaimed = true; + return new PathStateRoot(scope, participant -> participant(participant.getDbName()).nodeStore(), + superStore.nodeStore()); + } + + public Path getDirectory() { + return directory; + } + + public byte[] getFormatDigest() { + return manifest.getIdentityDigest(); + } + + PathStateParticipantScope participantScope() { + return scope; + } + + long residentNodeCacheBytes() { + return residentNodeCache.bytes(); + } + + int residentNodeCacheEntries() { + return residentNodeCache.size(); + } + + long residentNodeCacheEvictions() { + return residentNodeCache.evictions(); + } + + synchronized void saveIngestCheckpoint(String dbName, PathStatePhysicalIngestCheckpoint value) { + participant(dbName).putMetadata(FLAT_INGEST_CHECKPOINT, + Objects.requireNonNull(value, "value").encode()); + } + + synchronized PathStatePhysicalIngestCheckpoint ingestCheckpoint(String dbName) { + byte[] encoded = participant(dbName).getMetadata(FLAT_INGEST_CHECKPOINT); + return encoded == null ? null : PathStatePhysicalIngestCheckpoint.decode(encoded); + } + + /** Ingests exact physical rows into one F domain and durably advances its source cursor. */ + void ingestFlat(String dbName, PathStateRebuildCoordinator.SnapshotSource source, + long rowThreshold, long byteThreshold) throws IOException { + if (rowThreshold <= 0 || byteThreshold <= 0) { + throw new IllegalArgumentException("ingest checkpoint thresholds must be positive"); + } + PathStateParticipant participant = scope.require(dbName); + PhysicalStore store = participants.get(dbName); + if (store == null) { + throw new IllegalArgumentException("unknown path-state participant: " + dbName); + } + PathStateRebuildCoordinator.SnapshotSource pinned = Objects.requireNonNull(source, "source"); + byte[] identity = pinned.sourceIdentityDigest(); + if (identity.length != PathStateCommitmentCodec.ROOT_LENGTH) { + throw new IOException("physical ingest source identity must contain exactly 32 bytes"); + } + byte[] complete = store.getMetadata(FLAT_INGEST_COMPLETE); + if (complete != null) { + if (!Arrays.equals(complete, identity)) { + throw new IOException("physical ingest completion source identity differs: " + dbName); + } + return; + } + byte[] encodedCheckpoint = store.getMetadata(FLAT_INGEST_CHECKPOINT); + PathStatePhysicalIngestCheckpoint prior = encodedCheckpoint == null ? null + : PathStatePhysicalIngestCheckpoint.decode(encodedCheckpoint); + if (prior != null && !Arrays.equals(prior.getSourceIdentity(), identity)) { + throw new IOException("physical ingest checkpoint source identity differs: " + dbName); + } + long[] progress = prior == null ? new long[]{0, 0} : new long[]{prior.getRows(), prior.getBytes()}; + byte[][] cursor = new byte[][]{prior == null ? null : prior.getCursor()}; + List pending = + new ArrayList<>(BOOTSTRAP_WRITE_BATCH_ENTRIES + 2); + long[] pendingBytes = new long[1]; + long[] sinceCheckpoint = new long[2]; + long startedNanos = System.nanoTime(); + long initialRows = progress[0]; + long initialBatchCalls = store.getWriteBatchCalls(); + long initialBatchMutations = store.getWriteBatchMutations(); + pinned.scanAfter(dbName, cursor[0], (physicalKey, physicalValue) -> { + byte[] key = Arrays.copyOf(physicalKey, physicalKey.length); + byte[] secureKey = PathStateCommitmentCodec.storeLeafKey(participant.getStoreId(), key); + byte[] encodedLeaf = PathStateCommitmentCodec.presentLeafValue(physicalValue); + byte[] storedKey = prefixed(FLAT_STATE_PREFIX, secureKey, "secureKey"); + long mutationBytes = storedKey.length + encodedLeaf.length; + if (!pending.isEmpty() + && pendingBytes[0] + mutationBytes > BOOTSTRAP_WRITE_BATCH_BYTES) { + store.writeBatch(pending); + pending.clear(); + pendingBytes[0] = 0; + } + pending.add(PathStateNativeNodeStore.BatchMutation.put(storedKey, encodedLeaf)); + pendingBytes[0] = Math.addExact(pendingBytes[0], mutationBytes); + cursor[0] = key; + progress[0]++; + progress[1] += key.length + physicalValue.length; + sinceCheckpoint[0]++; + sinceCheckpoint[1] += key.length + physicalValue.length; + boolean checkpointDue = sinceCheckpoint[0] >= rowThreshold + || sinceCheckpoint[1] >= byteThreshold; + if (checkpointDue) { + pending.add(PhysicalStore.metadataMutation(FLAT_INGEST_CHECKPOINT, + new PathStatePhysicalIngestCheckpoint(identity, cursor[0], progress[0], progress[1]) + .encode())); + } + if (checkpointDue || pending.size() >= BOOTSTRAP_WRITE_BATCH_ENTRIES + || pendingBytes[0] >= BOOTSTRAP_WRITE_BATCH_BYTES) { + store.writeBatch(pending); + pending.clear(); + pendingBytes[0] = 0; + } + if (checkpointDue) { + logger.info("Path-state physical ingest checkpointed: storeId={}, dbName={}, rows={}, " + + "inputBytes={}, batches={}, mutations={}, elapsedMs={}, rowsPerSecond={}", + participant.getStoreId(), dbName, progress[0], progress[1], + store.getWriteBatchCalls() - initialBatchCalls, + store.getWriteBatchMutations() - initialBatchMutations, + elapsedMillis(startedNanos), rowsPerSecond(progress[0] - initialRows, startedNanos)); + sinceCheckpoint[0] = 0; + sinceCheckpoint[1] = 0; + } + }); + if (cursor[0] != null) { + pending.add(PhysicalStore.metadataMutation(FLAT_INGEST_CHECKPOINT, + new PathStatePhysicalIngestCheckpoint(identity, cursor[0], progress[0], progress[1]) + .encode())); + } + pending.add(PhysicalStore.metadataMutation(FLAT_INGEST_COMPLETE, identity)); + store.writeBatch(pending); + logger.info("Path-state physical ingest completed: storeId={}, dbName={}, rows={}, " + + "inputBytes={}, batches={}, mutations={}, elapsedMs={}, rowsPerSecond={}", + participant.getStoreId(), dbName, progress[0], progress[1], + store.getWriteBatchCalls() - initialBatchCalls, + store.getWriteBatchMutations() - initialBatchMutations, + elapsedMillis(startedNanos), rowsPerSecond(progress[0] - initialRows, startedNanos)); + } + + /** Validates one exact-27 pinned source, resumes all unfinished F ingests, then builds the root. */ + public synchronized PathStateRoot ingestAndBuild( + PathStateRebuildCoordinator.SnapshotSource source) + throws IOException { + return ingestAndBuild(source, DEFAULT_CHECKPOINT_ROWS, DEFAULT_CHECKPOINT_BYTES); + } + + /** Validates one exact-27 pinned source, resumes all unfinished F ingests, then builds the root. */ + synchronized PathStateRoot ingestAndBuild(PathStateRebuildCoordinator.SnapshotSource source, + long rowThreshold, long byteThreshold) throws IOException { + PathStateRebuildCoordinator.SnapshotSource pinned = Objects.requireNonNull(source, "source"); + PathStateRebuildCoordinator.SnapshotIdentity identity = Objects.requireNonNull( + pinned.identity(), "source identity"); + pinned.verifyIdentity(identity); + PathStateParticipantDescriptor.current().requireExactDatabases(pinned.databases()); + ingestFlatParticipants(pinned, rowThreshold, byteThreshold); + pinned.verifyIdentity(identity); + return buildRootFromFlat(); + } + + private void ingestFlatParticipants(PathStateRebuildCoordinator.SnapshotSource source, + long rowThreshold, long byteThreshold) throws IOException { + ExecutorService largeExecutor = newBootstrapExecutor("large"); + ExecutorService smallExecutor = newBootstrapExecutor("small"); + List> futures = new ArrayList<>(); + try { + for (PathStateParticipant participant : scope.getParticipants()) { + ExecutorService executor = LARGE_BOOTSTRAP_STORES.contains(participant.getDbName()) + ? largeExecutor : smallExecutor; + futures.add(executor.submit(() -> { + ingestFlat(participant.getDbName(), source, rowThreshold, byteThreshold); + return null; + })); + } + for (Future future : futures) { + try { + future.get(); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + cancelOutstanding(futures); + throw new IOException("path-state physical ingest interrupted", interrupted); + } catch (ExecutionException failed) { + cancelOutstanding(futures); + Throwable cause = failed.getCause(); + if (cause instanceof IOException) { + throw (IOException) cause; + } + if (cause instanceof RuntimeException) { + throw (RuntimeException) cause; + } + throw new IOException("path-state physical ingest failed", cause); + } + } + } finally { + largeExecutor.shutdownNow(); + smallExecutor.shutdownNow(); + } + } + + private static ExecutorService newBootstrapExecutor(String tier) { + return Executors.newSingleThreadExecutor(task -> { + Thread thread = new Thread(task, "path-state-physical-bootstrap-" + tier); + thread.setDaemon(true); + return thread; + }); + } + + private static ExecutorService newParticipantWriteExecutor() { + return Executors.newFixedThreadPool(MAX_PARALLEL_PARTICIPANT_WRITES, task -> { + Thread thread = new Thread(task, "path-state-physical-participant-write"); + thread.setDaemon(true); + return thread; + }); + } + + private static ExecutorService newTrieExecutor(String name, int threads) { + return Executors.newFixedThreadPool(threads, task -> { + Thread thread = new Thread(task, "path-state-physical-" + name); + thread.setDaemon(true); + return thread; + }); + } + + private static void cancelOutstanding(List> futures) { + for (Future future : futures) { + if (!future.isDone()) { + future.cancel(true); + } + } + } + + /** + * Persists a complete local F-domain snapshot and its root marker. + * + *

This is intentionally not a 28-database global publication protocol. Callers must add the + * TASK-018 global intent before treating this marker as a runtime CURRENT authority. + */ + public synchronized void persistFlatSnapshot(PathStateRoot root) { + requireOpen(); + PathStateRoot supplied = Objects.requireNonNull(root, "root"); + byte[] stateRoot = supplied.rootHash(); + for (PathStateRoot.LeafRecord record : supplied.leafRecords()) { + PathStateParticipant participant = participant(record.getStoreId()); + participant(participant.getDbName()).putFlat(record.getSecureKey(), + record.getEncodedValue()); + } + superStore.putMetadata(FLAT_ROOT_METADATA, stateRoot); + } + + /** Restores one root from every participant F domain and verifies the stored local root marker. */ + public synchronized PathStateRoot restoreRootFromFlat() throws IOException { + requireOpen(); + byte[] expectedRoot = superStore.getMetadata(FLAT_ROOT_METADATA); + if (expectedRoot == null || expectedRoot.length != PathStateCommitmentCodec.ROOT_LENGTH) { + throw new IllegalStateException("path-state physical F root marker is missing or invalid"); + } + PathStateRoot root = createRoot(); + List records = new ArrayList<>(); + for (PathStateParticipant participant : scope.getParticipants()) { + PhysicalStore store = participant(participant.getDbName()); + store.scanFlat(entry -> records.add(new PathStateRoot.LeafRecord(participant.getStoreId(), + unprefixedFlatKey(entry.getKey()), entry.getValue()))); + } + root.restoreLeaves(records, expectedRoot); + return root; + } + + /** + * Streams each participant F domain in secure-key order into its N domain and marks completion. + * + *

A valid per-Store completion marker skips that Store on retry. This method has no source + * database parameter and therefore cannot trigger a source rescan. + */ + public synchronized PathStateRoot buildRootFromFlat() throws IOException { + return buildRootFromFlat((participant, storeRoot) -> { }); + } + + synchronized PathStateRoot buildRootFromFlat(BuildFaultHook faultHook) throws IOException { + requireOpen(); + BuildFaultHook hook = Objects.requireNonNull(faultHook, "faultHook"); + PathStateRoot root = createRoot(); + List targets = new ArrayList<>(); + for (PathStateParticipant participant : scope.getParticipants()) { + PhysicalStore store = participant(participant.getDbName()); + byte[] completedRoot = store.getMetadata(FLAT_COMPLETE_METADATA); + if (completedRoot != null) { + if (completedRoot.length != PathStateCommitmentCodec.ROOT_LENGTH) { + throw new IllegalStateException("path-state physical FLAT_COMPLETE marker is invalid"); + } + byte[] flatDigest = requireDigest(store.getMetadata(FLAT_DIGEST_METADATA), + "path-state physical flat digest is missing or invalid"); + byte[] generation = requireDigest(store.getMetadata(STORE_GENERATION_METADATA), + "path-state physical Store generation is missing or invalid"); + requireSame(generation, participantGeneration(participant, flatDigest, completedRoot), + "path-state physical Store generation differs"); + targets.add(new PathStatePhysicalGlobalIntent.ParticipantTarget(participant.getStoreId(), + generation, flatDigest, completedRoot)); + root.completeRebuildParticipant(participant.getDbName(), completedRoot); + continue; + } + store.clearNodes(); + long startedNanos = System.nanoTime(); + long initialBatchCalls = store.getWriteBatchCalls(); + long initialBatchMutations = store.getWriteBatchMutations(); + long[] rows = new long[1]; + PhysicalNodeBatchWriter nodeWriter = store.nodeBatchWriter(); + PathStateStackTrie trie = new PathStateStackTrie(nodeWriter::put); + Hasher flatHasher = Hashing.sha256().newHasher(); + store.scanFlat(entry -> { + byte[] secureKey = unprefixedFlatKey(entry.getKey()); + byte[] encodedValue = entry.getValue(); + flatHasher.putInt(secureKey.length).putBytes(secureKey) + .putInt(encodedValue.length).putBytes(encodedValue); + trie.update(secureKey, encodedValue); + rows[0]++; + }); + byte[] storeRoot = trie.rootHash(); + nodeWriter.flush(); + byte[] flatDigest = flatHasher.hash().asBytes(); + byte[] generation = participantGeneration(participant, flatDigest, storeRoot); + hook.beforeCompletion(participant, storeRoot); + store.completeFlatBuild(flatDigest, generation, storeRoot); + logger.info("Path-state physical trie completed: storeId={}, dbName={}, rows={}, " + + "batches={}, mutations={}, elapsedMs={}, rowsPerSecond={}", + participant.getStoreId(), participant.getDbName(), rows[0], + store.getWriteBatchCalls() - initialBatchCalls, + store.getWriteBatchMutations() - initialBatchMutations, + elapsedMillis(startedNanos), rowsPerSecond(rows[0], startedNanos)); + targets.add(new PathStatePhysicalGlobalIntent.ParticipantTarget(participant.getStoreId(), + generation, flatDigest, storeRoot)); + root.completeRebuildParticipant(participant.getDbName(), storeRoot); + } + byte[] stateRoot = root.rootHash(); + superStore.putMetadata(SUPER_GENERATION_METADATA, + superGeneration(targets, stateRoot)); + superStore.putMetadata(FLAT_ROOT_METADATA, stateRoot); + return root; + } + + /** Publishes the exact prepared 27+1 target through INTENT, CURRENT, and intent retirement. */ + public synchronized byte[] publishCurrent() throws IOException { + return publishCurrent(syntheticMetadata(publicationTargetRoot()), stage -> { }); + } + + public synchronized byte[] publishCurrent(PathStateRootMetadata metadata) throws IOException { + return publishCurrent(metadata, stage -> { }); + } + + synchronized byte[] publishCurrent(PublicationFaultHook faultHook) throws IOException { + return publishCurrent(syntheticMetadata(publicationTargetRoot()), faultHook); + } + + synchronized byte[] publishCurrent(PathStateRootMetadata metadata, + PublicationFaultHook faultHook) throws IOException { + requireOpen(); + PublicationFaultHook hook = Objects.requireNonNull(faultHook, "faultHook"); + PathStatePhysicalGlobalIntent target = publicationTarget( + Objects.requireNonNull(metadata, "metadata")); + byte[] encoded = target.encode(); + Path intent = directory.resolve(INTENT_FILE); + Path current = directory.resolve(CURRENT_FILE); + PathStateMetadataFile.publishImmutableBytes(intent, encoded); + hook.after(PublicationStage.AFTER_INTENT); + PathStateMetadataFile.replaceCurrentBytes(current, encoded); + hook.after(PublicationStage.AFTER_CURRENT); + PathStateMetadataFile.deleteDurable(intent); + hook.after(PublicationStage.AFTER_RETIRE); + return target.getSuperRoot(); + } + + /** Returns the exact block-bound metadata bound into the validated physical CURRENT. */ + public synchronized PathStateRootMetadata currentMetadata() throws IOException { + requireOpen(); + PathStatePhysicalGlobalIntent current = currentTarget(); + return current.getMetadata(); + } + + synchronized void verifyReverseJournals(PathStateLayerLimits limits) throws IOException { + requireOpen(); + reverseJournalIndex = loadReverseJournalIndex(Objects.requireNonNull(limits, "limits")); + } + + /** + * Loads one exact canonical reverse window without changing physical storage authority. + * + *

This diagnostic entry requires an offline coherent checkpoint. It deliberately reloads and + * validates every retained journal instead of trusting the steady-state in-memory index. Missing + * ancestry fails rather than returning a shorter window. + */ + synchronized PathStatePhysicalOracleWindow loadOracleWindow(int blockCount, + PathStateLayerLimits limits) throws IOException { + requireOpen(); + if (blockCount <= 0) { + throw new IllegalArgumentException("physical oracle block count must be positive"); + } + if (Files.exists(directory.resolve(INTENT_FILE), LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("physical oracle requires a settled CURRENT without INTENT"); + } + PathStatePhysicalGlobalIntent current = currentTarget(); + Map indexed = loadReverseJournalIndex( + Objects.requireNonNull(limits, "limits")); + List journals = new ArrayList<>(blockCount); + PathStatePhysicalGlobalIntent cursor = current; + for (int index = 0; index < blockCount; index++) { + ReverseJournalIndexEntry entry = indexed.get(new BytesKey(cursor.encode())); + if (entry == null) { + throw new IOException("physical oracle fixed window ancestry is missing at height " + + cursor.getMetadata().getBlockNumber()); + } + PathStatePhysicalReverseJournal journal = loadReverseJournal(entry.path); + if (!Arrays.equals(cursor.encode(), journal.getChildTarget())) { + throw new IOException("physical oracle journal child differs from canonical cursor"); + } + journals.add(journal); + cursor = PathStatePhysicalGlobalIntent.decode(journal.getParentTarget()); + } + return new PathStatePhysicalOracleWindow(current.encode(), journals); + } + + /** Computes one exact child target without changing F/N/M, INTENT, or CURRENT. */ + public synchronized PathStateRootMetadata previewTransition(PathStateBlockTransition transition) + throws IOException { + requireOpen(); + recoverPublication(); + return prepareTransition(transition).target.getMetadata(); + } + + /** Prepares one physical transition and its Snapshot-owned forward delta without any write. */ + synchronized PreparedPhysicalTransition prepareSnapshotDelta(BlockSnapshotMeta meta, + PathStateBlockTransition transition) throws IOException { + requireOpen(); + if (Files.exists(directory.resolve(INTENT_FILE), LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("physical path-state prepare requires a settled CURRENT"); + } + TransitionPlan plan = prepareTransition(transition); + return new PreparedPhysicalTransition(transition, plan, + snapshotDelta(Objects.requireNonNull(meta, "meta"), transition, plan)); + } + + /** Materializes an unchanged, previously prepared physical transition exactly once. */ + synchronized PathStateRootMetadata applyAndPublish(PreparedPhysicalTransition prepared, + PathStateLayerLimits limits) throws IOException { + PreparedPhysicalTransition admitted = Objects.requireNonNull(prepared, "prepared"); + return applyAndPublishInternal(admitted.transition, limits, stage -> { }, true, + admitted.plan); + } + + /** Applies one block-final child to the physical 27+1 stores and publishes its CURRENT. */ + public synchronized PathStateRootMetadata applyAndPublish(PathStateBlockTransition transition) + throws IOException { + return applyAndPublishInternal(transition, PathStateLayerLimits.defaults(), stage -> { }, + true); + } + + synchronized PathStateRootMetadata applyAndPublish(PathStateBlockTransition transition, + PathStateLayerLimits limits) throws IOException { + return applyAndPublishInternal(transition, limits, stage -> { }, true); + } + + synchronized PathStateRootMetadata applyAndPublish(PathStateBlockTransition transition, + TransitionFaultHook faultHook) throws IOException { + return applyAndPublishInternal(transition, PathStateLayerLimits.defaults(), faultHook, false); + } + + synchronized PathStateRootMetadata applyAndPublish(PathStateBlockTransition transition, + PathStateLayerLimits limits, TransitionFaultHook faultHook) throws IOException { + return applyAndPublishInternal(transition, limits, faultHook, false); + } + + private PathStateRootMetadata applyAndPublishInternal(PathStateBlockTransition transition, + PathStateLayerLimits limits, TransitionFaultHook faultHook, boolean parallelParticipants) + throws IOException { + return applyAndPublishInternal(transition, limits, faultHook, parallelParticipants, null); + } + + private PathStateRootMetadata applyAndPublishInternal(PathStateBlockTransition transition, + PathStateLayerLimits limits, TransitionFaultHook faultHook, boolean parallelParticipants, + TransitionPlan preparedPlan) throws IOException { + requireOpen(); + long startedNanos = System.nanoTime(); + recoverPublication(); + TransitionPlan plan = preparedPlan == null ? prepareTransition(transition) : preparedPlan; + if (preparedPlan != null + && (!Arrays.equals(currentTarget().encode(), plan.parentTarget) + || preparedPlan.transition != transition)) { + throw new IOException("prepared physical transition no longer extends CURRENT"); + } + long preparedNanos = System.nanoTime(); + TransitionFaultHook hook = Objects.requireNonNull(faultHook, "faultHook"); + byte[] encoded = plan.target.encode(); + byte[] encodedJournal = plan.journal.encode(); + Path journal = reverseJournalPath(plan.target.getMetadata()); + pruneReverseJournals(currentTarget(), Objects.requireNonNull(limits, "limits"), journal, + encodedJournal.length); + PathStateMetadataFile.publishImmutableBytes(journal, encodedJournal); + rememberReverseJournal(plan.journal, journal, encodedJournal.length); + long journalNanos = System.nanoTime(); + hook.after(TransitionStage.AFTER_JOURNAL); + Path intent = directory.resolve(INTENT_FILE); + Path current = directory.resolve(CURRENT_FILE); + PathStateMetadataFile.publishImmutableBytes(intent, encoded); + hook.after(TransitionStage.AFTER_INTENT); + long intentNanos = System.nanoTime(); + if (parallelParticipants) { + applyParticipantTransitionsInParallel(plan.participants); + for (int completed = 0; completed < plan.participants.size(); completed++) { + hook.after(TransitionStage.AFTER_PARTICIPANT_BATCH); + } + } else { + for (ParticipantTransition participant : plan.participants) { + applyParticipantTransition(participant); + hook.after(TransitionStage.AFTER_PARTICIPANT_BATCH); + } + } + long participantsNanos = System.nanoTime(); + plan.superStore.applySuperTransition(plan.superNodeMutations, + plan.target.getSuperGeneration(), plan.target.getSuperRoot()); + hook.after(TransitionStage.AFTER_SUPER_BATCH); + PathStateMetadataFile.replaceCurrentBytes(current, encoded); + hook.after(TransitionStage.AFTER_CURRENT); + PathStateMetadataFile.deleteDurable(intent); + hook.after(TransitionStage.AFTER_RETIRE); + cacheTrieSnapshot(encoded, plan.trieSnapshot); + long completedNanos = System.nanoTime(); + logger.info("Path-state physical transition completed: head={}, changedStores={}, " + + "journalBytes={}, journalCount={}, journalWindowBytes={}, nodeReadMisses={}, " + + "nodeReadHits={}, residentNodeHits={}, residentCleanHits={}, " + + "residentUpdatedHits={}, nativeNodeReads={}, residentCacheBytes={}, " + + "residentEvictions={}, nodeDecodes={}, nodeHashVerifies={}, " + + "hashReferencesCreated={}, hashReferencesResolved={}, flatReverseReads={}, " + + "flatReverseReused={}, prepareMs={}, " + + "journalMs={}, intentMs={}, participantWaitMs={}, finalizeMs={}, totalMs={}", + plan.target.getMetadata().getBlockNumber(), plan.participants.size(), + encodedJournal.length, reverseJournalCount(), reverseJournalBytes(), + plan.nodeReadMisses, plan.nodeReadHits, plan.residentNodeHits, + plan.residentCleanHits, plan.residentUpdatedHits, plan.nativeNodeReads, + residentNodeCache.bytes(), residentNodeCache.evictions() - plan.initialResidentEvictions, + plan.nodeDecodes, + plan.nodeHashVerifies, + plan.hashReferencesCreated, + plan.hashReferencesResolved, + plan.flatReverseReads, + plan.flatReverseReused, + elapsedMillis(startedNanos, preparedNanos), elapsedMillis(preparedNanos, journalNanos), + elapsedMillis(journalNanos, intentNanos), elapsedMillis(intentNanos, participantsNanos), + elapsedMillis(participantsNanos, completedNanos), + elapsedMillis(startedNanos, completedNanos)); + return plan.target.getMetadata(); + } + + /** Rewinds through validated direct-parent journals to one exact bounded ancestor. */ + public synchronized PathStateRootMetadata rewindTo(long blockNumber, byte[] blockHash, + PathStateLayerLimits limits) throws IOException { + return rewindTo(blockNumber, blockHash, limits, stage -> { }); + } + + synchronized PathStateRootMetadata rewindTo(long blockNumber, byte[] blockHash, + PathStateLayerLimits limits, RewindFaultHook faultHook) throws IOException { + requireOpen(); + recoverPublication(); + byte[] targetHash = Arrays.copyOf(Objects.requireNonNull(blockHash, "blockHash"), + blockHash.length); + if (targetHash.length != PathStateRootMetadata.DIGEST_LENGTH) { + throw new IOException("physical rewind block hash must contain exactly 32 bytes"); + } + List chain = loadRewindChain(currentTarget(), blockNumber, + targetHash, Objects.requireNonNull(limits, "limits")); + RewindFaultHook hook = Objects.requireNonNull(faultHook, "faultHook"); + for (PathStatePhysicalReverseJournal journal : chain) { + applyReverseJournal(journal, hook); + } + PathStateRootMetadata rewound = currentMetadata(); + if (rewound.getBlockNumber() != blockNumber + || !Arrays.equals(rewound.getBlockHash(), targetHash)) { + throw new IOException("physical rewind did not reach the requested ancestor"); + } + return rewound; + } + + private TransitionPlan prepareTransition(PathStateBlockTransition supplied) throws IOException { + long initialResidentEvictions = residentNodeCache.evictions(); + PathStateBlockTransition transition = Objects.requireNonNull(supplied, "transition"); + PathStatePhysicalGlobalIntent current = currentTarget(); + PathStateRootMetadata parent = current.getMetadata(); + if (transition.getBlockNumber() != parent.getBlockNumber() + 1 + || !Arrays.equals(transition.getParentHash(), parent.getBlockHash())) { + throw new IOException("physical path-state transition does not extend CURRENT"); + } + + Map recordings = new LinkedHashMap<>(); + PathStateRoot.PathNodeStoreFactory storeFactory = participant -> recordings.computeIfAbsent( + participant.getStoreId(), ignored -> + new RecordingNodeStore(participant(participant.getDbName()).nodeStore())); + RecordingNodeStore recordingSuper = recordings.computeIfAbsent(0, ignored -> + new RecordingNodeStore(superStore.nodeStore())); + PathStateRoot candidate; + boolean reusedTrieSnapshot = cachedTrieSnapshot != null + && Arrays.equals(cachedTrieTarget, current.encode()); + if (reusedTrieSnapshot) { + candidate = PathStateRoot.fromSnapshot(scope, storeFactory, recordingSuper, + cachedTrieSnapshot); + } else { + candidate = new PathStateRoot(scope, storeFactory, recordingSuper); + candidate.restoreStoredRoots(current.getSuperRoot()); + requireParticipantRoots(candidate, current); + } + if (!transition.getMutations().isEmpty()) { + candidate.applyParallel(transition.getMutations(), participantPrepareExecutor, + trieBranchExecutor); + } + byte[] stateRoot = candidate.rootHash(); + PathStateRoot.Snapshot trieSnapshot = candidate.snapshot(); + + Map> flatByStore = new LinkedHashMap<>(); + for (PathStateMutation mutation : transition.getMutations()) { + PathStateParticipant participant = scope.require(mutation.getDbName()); + byte[] secureKey = PathStateCommitmentCodec.storeLeafKey(participant.getStoreId(), + mutation.getPhysicalKey()); + byte[] encodedValue = mutation.isDelete() ? null + : PathStateCommitmentCodec.presentLeafValue(mutation.getPhysicalValue()); + byte[] previousEncodedValue = null; + if (mutation.isPreviousValueKnown() && mutation.getPreviousPhysicalValue() != null) { + previousEncodedValue = PathStateCommitmentCodec.presentLeafValue( + mutation.getPreviousPhysicalValue()); + } + flatByStore.computeIfAbsent(participant.getStoreId(), ignored -> new ArrayList<>()) + .add(new FlatMutation(secureKey, encodedValue, mutation.isPreviousValueKnown(), + previousEncodedValue)); + } + + List targets = new ArrayList<>(); + List participantTransitions = new ArrayList<>(); + List reverseStores = new ArrayList<>(); + long flatReverseReads = 0; + long flatReverseReused = 0; + for (PathStatePhysicalGlobalIntent.ParticipantTarget oldTarget + : current.getParticipants()) { + List flatMutations = flatByStore.get(oldTarget.getStoreId()); + if (flatMutations == null) { + targets.add(oldTarget); + continue; + } + PathStateParticipant participant = participant(oldTarget.getStoreId()); + byte[] storeRoot = candidate.participantRoot(participant.getDbName()); + byte[] flatDigest = nextFlatDigest(participant, oldTarget.getFlatDigest(), transition, + flatMutations); + byte[] generation = participantGeneration(participant, flatDigest, storeRoot); + targets.add(new PathStatePhysicalGlobalIntent.ParticipantTarget(participant.getStoreId(), + generation, flatDigest, storeRoot)); + participantTransitions.add(new ParticipantTransition(participant, + participant(participant.getDbName()), flatMutations, + recordings.get(participant.getStoreId()).mutations(), flatDigest, generation, + storeRoot)); + List reverseFlat = new ArrayList<>(); + for (FlatMutation mutation : flatMutations) { + byte[] oldValue; + if (mutation.previousValueKnown) { + oldValue = mutation.previousEncodedValue; + flatReverseReused++; + } else { + oldValue = participant(participant.getDbName()).getFlat(mutation.secureKey); + flatReverseReads++; + } + reverseFlat.add(new PathStatePhysicalReverseJournal.Entry(mutation.secureKey, + oldValue)); + } + reverseStores.add(new PathStatePhysicalReverseJournal.StoreReverse( + participant.getStoreId(), reverseFlat, + recordings.get(participant.getStoreId()).reverseEntries())); + } + byte[] superGeneration = superGeneration(targets, stateRoot); + PathStateRootMetadata metadata = PathStateRootMetadata.layer(transition.getBlockNumber(), + transition.getBlockHash(), transition.getParentHash(), transition.getTimestamp(), + transition.getPhase(), manifest.getIdentityDigest(), parent.getStateRoot(), stateRoot, + transition.getPayloadDigest()); + PathStatePhysicalGlobalIntent target = new PathStatePhysicalGlobalIntent( + manifest.getIdentityDigest(), metadata, targets, superGeneration, stateRoot); + PathStatePhysicalReverseJournal journal = new PathStatePhysicalReverseJournal(target.encode(), + current.encode(), reverseStores, recordings.get(0).reverseEntries()); + long nodeReadMisses = recordings.values().stream() + .mapToLong(RecordingNodeStore::getBaseReadMisses).sum(); + long nodeReadHits = recordings.values().stream() + .mapToLong(RecordingNodeStore::getBaseReadHits).sum(); + long residentNodeHits = recordings.values().stream() + .mapToLong(RecordingNodeStore::getResidentReadHits).sum(); + long nativeNodeReads = recordings.values().stream() + .mapToLong(RecordingNodeStore::getNativeReads).sum(); + long residentCleanHits = recordings.values().stream() + .mapToLong(RecordingNodeStore::getResidentCleanHits).sum(); + long residentUpdatedHits = recordings.values().stream() + .mapToLong(RecordingNodeStore::getResidentUpdatedHits).sum(); + return new TransitionPlan(transition, current.encode(), target, participantTransitions, + superStore, + recordings.get(0).mutations(), journal, nodeReadMisses, nodeReadHits, + residentNodeHits, residentCleanHits, residentUpdatedHits, nativeNodeReads, + initialResidentEvictions, candidate.nodeDecodeCount(), + candidate.nodeHashVerifyCount(), candidate.hashReferenceCreateCount(), + candidate.hashReferenceResolveCount(), flatReverseReads, flatReverseReused, + trieSnapshot, reusedTrieSnapshot); + } + + private PathStateSnapshotDelta snapshotDelta(BlockSnapshotMeta meta, + PathStateBlockTransition transition, TransitionPlan plan) { + List stores = new ArrayList<>(); + for (ParticipantTransition participant : plan.participants) { + stores.add(new PathStateSnapshotDelta.StoreDelta(participant.participant, + participant.storeRoot, snapshotMutations(participant.flatMutations), + snapshotNodeMutations(participant.nodeMutations))); + } + return PathStateSnapshotDelta.fromPhysical(meta, + PathStatePhysicalGlobalIntent.decode(plan.parentTarget).getMetadata(), transition, + plan.trieSnapshot, stores, snapshotNodeMutations(plan.superNodeMutations)); + } + + private static List snapshotMutations( + List mutations) { + List result = new ArrayList<>(); + for (FlatMutation mutation : mutations) { + result.add(new PathStateSnapshotDelta.Mutation(mutation.secureKey, + mutation.encodedValue)); + } + return result; + } + + private static List snapshotNodeMutations( + List mutations) { + List result = new ArrayList<>(); + for (NodeMutation mutation : mutations) { + result.add(new PathStateSnapshotDelta.Mutation(mutation.path, mutation.encodedNode)); + } + return result; + } + + private void cacheTrieSnapshot(byte[] target, PathStateRoot.Snapshot snapshot) { + cachedTrieTarget = Arrays.copyOf(target, target.length); + cachedTrieSnapshot = Objects.requireNonNull(snapshot, "snapshot"); + } + + private void applyParticipantTransitionsInParallel(List transitions) + throws IOException { + List writes = new ArrayList<>(); + for (ParticipantTransition participant : transitions) { + writes.add(() -> applyParticipantTransition(participant)); + } + awaitParallelWrites(participantWriteExecutor, writes); + } + + static void awaitParallelWrites(ExecutorService executor, List writes) + throws IOException { + List> futures = new ArrayList<>(); + for (Runnable write : Objects.requireNonNull(writes, "writes")) { + futures.add(Objects.requireNonNull(executor, "executor").submit( + Objects.requireNonNull(write, "write"))); + } + IOException failure = null; + boolean interrupted = false; + for (Future future : futures) { + boolean complete = false; + while (!complete) { + try { + future.get(); + complete = true; + } catch (InterruptedException interruptedFailure) { + interrupted = true; + } catch (ExecutionException writeFailure) { + complete = true; + Throwable cause = writeFailure.getCause(); + IOException participantFailure = cause instanceof IOException + ? (IOException) cause + : new IOException("path-state participant batch failed", cause); + failure = append(failure, participantFailure); + } + } + } + if (interrupted) { + Thread.currentThread().interrupt(); + failure = append(failure, + new IOException("path-state participant batch wait was interrupted")); + } + if (failure != null) { + throw failure; + } + } + + private static void applyParticipantTransition(ParticipantTransition participant) { + participant.store.applyParticipantTransition(participant.flatMutations, + participant.nodeMutations, participant.flatDigest, participant.generation, + participant.storeRoot); + } + + private void applyReverseJournal(PathStatePhysicalReverseJournal journal, + RewindFaultHook hook) throws IOException { + PathStatePhysicalGlobalIntent child = PathStatePhysicalGlobalIntent.decode( + journal.getChildTarget()); + PathStatePhysicalGlobalIntent parent = PathStatePhysicalGlobalIntent.decode( + journal.getParentTarget()); + if (!Arrays.equals(currentTarget().encode(), child.encode())) { + throw new IOException("physical reverse journal child differs from CURRENT"); + } + Path intent = directory.resolve(INTENT_FILE); + Path current = directory.resolve(CURRENT_FILE); + PathStateMetadataFile.publishImmutableBytes(intent, parent.encode()); + hook.after(RewindStage.AFTER_INTENT); + for (PathStatePhysicalReverseJournal.StoreReverse reverse : journal.getStores()) { + PathStatePhysicalGlobalIntent.ParticipantTarget target = participantTarget(parent, + reverse.getStoreId()); + PhysicalStore store = participant(participant(reverse.getStoreId()).getDbName()); + store.applyParticipantTransition(flatMutations(reverse.getFlatEntries()), + nodeMutations(reverse.getNodeEntries()), target.getFlatDigest(), + target.getGeneration(), target.getStoreRoot()); + hook.after(RewindStage.AFTER_PARTICIPANT_BATCH); + } + superStore.applySuperTransition(nodeMutations(journal.getSuperNodes()), + parent.getSuperGeneration(), parent.getSuperRoot()); + hook.after(RewindStage.AFTER_SUPER_BATCH); + PathStateMetadataFile.replaceCurrentBytes(current, parent.encode()); + hook.after(RewindStage.AFTER_CURRENT); + PathStateMetadataFile.deleteDurable(intent); + cachedTrieTarget = null; + cachedTrieSnapshot = null; + hook.after(RewindStage.AFTER_RETIRE); + } + + private List loadRewindChain( + PathStatePhysicalGlobalIntent current, long targetNumber, byte[] targetHash, + PathStateLayerLimits limits) throws IOException { + if (targetNumber < 0 || targetNumber > current.getMetadata().getBlockNumber()) { + throw new IOException("physical rewind target height is outside CURRENT ancestry"); + } + Map journals = reverseJournalIndex(limits); + List chain = new ArrayList<>(); + PathStatePhysicalGlobalIntent cursor = current; + while (cursor.getMetadata().getBlockNumber() > targetNumber) { + ReverseJournalIndexEntry entry = journals.get(new BytesKey(cursor.encode())); + if (entry == null) { + throw new IOException("physical reverse journal ancestry is missing"); + } + PathStatePhysicalReverseJournal journal = loadReverseJournal(entry.path); + chain.add(journal); + cursor = PathStatePhysicalGlobalIntent.decode(entry.parentTarget); + } + if (cursor.getMetadata().getBlockNumber() != targetNumber + || !Arrays.equals(cursor.getMetadata().getBlockHash(), targetHash)) { + throw new IOException("physical rewind target is not an exact ancestor"); + } + return chain; + } + + private Map loadReverseJournalIndex( + PathStateLayerLimits limits) throws IOException { + Map journals = new LinkedHashMap<>(); + Path reverse = directory.resolve(REVERSE_DIRECTORY); + if (!Files.exists(reverse, LinkOption.NOFOLLOW_LINKS)) { + return journals; + } + if (!Files.isDirectory(reverse, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(reverse)) { + throw new IOException("physical reverse journal root is not a direct directory"); + } + long total = 0; + int count = 0; + try (Stream files = Files.list(reverse)) { + for (Path file : (Iterable) files::iterator) { + if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("physical reverse journal is not a regular file: " + file); + } + long length = Files.size(file); + total = Math.addExact(total, length); + count = Math.addExact(count, 1); + PathStatePhysicalReverseJournal journal = loadReverseJournal(file); + ReverseJournalIndexEntry entry = new ReverseJournalIndexEntry(file, length, + journal.getChildTarget(), journal.getParentTarget()); + if (journals.put(new BytesKey(entry.childTarget), entry) != null) { + throw new IOException("physical reverse journal child identity is duplicated"); + } + } + } catch (ArithmeticException overflow) { + throw new IOException("physical reverse journal usage overflow", overflow); + } + if (count > limits.getMaxLayers() || total > limits.getMaxLogicalBytes()) { + throw new IOException("physical reverse journal exceeds configured bounds"); + } + return journals; + } + + private PathStatePhysicalReverseJournal loadReverseJournal(Path file) throws IOException { + try { + return PathStatePhysicalReverseJournal.decode( + PathStateMetadataFile.loadImmutableBytes(file, + PathStatePhysicalReverseJournal.MAX_ENCODED_LENGTH)); + } catch (IllegalArgumentException invalid) { + throw new IOException("physical reverse journal is corrupt: " + file, invalid); + } + } + + private Map reverseJournalIndex( + PathStateLayerLimits limits) throws IOException { + if (reverseJournalIndex == null) { + reverseJournalIndex = loadReverseJournalIndex(limits); + } else { + requireReverseJournalLimits(reverseJournalIndex, limits); + } + return reverseJournalIndex; + } + + private static void requireReverseJournalLimits( + Map journals, PathStateLayerLimits limits) + throws IOException { + long total = 0; + try { + for (ReverseJournalIndexEntry entry : journals.values()) { + total = Math.addExact(total, entry.length); + } + } catch (ArithmeticException overflow) { + throw new IOException("physical reverse journal usage overflow", overflow); + } + if (journals.size() > limits.getMaxLayers() || total > limits.getMaxLogicalBytes()) { + throw new IOException("physical reverse journal exceeds configured bounds"); + } + } + + private void rememberReverseJournal(PathStatePhysicalReverseJournal journal, Path path, + long length) throws IOException { + Map journals = reverseJournalIndex( + new PathStateLayerLimits(Integer.MAX_VALUE, Long.MAX_VALUE)); + ReverseJournalIndexEntry entry = new ReverseJournalIndexEntry(path, length, + journal.getChildTarget(), journal.getParentTarget()); + BytesKey identity = new BytesKey(entry.childTarget); + ReverseJournalIndexEntry previous = journals.put(identity, entry); + if (previous != null && !previous.path.equals(entry.path)) { + journals.put(identity, previous); + throw new IOException("physical reverse journal child identity is duplicated"); + } + } + + private int reverseJournalCount() { + return reverseJournalIndex == null ? 0 : reverseJournalIndex.size(); + } + + private long reverseJournalBytes() throws IOException { + if (reverseJournalIndex == null) { + return 0; + } + long total = 0; + try { + for (ReverseJournalIndexEntry entry : reverseJournalIndex.values()) { + total = Math.addExact(total, entry.length); + } + return total; + } catch (ArithmeticException overflow) { + throw new IOException("physical reverse journal usage overflow", overflow); + } + } + + private void pruneReverseJournals(PathStatePhysicalGlobalIntent current, + PathStateLayerLimits limits, Path candidate, long candidateBytes) throws IOException { + if (candidateBytes > limits.getMaxLogicalBytes()) { + throw new IOException("physical reverse journal candidate exceeds byte limit"); + } + Path reverse = directory.resolve(REVERSE_DIRECTORY); + if (!Files.exists(reverse, LinkOption.NOFOLLOW_LINKS)) { + return; + } + Map indexed = reverseJournalIndex( + new PathStateLayerLimits(Integer.MAX_VALUE, Long.MAX_VALUE)); + Set keep = new HashSet<>(); + byte[] cursor = current.encode(); + int remainingCount = limits.getMaxLayers() - 1; + long remainingBytes = limits.getMaxLogicalBytes() - candidateBytes; + while (remainingCount > 0) { + BytesKey identity = new BytesKey(cursor); + ReverseJournalIndexEntry entry = indexed.get(identity); + if (entry == null) { + break; + } + if (entry.length > remainingBytes) { + break; + } + keep.add(identity); + remainingCount--; + remainingBytes -= entry.length; + cursor = entry.parentTarget; + } + java.util.Iterator> iterator = + indexed.entrySet().iterator(); + while (iterator.hasNext()) { + Map.Entry indexedJournal = iterator.next(); + ReverseJournalIndexEntry entry = indexedJournal.getValue(); + if (!entry.path.equals(candidate) && !keep.contains(indexedJournal.getKey())) { + PathStateMetadataFile.deleteDurable(entry.path); + iterator.remove(); + } + } + } + + private Path reverseJournalPath(PathStateRootMetadata child) { + StringBuilder hash = new StringBuilder(child.getBlockHash().length * 2); + for (byte value : child.getBlockHash()) { + hash.append(String.format("%02x", value & 0xff)); + } + return directory.resolve(REVERSE_DIRECTORY).resolve(String.format("%020d-%s.journal", + child.getBlockNumber(), hash)); + } + + private static PathStatePhysicalGlobalIntent.ParticipantTarget participantTarget( + PathStatePhysicalGlobalIntent target, int storeId) throws IOException { + for (PathStatePhysicalGlobalIntent.ParticipantTarget participant : target.getParticipants()) { + if (participant.getStoreId() == storeId) { + return participant; + } + } + throw new IOException("physical reverse target Store is absent"); + } + + private static List flatMutations( + List entries) { + List mutations = new ArrayList<>(); + for (PathStatePhysicalReverseJournal.Entry entry : entries) { + mutations.add(new FlatMutation(entry.getKey(), entry.getOldValue())); + } + return mutations; + } + + private static List nodeMutations( + List entries) { + List mutations = new ArrayList<>(); + for (PathStatePhysicalReverseJournal.Entry entry : entries) { + mutations.add(new NodeMutation(entry.getKey(), entry.getOldValue())); + } + return mutations; + } + + private byte[] nextFlatDigest(PathStateParticipant participant, byte[] previous, + PathStateBlockTransition transition, List mutations) { + Hasher hasher = Hashing.sha256().newHasher() + .putString("java-tron/path-state/flat-transition/v1", StandardCharsets.US_ASCII) + .putBytes(manifest.getIdentityDigest()).putInt(participant.getStoreId()) + .putBytes(previous).putBytes(transition.getPayloadDigest()).putInt(mutations.size()); + for (FlatMutation mutation : mutations) { + hasher.putInt(mutation.secureKey.length).putBytes(mutation.secureKey); + if (mutation.encodedValue == null) { + hasher.putInt(-1); + } else { + hasher.putInt(mutation.encodedValue.length).putBytes(mutation.encodedValue); + } + } + return hasher.hash().asBytes(); + } + + /** Reconciles an interrupted global publication without accepting a partial 28-DB target. */ + public synchronized PublicationRecovery recoverPublication() throws IOException { + requireOpen(); + Path intentPath = directory.resolve(INTENT_FILE); + Path currentPath = directory.resolve(CURRENT_FILE); + boolean hasIntent = Files.exists(intentPath, LinkOption.NOFOLLOW_LINKS); + boolean hasCurrent = Files.exists(currentPath, LinkOption.NOFOLLOW_LINKS); + boolean currentValid = false; + IOException invalidCurrent = null; + if (hasCurrent) { + try { + currentValid = matchesPreparedTarget(loadGlobalTarget(currentPath)); + } catch (IOException failure) { + invalidCurrent = failure; + } + } + if (!hasIntent) { + if (hasCurrent && !currentValid) { + throw new IOException("path-state physical CURRENT is not the exact prepared 28-DB target", + invalidCurrent); + } + return PublicationRecovery.NONE; + } + + PathStatePhysicalGlobalIntent intent; + boolean intentValid; + try { + intent = loadGlobalTarget(intentPath); + intentValid = matchesPreparedTarget(intent); + } catch (IOException invalidIntent) { + if (!currentValid) { + throw invalidIntent; + } + PathStateMetadataFile.deleteDurable(intentPath); + return PublicationRecovery.RETAINED_CURRENT; + } + if (!intentValid) { + if (!currentValid) { + throw new IOException("path-state physical INTENT is not the exact prepared 28-DB target"); + } + PathStateMetadataFile.deleteDurable(intentPath); + return PublicationRecovery.RETAINED_CURRENT; + } + PathStateMetadataFile.replaceCurrentBytes(currentPath, intent.encode()); + PathStateMetadataFile.deleteDurable(intentPath); + return PublicationRecovery.COMPLETED_INTENT; + } + + /** Deletes one exact physical key, commits changed paths, and republishes the 28-DB target. */ + public synchronized byte[] deleteAndPublish(String dbName, byte[] physicalKey) + throws IOException { + return deleteAndPublish(dbName, physicalKey, stage -> { }).getStateRoot(); + } + + synchronized PhysicalDeleteResult deleteAndPublish(String dbName, byte[] physicalKey, + DeleteFaultHook faultHook) throws IOException { + requireOpen(); + DeleteFaultHook hook = Objects.requireNonNull(faultHook, "faultHook"); + recoverPublication(); + PathStatePhysicalGlobalIntent current = currentTarget(); + PathStateRoot restored = createRoot(); + restored.restoreStoredRoots(current.getSuperRoot()); + requireParticipantRoots(restored, current); + PathStateRoot.Snapshot snapshot = restored.snapshot(); + + Map recordings = new LinkedHashMap<>(); + PathStateRoot candidate = PathStateRoot.fromSnapshot(scope, + participant -> recordings.computeIfAbsent(participant.getStoreId(), ignored -> + new RecordingNodeStore(participant(participant.getDbName()).nodeStore())), + recordings.computeIfAbsent(0, ignored -> new RecordingNodeStore(superStore.nodeStore())), + snapshot); + PathStateParticipant targetParticipant = scope.require(dbName); + byte[] rawKey = Arrays.copyOf(Objects.requireNonNull(physicalKey, "physicalKey"), + physicalKey.length); + byte[] secureKey = PathStateCommitmentCodec.storeLeafKey(targetParticipant.getStoreId(), + rawKey); + PhysicalStore targetStore = participant(targetParticipant.getDbName()); + if (targetStore.getFlat(secureKey) == null) { + throw new IOException("path-state physical delete leaf is missing: " + dbName); + } + candidate.delete(targetParticipant.getDbName(), rawKey); + byte[] stateRoot = candidate.rootHash(); + byte[] storeRoot = candidate.participantRoot(targetParticipant.getDbName()); + RecordingNodeStore participantChanges = recordings.get(targetParticipant.getStoreId()); + requireOnlyTargetParticipantChanged(recordings, targetParticipant.getStoreId()); + byte[] flatDigest = flatDigestExcluding(targetStore, secureKey); + byte[] generation = participantGeneration(targetParticipant, flatDigest, storeRoot); + targetStore.applyParticipantDelete(secureKey, participantChanges.mutations(), flatDigest, + generation, storeRoot); + hook.after(DeleteStage.AFTER_PARTICIPANT_BATCH); + + List targets = replaceParticipantTarget( + current.getParticipants(), targetParticipant.getStoreId(), generation, flatDigest, + storeRoot); + RecordingNodeStore superChanges = recordings.get(0); + byte[] nextSuperGeneration = superGeneration(targets, stateRoot); + superStore.applySuperTransition(superChanges.mutations(), nextSuperGeneration, stateRoot); + hook.after(DeleteStage.AFTER_SUPER_BATCH); + + byte[] published = publishCurrent(metadataWithRoot(current.getMetadata(), stateRoot)); + hook.after(DeleteStage.AFTER_CURRENT); + return new PhysicalDeleteResult(published, participantChanges.putCount(), + participantChanges.deleteCount(), superChanges.putCount(), superChanges.deleteCount()); + } + + private PathStatePhysicalGlobalIntent currentTarget() throws IOException { + Path currentPath = directory.resolve(CURRENT_FILE); + if (!Files.exists(currentPath, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("path-state physical CURRENT is missing"); + } + PathStatePhysicalGlobalIntent current = loadGlobalTarget(currentPath); + if (!matchesPreparedTarget(current)) { + throw new IOException("path-state physical CURRENT differs from prepared target"); + } + return current; + } + + private void requireParticipantRoots(PathStateRoot root, + PathStatePhysicalGlobalIntent current) throws IOException { + for (PathStatePhysicalGlobalIntent.ParticipantTarget target : current.getParticipants()) { + PathStateParticipant participant = participant(target.getStoreId()); + requireSame(target.getStoreRoot(), root.participantRoot(participant.getDbName()), + "path-state physical participant root differs from CURRENT: " + + participant.getDbName()); + } + } + + private static void requireOnlyTargetParticipantChanged( + Map recordings, int targetStoreId) { + for (Map.Entry entry : recordings.entrySet()) { + if (entry.getKey() != 0 && entry.getKey() != targetStoreId + && !entry.getValue().isEmpty()) { + throw new IllegalStateException("physical delete changed another participant Store"); + } + } + } + + private static List replaceParticipantTarget( + List current, int storeId, + byte[] generation, byte[] flatDigest, byte[] storeRoot) { + List targets = new ArrayList<>(); + boolean replaced = false; + for (PathStatePhysicalGlobalIntent.ParticipantTarget target : current) { + if (target.getStoreId() == storeId) { + targets.add(new PathStatePhysicalGlobalIntent.ParticipantTarget(storeId, generation, + flatDigest, storeRoot)); + replaced = true; + } else { + targets.add(target); + } + } + if (!replaced) { + throw new IllegalArgumentException("physical delete Store ID is not in CURRENT"); + } + return targets; + } + + private static byte[] flatDigestExcluding(PhysicalStore store, byte[] excludedSecureKey) + throws IOException { + Hasher hasher = Hashing.sha256().newHasher(); + boolean[] excluded = new boolean[1]; + store.scanFlat(entry -> { + byte[] secureKey = unprefixedFlatKey(entry.getKey()); + if (Arrays.equals(secureKey, excludedSecureKey)) { + excluded[0] = true; + return; + } + byte[] encodedValue = entry.getValue(); + hasher.putInt(secureKey.length).putBytes(secureKey) + .putInt(encodedValue.length).putBytes(encodedValue); + }); + if (!excluded[0]) { + throw new IOException("path-state physical delete leaf disappeared during digest scan"); + } + return hasher.hash().asBytes(); + } + + private byte[] publicationTargetRoot() throws IOException { + return requireDigest(superStore.getMetadata(FLAT_ROOT_METADATA), + "path-state physical super root is missing or invalid"); + } + + private PathStatePhysicalGlobalIntent publicationTarget(PathStateRootMetadata metadata) + throws IOException { + List targets = new ArrayList<>(); + for (PathStateParticipant participant : scope.getParticipants()) { + PhysicalStore store = participant(participant.getDbName()); + byte[] storeRoot = requireDigest(store.getMetadata(FLAT_COMPLETE_METADATA), + "path-state physical Store root is missing or invalid: " + participant.getDbName()); + requireRootNode(store, storeRoot, + "path-state physical Store root node differs: " + participant.getDbName()); + byte[] flatDigest = requireDigest(store.getMetadata(FLAT_DIGEST_METADATA), + "path-state physical flat digest is missing or invalid: " + participant.getDbName()); + byte[] generation = requireDigest(store.getMetadata(STORE_GENERATION_METADATA), + "path-state physical Store generation is missing or invalid: " + + participant.getDbName()); + requireSame(generation, participantGeneration(participant, flatDigest, storeRoot), + "path-state physical Store generation differs: " + participant.getDbName()); + targets.add(new PathStatePhysicalGlobalIntent.ParticipantTarget(participant.getStoreId(), + generation, flatDigest, storeRoot)); + } + byte[] superRoot = requireDigest(superStore.getMetadata(FLAT_ROOT_METADATA), + "path-state physical super root is missing or invalid"); + requireRootNode(superStore, superRoot, + "path-state physical super root node differs"); + byte[] generation = requireDigest(superStore.getMetadata(SUPER_GENERATION_METADATA), + "path-state physical super generation is missing or invalid"); + requireSame(generation, superGeneration(targets, superRoot), + "path-state physical super generation differs"); + return new PathStatePhysicalGlobalIntent(manifest.getIdentityDigest(), metadata, targets, + generation, superRoot); + } + + private boolean matchesPreparedTarget(PathStatePhysicalGlobalIntent target) throws IOException { + try { + return Arrays.equals(target.encode(), publicationTarget(target.getMetadata()).encode()); + } catch (IllegalArgumentException invalidTarget) { + throw new IOException("path-state physical target metadata differs from prepared storage", + invalidTarget); + } + } + + private PathStateRootMetadata syntheticMetadata(byte[] stateRoot) { + byte[] zero = new byte[PathStateRootMetadata.DIGEST_LENGTH]; + return PathStateRootMetadata.base(0, zero, zero, 0, + PathStateCanonicalizer.P66Phase.P66_OFF, manifest.getIdentityDigest(), stateRoot, zero); + } + + private PathStateRootMetadata metadataWithRoot(PathStateRootMetadata previous, + byte[] stateRoot) { + if (previous.getKind() == PathStateRootMetadata.Kind.BASE) { + return PathStateRootMetadata.base(previous.getBlockNumber(), previous.getBlockHash(), + previous.getParentHash(), previous.getTimestamp(), previous.getPhase(), + manifest.getIdentityDigest(), stateRoot, previous.getPayloadDigest()); + } + return PathStateRootMetadata.layer(previous.getBlockNumber(), previous.getBlockHash(), + previous.getParentHash(), previous.getTimestamp(), previous.getPhase(), + manifest.getIdentityDigest(), previous.getParentStateRoot(), stateRoot, + previous.getPayloadDigest()); + } + + private static PathStatePhysicalGlobalIntent loadGlobalTarget(Path path) throws IOException { + try { + return PathStatePhysicalGlobalIntent.decode(PathStateMetadataFile.loadImmutableBytes(path, + PathStatePhysicalGlobalIntent.MAX_ENCODED_LENGTH)); + } catch (IllegalArgumentException invalid) { + throw new IOException("path-state physical global target is corrupt: " + path, invalid); + } + } + + private byte[] participantGeneration(PathStateParticipant participant, byte[] flatDigest, + byte[] storeRoot) { + return Hashing.sha256().newHasher() + .putString("java-tron/path-state/participant-generation/v1", StandardCharsets.US_ASCII) + .putBytes(manifest.getIdentityDigest()).putInt(participant.getStoreId()) + .putBytes(flatDigest).putBytes(storeRoot).hash().asBytes(); + } + + private byte[] superGeneration( + List targets, byte[] superRoot) { + Hasher hasher = Hashing.sha256().newHasher() + .putString("java-tron/path-state/super-generation/v1", StandardCharsets.US_ASCII) + .putBytes(manifest.getIdentityDigest()).putInt(targets.size()); + for (PathStatePhysicalGlobalIntent.ParticipantTarget target : targets) { + hasher.putInt(target.getStoreId()).putBytes(target.getGeneration()) + .putBytes(target.getFlatDigest()).putBytes(target.getStoreRoot()); + } + return hasher.putBytes(superRoot).hash().asBytes(); + } + + private static byte[] requireDigest(byte[] value, String error) throws IOException { + if (value == null || value.length != PathStatePhysicalGlobalIntent.DIGEST_LENGTH) { + throw new IOException(error); + } + return Arrays.copyOf(value, value.length); + } + + private static void requireSame(byte[] expected, byte[] actual, String error) + throws IOException { + if (!Arrays.equals(expected, actual)) { + throw new IOException(error); + } + } + + private static void requireRootNode(PhysicalStore store, byte[] expectedRoot, String error) + throws IOException { + byte[] encodedRoot = store.nodeStore().get(new byte[0]); + if (Arrays.equals(expectedRoot, Hash.EMPTY_TRIE_HASH)) { + if (encodedRoot != null) { + throw new IOException(error); + } + } else if (encodedRoot == null || !Arrays.equals(expectedRoot, Hash.sha3(encodedRoot))) { + throw new IOException(error); + } + } + + @Override + public synchronized void close() throws IOException { + if (closed) { + return; + } + closed = true; + participantWriteExecutor.shutdownNow(); + participantPrepareExecutor.shutdownNow(); + trieBranchExecutor.shutdownNow(); + IOException failure = null; + for (PhysicalStore store : participants.values()) { + try { + store.close(); + } catch (IOException closeFailure) { + failure = append(failure, closeFailure); + } + } + try { + superStore.close(); + } catch (IOException closeFailure) { + failure = append(failure, closeFailure); + } + if (failure != null) { + throw failure; + } + } + + private void closeAfterFailure(Throwable original) { + participantWriteExecutor.shutdownNow(); + participantPrepareExecutor.shutdownNow(); + trieBranchExecutor.shutdownNow(); + for (PhysicalStore store : participants.values()) { + try { + store.close(); + } catch (IOException closeFailure) { + original.addSuppressed(closeFailure); + } + } + } + + private static void rejectLegacySharedNodes(Path root) throws IOException { + Path oldNodes = root.resolve(PathStateStoreManifest.BASE_DIRECTORY).resolve(NODES_DIRECTORY); + if (Files.exists(oldNodes, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("TASK-018 physical layout rejects legacy shared base/nodes: " + + oldNodes); + } + } + + private static void requireStoreDirectory(Path path) throws IOException { + if (!Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("path-state physical Store directory is missing: " + path); + } + } + + private void requireOpen() { + if (closed) { + throw new IllegalStateException("path-state physical store set is closed: " + directory); + } + } + + private PathStateParticipant participant(int storeId) { + for (PathStateParticipant participant : scope.getParticipants()) { + if (participant.getStoreId() == storeId) { + return participant; + } + } + throw new IllegalArgumentException("unknown path-state Store ID: " + storeId); + } + + private static IOException append(IOException previous, IOException next) { + if (previous == null) { + return next; + } + previous.addSuppressed(next); + return previous; + } + + private static PathStateParticipantScope requireExactScope(PathStateParticipantScope scope) { + PathStateParticipantScope supplied = Objects.requireNonNull(scope, "scope"); + List dbNames = new ArrayList<>(); + for (PathStateParticipant participant : supplied.getParticipants()) { + dbNames.add(participant.getDbName()); + } + PathStateParticipantDescriptor descriptor = PathStateParticipantDescriptor.current(); + descriptor.requireExactDatabases(dbNames); + for (PathStateParticipant participant : supplied.getParticipants()) { + if (descriptor.require(participant.getDbName()).getStoreId() != participant.getStoreId()) { + throw new IllegalArgumentException("path-state physical Store ID differs: " + + participant.getDbName()); + } + } + return supplied; + } + + private static byte[] unprefixedFlatKey(byte[] storedKey) { + byte[] key = Arrays.copyOf(Objects.requireNonNull(storedKey, "storedKey"), storedKey.length); + if (key.length != PathStateCommitmentCodec.ROOT_LENGTH + 1 + || key[0] != FLAT_STATE_PREFIX) { + throw new IllegalStateException("path-state physical F key is corrupt"); + } + return Arrays.copyOfRange(key, 1, key.length); + } + + private static long elapsedMillis(long startedNanos) { + return java.util.concurrent.TimeUnit.NANOSECONDS.toMillis( + System.nanoTime() - startedNanos); + } + + private static long elapsedMillis(long startedNanos, long completedNanos) { + return java.util.concurrent.TimeUnit.NANOSECONDS.toMillis(completedNanos - startedNanos); + } + + private static long rowsPerSecond(long rows, long startedNanos) { + long elapsedNanos = Math.max(1L, System.nanoTime() - startedNanos); + return (long) (rows * 1_000_000_000D / elapsedNanos); + } + + /** One participant or super database with independent F/N/M key domains. */ + public static final class PhysicalStore implements Closeable { + + private final PathStateNativeNodeStore nativeStore; + private final ResidentNodeStore nodeStore; + + private PhysicalStore(Path directory, Engine engine, int storeId, + ResidentNodeCache residentNodeCache, String storageProfile, NativeDbConfig dbSettings) + throws IOException { + nativeStore = PathStateNativeNodeStore.open(directory, engine, storageProfile, dbSettings); + nodeStore = new ResidentNodeStore(new PhysicalNodeStore(nativeStore), residentNodeCache, + storeId); + } + + public void putFlat(byte[] secureKey, byte[] encodedLeaf) { + nativeStore.put(prefixed(FLAT_STATE_PREFIX, secureKey, "secureKey"), encodedLeaf); + } + + private void writeBatch(List mutations) { + nativeStore.writeBatch(new ArrayList<>(mutations)); + } + + public byte[] getFlat(byte[] secureKey) { + return nativeStore.get(prefixed(FLAT_STATE_PREFIX, secureKey, "secureKey")); + } + + public void deleteFlat(byte[] secureKey) { + nativeStore.delete(prefixed(FLAT_STATE_PREFIX, secureKey, "secureKey")); + } + + void scanFlat(PathStateNativeNodeStore.EntryConsumer consumer) throws IOException { + nativeStore.scanPrefix(new byte[]{FLAT_STATE_PREFIX}, + Objects.requireNonNull(consumer, "consumer")); + } + + public PathNodeStore nodeStore() { + return nodeStore; + } + + private PhysicalNodeBatchWriter nodeBatchWriter() { + return new PhysicalNodeBatchWriter(nativeStore); + } + + private void completeFlatBuild(byte[] flatDigest, byte[] generation, byte[] storeRoot) { + List mutations = new ArrayList<>(3); + mutations.add(metadataMutation(FLAT_DIGEST_METADATA, flatDigest)); + mutations.add(metadataMutation(STORE_GENERATION_METADATA, generation)); + mutations.add(metadataMutation(FLAT_COMPLETE_METADATA, storeRoot)); + nativeStore.writeBatch(mutations); + } + + long getWriteBatchCalls() { + return nativeStore.getWriteBatchCalls(); + } + + long getWriteBatchMutations() { + return nativeStore.getWriteBatchMutations(); + } + + long getSyncedWriteBatchCalls() { + return nativeStore.getSyncedWriteBatchCalls(); + } + + long getUnsyncedWriteBatchCalls() { + return nativeStore.getUnsyncedWriteBatchCalls(); + } + + void clearNodes() throws IOException { + nodeStore.clear(); + List pending = new ArrayList<>(4096); + nativeStore.scanPrefix(new byte[]{TRIE_NODE_PREFIX}, entry -> { + pending.add(PathStateNativeNodeStore.BatchMutation.delete(entry.getKey())); + if (pending.size() == 4096) { + nativeStore.writeBatch(new ArrayList<>(pending)); + pending.clear(); + } + }); + if (!pending.isEmpty()) { + nativeStore.writeBatch(pending); + } + } + + void applyParticipantDelete(byte[] secureKey, List nodeMutations, + byte[] flatDigest, byte[] generation, byte[] storeRoot) { + List mutations = new ArrayList<>(); + mutations.add(PathStateNativeNodeStore.BatchMutation.delete( + prefixed(FLAT_STATE_PREFIX, secureKey, "secureKey"))); + appendNodeMutations(mutations, nodeMutations); + mutations.add(metadataMutation(FLAT_DIGEST_METADATA, flatDigest)); + mutations.add(metadataMutation(STORE_GENERATION_METADATA, generation)); + mutations.add(metadataMutation(FLAT_COMPLETE_METADATA, storeRoot)); + nativeStore.writeBatchUnsynced(mutations); + nodeStore.apply(nodeMutations); + } + + void applyParticipantTransition(List flatMutations, + List nodeMutations, byte[] flatDigest, byte[] generation, + byte[] storeRoot) { + List mutations = new ArrayList<>(); + for (FlatMutation mutation : Objects.requireNonNull(flatMutations, "flatMutations")) { + byte[] key = prefixed(FLAT_STATE_PREFIX, mutation.secureKey, "secureKey"); + mutations.add(mutation.encodedValue == null + ? PathStateNativeNodeStore.BatchMutation.delete(key) + : PathStateNativeNodeStore.BatchMutation.put(key, mutation.encodedValue)); + } + appendNodeMutations(mutations, nodeMutations); + mutations.add(metadataMutation(FLAT_DIGEST_METADATA, flatDigest)); + mutations.add(metadataMutation(STORE_GENERATION_METADATA, generation)); + mutations.add(metadataMutation(FLAT_COMPLETE_METADATA, storeRoot)); + nativeStore.writeBatchUnsynced(mutations); + nodeStore.apply(nodeMutations); + } + + void applySuperTransition(List nodeMutations, byte[] generation, + byte[] superRoot) { + List mutations = new ArrayList<>(); + appendNodeMutations(mutations, nodeMutations); + mutations.add(metadataMutation(SUPER_GENERATION_METADATA, generation)); + mutations.add(metadataMutation(FLAT_ROOT_METADATA, superRoot)); + nativeStore.writeBatchUnsynced(mutations); + nodeStore.apply(nodeMutations); + } + + void applyCheckpointParticipant(CommonCheckpointPayload.PathStoreTarget target, + byte[] marker) { + List mutations = new ArrayList<>(); + for (CommonCheckpointPayload.Mutation mutation : target.getFlatMutations()) { + byte[] key = prefixed(FLAT_STATE_PREFIX, mutation.getKey(), "secureKey"); + mutations.add(mutation.isDelete() + ? PathStateNativeNodeStore.BatchMutation.delete(key) + : PathStateNativeNodeStore.BatchMutation.put(key, mutation.getValue())); + } + List cacheMutations = new ArrayList<>(); + for (CommonCheckpointPayload.Mutation mutation : target.getNodeMutations()) { + byte[] path = mutation.getKey(); + byte[] value = mutation.getValue(); + mutations.add(mutation.isDelete() + ? PathStateNativeNodeStore.BatchMutation.delete( + prefixed(TRIE_NODE_PREFIX, path, "path")) + : PathStateNativeNodeStore.BatchMutation.put( + prefixed(TRIE_NODE_PREFIX, path, "path"), + value)); + cacheMutations.add(new NodeMutation(path, value)); + } + mutations.add(metadataMutation(CHECKPOINT_TARGET_METADATA, marker)); + nativeStore.writeBatch(mutations); + nodeStore.apply(cacheMutations); + } + + void applyCheckpointSuper(List supplied, byte[] marker) { + List mutations = new ArrayList<>(); + List cacheMutations = new ArrayList<>(); + for (CommonCheckpointPayload.Mutation mutation : supplied) { + byte[] path = mutation.getKey(); + byte[] value = mutation.getValue(); + mutations.add(mutation.isDelete() + ? PathStateNativeNodeStore.BatchMutation.delete( + prefixed(TRIE_NODE_PREFIX, path, "path")) + : PathStateNativeNodeStore.BatchMutation.put( + prefixed(TRIE_NODE_PREFIX, path, "path"), + value)); + cacheMutations.add(new NodeMutation(path, value)); + } + mutations.add(metadataMutation(CHECKPOINT_TARGET_METADATA, marker)); + nativeStore.writeBatch(mutations); + nodeStore.apply(cacheMutations); + } + + byte[] checkpointTargetMarker() { + return getMetadata(CHECKPOINT_TARGET_METADATA); + } + + private static void appendNodeMutations( + List target, + List nodeMutations) { + for (NodeMutation mutation : Objects.requireNonNull(nodeMutations, "nodeMutations")) { + byte[] key = prefixed(TRIE_NODE_PREFIX, mutation.path, "path"); + target.add(mutation.encodedNode == null + ? PathStateNativeNodeStore.BatchMutation.delete(key) + : PathStateNativeNodeStore.BatchMutation.put(key, mutation.encodedNode)); + } + } + + private static PathStateNativeNodeStore.BatchMutation metadataMutation(byte[] name, + byte[] value) { + return PathStateNativeNodeStore.BatchMutation.put(prefixed(METADATA_PREFIX, name, + "metadata name"), value); + } + + public void putMetadata(byte[] name, byte[] value) { + nativeStore.put(prefixed(METADATA_PREFIX, name, "metadata name"), value); + } + + public byte[] getMetadata(byte[] name) { + return nativeStore.get(prefixed(METADATA_PREFIX, name, "metadata name")); + } + + void deleteMetadata(byte[] name) { + nativeStore.delete(prefixed(METADATA_PREFIX, name, "metadata name")); + } + + public Path getDirectory() { + return nativeStore.getDirectory(); + } + + @Override + public void close() throws IOException { + nodeStore.clear(); + nativeStore.close(); + } + } + + private static final class PhysicalNodeStore implements PathNodeStore { + + private final PathStateNativeNodeStore nativeStore; + + private PhysicalNodeStore(PathStateNativeNodeStore nativeStore) { + this.nativeStore = nativeStore; + } + + @Override + public byte[] get(byte[] path) { + return nativeStore.get(prefixed(TRIE_NODE_PREFIX, path, "path")); + } + + @Override + public void put(byte[] path, byte[] encodedNode) { + nativeStore.put(prefixed(TRIE_NODE_PREFIX, path, "path"), encodedNode); + } + + @Override + public void delete(byte[] path) { + nativeStore.delete(prefixed(TRIE_NODE_PREFIX, path, "path")); + } + } + + static final class ResidentNodeStore implements PathNodeStore { + + private final PathNodeStore base; + private final ResidentNodeCache cache; + private final int storeId; + private final AtomicLong hits = new AtomicLong(); + private final AtomicLong cleanHits = new AtomicLong(); + private final AtomicLong updatedHits = new AtomicLong(); + private final AtomicLong nativeReads = new AtomicLong(); + + ResidentNodeStore(PathNodeStore base, ResidentNodeCache cache, int storeId) { + this.base = Objects.requireNonNull(base, "base"); + this.cache = Objects.requireNonNull(cache, "cache"); + this.storeId = storeId; + } + + @Override + public byte[] get(byte[] path) { + ResidentNodeCache.Lookup lookup = cache.get(storeId, path); + if (lookup.found) { + hits.incrementAndGet(); + if (lookup.updated) { + updatedHits.incrementAndGet(); + } else { + cleanHits.incrementAndGet(); + } + return owned(lookup.value); + } + byte[] value = base.get(path); + nativeReads.incrementAndGet(); + cache.put(storeId, path, value, false); + return owned(value); + } + + @Override + public void put(byte[] path, byte[] encodedNode) { + base.put(path, encodedNode); + cache.put(storeId, path, Objects.requireNonNull(encodedNode, "encodedNode"), true); + } + + @Override + public void delete(byte[] path) { + base.delete(path); + cache.put(storeId, path, null, true); + } + + void apply(List mutations) { + for (NodeMutation mutation : Objects.requireNonNull(mutations, "mutations")) { + cache.put(storeId, mutation.path, mutation.encodedNode, true); + } + } + + void clear() { + cache.clear(storeId); + } + + long getHits() { + return hits.get(); + } + + long getCleanHits() { + return cleanHits.get(); + } + + long getUpdatedHits() { + return updatedHits.get(); + } + + long getNativeReads() { + return nativeReads.get(); + } + + private static byte[] owned(byte[] value) { + return value == null ? null : Arrays.copyOf(value, value.length); + } + } + + static final class ResidentNodeCache { + + private static final long ENTRY_OVERHEAD_BYTES = 64; + private final long maxBytes; + private final Map values = + new LinkedHashMap<>(1024, 0.75f, true); + private long bytes; + private long evictions; + + ResidentNodeCache(long maxBytes) { + if (maxBytes <= 0) { + throw new IllegalArgumentException("resident node cache bytes must be positive"); + } + this.maxBytes = maxBytes; + } + + synchronized Lookup get(int storeId, byte[] path) { + ResidentNodeCacheValue value = values.get(new ResidentNodeCacheKey(storeId, path)); + return value == null ? Lookup.missing() : Lookup.found(value.value, value.updated); + } + + synchronized void put(int storeId, byte[] path, byte[] value, boolean updated) { + ResidentNodeCacheKey key = new ResidentNodeCacheKey(storeId, path); + ResidentNodeCacheValue replacement = new ResidentNodeCacheValue(value, updated, + ENTRY_OVERHEAD_BYTES + Integer.BYTES + key.path.length + + (value == null ? 0 : value.length)); + ResidentNodeCacheValue previous = values.put(key, replacement); + if (previous != null) { + bytes -= previous.weight; + } + bytes += replacement.weight; + java.util.Iterator> iterator = + values.entrySet().iterator(); + while (bytes > maxBytes && iterator.hasNext()) { + Map.Entry eldest = iterator.next(); + bytes -= eldest.getValue().weight; + iterator.remove(); + evictions++; + } + } + + synchronized void clear(int storeId) { + java.util.Iterator> iterator = + values.entrySet().iterator(); + while (iterator.hasNext()) { + Map.Entry entry = iterator.next(); + if (entry.getKey().storeId == storeId) { + bytes -= entry.getValue().weight; + iterator.remove(); + } + } + } + + synchronized long bytes() { + return bytes; + } + + synchronized int size() { + return values.size(); + } + + synchronized long evictions() { + return evictions; + } + + static final class Lookup { + + private final boolean found; + private final byte[] value; + private final boolean updated; + + private Lookup(boolean found, byte[] value, boolean updated) { + this.found = found; + this.value = value == null ? null : Arrays.copyOf(value, value.length); + this.updated = updated; + } + + private static Lookup missing() { + return new Lookup(false, null, false); + } + + private static Lookup found(byte[] value, boolean updated) { + return new Lookup(true, value, updated); + } + } + } + + private static final class ResidentNodeCacheKey { + + private final int storeId; + private final byte[] path; + + private ResidentNodeCacheKey(int storeId, byte[] path) { + this.storeId = storeId; + this.path = Arrays.copyOf(Objects.requireNonNull(path, "path"), path.length); + } + + @Override + public boolean equals(Object other) { + return this == other || other instanceof ResidentNodeCacheKey + && storeId == ((ResidentNodeCacheKey) other).storeId + && Arrays.equals(path, ((ResidentNodeCacheKey) other).path); + } + + @Override + public int hashCode() { + return 31 * storeId + Arrays.hashCode(path); + } + } + + private static final class ResidentNodeCacheValue { + + private final byte[] value; + private final boolean updated; + private final long weight; + + private ResidentNodeCacheValue(byte[] value, boolean updated, long weight) { + this.value = value == null ? null : Arrays.copyOf(value, value.length); + this.updated = updated; + this.weight = weight; + } + } + + private static final class PhysicalNodeBatchWriter implements PathNodeStore { + + private final PathStateNativeNodeStore nativeStore; + private final List pending = + new ArrayList<>(BOOTSTRAP_WRITE_BATCH_ENTRIES); + private long pendingBytes; + + private PhysicalNodeBatchWriter(PathStateNativeNodeStore nativeStore) { + this.nativeStore = nativeStore; + } + + @Override + public byte[] get(byte[] path) { + flush(); + return nativeStore.get(prefixed(TRIE_NODE_PREFIX, path, "path")); + } + + @Override + public void put(byte[] path, byte[] encodedNode) { + byte[] key = prefixed(TRIE_NODE_PREFIX, path, "path"); + byte[] value = Arrays.copyOf(Objects.requireNonNull(encodedNode, "encodedNode"), + encodedNode.length); + flushBeforeOversizedMutation(key.length + value.length); + pending.add(PathStateNativeNodeStore.BatchMutation.put(key, value)); + pendingBytes = Math.addExact(pendingBytes, key.length + value.length); + flushIfFull(); + } + + @Override + public void delete(byte[] path) { + byte[] key = prefixed(TRIE_NODE_PREFIX, path, "path"); + flushBeforeOversizedMutation(key.length); + pending.add(PathStateNativeNodeStore.BatchMutation.delete(key)); + pendingBytes = Math.addExact(pendingBytes, key.length); + flushIfFull(); + } + + private void flushIfFull() { + if (pending.size() >= BOOTSTRAP_WRITE_BATCH_ENTRIES + || pendingBytes >= BOOTSTRAP_WRITE_BATCH_BYTES) { + flush(); + } + } + + private void flushBeforeOversizedMutation(long mutationBytes) { + if (!pending.isEmpty() && pendingBytes + mutationBytes > BOOTSTRAP_WRITE_BATCH_BYTES) { + flush(); + } + } + + private void flush() { + if (!pending.isEmpty()) { + nativeStore.writeBatch(new ArrayList<>(pending)); + pending.clear(); + pendingBytes = 0; + } + } + } + + static final class RecordingNodeStore implements PathNodeStore { + + private static final byte[] ABSENT = new byte[0]; + private final PathNodeStore base; + private final Map changes = new LinkedHashMap<>(); + private final Map baseReads = new ConcurrentHashMap<>(); + private final long initialResidentReadHits; + private final long initialResidentCleanHits; + private final long initialResidentUpdatedHits; + private final long initialNativeReads; + private final AtomicLong baseReadMisses = new AtomicLong(); + private final AtomicLong baseReadHits = new AtomicLong(); + + RecordingNodeStore(PathNodeStore base) { + this.base = Objects.requireNonNull(base, "base"); + ResidentNodeStore resident = base instanceof ResidentNodeStore + ? (ResidentNodeStore) base : null; + initialResidentReadHits = resident == null ? 0 : resident.getHits(); + initialResidentCleanHits = resident == null ? 0 : resident.getCleanHits(); + initialResidentUpdatedHits = resident == null ? 0 : resident.getUpdatedHits(); + initialNativeReads = resident == null ? 0 : resident.getNativeReads(); + } + + @Override + public byte[] get(byte[] path) { + BytesKey key = new BytesKey(path); + if (changes.containsKey(key)) { + byte[] value = changes.get(key); + return value == null ? null : Arrays.copyOf(value, value.length); + } + return baseValue(key); + } + + @Override + public void put(byte[] path, byte[] encodedNode) { + changes.put(new BytesKey(path), Arrays.copyOf( + Objects.requireNonNull(encodedNode, "encodedNode"), encodedNode.length)); + } + + @Override + public void delete(byte[] path) { + changes.put(new BytesKey(path), null); + } + + private boolean isEmpty() { + return changes.isEmpty(); + } + + private List mutations() { + List mutations = new ArrayList<>(); + for (Map.Entry change : changes.entrySet()) { + mutations.add(new NodeMutation(change.getKey().bytes, change.getValue())); + } + return mutations; + } + + private List reverseEntries() { + List entries = new ArrayList<>(); + for (BytesKey key : changes.keySet()) { + entries.add(new PathStatePhysicalReverseJournal.Entry(key.bytes, baseValue(key))); + } + return entries; + } + + private byte[] baseValue(BytesKey key) { + byte[] value = baseReads.get(key); + if (value == null) { + byte[] loaded = base.get(key.bytes); + baseReadMisses.incrementAndGet(); + byte[] owned = loaded == null ? ABSENT : Arrays.copyOf(loaded, loaded.length); + byte[] raced = baseReads.putIfAbsent(key, owned); + value = raced == null ? owned : raced; + } else { + baseReadHits.incrementAndGet(); + } + return value == ABSENT ? null : Arrays.copyOf(value, value.length); + } + + long getBaseReadMisses() { + return baseReadMisses.get(); + } + + long getBaseReadHits() { + return baseReadHits.get(); + } + + long getResidentReadHits() { + return base instanceof ResidentNodeStore + ? ((ResidentNodeStore) base).getHits() - initialResidentReadHits : 0; + } + + long getResidentCleanHits() { + return base instanceof ResidentNodeStore + ? ((ResidentNodeStore) base).getCleanHits() - initialResidentCleanHits : 0; + } + + long getResidentUpdatedHits() { + return base instanceof ResidentNodeStore + ? ((ResidentNodeStore) base).getUpdatedHits() - initialResidentUpdatedHits : 0; + } + + long getNativeReads() { + return base instanceof ResidentNodeStore + ? ((ResidentNodeStore) base).getNativeReads() - initialNativeReads + : baseReadMisses.get(); + } + + private int putCount() { + int count = 0; + for (byte[] value : changes.values()) { + if (value != null) { + count++; + } + } + return count; + } + + private int deleteCount() { + return changes.size() - putCount(); + } + } + + private static final class NodeMutation { + + private final byte[] path; + private final byte[] encodedNode; + + private NodeMutation(byte[] path, byte[] encodedNode) { + this.path = Arrays.copyOf(Objects.requireNonNull(path, "path"), path.length); + this.encodedNode = encodedNode == null ? null + : Arrays.copyOf(encodedNode, encodedNode.length); + } + } + + private static final class FlatMutation { + + private final byte[] secureKey; + private final byte[] encodedValue; + private final boolean previousValueKnown; + private final byte[] previousEncodedValue; + + private FlatMutation(byte[] secureKey, byte[] encodedValue) { + this(secureKey, encodedValue, false, null); + } + + private FlatMutation(byte[] secureKey, byte[] encodedValue, boolean previousValueKnown, + byte[] previousEncodedValue) { + this.secureKey = Arrays.copyOf(Objects.requireNonNull(secureKey, "secureKey"), + secureKey.length); + this.encodedValue = encodedValue == null ? null + : Arrays.copyOf(encodedValue, encodedValue.length); + this.previousValueKnown = previousValueKnown; + this.previousEncodedValue = previousEncodedValue == null ? null + : Arrays.copyOf(previousEncodedValue, previousEncodedValue.length); + } + } + + private static final class ParticipantTransition { + + private final PathStateParticipant participant; + private final PhysicalStore store; + private final List flatMutations; + private final List nodeMutations; + private final byte[] flatDigest; + private final byte[] generation; + private final byte[] storeRoot; + + private ParticipantTransition(PathStateParticipant participant, PhysicalStore store, + List flatMutations, List nodeMutations, byte[] flatDigest, + byte[] generation, byte[] storeRoot) { + this.participant = participant; + this.store = store; + this.flatMutations = flatMutations; + this.nodeMutations = nodeMutations; + this.flatDigest = flatDigest; + this.generation = generation; + this.storeRoot = storeRoot; + } + } + + private static final class TransitionPlan { + + private final PathStateBlockTransition transition; + private final byte[] parentTarget; + private final PathStatePhysicalGlobalIntent target; + private final List participants; + private final PhysicalStore superStore; + private final List superNodeMutations; + private final PathStatePhysicalReverseJournal journal; + private final long nodeReadMisses; + private final long nodeReadHits; + private final long residentNodeHits; + private final long residentCleanHits; + private final long residentUpdatedHits; + private final long nativeNodeReads; + private final long initialResidentEvictions; + private final long nodeDecodes; + private final long nodeHashVerifies; + private final long hashReferencesCreated; + private final long hashReferencesResolved; + private final long flatReverseReads; + private final long flatReverseReused; + private final PathStateRoot.Snapshot trieSnapshot; + private final boolean reusedTrieSnapshot; + + private TransitionPlan(PathStateBlockTransition transition, byte[] parentTarget, + PathStatePhysicalGlobalIntent target, + List participants, PhysicalStore superStore, + List superNodeMutations, PathStatePhysicalReverseJournal journal, + long nodeReadMisses, long nodeReadHits, long residentNodeHits, + long residentCleanHits, long residentUpdatedHits, long nativeNodeReads, + long initialResidentEvictions, long nodeDecodes, long nodeHashVerifies, + long hashReferencesCreated, long hashReferencesResolved, + long flatReverseReads, long flatReverseReused, + PathStateRoot.Snapshot trieSnapshot, boolean reusedTrieSnapshot) { + this.transition = transition; + this.parentTarget = Arrays.copyOf(parentTarget, parentTarget.length); + this.target = target; + this.participants = participants; + this.superStore = superStore; + this.superNodeMutations = superNodeMutations; + this.journal = journal; + this.nodeReadMisses = nodeReadMisses; + this.nodeReadHits = nodeReadHits; + this.residentNodeHits = residentNodeHits; + this.residentCleanHits = residentCleanHits; + this.residentUpdatedHits = residentUpdatedHits; + this.nativeNodeReads = nativeNodeReads; + this.initialResidentEvictions = initialResidentEvictions; + this.nodeDecodes = nodeDecodes; + this.nodeHashVerifies = nodeHashVerifies; + this.hashReferencesCreated = hashReferencesCreated; + this.hashReferencesResolved = hashReferencesResolved; + this.flatReverseReads = flatReverseReads; + this.flatReverseReused = flatReverseReused; + this.trieSnapshot = Objects.requireNonNull(trieSnapshot, "trieSnapshot"); + this.reusedTrieSnapshot = reusedTrieSnapshot; + } + } + + static final class PreparedPhysicalTransition { + + private final PathStateBlockTransition transition; + private final TransitionPlan plan; + private final PathStateSnapshotDelta snapshotDelta; + + private PreparedPhysicalTransition(PathStateBlockTransition transition, + TransitionPlan plan, PathStateSnapshotDelta snapshotDelta) { + this.transition = transition; + this.plan = plan; + this.snapshotDelta = snapshotDelta; + } + + PathStateSnapshotDelta getSnapshotDelta() { + return snapshotDelta; + } + + boolean reusedTrieSnapshot() { + return plan.reusedTrieSnapshot; + } + + boolean matches(PathStateBlockTransition supplied) { + return transition == supplied; + } + } + + private static final class BytesKey { + + private final byte[] bytes; + + private BytesKey(byte[] bytes) { + this.bytes = Arrays.copyOf(Objects.requireNonNull(bytes, "bytes"), bytes.length); + } + + @Override + public boolean equals(Object other) { + return this == other || other instanceof BytesKey + && Arrays.equals(bytes, ((BytesKey) other).bytes); + } + + @Override + public int hashCode() { + return Arrays.hashCode(bytes); + } + } + + private static final class ReverseJournalIndexEntry { + + private final Path path; + private final long length; + private final byte[] childTarget; + private final byte[] parentTarget; + + private ReverseJournalIndexEntry(Path path, long length, byte[] childTarget, + byte[] parentTarget) { + this.path = Objects.requireNonNull(path, "path").toAbsolutePath().normalize(); + if (length <= 0) { + throw new IllegalArgumentException("reverse journal length must be positive"); + } + this.length = length; + this.childTarget = Arrays.copyOf(Objects.requireNonNull(childTarget, "childTarget"), + childTarget.length); + this.parentTarget = Arrays.copyOf(Objects.requireNonNull(parentTarget, "parentTarget"), + parentTarget.length); + } + } + + @FunctionalInterface + interface BuildFaultHook { + + void beforeCompletion(PathStateParticipant participant, byte[] storeRoot) throws IOException; + } + + enum PublicationStage { + AFTER_INTENT, + AFTER_CURRENT, + AFTER_RETIRE + } + + public enum PublicationRecovery { + NONE, + RETAINED_CURRENT, + COMPLETED_INTENT + } + + enum DeleteStage { + AFTER_PARTICIPANT_BATCH, + AFTER_SUPER_BATCH, + AFTER_CURRENT + } + + enum TransitionStage { + AFTER_JOURNAL, + AFTER_INTENT, + AFTER_PARTICIPANT_BATCH, + AFTER_SUPER_BATCH, + AFTER_CURRENT, + AFTER_RETIRE + } + + @FunctionalInterface + interface TransitionFaultHook { + + void after(TransitionStage stage) throws IOException; + } + + enum RewindStage { + AFTER_INTENT, + AFTER_PARTICIPANT_BATCH, + AFTER_SUPER_BATCH, + AFTER_CURRENT, + AFTER_RETIRE + } + + @FunctionalInterface + interface RewindFaultHook { + + void after(RewindStage stage) throws IOException; + } + + static final class PhysicalDeleteResult { + + private final byte[] stateRoot; + private final int participantNodePuts; + private final int participantNodeDeletes; + private final int superNodePuts; + private final int superNodeDeletes; + + private PhysicalDeleteResult(byte[] stateRoot, int participantNodePuts, + int participantNodeDeletes, int superNodePuts, int superNodeDeletes) { + this.stateRoot = Arrays.copyOf(stateRoot, stateRoot.length); + this.participantNodePuts = participantNodePuts; + this.participantNodeDeletes = participantNodeDeletes; + this.superNodePuts = superNodePuts; + this.superNodeDeletes = superNodeDeletes; + } + + byte[] getStateRoot() { + return Arrays.copyOf(stateRoot, stateRoot.length); + } + + int getParticipantNodePuts() { + return participantNodePuts; + } + + int getParticipantNodeDeletes() { + return participantNodeDeletes; + } + + int getSuperNodePuts() { + return superNodePuts; + } + + int getSuperNodeDeletes() { + return superNodeDeletes; + } + } + + @FunctionalInterface + interface PublicationFaultHook { + + void after(PublicationStage stage) throws IOException; + } + + @FunctionalInterface + interface DeleteFaultHook { + + void after(DeleteStage stage) throws IOException; + } + + private static byte[] prefixed(byte prefix, byte[] suffix, String name) { + byte[] supplied = Arrays.copyOf(Objects.requireNonNull(suffix, name), suffix.length); + byte[] key = new byte[supplied.length + 1]; + key[0] = prefix; + System.arraycopy(supplied, 0, key, 1, supplied.length); + return key; + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateRebuildCheckpoint.java b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateRebuildCheckpoint.java new file mode 100644 index 00000000000..1a8a4fec9ee --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateRebuildCheckpoint.java @@ -0,0 +1,221 @@ +package org.tron.core.db2.stateroot; + +import com.google.common.hash.Hashing; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import org.tron.core.db2.stateroot.PathStateCanonicalizer.P66Phase; +import org.tron.core.db2.stateroot.PathStateRebuildCoordinator.SnapshotIdentity; +import org.tron.core.db2.stateroot.PathStateRebuildCoordinator.StoreResult; + +/** Durable, non-authoritative per-Store rebuild checkpoint stored inside the BASE native DB. */ +final class PathStateRebuildCheckpoint { + + private static final int MAGIC = 0x50535243; // PSRC + private static final short LEGACY_VERSION = 1; + private static final short VERSION = 2; + private static final int MAX_LENGTH = 64 * 1024; + + private final byte[] manifestDigest; + private final byte[] sourceIdentityDigest; + private final SnapshotIdentity identity; + private final List completedStores; + private final byte[] partialRoot; + private final boolean independentStores; + + PathStateRebuildCheckpoint(byte[] manifestDigest, byte[] sourceIdentityDigest, + SnapshotIdentity identity, List completedStores, byte[] partialRoot) { + this(manifestDigest, sourceIdentityDigest, identity, completedStores, partialRoot, true); + } + + PathStateRebuildCheckpoint(byte[] manifestDigest, byte[] sourceIdentityDigest, + SnapshotIdentity identity, List completedStores) { + this(manifestDigest, sourceIdentityDigest, identity, completedStores, + new byte[PathStateRootMetadata.DIGEST_LENGTH], true); + } + + private PathStateRebuildCheckpoint(byte[] manifestDigest, byte[] sourceIdentityDigest, + SnapshotIdentity identity, List completedStores, byte[] partialRoot, + boolean independentStores) { + this.manifestDigest = copy32(manifestDigest, "manifestDigest"); + this.sourceIdentityDigest = copy32(sourceIdentityDigest, "sourceIdentityDigest"); + this.identity = Objects.requireNonNull(identity, "identity"); + this.completedStores = validateStores(completedStores); + this.partialRoot = copy32(partialRoot, "partialRoot"); + this.independentStores = independentStores; + } + + byte[] getManifestDigest() { + return Arrays.copyOf(manifestDigest, manifestDigest.length); + } + + byte[] getSourceIdentityDigest() { + return Arrays.copyOf(sourceIdentityDigest, sourceIdentityDigest.length); + } + + SnapshotIdentity getIdentity() { + return identity; + } + + List getCompletedStores() { + return completedStores; + } + + byte[] getPartialRoot() { + return Arrays.copyOf(partialRoot, partialRoot.length); + } + + boolean hasIndependentStores() { + return independentStores; + } + + byte[] encode() { + try { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + DataOutputStream output = new DataOutputStream(bytes); + output.writeInt(MAGIC); + output.writeShort(VERSION); + output.writeShort(0); + output.writeInt(0); + output.write(manifestDigest); + output.write(sourceIdentityDigest); + output.writeLong(identity.getBlockNumber()); + output.write(identity.getBlockHash()); + output.write(identity.getParentHash()); + output.writeLong(identity.getTimestamp()); + output.writeByte(identity.getPhase().ordinal()); + output.writeByte(completedStores.size()); + for (StoreResult store : completedStores) { + output.writeInt(store.getStoreId()); + writeString(output, store.getDbName()); + output.writeLong(store.getEntryCount()); + output.write(store.getInputDigest()); + output.write(store.getStoreRoot()); + } + output.write(partialRoot); + output.flush(); + byte[] payload = bytes.toByteArray(); + ByteBuffer.wrap(payload).putInt(8, payload.length + Integer.BYTES); + bytes.reset(); + output = new DataOutputStream(bytes); + output.write(payload); + output.writeInt(Hashing.crc32c().hashBytes(payload).asInt()); + output.flush(); + return bytes.toByteArray(); + } catch (IOException impossible) { + throw new IllegalStateException("in-memory rebuild checkpoint encoding failed", impossible); + } + } + + static PathStateRebuildCheckpoint decode(byte[] encoded) throws IOException { + byte[] bytes = Arrays.copyOf(Objects.requireNonNull(encoded, "encoded"), encoded.length); + if (bytes.length <= Integer.BYTES || bytes.length > MAX_LENGTH) { + throw new IOException("path-state rebuild checkpoint length is invalid"); + } + byte[] payload = Arrays.copyOf(bytes, bytes.length - Integer.BYTES); + int checksum = ByteBuffer.wrap(bytes, payload.length, Integer.BYTES).getInt(); + if (checksum != Hashing.crc32c().hashBytes(payload).asInt()) { + throw new IOException("path-state rebuild checkpoint checksum mismatch"); + } + try (DataInputStream input = new DataInputStream(new ByteArrayInputStream(bytes))) { + if (input.readInt() != MAGIC) { + throw new IOException("unsupported path-state rebuild checkpoint header"); + } + short version = input.readShort(); + if ((version != LEGACY_VERSION && version != VERSION) || input.readShort() != 0 + || input.readInt() != bytes.length) { + throw new IOException("unsupported path-state rebuild checkpoint header"); + } + byte[] manifestDigest = read32(input); + byte[] sourceIdentityDigest = read32(input); + long blockNumber = input.readLong(); + byte[] blockHash = read32(input); + byte[] parentHash = read32(input); + long timestamp = input.readLong(); + int phaseTag = input.readUnsignedByte(); + P66Phase[] phases = P66Phase.values(); + if (phaseTag >= phases.length) { + throw new IOException("path-state rebuild checkpoint phase is invalid"); + } + SnapshotIdentity identity = new SnapshotIdentity(blockNumber, blockHash, parentHash, + timestamp, phases[phaseTag]); + int completed = input.readUnsignedByte(); + List stores = new ArrayList<>(completed); + for (int index = 0; index < completed; index++) { + stores.add(StoreResult.restore(input.readInt(), readString(input), input.readLong(), + read32(input), read32(input))); + } + byte[] partialRoot = read32(input); + if (input.available() != Integer.BYTES) { + throw new IOException("path-state rebuild checkpoint payload mismatch"); + } + return new PathStateRebuildCheckpoint(manifestDigest, sourceIdentityDigest, identity, stores, + partialRoot, version == VERSION); + } catch (IllegalArgumentException invalid) { + throw new IOException("path-state rebuild checkpoint is invalid", invalid); + } + } + + private static List validateStores(List stores) { + List supplied = new ArrayList<>(Objects.requireNonNull(stores, "stores")); + List expected = + PathStateParticipantDescriptor.current().getStores(); + if (supplied.size() > expected.size() || supplied.contains(null)) { + throw new IllegalArgumentException("rebuild checkpoint Store count is invalid"); + } + int previousStoreId = 0; + for (StoreResult actual : supplied) { + if (actual.getStoreId() <= previousStoreId + || actual.getStoreId() > expected.size()) { + throw new IllegalArgumentException("rebuild checkpoint Store order is invalid"); + } + PathStateParticipantDescriptor.StoreIdentity participant = + expected.get(actual.getStoreId() - 1); + if (actual.getStoreId() != participant.getStoreId() + || !actual.getDbName().equals(participant.getDbName())) { + throw new IllegalArgumentException("rebuild checkpoint Store order is invalid"); + } + previousStoreId = actual.getStoreId(); + } + return Collections.unmodifiableList(supplied); + } + + private static byte[] copy32(byte[] value, String name) { + byte[] copy = Arrays.copyOf(Objects.requireNonNull(value, name), value.length); + if (copy.length != PathStateRootMetadata.DIGEST_LENGTH) { + throw new IllegalArgumentException(name + " must be exactly 32 bytes"); + } + return copy; + } + + private static byte[] read32(DataInputStream input) throws IOException { + byte[] value = new byte[PathStateRootMetadata.DIGEST_LENGTH]; + input.readFully(value); + return value; + } + + private static void writeString(DataOutputStream output, String value) throws IOException { + byte[] encoded = Objects.requireNonNull(value, "value").getBytes(StandardCharsets.UTF_8); + output.writeShort(encoded.length); + output.write(encoded); + } + + private static String readString(DataInputStream input) throws IOException { + int length = input.readUnsignedShort(); + if (length == 0 || length > 1024 || length > input.available() - Integer.BYTES) { + throw new IOException("path-state rebuild checkpoint string is invalid"); + } + byte[] value = new byte[length]; + input.readFully(value); + return new String(value, StandardCharsets.UTF_8); + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateRebuildCoordinator.java b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateRebuildCoordinator.java new file mode 100644 index 00000000000..d7280f66117 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateRebuildCoordinator.java @@ -0,0 +1,850 @@ +package org.tron.core.db2.stateroot; + +import com.google.common.hash.Hasher; +import com.google.common.hash.Hashing; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.TreeMap; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Stream; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.tron.core.capsule.utils.MarketUtils; +import org.tron.core.db2.stateroot.PathStateCanonicalizer.P66Phase; +import org.tron.core.db2.stateroot.PathStateParticipantDescriptor.StoreIdentity; + +/** Builds and atomically publishes the first current path-state root from one admitted snapshot. */ +public final class PathStateRebuildCoordinator { + + private static final Logger logger = LoggerFactory.getLogger("DB"); + private static final String STORE_DIGEST_DOMAIN = "path-state-rebuild-store/v1"; + private static final String SOURCE_DIGEST_DOMAIN = "path-state-rebuild-source/v1"; + private static final long PROGRESS_LOG_INTERVAL_NANOS = TimeUnit.SECONDS.toNanos(30); + private static final long PROGRESS_LOG_ROW_MASK = 1023L; + private static final int LARGE_STORE_WORKERS = 1; + private static final int SMALL_STORE_WORKERS = 1; + private static final Set LARGE_STORES = Collections.unmodifiableSet( + new LinkedHashSet<>(Arrays.asList( + "account", "account-asset", "delegation", "storage-row"))); + + private final PathStateParticipantDescriptor descriptor; + private final PathStateCanonicalizer canonicalizer; + private final FaultHook faultHook; + + public PathStateRebuildCoordinator() { + this(store -> { }); + } + + PathStateRebuildCoordinator(FaultHook faultHook) { + descriptor = PathStateParticipantDescriptor.current(); + canonicalizer = new PathStateCanonicalizer(); + this.faultHook = Objects.requireNonNull(faultHook, "faultHook"); + } + + /** + * Consumes every exact-27 Store from one caller-owned native snapshot and publishes BASE(P0). + * + *

The source must keep all Store snapshots pinned until {@link SnapshotSource#verifyIdentity} + * returns. Each Store scan must use the comparator declared by the manifest and supply strictly + * increasing physical keys. The coordinator does not expose a reusable Store iterator. + */ + public RebuildResult rebuild(PathStateStoreManifest manifest, SnapshotSource source) + throws IOException { + return rebuildInternal(manifest, source, null, PathStateLayerLimits.defaults()); + } + + /** Publishes BASE(P0) through the catch-up handoff and drains every queued transition. */ + public RebuildResult rebuild(PathStateStoreManifest manifest, SnapshotSource source, + PathStateCatchUpQueue catchUpQueue, PathStateLayerLimits layerLimits) throws IOException { + return rebuildInternal(manifest, source, + Objects.requireNonNull(catchUpQueue, "catchUpQueue"), layerLimits); + } + + private RebuildResult rebuildInternal(PathStateStoreManifest manifest, SnapshotSource source, + PathStateCatchUpQueue catchUpQueue, PathStateLayerLimits layerLimits) throws IOException { + PathStateStoreManifest admittedManifest = Objects.requireNonNull(manifest, "manifest"); + SnapshotSource admittedSource = Objects.requireNonNull(source, "source"); + PathStateLayerLimits admittedLimits = Objects.requireNonNull(layerLimits, "layerLimits"); + SnapshotIdentity identity = Objects.requireNonNull(admittedSource.identity(), "identity"); + byte[] sourceIdentityDigest = SnapshotIdentity.copy32( + admittedSource.sourceIdentityDigest(), "sourceIdentityDigest"); + descriptor.requireExactDatabases(admittedSource.databases()); + admittedSource.verifyIdentity(identity); + + PathStateCurrentStore currentStore = new PathStateCurrentStore(admittedManifest); + if (currentStore.isInitialized()) { + throw new IOException("path-state rebuild requires an uninitialized current store"); + } + if (catchUpQueue != null) { + catchUpQueue.admitSnapshot(identity); + } + + RebuildResult rebuildResult; + try (PathStateNodeStoreSet stores = PathStateNodeStoreSet.openBase(admittedManifest)) { + PathStateRoot root = stores.createRoot(); + PathStateRebuildCheckpoint checkpoint = stores.getRebuildCheckpoint(); + Map completedStores = new TreeMap<>(); + if (checkpoint != null) { + root.restoreRebuildParticipants(checkpoint); + for (StoreResult completed : checkpoint.getCompletedStores()) { + completedStores.put(completed.getStoreId(), completed); + } + logger.info("Path-state rebuild checkpoint restored: completedStores={}, " + + "remainingStores={}, reuseScope=completed-store, " + + "inProgressStoreResume=from-beginning", + completedStores.size(), descriptor.getStores().size() - completedStores.size()); + } else { + logger.info("Path-state rebuild starting without checkpoint: stores={}, " + + "reuseScope=completed-store, inProgressStoreResume=from-beginning", + descriptor.getStores().size()); + } + if (checkpoint != null && !identity.sameAs(checkpoint.getIdentity())) { + throw new IOException("path-state rebuild checkpoint snapshot identity mismatch"); + } + if (checkpoint != null && !Arrays.equals(admittedManifest.getIdentityDigest(), + checkpoint.getManifestDigest())) { + throw new IOException("path-state rebuild checkpoint manifest identity mismatch"); + } + if (checkpoint != null && !Arrays.equals(sourceIdentityDigest, + checkpoint.getSourceIdentityDigest())) { + throw new IOException("path-state rebuild checkpoint source identity mismatch"); + } + retireCompletedSpools(admittedManifest, completedStores.values()); + try (StoreBuilders builders = new StoreBuilders(admittedManifest, sourceIdentityDigest, + stores)) { + buildStoresInParallel(admittedManifest, admittedSource, identity, sourceIdentityDigest, + root, stores, builders, completedStores); + } + List storeResults = new ArrayList<>(completedStores.values()); + long totalEntries = 0; + for (StoreResult completed : storeResults) { + totalEntries = Math.addExact(totalEntries, completed.getEntryCount()); + } + + admittedSource.verifyIdentity(identity); + byte[] stateRoot = root.rootHash(); + byte[] sourceDigest = sourceDigest(identity, sourceIdentityDigest, storeResults, stateRoot); + PathStateRootMetadata metadata = PathStateRootMetadata.base(identity.getBlockNumber(), + identity.getBlockHash(), identity.getParentHash(), identity.getTimestamp(), + identity.getPhase(), admittedManifest.getIdentityDigest(), stateRoot, sourceDigest); + PathStateBasePublication publication = new PathStateBasePublication(admittedManifest); + PathStateRootMetadata published = catchUpQueue == null + ? publication.publish(stores, metadata) + : catchUpQueue.publishBase(identity, metadata, + () -> publication.publish(stores, metadata)); + rebuildResult = new RebuildResult(published, storeResults, totalEntries, sourceDigest); + } catch (ArithmeticException overflow) { + throw new IOException("path-state rebuild entry count overflow", overflow); + } + if (catchUpQueue != null) { + catchUpQueue.drain(admittedManifest, admittedLimits); + } + return rebuildResult; + } + + private void buildStoresInParallel(PathStateStoreManifest manifest, SnapshotSource source, + SnapshotIdentity identity, byte[] sourceIdentityDigest, PathStateRoot root, + PathStateNodeStoreSet stores, StoreBuilders builders, + Map completedStores) throws IOException { + Object checkpointLock = new Object(); + ExecutorService largeExecutor = Executors.newFixedThreadPool(LARGE_STORE_WORKERS, + rebuildThreadFactory("large")); + ExecutorService smallExecutor = Executors.newFixedThreadPool(SMALL_STORE_WORKERS, + rebuildThreadFactory("small")); + List> futures = new ArrayList<>(); + AtomicReference abort = new AtomicReference<>(); + Future accountFuture = null; + StoreIdentity accountAsset = null; + try { + for (StoreIdentity store : descriptor.getStores()) { + if (completedStores.containsKey(store.getStoreId())) { + StoreResult completed = completedStores.get(store.getStoreId()); + logger.info("Path-state rebuild Store reused: storeId={}, dbName={}, entries={}", + store.getStoreId(), store.getDbName(), completed.getEntryCount()); + continue; + } + if ("account-asset".equals(store.getDbName())) { + accountAsset = store; + continue; + } + ExecutorService executor = LARGE_STORES.contains(store.getDbName()) + ? largeExecutor : smallExecutor; + Future future = submitStore(executor, null, store, manifest, source, identity, + sourceIdentityDigest, root, stores, builders, completedStores, checkpointLock, abort); + futures.add(future); + if ("account".equals(store.getDbName())) { + accountFuture = future; + } + } + if (accountAsset != null) { + futures.add(submitStore(largeExecutor, accountFuture, accountAsset, manifest, source, + identity, sourceIdentityDigest, root, stores, builders, completedStores, + checkpointLock, abort)); + } + Throwable failure = null; + for (Future future : futures) { + try { + future.get(); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + failure = appendFailure(failure, + new IOException("path-state rebuild interrupted", interrupted)); + cancelOutstanding(futures); + break; + } catch (ExecutionException failed) { + Throwable firstFailure = abort.get(); + Throwable cause = firstFailure == null ? failed.getCause() : firstFailure; + failure = appendFailure(failure, cause instanceof IOException + || cause instanceof RuntimeException ? cause + : new IOException("path-state Store rebuild failed", cause)); + cancelOutstanding(futures); + break; + } + } + if (failure != null) { + if (failure instanceof IOException) { + throw (IOException) failure; + } + throw (RuntimeException) failure; + } + } finally { + largeExecutor.shutdownNow(); + smallExecutor.shutdownNow(); + } + } + + private Future submitStore(ExecutorService executor, Future dependency, + StoreIdentity store, PathStateStoreManifest manifest, SnapshotSource source, + SnapshotIdentity identity, byte[] sourceIdentityDigest, PathStateRoot root, + PathStateNodeStoreSet stores, StoreBuilders builders, + Map completedStores, + Object checkpointLock, AtomicReference abort) { + return executor.submit(() -> { + try { + requireNotAborted(abort); + awaitDependency(dependency); + requireNotAborted(abort); + StoreAccumulator accumulator = new StoreAccumulator(store, identity.getPhase(), root, + builders, abort); + if (!"account-asset".equals(store.getDbName())) { + builders.reset(store.getDbName()); + } + if ("account".equals(store.getDbName())) { + builders.reset("account-asset"); + } + logger.info("Path-state rebuild Store started: storeId={}, dbName={}, tier={}", + store.getStoreId(), store.getDbName(), storeTier(store)); + source.scan(store.getDbName(), accumulator::accept); + StoreResult result = accumulator.finish(); + accumulator.logCompleted(); + if ("account".equals(store.getDbName())) { + root.participantRoot("account-asset"); + } + synchronized (checkpointLock) { + completedStores.put(result.getStoreId(), result); + PathStateRebuildCheckpoint next = new PathStateRebuildCheckpoint( + manifest.getIdentityDigest(), sourceIdentityDigest, identity, + new ArrayList<>(completedStores.values())); + stores.checkpointStreamedRebuild(next); + logger.info("Path-state rebuild Store checkpointed: storeId={}, dbName={}, entries={}, " + + "completedStores={}, remainingStores={}", + result.getStoreId(), result.getDbName(), result.getEntryCount(), + completedStores.size(), descriptor.getStores().size() - completedStores.size()); + } + builders.retire(store); + logger.info("Path-state rebuild Store spool retired: storeId={}, dbName={}", + result.getStoreId(), result.getDbName()); + faultHook.afterStore(result); + return null; + } catch (IOException | RuntimeException failure) { + abort.compareAndSet(null, failure); + logger.error("Path-state rebuild Store failed: storeId={}, dbName={}, tier={}", + store.getStoreId(), store.getDbName(), storeTier(store), failure); + throw failure; + } + }); + } + + private static void cancelOutstanding(List> futures) { + for (Future future : futures) { + if (!future.isDone()) { + future.cancel(true); + } + } + } + + private static void requireNotAborted(AtomicReference abort) throws IOException { + Throwable failure = abort.get(); + if (failure != null || Thread.currentThread().isInterrupted()) { + throw new IOException("path-state Store rebuild aborted after peer failure", failure); + } + } + + private static void awaitDependency(Future dependency) throws IOException { + if (dependency == null) { + return; + } + try { + dependency.get(); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IOException("path-state dependent Store rebuild interrupted", interrupted); + } catch (ExecutionException failed) { + throw new IOException("path-state dependent Store rebuild failed", failed.getCause()); + } + } + + private static ThreadFactory rebuildThreadFactory(String tier) { + AtomicInteger sequence = new AtomicInteger(); + return task -> { + Thread thread = new Thread(task, + "path-state-rebuild-" + tier + "-" + sequence.incrementAndGet()); + thread.setDaemon(true); + return thread; + }; + } + + private static String storeTier(StoreIdentity store) { + return LARGE_STORES.contains(store.getDbName()) ? "large" : "small"; + } + + private static Throwable appendFailure(Throwable previous, Throwable next) { + if (previous == null) { + return next; + } + previous.addSuppressed(next); + return previous; + } + + private byte[] sourceDigest(SnapshotIdentity identity, byte[] sourceIdentityDigest, + List stores, byte[] stateRoot) { + Hasher hasher = domainHasher(SOURCE_DIGEST_DOMAIN); + putLong(hasher, identity.getBlockNumber()); + putBytes(hasher, identity.getBlockHash()); + putBytes(hasher, identity.getParentHash()); + putLong(hasher, identity.getTimestamp()); + putInt(hasher, identity.getPhase().ordinal()); + putBytes(hasher, sourceIdentityDigest); + putInt(hasher, stores.size()); + for (StoreResult store : stores) { + putInt(hasher, store.getStoreId()); + putString(hasher, store.getDbName()); + putLong(hasher, store.getEntryCount()); + putBytes(hasher, store.getInputDigest()); + putBytes(hasher, store.getStoreRoot()); + } + putBytes(hasher, stateRoot); + return hasher.hash().asBytes(); + } + + private final class StoreAccumulator { + + private final StoreIdentity store; + private final P66Phase phase; + private final PathStateRoot root; + private final StoreBuilders builders; + private final AtomicReference abort; + private final Hasher inputDigest; + private final long startedNanos = System.nanoTime(); + private byte[] previousKey; + private long entryCount; + private long keyBytes; + private long valueBytes; + private long lastProgressLogNanos = startedNanos; + + private StoreAccumulator(StoreIdentity store, P66Phase phase, PathStateRoot root, + StoreBuilders builders, AtomicReference abort) { + this.store = store; + this.phase = phase; + this.root = root; + this.builders = builders; + this.abort = abort; + inputDigest = domainHasher(STORE_DIGEST_DOMAIN); + putInt(inputDigest, store.getStoreId()); + putString(inputDigest, store.getDbName()); + putString(inputDigest, store.getComparatorId()); + putString(inputDigest, canonicalizer.requireFormat(store.getDbName()).getCodecId()); + } + + private void accept(byte[] physicalKey, byte[] rawValue) throws IOException { + requireNotAborted(abort); + byte[] key = copy(physicalKey, "physicalKey"); + byte[] value = Arrays.copyOf(Objects.requireNonNull(rawValue, "rawValue"), + rawValue.length); + if (previousKey != null && compare(store, previousKey, key) >= 0) { + throw new IllegalArgumentException( + "path-state snapshot keys are not strictly increasing: " + store.getDbName()); + } + validateAccountLayout(key, value); + PathStateMutation mutation = canonicalizer.put(phase, store.getDbName(), key, value); + builders.put(mutation); + if ("account".equals(store.getDbName())) { + List projected = canonicalizer.projectSnapshotAccountAssets( + phase, key, value); + if (!projected.isEmpty()) { + for (PathStateMutation asset : projected) { + // A zero embedded balance projects to ABSENT; snapshot rebuilds spool PRESENT rows. + if (!asset.isDelete()) { + builders.put(asset); + } + } + } + } + putBytes(inputDigest, key); + putBytes(inputDigest, value); + previousKey = key; + entryCount = Math.addExact(entryCount, 1L); + keyBytes = Math.addExact(keyBytes, key.length); + valueBytes = Math.addExact(valueBytes, value.length); + logProgressIfDue(); + } + + private void validateAccountLayout(byte[] key, byte[] value) { + if ("account".equals(store.getDbName())) { + canonicalizer.requireSnapshotAccountLayout(phase, key, value); + } + } + + private StoreResult finish() throws IOException { + byte[] storeRoot = builders.build(store.getDbName()); + root.completeRebuildParticipant(store.getDbName(), storeRoot); + return new StoreResult(store.getStoreId(), store.getDbName(), entryCount, + inputDigest.hash().asBytes(), storeRoot); + } + + private void logProgressIfDue() { + if ((entryCount & PROGRESS_LOG_ROW_MASK) != 0) { + return; + } + long now = System.nanoTime(); + if (now - lastProgressLogNanos < PROGRESS_LOG_INTERVAL_NANOS) { + return; + } + lastProgressLogNanos = now; + logProgress("scanning", now); + } + + private void logCompleted() { + logProgress("completed", System.nanoTime()); + } + + private void logProgress(String status, long now) { + long elapsedMillis = TimeUnit.NANOSECONDS.toMillis(now - startedNanos); + long entriesPerSecond = elapsedMillis == 0 ? entryCount + : (long) (entryCount * 1000.0d / elapsedMillis); + logger.info("Path-state rebuild Store {}: storeId={}, dbName={}, tier={}, rows={}, " + + "keyBytes={}, valueBytes={}, elapsedMs={}, rowsPerSecond={}", + status, store.getStoreId(), store.getDbName(), storeTier(store), entryCount, + keyBytes, valueBytes, elapsedMillis, entriesPerSecond); + } + } + + private final class StoreBuilders implements AutoCloseable { + + private final PathStateStoreManifest manifest; + private final byte[] generation; + private final PathStateNodeStoreSet stores; + private final Map opened = new LinkedHashMap<>(); + + private StoreBuilders(PathStateStoreManifest manifest, byte[] generation, + PathStateNodeStoreSet stores) { + this.manifest = manifest; + this.generation = Arrays.copyOf(generation, generation.length); + this.stores = stores; + } + + private synchronized void put(PathStateMutation mutation) throws IOException { + PathStateMutation present = Objects.requireNonNull(mutation, "mutation"); + if (present.isDelete()) { + throw new IOException("snapshot rebuild cannot spool a delete mutation"); + } + StoreIdentity identity = descriptor.require(present.getDbName()); + byte[] secureKey = PathStateCommitmentCodec.storeLeafKey(identity.getStoreId(), + present.getCanonicalKey()); + byte[] encodedValue = PathStateCommitmentCodec.presentLeafValue( + present.getCanonicalValue()); + handle(identity).builder.put(secureKey, encodedValue); + } + + private synchronized byte[] build(String dbName) throws IOException { + BuilderHandle handle = handle(descriptor.require(dbName)); + byte[] root = handle.builder.build(); + handle.writer.close(); + return root; + } + + private synchronized void reset(String dbName) throws IOException { + StoreIdentity identity = descriptor.require(dbName); + BuilderHandle handle = opened.remove(identity.getDbName()); + if (handle != null) { + handle.builder.close(); + handle.writer.close(); + } + deleteSpoolDirectory(manifest, identity); + logger.info("Path-state rebuild incomplete Store spool reset: storeId={}, dbName={}", + identity.getStoreId(), identity.getDbName()); + } + + private synchronized void retire(StoreIdentity identity) throws IOException { + BuilderHandle handle = opened.remove(identity.getDbName()); + if (handle != null) { + handle.builder.close(); + handle.writer.close(); + } + deleteSpoolDirectory(manifest, identity); + } + + private BuilderHandle handle(StoreIdentity identity) throws IOException { + BuilderHandle existing = opened.get(identity.getDbName()); + if (existing != null) { + return existing; + } + Path spool = manifest.getBaseDirectory().resolve("rebuild-spool") + .resolve(Integer.toString(identity.getStoreId())); + PathStateNodeStoreSet.RebuildWriter writer = stores.openRebuildWriter( + identity.getStoreId()); + try { + PathStateStoreTrieBuilder builder = new PathStateStoreTrieBuilder(spool, + manifest.getEngine(), generation, writer::putNode, writer::putLeaf, + (sortedRows, elapsedMillis) -> logBuildProgress(identity, writer, sortedRows, + elapsedMillis)); + BuilderHandle created = new BuilderHandle(builder, writer); + opened.put(identity.getDbName(), created); + return created; + } catch (IOException | RuntimeException failure) { + writer.close(); + throw failure; + } + } + + private void logBuildProgress(StoreIdentity identity, + PathStateNodeStoreSet.RebuildWriter writer, long sortedRows, long elapsedMillis) { + long rowsPerSecond = elapsedMillis == 0 ? sortedRows + : (long) (sortedRows * 1000.0d / elapsedMillis); + logger.info("Path-state rebuild Store building: storeId={}, dbName={}, tier={}, " + + "sortedRows={}, nodeEntries={}, leafEntries={}, elapsedMs={}, rowsPerSecond={}", + identity.getStoreId(), identity.getDbName(), storeTier(identity), sortedRows, + writer.getNodeEntries(), writer.getLeafEntries(), elapsedMillis, rowsPerSecond); + } + + @Override + public synchronized void close() throws IOException { + IOException failure = null; + for (BuilderHandle handle : opened.values()) { + try { + handle.builder.close(); + handle.writer.close(); + } catch (IOException closeFailure) { + if (failure == null) { + failure = closeFailure; + } else { + failure.addSuppressed(closeFailure); + } + } + } + if (failure != null) { + throw failure; + } + } + } + + private void retireCompletedSpools(PathStateStoreManifest manifest, + Collection completedStores) throws IOException { + for (StoreResult completed : completedStores) { + StoreIdentity identity = descriptor.require(completed.getDbName()); + if (identity.getStoreId() != completed.getStoreId()) { + throw new IOException("path-state completed Store identity mismatch"); + } + deleteSpoolDirectory(manifest, identity); + logger.info("Path-state rebuild completed Store spool absent: storeId={}, dbName={}", + identity.getStoreId(), identity.getDbName()); + } + } + + private static void deleteSpoolDirectory(PathStateStoreManifest manifest, + StoreIdentity identity) throws IOException { + Path spoolRoot = manifest.getBaseDirectory().resolve("rebuild-spool"); + Path directory = spoolRoot.resolve(Integer.toString(identity.getStoreId())); + if (!Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) { + return; + } + if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS) + || Files.isSymbolicLink(directory)) { + throw new IOException("path-state rebuild spool is not a direct directory: " + directory); + } + List entries = new ArrayList<>(); + try (Stream paths = Files.walk(directory)) { + paths.forEach(entries::add); + } + for (Path entry : entries) { + if (Files.isSymbolicLink(entry)) { + throw new IOException("path-state rebuild spool contains a symbolic link: " + entry); + } + } + entries.sort(Comparator.reverseOrder()); + for (Path entry : entries) { + Files.deleteIfExists(entry); + } + PathStateMetadataFile.syncDirectory(spoolRoot); + } + + private static final class BuilderHandle { + + private final PathStateStoreTrieBuilder builder; + private final PathStateNodeStoreSet.RebuildWriter writer; + + private BuilderHandle(PathStateStoreTrieBuilder builder, + PathStateNodeStoreSet.RebuildWriter writer) { + this.builder = builder; + this.writer = writer; + } + } + + private static int compare(StoreIdentity store, byte[] left, byte[] right) { + if (PathStateParticipantDescriptor.MARKET_PRICE_COMPARATOR.equals( + store.getComparatorId())) { + return MarketUtils.comparePriceKey(left, right); + } + for (int index = 0; index < Math.min(left.length, right.length); index++) { + int compared = Integer.compare(left[index] & 0xff, right[index] & 0xff); + if (compared != 0) { + return compared; + } + } + return Integer.compare(left.length, right.length); + } + + private static Hasher domainHasher(String domain) { + Hasher hasher = Hashing.sha256().newHasher(); + putString(hasher, domain); + return hasher; + } + + private static void putString(Hasher hasher, String value) { + putBytes(hasher, value.getBytes(StandardCharsets.UTF_8)); + } + + private static void putBytes(Hasher hasher, byte[] value) { + byte[] bytes = Objects.requireNonNull(value, "value"); + putInt(hasher, bytes.length); + hasher.putBytes(bytes); + } + + private static void putInt(Hasher hasher, int value) { + hasher.putBytes(ByteBuffer.allocate(Integer.BYTES).putInt(value).array()); + } + + private static void putLong(Hasher hasher, long value) { + hasher.putBytes(ByteBuffer.allocate(Long.BYTES).putLong(value).array()); + } + + private static byte[] copy(byte[] value, String name) { + return Arrays.copyOf(Objects.requireNonNull(value, name), value.length); + } + + /** Caller-owned exact-27 native snapshot boundary. */ + public interface SnapshotSource { + + SnapshotIdentity identity(); + + Collection databases(); + + /** Stable identity of the exact physical Store generations held by this snapshot. */ + byte[] sourceIdentityDigest(); + + void scan(String dbName, EntryConsumer consumer) throws IOException; + + /** Scans strictly after one physical cursor; unsupported comparators must fail closed. */ + default void scanAfter(String dbName, byte[] exclusivePhysicalCursor, EntryConsumer consumer) + throws IOException { + throw new IOException("path-state snapshot source does not support resumable scan: " + dbName); + } + + void verifyIdentity(SnapshotIdentity expected) throws IOException; + } + + /** One physical PRESENT row read from a pinned Store snapshot. */ + @FunctionalInterface + public interface EntryConsumer { + + void accept(byte[] physicalKey, byte[] rawValue) throws IOException; + } + + @FunctionalInterface + interface FaultHook { + + void afterStore(StoreResult store) throws IOException; + } + + /** Immutable canonical block boundary shared by every Store snapshot in one rebuild. */ + public static final class SnapshotIdentity { + + private final long blockNumber; + private final byte[] blockHash; + private final byte[] parentHash; + private final long timestamp; + private final P66Phase phase; + + public SnapshotIdentity(long blockNumber, byte[] blockHash, byte[] parentHash, + long timestamp, P66Phase phase) { + if (blockNumber < 0) { + throw new IllegalArgumentException("blockNumber must not be negative"); + } + this.blockNumber = blockNumber; + this.blockHash = copy32(blockHash, "blockHash"); + this.parentHash = copy32(parentHash, "parentHash"); + this.timestamp = timestamp; + this.phase = Objects.requireNonNull(phase, "phase"); + } + + public long getBlockNumber() { + return blockNumber; + } + + public byte[] getBlockHash() { + return Arrays.copyOf(blockHash, blockHash.length); + } + + public byte[] getParentHash() { + return Arrays.copyOf(parentHash, parentHash.length); + } + + public long getTimestamp() { + return timestamp; + } + + public P66Phase getPhase() { + return phase; + } + + public boolean sameAs(SnapshotIdentity other) { + return other != null && blockNumber == other.blockNumber && timestamp == other.timestamp + && phase == other.phase && Arrays.equals(blockHash, other.blockHash) + && Arrays.equals(parentHash, other.parentHash); + } + + private static byte[] copy32(byte[] value, String name) { + byte[] copy = Arrays.copyOf(Objects.requireNonNull(value, name), value.length); + if (copy.length != PathStateRootMetadata.DIGEST_LENGTH) { + throw new IllegalArgumentException(name + " must be exactly 32 bytes"); + } + return copy; + } + } + + /** Immutable per-Store evidence emitted after one complete snapshot scan. */ + public static final class StoreResult { + + private final int storeId; + private final String dbName; + private final long entryCount; + private final byte[] inputDigest; + private final byte[] storeRoot; + + private StoreResult(int storeId, String dbName, long entryCount, byte[] inputDigest, + byte[] storeRoot) { + if (storeId <= 0 || entryCount < 0) { + throw new IllegalArgumentException("rebuild Store result identity is invalid"); + } + this.storeId = storeId; + this.dbName = Objects.requireNonNull(dbName, "dbName"); + this.entryCount = entryCount; + this.inputDigest = SnapshotIdentity.copy32(inputDigest, "inputDigest"); + this.storeRoot = SnapshotIdentity.copy32(storeRoot, "storeRoot"); + } + + static StoreResult restore(int storeId, String dbName, long entryCount, byte[] inputDigest, + byte[] storeRoot) { + return new StoreResult(storeId, dbName, entryCount, inputDigest, storeRoot); + } + + public int getStoreId() { + return storeId; + } + + public String getDbName() { + return dbName; + } + + public long getEntryCount() { + return entryCount; + } + + public byte[] getInputDigest() { + return Arrays.copyOf(inputDigest, inputDigest.length); + } + + public byte[] getStoreRoot() { + return Arrays.copyOf(storeRoot, storeRoot.length); + } + } + + /** Immutable publication result for BASE(P0). */ + public static final class RebuildResult { + + private final PathStateRootMetadata metadata; + private final List stores; + private final long totalEntries; + private final byte[] sourceDigest; + private final Map storesByName; + + private RebuildResult(PathStateRootMetadata metadata, List stores, + long totalEntries, byte[] sourceDigest) { + this.metadata = metadata; + this.stores = Collections.unmodifiableList(new ArrayList<>(stores)); + this.totalEntries = totalEntries; + this.sourceDigest = Arrays.copyOf(sourceDigest, sourceDigest.length); + LinkedHashMap indexed = new LinkedHashMap<>(); + for (StoreResult store : stores) { + indexed.put(store.getDbName(), store); + } + storesByName = Collections.unmodifiableMap(indexed); + } + + public PathStateRootMetadata getMetadata() { + return metadata; + } + + public List getStores() { + return stores; + } + + public StoreResult requireStore(String dbName) { + StoreResult result = storesByName.get(Objects.requireNonNull(dbName, "dbName")); + if (result == null) { + throw new IllegalArgumentException("unknown rebuild Store: " + dbName); + } + return result; + } + + public long getTotalEntries() { + return totalEntries; + } + + public byte[] getSourceDigest() { + return Arrays.copyOf(sourceDigest, sourceDigest.length); + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateRoot.java b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateRoot.java new file mode 100644 index 00000000000..e2880c1b403 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateRoot.java @@ -0,0 +1,848 @@ +package org.tron.core.db2.stateroot; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.IdentityHashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +/** + * Current-only per-Store trie and super-trie aggregator for TASK-016. + * + *

Every participant, including an empty Store, has one super-trie leaf. Consequently the root + * commits to the supplied participant exact-set as well as each Store's current entries. This + * component has no block, database, history, restart, or publication lifecycle. + */ +public final class PathStateRoot { + + private static final Comparator MUTATION_COMPARATOR = (left, right) -> { + int participantOrder = Integer.compare(left.participant.getStoreId(), + right.participant.getStoreId()); + return participantOrder != 0 ? participantOrder : compareUnsigned(left.secureKey, + right.secureKey); + }; + + private final PathStateParticipantScope scope; + private final Map participantTries = new LinkedHashMap<>(); + private final Map rebuiltParticipantRoots = new LinkedHashMap<>(); + private final Map> + pendingLeafMutations = new LinkedHashMap<>(); + private final PathMerkleTrie superTrie; + private volatile boolean rootMaterialized; + + public PathStateRoot(PathStateParticipantScope scope, PathNodeStoreFactory storeFactory, + PathNodeStore superNodeStore) { + this(scope, storeFactory, superNodeStore, null); + } + + private PathStateRoot(PathStateParticipantScope scope, PathNodeStoreFactory storeFactory, + PathNodeStore superNodeStore, Snapshot snapshot) { + this.scope = Objects.requireNonNull(scope, "scope"); + PathNodeStoreFactory factory = Objects.requireNonNull(storeFactory, "storeFactory"); + Set uniqueStores = Collections.newSetFromMap(new IdentityHashMap<>()); + for (PathStateParticipant participant : scope.getParticipants()) { + PathNodeStore nodeStore = Objects.requireNonNull(factory.open(participant), + "participant node store"); + if (!uniqueStores.add(nodeStore)) { + throw new IllegalArgumentException("participant node Stores must have distinct identities"); + } + PathMerkleTrie.Snapshot trieSnapshot = snapshot == null ? null + : snapshot.participants.get(participant.getDbName()); + if (snapshot != null && trieSnapshot == null) { + throw new IllegalArgumentException("path-state snapshot participant scope mismatch"); + } + participantTries.put(participant.getDbName(), snapshot == null + ? new PathMerkleTrie(nodeStore) : PathMerkleTrie.fromSnapshot(nodeStore, trieSnapshot)); + pendingLeafMutations.put(participant.getStoreId(), new LinkedHashMap<>()); + } + PathNodeStore rootStore = Objects.requireNonNull(superNodeStore, "superNodeStore"); + if (!uniqueStores.add(rootStore)) { + throw new IllegalArgumentException("super node Store must have a distinct identity"); + } + superTrie = snapshot == null ? new PathMerkleTrie(rootStore) + : PathMerkleTrie.fromSnapshot(rootStore, snapshot.superTrie); + if (snapshot != null) { + if (snapshot.participants.size() != scope.getParticipants().size() + || !Arrays.equals(superTrie.rootHash(), snapshot.stateRoot)) { + throw new IllegalArgumentException("path-state snapshot root or scope mismatch"); + } + rootMaterialized = true; + } + } + + public synchronized void put(String dbName, byte[] canonicalKey, byte[] canonicalValue) { + apply(Collections.singletonList(PathStateMutation.put(dbName, canonicalKey, canonicalValue))); + } + + public synchronized void delete(String dbName, byte[] canonicalKey) { + apply(Collections.singletonList(PathStateMutation.delete(dbName, canonicalKey))); + } + + /** Validates a complete mutation set before changing any participant trie. */ + public synchronized void apply(Collection mutations) { + List prepared = prepare(mutations); + for (PreparedMutation mutation : prepared) { + PathMerkleTrie trie = participantTries.get(mutation.participant.getDbName()); + if (mutation.encodedValue == null) { + trie.delete(mutation.secureKey); + } else { + trie.put(mutation.secureKey, mutation.encodedValue); + } + } + recordPendingLeafMutations(prepared); + rootMaterialized = false; + } + + synchronized ParallelApplyStats applyParallel(Collection mutations, + ExecutorService participantExecutor, ExecutorService branchExecutor) { + List prepared = prepare(mutations); + Map> grouped = new LinkedHashMap<>(); + for (PreparedMutation mutation : prepared) { + grouped.computeIfAbsent(mutation.participant.getStoreId(), ignored -> new ArrayList<>()) + .add(mutation); + } + List work = new ArrayList<>(grouped.size()); + for (List participantMutations : grouped.values()) { + work.add(new ParticipantWork(participantMutations)); + } + work.sort((left, right) -> { + int compared = Integer.compare(right.mutations.size(), left.mutations.size()); + return compared != 0 ? compared + : Integer.compare(left.participant.getStoreId(), right.participant.getStoreId()); + }); + List> futures = new ArrayList<>(work.size()); + long startedNanos = System.nanoTime(); + for (ParticipantWork participantWork : work) { + futures.add(Objects.requireNonNull(participantExecutor, "participantExecutor").submit(() -> { + long participantStartedNanos = System.nanoTime(); + List batch = + new ArrayList<>(participantWork.mutations.size()); + for (PreparedMutation mutation : participantWork.mutations) { + batch.add(new PathMerkleTrie.BatchMutation(mutation.secureKey, mutation.encodedValue, + mutation.previousValueKnown, mutation.previousEncodedValue)); + } + PathMerkleTrie participantTrie = participantTries.get( + participantWork.participant.getDbName()); + participantTrie.applyBatch(batch, + Objects.requireNonNull(branchExecutor, "branchExecutor"), + usesDeferredNodeEncoding(participantWork.participant)); + participantTrie.rootHash(); + participantWork.elapsedNanos = System.nanoTime() - participantStartedNanos; + })); + } + try { + for (Future future : futures) { + future.get(); + } + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + cancel(futures); + throw new IllegalStateException("parallel path-state prepare interrupted", interrupted); + } catch (ExecutionException failed) { + cancel(futures); + Throwable cause = failed.getCause(); + if (cause instanceof RuntimeException) { + throw (RuntimeException) cause; + } + throw new IllegalStateException("parallel path-state prepare failed", cause); + } + recordPendingLeafMutations(prepared); + rootMaterialized = false; + long totalWorkNanos = 0; + int maxMutations = 0; + int authoritativePreviousValues = 0; + long maxElapsedNanos = 0; + int maxElapsedStoreId = 0; + for (ParticipantWork participantWork : work) { + totalWorkNanos += participantWork.elapsedNanos; + maxMutations = Math.max(maxMutations, participantWork.mutations.size()); + if (participantWork.elapsedNanos > maxElapsedNanos) { + maxElapsedNanos = participantWork.elapsedNanos; + maxElapsedStoreId = participantWork.participant.getStoreId(); + } + } + for (PreparedMutation mutation : prepared) { + if (mutation.previousValueKnown) { + authoritativePreviousValues++; + } + } + return new ParallelApplyStats(work.size(), prepared.size(), authoritativePreviousValues, + maxMutations, + TimeUnit.NANOSECONDS.toMillis(maxElapsedNanos), maxElapsedStoreId, + TimeUnit.NANOSECONDS.toMillis(totalWorkNanos), + TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedNanos)); + } + + private static void cancel(List> futures) { + for (Future future : futures) { + if (!future.isDone()) { + future.cancel(true); + } + } + } + + static final class ParallelApplyStats { + + private final int participantCount; + private final int mutationCount; + private final int authoritativePreviousValues; + private final int maxParticipantMutations; + private final long maxParticipantMillis; + private final int maxParticipantStoreId; + private final long participantWorkMillis; + private final long wallMillis; + + private ParallelApplyStats(int participantCount, int mutationCount, + int authoritativePreviousValues, + int maxParticipantMutations, long maxParticipantMillis, int maxParticipantStoreId, + long participantWorkMillis, long wallMillis) { + this.participantCount = participantCount; + this.mutationCount = mutationCount; + this.authoritativePreviousValues = authoritativePreviousValues; + this.maxParticipantMutations = maxParticipantMutations; + this.maxParticipantMillis = maxParticipantMillis; + this.maxParticipantStoreId = maxParticipantStoreId; + this.participantWorkMillis = participantWorkMillis; + this.wallMillis = wallMillis; + } + + int participantCount() { + return participantCount; + } + + int mutationCount() { + return mutationCount; + } + + int authoritativePreviousValues() { + return authoritativePreviousValues; + } + + int maxParticipantMutations() { + return maxParticipantMutations; + } + + long maxParticipantMillis() { + return maxParticipantMillis; + } + + int maxParticipantStoreId() { + return maxParticipantStoreId; + } + + long participantWorkMillis() { + return participantWorkMillis; + } + + long wallMillis() { + return wallMillis; + } + } + + private static final class ParticipantWork { + + private final PathStateParticipant participant; + private final List mutations; + private long elapsedNanos; + + private ParticipantWork(List mutations) { + this.mutations = mutations; + participant = mutations.get(0).participant; + } + } + + static boolean usesDeferredNodeEncoding(PathStateParticipant participant) { + String dbName = participant.getDbName(); + return "account".equals(dbName) || "account-asset".equals(dbName); + } + + /** Applies one rebuild batch while locking only the participant tries touched by that batch. */ + void applyRebuild(Collection mutations) { + List prepared = prepare(mutations); + for (PreparedMutation mutation : prepared) { + PathMerkleTrie trie = participantTries.get(mutation.participant.getDbName()); + if (mutation.encodedValue == null) { + trie.delete(mutation.secureKey); + } else { + trie.put(mutation.secureKey, mutation.encodedValue); + } + } + recordPendingLeafMutations(prepared); + rootMaterialized = false; + } + + /** Records a fully streamed participant root without retaining that Store's leaves or tree. */ + void completeRebuildParticipant(String dbName, byte[] storeRoot) { + PathStateParticipant participant = scope.require(dbName); + byte[] root = Arrays.copyOf(Objects.requireNonNull(storeRoot, "storeRoot"), + storeRoot.length); + if (root.length != PathStateCommitmentCodec.ROOT_LENGTH) { + throw new IllegalArgumentException("Store root must contain exactly 32 bytes"); + } + synchronized (rebuiltParticipantRoots) { + byte[] previous = rebuiltParticipantRoots.put(participant.getDbName(), root); + if (previous != null && !Arrays.equals(previous, root)) { + throw new IllegalStateException("path-state rebuild Store root changed"); + } + } + rootMaterialized = false; + } + + void restoreRebuildParticipants(PathStateRebuildCheckpoint checkpoint) { + for (PathStateRebuildCoordinator.StoreResult result + : Objects.requireNonNull(checkpoint, "checkpoint").getCompletedStores()) { + completeRebuildParticipant(result.getDbName(), result.getStoreRoot()); + } + } + + synchronized void restoreStoredRoots(byte[] expectedRoot) { + Map participantRoots = new LinkedHashMap<>(); + for (PathStateParticipant participant : scope.getParticipants()) { + participantRoots.put(participant.getDbName(), + participantTries.get(participant.getDbName()).restoreRoot()); + } + superTrie.restoreRoot(expectedRoot); + if (!Arrays.equals(superTrie.rootHash(), expectedRoot)) { + throw new IllegalStateException("restored path-state root differs from durable progress"); + } + for (PathStateParticipant participant : scope.getParticipants()) { + byte[] expectedLeaf = PathStateCommitmentCodec.superLeafValue(participant.getStoreId(), + participant.getDbName(), participant.getStoreFormatVersion(), + participantRoots.get(participant.getDbName())); + byte[] actualLeaf = superTrie.get( + PathStateCommitmentCodec.superLeafKey(participant.getStoreId())); + if (!Arrays.equals(expectedLeaf, actualLeaf)) { + throw new IllegalStateException( + "restored path-state participant root is not bound by the super trie"); + } + } + rootMaterialized = true; + } + + /** Replays one immutable secure-key delta without requiring the original raw Store keys. */ + synchronized void replaySnapshotDelta(PathStateSnapshotDelta delta) { + PathStateSnapshotDelta admitted = Objects.requireNonNull(delta, "delta"); + if (!Arrays.equals(rootHash(), admitted.getParentStateRoot())) { + throw new IllegalArgumentException("path-state replay parent root mismatch"); + } + Set changed = new LinkedHashSet<>(); + for (PathStateSnapshotDelta.StoreDelta store : admitted.getStores()) { + PathStateParticipant participant = scope.require(store.getDbName()); + if (participant.getStoreId() != store.getStoreId() || !changed.add(store.getStoreId())) { + throw new IllegalArgumentException("path-state replay participant identity mismatch"); + } + PathMerkleTrie trie = participantTries.get(participant.getDbName()); + for (PathStateSnapshotDelta.Mutation mutation : store.getFlatMutations()) { + if (mutation.isDelete()) { + trie.delete(mutation.getKey()); + } else { + trie.put(mutation.getKey(), mutation.getValue()); + } + } + if (!Arrays.equals(trie.rootHash(), store.getStoreRoot())) { + throw new IllegalArgumentException("path-state replay Store root mismatch: " + + store.getDbName()); + } + } + rootMaterialized = false; + if (!Arrays.equals(rootHash(), admitted.getStateRoot())) { + throw new IllegalArgumentException("path-state replay state root mismatch"); + } + } + + synchronized void recordPendingLeafMutations(Collection mutations) { + recordPendingLeafMutations(prepare(mutations)); + } + + private void recordPendingLeafMutations(List prepared) { + for (PreparedMutation mutation : prepared) { + MutationKey key = new MutationKey(mutation.participant.getStoreId(), mutation.secureKey); + Map participantMutations = + pendingLeafMutations.get(mutation.participant.getStoreId()); + synchronized (participantMutations) { + participantMutations.put(key, mutation.participant); + } + } + } + + public synchronized byte[] participantRoot(String dbName) { + PathStateParticipant participant = scope.require(dbName); + synchronized (rebuiltParticipantRoots) { + byte[] rebuilt = rebuiltParticipantRoots.get(participant.getDbName()); + if (rebuilt != null) { + return Arrays.copyOf(rebuilt, rebuilt.length); + } + } + return participantTries.get(participant.getDbName()).rootHash(); + } + + synchronized long nodeDecodeCount() { + long count = superTrie.getNodeDecodeCount(); + for (PathMerkleTrie trie : participantTries.values()) { + count += trie.getNodeDecodeCount(); + } + return count; + } + + synchronized long nodeHashVerifyCount() { + long count = superTrie.getNodeHashVerifyCount(); + for (PathMerkleTrie trie : participantTries.values()) { + count += trie.getNodeHashVerifyCount(); + } + return count; + } + + synchronized long hashReferenceCreateCount() { + long count = superTrie.getHashReferenceCreateCount(); + for (PathMerkleTrie trie : participantTries.values()) { + count += trie.getHashReferenceCreateCount(); + } + return count; + } + + synchronized long hashReferenceResolveCount() { + long count = superTrie.getHashReferenceResolveCount(); + for (PathMerkleTrie trie : participantTries.values()) { + count += trie.getHashReferenceResolveCount(); + } + return count; + } + + synchronized long nodeCommitPlanNanos() { + long nanos = superTrie.getLastCommitPlanNanos(); + for (PathMerkleTrie trie : participantTries.values()) { + nanos += trie.getLastCommitPlanNanos(); + } + return nanos; + } + + synchronized long nodeCommitStoreNanos() { + long nanos = superTrie.getLastCommitStoreNanos(); + for (PathMerkleTrie trie : participantTries.values()) { + nanos += trie.getLastCommitStoreNanos(); + } + return nanos; + } + + synchronized long nodeCommitFinalizeNanos() { + long nanos = superTrie.getLastCommitFinalizeNanos(); + for (PathMerkleTrie trie : participantTries.values()) { + nanos += trie.getLastCommitFinalizeNanos(); + } + return nanos; + } + + /** Returns the super root after binding every participant identity, format, and current root. */ + public synchronized byte[] rootHash() { + if (rootMaterialized) { + return superTrie.rootHash(); + } + for (PathStateParticipant participant : scope.getParticipants()) { + byte[] storeRoot = participantRoot(participant.getDbName()); + superTrie.put(PathStateCommitmentCodec.superLeafKey(participant.getStoreId()), + PathStateCommitmentCodec.superLeafValue(participant.getStoreId(), + participant.getDbName(), participant.getStoreFormatVersion(), storeRoot)); + } + byte[] root = superTrie.rootHash(); + rootMaterialized = true; + return root; + } + + /** Explicitly scans all current participant nodes and the published super-trie nodes. */ + public synchronized void verifyAllNodeStores() { + if (!rootMaterialized) { + throw new IllegalStateException("path state root is not materialized"); + } + for (PathMerkleTrie trie : participantTries.values()) { + trie.verifyAllNodeStore(); + } + superTrie.verifyAllNodeStore(); + } + + /** Compatibility alias for explicit rebuild, repair, and test callers. */ + public synchronized void verifyNodeStores() { + verifyAllNodeStores(); + } + + synchronized List leafRecords() { + List records = new ArrayList<>(); + for (PathStateParticipant participant : scope.getParticipants()) { + for (PathMerkleTrie.LeafEntry entry + : participantTries.get(participant.getDbName()).leafEntries()) { + records.add(new LeafRecord(participant.getStoreId(), entry.getSecureKey(), + entry.getEncodedValue())); + } + } + return records; + } + + /** Returns only leaf mutations accumulated since the last durable commit/checkpoint. */ + synchronized List pendingLeafMutations() { + List mutations = new ArrayList<>(); + for (Integer storeId : pendingLeafMutations.keySet()) { + mutations.addAll(pendingLeafMutations(storeId)); + } + return mutations; + } + + List pendingLeafMutations(int storeId) { + Map participantMutations = + pendingLeafMutations.get(storeId); + if (participantMutations == null) { + throw new IllegalArgumentException("unknown path-state Store ID: " + storeId); + } + synchronized (participantMutations) { + List mutations = new ArrayList<>(participantMutations.size()); + for (Map.Entry entry + : participantMutations.entrySet()) { + MutationKey key = entry.getKey(); + PathStateParticipant participant = entry.getValue(); + mutations.add(new LeafMutationRecord(participant.getStoreId(), key.secureKey, + participantTries.get(participant.getDbName()).get(key.secureKey))); + } + return mutations; + } + } + + synchronized void clearPendingLeafMutations() { + for (Integer storeId : pendingLeafMutations.keySet()) { + clearPendingLeafMutations(storeId); + } + } + + void clearPendingLeafMutations(int storeId) { + Map participantMutations = + pendingLeafMutations.get(storeId); + if (participantMutations == null) { + throw new IllegalArgumentException("unknown path-state Store ID: " + storeId); + } + synchronized (participantMutations) { + participantMutations.clear(); + } + } + + synchronized void recordPendingLeafRecords(Collection records) { + for (LeafRecord record : Objects.requireNonNull(records, "records")) { + LeafRecord present = Objects.requireNonNull(record, "record"); + PathStateParticipant participant = participant(present.storeId); + Map participantMutations = + pendingLeafMutations.get(present.storeId); + synchronized (participantMutations) { + participantMutations.put(new MutationKey(present.storeId, present.secureKey), participant); + } + } + } + + synchronized Snapshot snapshot() { + byte[] stateRoot = rootHash(); + Map snapshots = new LinkedHashMap<>(); + for (PathStateParticipant participant : scope.getParticipants()) { + snapshots.put(participant.getDbName(), + participantTries.get(participant.getDbName()).snapshot()); + } + return new Snapshot(snapshots, superTrie.snapshot(), stateRoot); + } + + static PathStateRoot fromSnapshot(PathStateParticipantScope scope, + PathNodeStoreFactory storeFactory, PathNodeStore superNodeStore, Snapshot snapshot) { + return new PathStateRoot(scope, storeFactory, superNodeStore, + Objects.requireNonNull(snapshot, "snapshot")); + } + + synchronized void initializeLeaves(Collection records, byte[] expectedRoot) { + restoreLeaves(records, expectedRoot, true); + } + + synchronized void restoreLeaves(Collection records, byte[] expectedRoot) { + restoreLeaves(records, expectedRoot, false); + } + + synchronized void restoreRebuildLeaves(Collection records, + PathStateRebuildCheckpoint checkpoint) { + restoreParticipantLeaves(records, false); + for (PathStateRebuildCoordinator.StoreResult result : checkpoint.getCompletedStores()) { + if (!Arrays.equals(participantRoot(result.getDbName()), result.getStoreRoot())) { + throw new IllegalStateException( + "restored path-state participant root differs from durable progress"); + } + } + rootMaterialized = false; + } + + private void restoreLeaves(Collection records, byte[] expectedRoot, + boolean initialize) { + restoreParticipantLeaves(records, initialize); + List superLeaves = new ArrayList<>(); + for (PathStateParticipant participant : scope.getParticipants()) { + PathMerkleTrie trie = participantTries.get(participant.getDbName()); + byte[] storeRoot = trie.rootHash(); + superLeaves.add(new PathMerkleTrie.LeafEntry( + PathStateCommitmentCodec.superLeafKey(participant.getStoreId()), + PathStateCommitmentCodec.superLeafValue(participant.getStoreId(), + participant.getDbName(), participant.getStoreFormatVersion(), storeRoot))); + } + if (initialize) { + superTrie.initializeLeaves(superLeaves); + } else { + superTrie.restoreLeaves(superLeaves); + } + byte[] restoredRoot = superTrie.rootHash(); + if (!Arrays.equals(restoredRoot, Objects.requireNonNull(expectedRoot, "expectedRoot"))) { + throw new IllegalStateException("restored path-state root differs from durable progress"); + } + rootMaterialized = true; + verifyAllNodeStores(); + } + + private Map> restoreParticipantLeaves( + Collection records, boolean initialize) { + Map participants = new LinkedHashMap<>(); + Map> leaves = new LinkedHashMap<>(); + for (PathStateParticipant participant : scope.getParticipants()) { + participants.put(participant.getStoreId(), participant); + leaves.put(participant.getStoreId(), new ArrayList<>()); + } + for (LeafRecord record : Objects.requireNonNull(records, "records")) { + LeafRecord present = Objects.requireNonNull(record, "record"); + if (!participants.containsKey(present.storeId)) { + throw new IllegalArgumentException("restored leaf has unknown path-state Store ID"); + } + leaves.get(present.storeId).add(new PathMerkleTrie.LeafEntry( + present.secureKey, present.encodedValue)); + } + + for (PathStateParticipant participant : scope.getParticipants()) { + PathMerkleTrie trie = participantTries.get(participant.getDbName()); + if (initialize) { + trie.initializeLeaves(leaves.get(participant.getStoreId())); + } else { + trie.restoreLeaves(leaves.get(participant.getStoreId())); + } + } + return leaves; + } + + private List prepare(Collection mutations) { + List supplied = new ArrayList<>( + Objects.requireNonNull(mutations, "mutations")); + if (supplied.isEmpty()) { + throw new IllegalArgumentException("mutation batch must not be empty"); + } + List prepared = new ArrayList<>(supplied.size()); + Set uniqueKeys = new LinkedHashSet<>(); + for (PathStateMutation mutation : supplied) { + PathStateMutation present = Objects.requireNonNull(mutation, "mutation"); + PathStateParticipant participant = scope.require(present.getDbName()); + byte[] secureKey = PathStateCommitmentCodec.storeLeafKey(participant.getStoreId(), + present.getPhysicalKey()); + if (!uniqueKeys.add(new MutationKey(participant.getStoreId(), secureKey))) { + throw new IllegalArgumentException("duplicate path-state mutation key"); + } + byte[] encodedValue = present.isDelete() ? null + : PathStateCommitmentCodec.presentLeafValue(present.getPhysicalValue()); + boolean previousValueKnown = present.isPreviousValueKnown(); + byte[] previousPhysicalValue = previousValueKnown + ? present.getPreviousPhysicalValue() : null; + byte[] previousEncodedValue = previousPhysicalValue == null ? null + : PathStateCommitmentCodec.presentLeafValue(previousPhysicalValue); + prepared.add(new PreparedMutation(participant, secureKey, encodedValue, + previousValueKnown, previousEncodedValue)); + } + Collections.sort(prepared, MUTATION_COMPARATOR); + return prepared; + } + + private PathStateParticipant participant(int storeId) { + for (PathStateParticipant participant : scope.getParticipants()) { + if (participant.getStoreId() == storeId) { + return participant; + } + } + throw new IllegalArgumentException("unknown path-state Store ID: " + storeId); + } + + private static int compareUnsigned(byte[] left, byte[] right) { + for (int i = 0; i < Math.min(left.length, right.length); i++) { + int result = Integer.compare(left[i] & 0xff, right[i] & 0xff); + if (result != 0) { + return result; + } + } + return Integer.compare(left.length, right.length); + } + + /** Creates an independent node Store for one immutable participant identity. */ + public interface PathNodeStoreFactory { + + PathNodeStore open(PathStateParticipant participant); + } + + public static final class Snapshot { + + private final Map participants; + private final PathMerkleTrie.Snapshot superTrie; + private final byte[] stateRoot; + + private Snapshot(Map participants, + PathMerkleTrie.Snapshot superTrie, byte[] stateRoot) { + this.participants = Collections.unmodifiableMap(new LinkedHashMap<>(participants)); + this.superTrie = Objects.requireNonNull(superTrie, "superTrie"); + this.stateRoot = Arrays.copyOf(stateRoot, stateRoot.length); + } + + public byte[] getStateRoot() { + return Arrays.copyOf(stateRoot, stateRoot.length); + } + + Snapshot detach() { + Map detached = new LinkedHashMap<>(); + for (Map.Entry entry : participants.entrySet()) { + detached.put(entry.getKey(), entry.getValue().detach()); + } + return new Snapshot(detached, superTrie.detach(), stateRoot); + } + + Snapshot reparent(Snapshot newParent) { + Snapshot parent = Objects.requireNonNull(newParent, "newParent"); + Map reparented = new LinkedHashMap<>(); + for (Map.Entry entry : participants.entrySet()) { + PathMerkleTrie.Snapshot parentTrie = parent.participants.get(entry.getKey()); + if (parentTrie == null) { + throw new IllegalArgumentException("snapshot parent participant set differs"); + } + reparented.put(entry.getKey(), entry.getValue().reparent(parentTrie)); + } + if (reparented.size() != parent.participants.size()) { + throw new IllegalArgumentException("snapshot parent participant set differs"); + } + return new Snapshot(reparented, superTrie.reparent(parent.superTrie), stateRoot); + } + + byte[] participantRoot(String dbName) { + PathMerkleTrie.Snapshot participant = participants.get( + Objects.requireNonNull(dbName, "dbName")); + if (participant == null) { + throw new IllegalArgumentException("snapshot has no path-state participant: " + dbName); + } + return participant.rootHash(); + } + + int maxTrieDepth() { + int depth = superTrie.depth(); + for (PathMerkleTrie.Snapshot participant : participants.values()) { + depth = Math.max(depth, participant.depth()); + } + return depth; + } + + int trieCount() { + return participants.size() + 1; + } + } + + static final class LeafRecord { + + private final int storeId; + private final byte[] secureKey; + private final byte[] encodedValue; + + LeafRecord(int storeId, byte[] secureKey, byte[] encodedValue) { + this.storeId = storeId; + this.secureKey = Arrays.copyOf(Objects.requireNonNull(secureKey, "secureKey"), + secureKey.length); + this.encodedValue = Arrays.copyOf(Objects.requireNonNull(encodedValue, "encodedValue"), + encodedValue.length); + } + + int getStoreId() { + return storeId; + } + + byte[] getSecureKey() { + return Arrays.copyOf(secureKey, secureKey.length); + } + + byte[] getEncodedValue() { + return Arrays.copyOf(encodedValue, encodedValue.length); + } + } + + static final class LeafMutationRecord { + + private final int storeId; + private final byte[] secureKey; + private final byte[] encodedValue; + + private LeafMutationRecord(int storeId, byte[] secureKey, byte[] encodedValue) { + this.storeId = storeId; + this.secureKey = Arrays.copyOf(Objects.requireNonNull(secureKey, "secureKey"), + secureKey.length); + this.encodedValue = encodedValue == null ? null + : Arrays.copyOf(encodedValue, encodedValue.length); + } + + int getStoreId() { + return storeId; + } + + byte[] getSecureKey() { + return Arrays.copyOf(secureKey, secureKey.length); + } + + byte[] getEncodedValue() { + return encodedValue == null ? null : Arrays.copyOf(encodedValue, encodedValue.length); + } + } + + private static final class PreparedMutation { + + private final PathStateParticipant participant; + private final byte[] secureKey; + private final byte[] encodedValue; + private final boolean previousValueKnown; + private final byte[] previousEncodedValue; + + private PreparedMutation(PathStateParticipant participant, byte[] secureKey, + byte[] encodedValue, boolean previousValueKnown, byte[] previousEncodedValue) { + this.participant = participant; + this.secureKey = secureKey; + this.encodedValue = encodedValue; + this.previousValueKnown = previousValueKnown; + this.previousEncodedValue = previousEncodedValue; + } + } + + private static final class MutationKey { + + private final int storeId; + private final byte[] secureKey; + + private MutationKey(int storeId, byte[] secureKey) { + this.storeId = storeId; + this.secureKey = secureKey; + } + + @Override + public boolean equals(Object other) { + return this == other || other instanceof MutationKey + && storeId == ((MutationKey) other).storeId + && Arrays.equals(secureKey, ((MutationKey) other).secureKey); + } + + @Override + public int hashCode() { + return 31 * storeId + Arrays.hashCode(secureKey); + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateRootMetadata.java b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateRootMetadata.java new file mode 100644 index 00000000000..a5c453d30b0 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateRootMetadata.java @@ -0,0 +1,275 @@ +package org.tron.core.db2.stateroot; + +import com.google.common.hash.Hashing; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Objects; +import org.tron.core.db2.stateroot.PathStateCanonicalizer.P66Phase; + +/** Immutable identity shared by a durable base or one reversible current-root layer. */ +public final class PathStateRootMetadata { + + public static final int DIGEST_LENGTH = 32; + + private static final int MAGIC = 0x50534d54; // PSMT + private static final short VERSION = 1; + private static final int MAX_LENGTH = 16 * 1024; + + private final Kind kind; + private final long blockNumber; + private final byte[] blockHash; + private final byte[] parentHash; + private final long timestamp; + private final P66Phase phase; + private final byte[] formatDigest; + private final byte[] parentStateRoot; + private final byte[] stateRoot; + private final byte[] payloadDigest; + + private PathStateRootMetadata(Kind kind, long blockNumber, byte[] blockHash, byte[] parentHash, + long timestamp, P66Phase phase, byte[] formatDigest, byte[] parentStateRoot, byte[] stateRoot, + byte[] payloadDigest) { + if (blockNumber < 0) { + throw new IllegalArgumentException("blockNumber must not be negative"); + } + this.kind = Objects.requireNonNull(kind, "kind"); + this.blockNumber = blockNumber; + this.blockHash = copy32(blockHash, "blockHash"); + this.parentHash = copy32(parentHash, "parentHash"); + this.timestamp = timestamp; + this.phase = Objects.requireNonNull(phase, "phase"); + this.formatDigest = copy32(formatDigest, "formatDigest"); + this.parentStateRoot = parentStateRoot == null ? null + : copy32(parentStateRoot, "parentStateRoot"); + this.stateRoot = copy32(stateRoot, "stateRoot"); + this.payloadDigest = copy32(payloadDigest, "payloadDigest"); + if (kind == Kind.BASE && this.parentStateRoot != null) { + throw new IllegalArgumentException("base metadata must not contain a parent state root"); + } + if (kind == Kind.LAYER && this.parentStateRoot == null) { + throw new IllegalArgumentException("layer metadata requires a parent state root"); + } + } + + /** Creates metadata for a rebuilt or compacted durable base. */ + public static PathStateRootMetadata base(long blockNumber, byte[] blockHash, byte[] parentHash, + long timestamp, P66Phase phase, byte[] formatDigest, byte[] stateRoot, + byte[] sourceDigest) { + return new PathStateRootMetadata(Kind.BASE, blockNumber, blockHash, parentHash, timestamp, + phase, formatDigest, null, stateRoot, sourceDigest); + } + + /** Creates metadata for one immutable reversible transition above a parent root. */ + public static PathStateRootMetadata layer(long blockNumber, byte[] blockHash, byte[] parentHash, + long timestamp, P66Phase phase, byte[] formatDigest, byte[] parentStateRoot, + byte[] stateRoot, byte[] transitionDigest) { + return new PathStateRootMetadata(Kind.LAYER, blockNumber, blockHash, parentHash, timestamp, + phase, formatDigest, parentStateRoot, stateRoot, transitionDigest); + } + + public Kind getKind() { + return kind; + } + + public long getBlockNumber() { + return blockNumber; + } + + public byte[] getBlockHash() { + return Arrays.copyOf(blockHash, blockHash.length); + } + + public byte[] getParentHash() { + return Arrays.copyOf(parentHash, parentHash.length); + } + + public long getTimestamp() { + return timestamp; + } + + public P66Phase getPhase() { + return phase; + } + + public byte[] getFormatDigest() { + return Arrays.copyOf(formatDigest, formatDigest.length); + } + + public byte[] getParentStateRoot() { + return parentStateRoot == null ? null : Arrays.copyOf(parentStateRoot, parentStateRoot.length); + } + + public byte[] getStateRoot() { + return Arrays.copyOf(stateRoot, stateRoot.length); + } + + public byte[] getPayloadDigest() { + return Arrays.copyOf(payloadDigest, payloadDigest.length); + } + + /** Encodes metadata with an exact scope identity and CRC32C corruption check. */ + public byte[] encode() { + try { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + DataOutputStream output = new DataOutputStream(bytes); + output.writeInt(MAGIC); + output.writeShort(VERSION); + output.writeShort(0); + output.writeInt(0); + writeString(output, PathStateParticipantDescriptor.SCOPE_ID); + output.write(formatDigest); + output.writeByte(kind.tag); + output.writeLong(blockNumber); + output.write(blockHash); + output.write(parentHash); + output.writeLong(timestamp); + output.writeByte(phaseTag(phase)); + if (parentStateRoot == null) { + output.writeByte(0); + } else { + output.writeByte(parentStateRoot.length); + output.write(parentStateRoot); + } + output.write(stateRoot); + output.write(payloadDigest); + output.flush(); + byte[] payload = bytes.toByteArray(); + int length = payload.length + Integer.BYTES; + ByteBuffer.wrap(payload).putInt(8, length); + bytes.reset(); + output = new DataOutputStream(bytes); + output.write(payload); + output.writeInt(Hashing.crc32c().hashBytes(payload).asInt()); + output.flush(); + return bytes.toByteArray(); + } catch (IOException impossible) { + throw new IllegalStateException("in-memory path-state metadata encoding failed", impossible); + } + } + + public static PathStateRootMetadata decode(byte[] encoded) { + byte[] bytes = Arrays.copyOf(Objects.requireNonNull(encoded, "encoded"), encoded.length); + if (bytes.length <= Integer.BYTES || bytes.length > MAX_LENGTH) { + throw new IllegalArgumentException("path-state metadata length is invalid"); + } + byte[] payload = Arrays.copyOf(bytes, bytes.length - Integer.BYTES); + int checksum = ByteBuffer.wrap(bytes, payload.length, Integer.BYTES).getInt(); + if (checksum != Hashing.crc32c().hashBytes(payload).asInt()) { + throw new IllegalArgumentException("path-state metadata checksum mismatch"); + } + try (DataInputStream input = new DataInputStream(new ByteArrayInputStream(bytes))) { + if (input.readInt() != MAGIC || input.readShort() != VERSION || input.readShort() != 0 + || input.readInt() != bytes.length) { + throw new IllegalArgumentException("unsupported path-state metadata header"); + } + if (!PathStateParticipantDescriptor.SCOPE_ID.equals(readString(input))) { + throw new IllegalArgumentException("path-state metadata scope mismatch"); + } + byte[] formatDigest = read32(input); + Kind kind = Kind.fromTag(input.readUnsignedByte()); + long blockNumber = input.readLong(); + byte[] blockHash = read32(input); + byte[] parentHash = read32(input); + long timestamp = input.readLong(); + P66Phase phase = phase(input.readUnsignedByte()); + int parentLength = input.readUnsignedByte(); + byte[] parentRoot = null; + if (parentLength == DIGEST_LENGTH) { + parentRoot = read32(input); + } else if (parentLength != 0) { + throw new IllegalArgumentException("path-state parent root length is invalid"); + } + byte[] stateRoot = read32(input); + byte[] digest = read32(input); + if (input.available() != Integer.BYTES) { + throw new IllegalArgumentException("path-state metadata payload mismatch"); + } + return new PathStateRootMetadata(kind, blockNumber, blockHash, parentHash, timestamp, phase, + formatDigest, parentRoot, stateRoot, digest); + } catch (IOException invalid) { + throw new IllegalArgumentException("path-state metadata is truncated", invalid); + } + } + + private static byte[] copy32(byte[] value, String name) { + byte[] copy = Arrays.copyOf(Objects.requireNonNull(value, name), value.length); + if (copy.length != DIGEST_LENGTH) { + throw new IllegalArgumentException(name + " must be exactly " + DIGEST_LENGTH + " bytes"); + } + return copy; + } + + private static void writeString(DataOutputStream output, String value) throws IOException { + byte[] encoded = value.getBytes(StandardCharsets.UTF_8); + output.writeShort(encoded.length); + output.write(encoded); + } + + private static String readString(DataInputStream input) throws IOException { + int length = input.readUnsignedShort(); + if (length == 0 || length > 1024 || length > input.available() - Integer.BYTES) { + throw new IllegalArgumentException("path-state metadata string is invalid"); + } + byte[] value = new byte[length]; + input.readFully(value); + return new String(value, StandardCharsets.UTF_8); + } + + private static byte[] read32(DataInputStream input) throws IOException { + byte[] value = new byte[DIGEST_LENGTH]; + input.readFully(value); + return value; + } + + private static int phaseTag(P66Phase phase) { + switch (phase) { + case P66_OFF: + return 0; + case P66_ACTIVATION: + return 1; + case P66_ON: + return 2; + default: + throw new IllegalArgumentException("unknown P66 phase: " + phase); + } + } + + private static P66Phase phase(int tag) { + switch (tag) { + case 0: + return P66Phase.P66_OFF; + case 1: + return P66Phase.P66_ACTIVATION; + case 2: + return P66Phase.P66_ON; + default: + throw new IllegalArgumentException("unknown path-state P66 phase tag: " + tag); + } + } + + public enum Kind { + BASE(0), + LAYER(1); + + private final int tag; + + Kind(int tag) { + this.tag = tag; + } + + private static Kind fromTag(int tag) { + for (Kind value : values()) { + if (value.tag == tag) { + return value; + } + } + throw new IllegalArgumentException("unknown path-state metadata kind: " + tag); + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateRuntimeAdmission.java b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateRuntimeAdmission.java new file mode 100644 index 00000000000..9371555a48d --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateRuntimeAdmission.java @@ -0,0 +1,55 @@ +package org.tron.core.db2.stateroot; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.util.Objects; +import org.tron.core.db2.stateroot.PathStateStoreManifest.Engine; + +/** Read-only startup admission that keeps the disabled path free of filesystem access. */ +public final class PathStateRuntimeAdmission { + + private PathStateRuntimeAdmission() { + } + + public static Result inspect(boolean enabled, Path directory, Engine engine) throws IOException { + if (!enabled) { + return new Result(Status.DISABLED, null); + } + Path root = Objects.requireNonNull(directory, "directory").toAbsolutePath().normalize(); + Engine selected = Objects.requireNonNull(engine, "engine"); + if (!Files.exists(root, LinkOption.NOFOLLOW_LINKS)) { + return new Result(Status.REBUILD_REQUIRED, null); + } + PathStateStoreManifest manifest = PathStateStoreManifest.validateExisting(root, selected); + Status status = new PathStateCurrentStore(manifest).isInitialized() + ? Status.CURRENT_READY : Status.REBUILD_REQUIRED; + return new Result(status, manifest); + } + + public enum Status { + DISABLED, + REBUILD_REQUIRED, + CURRENT_READY + } + + public static final class Result { + + private final Status status; + private final PathStateStoreManifest manifest; + + private Result(Status status, PathStateStoreManifest manifest) { + this.status = status; + this.manifest = manifest; + } + + public Status getStatus() { + return status; + } + + public PathStateStoreManifest getManifest() { + return manifest; + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateRuntimeAttachment.java b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateRuntimeAttachment.java new file mode 100644 index 00000000000..2dca4aeba89 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateRuntimeAttachment.java @@ -0,0 +1,635 @@ +package org.tron.core.db2.stateroot; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Locale; +import java.util.Objects; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.TimeUnit; +import lombok.extern.slf4j.Slf4j; +import org.tron.core.db2.archive.BlockChangeView; +import org.tron.core.db2.archive.BlockSnapshotMeta; + +/** Independent non-consensus runtime installed at the metadata-aware block commit boundary. */ +@Slf4j(topic = "DB") +public final class PathStateRuntimeAttachment { + + private final PathStateTransitionCollector collector; + private final TransitionSink sink; + private final BaseFlushSink baseFlushSink; + private final TransitionPreviewer previewer; + private final SnapshotDeltaPreparer snapshotDeltaPreparer; + private final boolean deferredCapture; + private final boolean commonCheckpointOnly; + private final BlockingQueue deferredQueue; + private final Thread deferredWorker; + private Throwable failure; + private FailureStage failureStage; + private long readyBlockNumber = -1; + private byte[] readyBlockHash; + private long observedBlockNumber = -1; + private byte[] observedBlockHash; + private PathStateBlockTransition pending; + private PathStateSnapshotDelta pendingSnapshotDelta; + private BlockChangeView pendingView; + private volatile boolean closed; + private HeaderDiagnostic headerDiagnostic = HeaderDiagnostic.NONE; + private long headerDiagnosticBlockNumber = -1; + private byte[] headerDiagnosticBlockHash; + + public PathStateRuntimeAttachment(PathStateTransitionCollector collector, TransitionSink sink) { + this(collector, sink, (blockNumber, blockHash) -> { }, null); + } + + public PathStateRuntimeAttachment(PathStateTransitionCollector collector, TransitionSink sink, + BaseFlushSink baseFlushSink) { + this(collector, sink, baseFlushSink, null); + } + + public PathStateRuntimeAttachment(PathStateTransitionCollector collector, TransitionSink sink, + BaseFlushSink baseFlushSink, TransitionPreviewer previewer) { + this(collector, sink, baseFlushSink, previewer, null); + } + + public PathStateRuntimeAttachment(PathStateTransitionCollector collector, TransitionSink sink, + BaseFlushSink baseFlushSink, TransitionPreviewer previewer, + SnapshotDeltaPreparer snapshotDeltaPreparer) { + this(collector, sink, baseFlushSink, previewer, snapshotDeltaPreparer, false); + } + + /** Creates a benchmark runtime that defers collect, delta preparation, and head advance. */ + public static PathStateRuntimeAttachment deferred(PathStateTransitionCollector collector, + TransitionSink sink, BaseFlushSink baseFlushSink, TransitionPreviewer previewer, + SnapshotDeltaPreparer snapshotDeltaPreparer) { + return new PathStateRuntimeAttachment(collector, sink, baseFlushSink, previewer, + snapshotDeltaPreparer, true, false); + } + + /** Creates the production in-memory head used exclusively by the common checkpoint owner. */ + public static PathStateRuntimeAttachment commonCheckpoint(PathStateTransitionCollector collector, + TransitionSink sink, TransitionPreviewer previewer, + SnapshotDeltaPreparer snapshotDeltaPreparer) { + return new PathStateRuntimeAttachment(collector, sink, (blockNumber, blockHash) -> { }, + previewer, snapshotDeltaPreparer, false, true); + } + + private PathStateRuntimeAttachment(PathStateTransitionCollector collector, TransitionSink sink, + BaseFlushSink baseFlushSink, TransitionPreviewer previewer, + SnapshotDeltaPreparer snapshotDeltaPreparer, boolean deferredCapture) { + this(collector, sink, baseFlushSink, previewer, snapshotDeltaPreparer, deferredCapture, false); + } + + private PathStateRuntimeAttachment(PathStateTransitionCollector collector, TransitionSink sink, + BaseFlushSink baseFlushSink, TransitionPreviewer previewer, + SnapshotDeltaPreparer snapshotDeltaPreparer, boolean deferredCapture, + boolean commonCheckpointOnly) { + this.collector = Objects.requireNonNull(collector, "collector"); + this.sink = Objects.requireNonNull(sink, "sink"); + this.baseFlushSink = Objects.requireNonNull(baseFlushSink, "baseFlushSink"); + this.previewer = previewer; + this.snapshotDeltaPreparer = snapshotDeltaPreparer; + this.deferredCapture = deferredCapture; + this.commonCheckpointOnly = commonCheckpointOnly; + deferredQueue = deferredCapture ? new ArrayBlockingQueue<>(64) : null; + deferredWorker = deferredCapture + ? new Thread(this::runDeferred, "path-state-deferred-capture") : null; + if (deferredWorker != null) { + deferredWorker.setDaemon(true); + deferredWorker.start(); + } + } + + public boolean isCommonCheckpointOnly() { + return commonCheckpointOnly; + } + + /** Computes producer metadata without observing, publishing, or failing this runtime. */ + public synchronized byte[] preview(BlockChangeView view) { + if (failure != null || previewer == null || status().getState() != State.READY) { + return null; + } + try { + PathStateBlockTransition transition = collectAndValidate(view); + byte[] candidate = previewer.prepare(transition); + if (candidate == null || candidate.length != 32) { + throw new IOException("path-state preview root must be exactly 32 bytes"); + } + return copy(candidate); + } catch (IOException | RuntimeException previewFailure) { + logger.warn("Path-state producer preview unavailable; state_root remains absent", + previewFailure); + return null; + } + } + + /** Capture failures fail only this shadow runtime and never reject the canonical block. */ + public synchronized PathStateBlockTransition capture(BlockChangeView view) { + BlockChangeView admitted = Objects.requireNonNull(view, "view"); + observe(admitted); + if (failure != null) { + return null; + } + if (deferredCapture) { + pendingView = admitted; + return null; + } + try { + PathStateBlockTransition transition = collectAndValidate(admitted); + PathStateSnapshotDelta snapshotDelta = snapshotDeltaPreparer == null ? null + : snapshotDeltaPreparer.prepare(admitted.getMeta(), transition); + validateSnapshotDelta(admitted.getMeta(), transition, snapshotDelta); + pending = transition; + pendingSnapshotDelta = snapshotDelta; + return transition; + } catch (IOException | RuntimeException currentFailure) { + fail(FailureStage.CAPTURE, currentFailure); + return null; + } + } + + /** Durable publication failures are retained as observable fail-stop state. */ + public void publish(PathStateBlockTransition transition) { + if (deferredCapture) { + publishDeferred(transition); + return; + } + publishNow(transition); + } + + private synchronized void publishNow(PathStateBlockTransition transition) { + if (failure != null || transition == null) { + return; + } + try { + if (pending != transition) { + throw new IOException("path-state publication differs from captured transition"); + } + sink.accept(transition); + readyBlockNumber = transition.getBlockNumber(); + readyBlockHash = transition.getBlockHash(); + pending = null; + pendingSnapshotDelta = null; + } catch (IOException | RuntimeException currentFailure) { + fail(FailureStage.PUBLISH, currentFailure); + } + } + + private void publishDeferred(PathStateBlockTransition transition) { + BlockChangeView admitted; + synchronized (this) { + if (failure != null) { + return; + } + if (transition != null || pendingView == null) { + fail(FailureStage.PUBLISH, + new IOException("deferred PathState publication has no captured view")); + return; + } + admitted = pendingView; + pendingView = null; + } + long startedNanos = System.nanoTime(); + try { + while (true) { + if (deferredWorkerUnavailable()) { + return; + } + if (!deferredQueue.offer(admitted, 100L, TimeUnit.MILLISECONDS)) { + continue; + } + if (deferredWorkerUnavailable()) { + deferredQueue.remove(admitted); + return; + } + logger.info("Path-state deferred view enqueued: head={}, queueDepth={}, enqueueMicros={}", + admitted.getMeta().getBlockNumber(), deferredQueue.size(), + TimeUnit.NANOSECONDS.toMicros(System.nanoTime() - startedNanos)); + return; + } + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + fail(FailureStage.PUBLISH, interrupted); + } + } + + private void runDeferred() { + while (!closed || !deferredQueue.isEmpty()) { + try { + BlockChangeView view = deferredQueue.poll(100L, TimeUnit.MILLISECONDS); + if (view == null) { + continue; + } + long startedNanos = System.nanoTime(); + PathStateBlockTransition transition = collectAndValidate(view); + PathStateSnapshotDelta delta = snapshotDeltaPreparer == null ? null + : snapshotDeltaPreparer.prepare(view.getMeta(), transition); + validateSnapshotDelta(view.getMeta(), transition, delta); + sink.accept(transition); + synchronized (this) { + readyBlockNumber = transition.getBlockNumber(); + readyBlockHash = transition.getBlockHash(); + } + logger.info("Path-state deferred view prepared: head={}, queueDepth={}, serviceMs={}", + transition.getBlockNumber(), deferredQueue.size(), + TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedNanos)); + } catch (InterruptedException interrupted) { + if (!closed) { + Thread.currentThread().interrupt(); + fail(FailureStage.CAPTURE, interrupted); + } + return; + } catch (Throwable currentFailure) { + fail(FailureStage.CAPTURE, currentFailure); + deferredQueue.clear(); + return; + } + } + } + + private synchronized boolean deferredWorkerUnavailable() { + if (failure != null) { + return true; + } + if (closed) { + fail(FailureStage.PUBLISH, + new IOException("deferred PathState runtime closed while publishing")); + return true; + } + if (!deferredWorker.isAlive()) { + fail(FailureStage.CAPTURE, + new IllegalStateException("deferred PathState worker terminated unexpectedly")); + return true; + } + return false; + } + + /** Drains the deferred benchmark worker before its Manager-owned head is closed. */ + public void close() throws IOException { + if (deferredWorker == null) { + return; + } + closed = true; + try { + deferredWorker.join(TimeUnit.SECONDS.toMillis(30)); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IOException("deferred PathState close interrupted", interrupted); + } + if (deferredWorker.isAlive()) { + deferredWorker.interrupt(); + throw new IOException("deferred PathState worker did not drain before close"); + } + if (failure != null) { + throw new IOException("deferred PathState worker failed", failure); + } + } + + /** Compacts only after Chainbase has durably refreshed the matching prefix. */ + public synchronized void flushBaseThrough(long blockNumber, byte[] blockHash) { + if (failure != null) { + return; + } + try { + baseFlushSink.accept(blockNumber, blockHash); + } catch (IOException | RuntimeException currentFailure) { + fail(FailureStage.BASE_FLUSH, currentFailure); + } + } + + public synchronized boolean isFailed() { + return failure != null; + } + + /** Returns the exact optional delta bound to the currently captured transition. */ + public synchronized PathStateSnapshotDelta preparedSnapshotDelta( + PathStateBlockTransition transition) { + if (pending != Objects.requireNonNull(transition, "transition")) { + throw new IllegalStateException("path-state Snapshot delta transition is not pending"); + } + return pendingSnapshotDelta; + } + + public synchronized Throwable getFailure() { + return failure; + } + + /** Returns a copy-only diagnostic snapshot; it never repairs or guesses a root. */ + public synchronized Status status() { + State state = failure != null ? State.FAILED + : pending == null && readyBlockNumber >= 0 + && readyBlockNumber == observedBlockNumber + && Arrays.equals(readyBlockHash, observedBlockHash) ? State.READY : State.NOT_READY; + return new Status(state, readyBlockNumber, readyBlockHash, observedBlockNumber, + observedBlockHash, failureStage, classify(failure), failure, headerDiagnostic, + headerDiagnosticBlockNumber, headerDiagnosticBlockHash); + } + + /** True only when an owner root read cannot wait for a deferred predecessor transition. */ + public synchronized boolean isReadyForHeaderDiagnostic(long blockNumber, byte[] blockHash) { + byte[] admittedHash = Objects.requireNonNull(blockHash, "blockHash"); + return failure == null && pending == null && pendingView == null + && readyBlockNumber == observedBlockNumber + && readyBlockNumber == blockNumber + && Arrays.equals(readyBlockHash, observedBlockHash) + && Arrays.equals(readyBlockHash, admittedHash); + } + + /** Records a non-blocking comparison of carried header metadata against the local READY root. */ + public synchronized void diagnoseHeader(long blockNumber, byte[] blockHash, byte[] carriedRoot, + byte[] localRoot) { + headerDiagnosticBlockNumber = blockNumber; + headerDiagnosticBlockHash = copy(blockHash); + if (carriedRoot == null || carriedRoot.length == 0) { + headerDiagnostic = HeaderDiagnostic.ABSENT; + return; + } + if (carriedRoot.length != 32) { + headerDiagnostic = HeaderDiagnostic.INVALID_LENGTH; + logger.warn("Path-state header diagnostic: block={}, result={}, length={}", blockNumber, + headerDiagnostic, carriedRoot.length); + return; + } + if (failure != null || readyBlockNumber != blockNumber + || !Arrays.equals(readyBlockHash, blockHash) || localRoot == null + || localRoot.length != 32) { + headerDiagnostic = HeaderDiagnostic.NOT_AVAILABLE; + return; + } + headerDiagnostic = Arrays.equals(carriedRoot, localRoot) + ? HeaderDiagnostic.MATCH : HeaderDiagnostic.MISMATCH; + if (headerDiagnostic == HeaderDiagnostic.MISMATCH) { + logger.warn("Path-state header diagnostic: block={}, result={}", blockNumber, + headerDiagnostic); + } + } + + /** Seeds or rewinds the exact verified durable head; failed runtimes cannot be reset. */ + public synchronized void synchronizeReadyHead(PathStateRootMetadata metadata) { + if (failure != null) { + throw new IllegalStateException("failed path-state runtime cannot become ready"); + } + if (pending != null) { + throw new IllegalStateException("captured path-state transition is not published"); + } + PathStateRootMetadata admitted = Objects.requireNonNull(metadata, "metadata"); + readyBlockNumber = admitted.getBlockNumber(); + readyBlockHash = admitted.getBlockHash(); + observedBlockNumber = readyBlockNumber; + observedBlockHash = copy(readyBlockHash); + } + + /** Marks an externally coordinated lifecycle operation as failed without replacing first cause. */ + public synchronized void fail(Throwable currentFailure) { + fail(FailureStage.EXTERNAL, currentFailure); + } + + public synchronized void fail(FailureStage stage, Throwable currentFailure) { + if (failure == null) { + failure = Objects.requireNonNull(currentFailure, "currentFailure"); + failureStage = Objects.requireNonNull(stage, "stage"); + logger.error( + "Path-state runtime fail-stop: stage={}, kind={}, readyBlock={}, observedBlock={}, " + + "rootLag={}", + failureStage, classify(failure), readyBlockNumber, observedBlockNumber, + lag(readyBlockNumber, observedBlockNumber), + failure); + } + } + + /** Records the exact canonical target of a failed external lifecycle operation. */ + public synchronized void failAt(FailureStage stage, long blockNumber, byte[] blockHash, + Throwable currentFailure) { + observedBlockNumber = blockNumber; + observedBlockHash = copy(Objects.requireNonNull(blockHash, "blockHash")); + fail(stage, currentFailure); + } + + private void observe(BlockChangeView view) { + long number = view.getMeta().getBlockNumber(); + byte[] hash = view.getMeta().getBlockHash(); + byte[] parentHash = view.getMeta().getParentHash(); + if (failure == null) { + if (pending != null || pendingView != null) { + fail(FailureStage.CAPTURE_GAP, + new IOException("path-state previous capture is not published")); + } + long expectedParentNumber = observedBlockNumber >= 0 + ? observedBlockNumber : readyBlockNumber; + byte[] expectedParentHash = observedBlockHash != null + ? observedBlockHash : readyBlockHash; + if (expectedParentNumber >= 0 && (number != expectedParentNumber + 1 + || !Arrays.equals(parentHash, expectedParentHash))) { + fail(FailureStage.CAPTURE_GAP, + new IOException("path-state block-final capture is not continuous")); + } + } + observedBlockNumber = number; + observedBlockHash = copy(hash); + } + + private PathStateBlockTransition collectAndValidate(BlockChangeView view) throws IOException { + BlockChangeView admitted = Objects.requireNonNull(view, "view"); + PathStateBlockTransition transition = Objects.requireNonNull(collector.collect(admitted), + "path-state collector returned null"); + if (transition.getBlockNumber() != admitted.getMeta().getBlockNumber() + || !Arrays.equals(transition.getBlockHash(), admitted.getMeta().getBlockHash()) + || !Arrays.equals(transition.getParentHash(), admitted.getMeta().getParentHash()) + || transition.getTimestamp() != admitted.getMeta().getTimestamp()) { + throw new IOException("path-state collector changed the captured block identity"); + } + return transition; + } + + private static void validateSnapshotDelta(BlockSnapshotMeta meta, + PathStateBlockTransition transition, PathStateSnapshotDelta delta) throws IOException { + if (delta == null) { + return; + } + if (!meta.equals(delta.getMeta()) + || !Arrays.equals(transition.getPayloadDigest(), delta.getTransitionPayloadDigest())) { + throw new IOException("path-state Snapshot delta identity mismatch"); + } + } + + private static FailureKind classify(Throwable failure) { + if (failure == null) { + return FailureKind.NONE; + } + StringBuilder messages = new StringBuilder(); + Throwable current = failure; + boolean io = false; + while (current != null) { + io |= current instanceof IOException; + if (current.getMessage() != null) { + messages.append(' ').append(current.getMessage().toLowerCase(Locale.ROOT)); + } + current = current.getCause(); + } + String text = messages.toString(); + if (text.contains("no space left") || text.contains("disk full") + || text.contains("out of space")) { + return FailureKind.STORAGE_FULL; + } + if (text.contains("corrupt") || text.contains("checksum") || text.contains("mismatch") + || text.contains("orphan") || text.contains("native progress")) { + return FailureKind.CORRUPTION; + } + return io ? FailureKind.IO : FailureKind.RUNTIME; + } + + private static byte[] copy(byte[] value) { + return value == null ? null : Arrays.copyOf(value, value.length); + } + + private static long lag(long readyBlockNumber, long observedBlockNumber) { + if (readyBlockNumber < 0 || observedBlockNumber < 0) { + return -1; + } + return observedBlockNumber >= readyBlockNumber + ? observedBlockNumber - readyBlockNumber : readyBlockNumber - observedBlockNumber; + } + + public enum State { + NOT_READY, + READY, + FAILED + } + + public enum FailureStage { + CAPTURE_GAP, + CAPTURE, + PUBLISH, + BASE_FLUSH, + REORG, + EXTERNAL + } + + public enum FailureKind { + NONE, + STORAGE_FULL, + CORRUPTION, + IO, + RUNTIME + } + + public enum HeaderDiagnostic { + NONE, + ABSENT, + INVALID_LENGTH, + NOT_AVAILABLE, + MATCH, + MISMATCH + } + + public static final class Status { + + private final State state; + private final long readyBlockNumber; + private final byte[] readyBlockHash; + private final long observedBlockNumber; + private final byte[] observedBlockHash; + private final FailureStage failureStage; + private final FailureKind failureKind; + private final String failureType; + private final String failureMessage; + private final HeaderDiagnostic headerDiagnostic; + private final long headerDiagnosticBlockNumber; + private final byte[] headerDiagnosticBlockHash; + + private Status(State state, long readyBlockNumber, byte[] readyBlockHash, + long observedBlockNumber, byte[] observedBlockHash, FailureStage failureStage, + FailureKind failureKind, Throwable failure, HeaderDiagnostic headerDiagnostic, + long headerDiagnosticBlockNumber, byte[] headerDiagnosticBlockHash) { + this.state = state; + this.readyBlockNumber = readyBlockNumber; + this.readyBlockHash = copy(readyBlockHash); + this.observedBlockNumber = observedBlockNumber; + this.observedBlockHash = copy(observedBlockHash); + this.failureStage = failureStage; + this.failureKind = failureKind; + this.failureType = failure == null ? null : failure.getClass().getName(); + this.failureMessage = failure == null ? null : failure.getMessage(); + this.headerDiagnostic = headerDiagnostic; + this.headerDiagnosticBlockNumber = headerDiagnosticBlockNumber; + this.headerDiagnosticBlockHash = copy(headerDiagnosticBlockHash); + } + + public State getState() { + return state; + } + + public long getReadyBlockNumber() { + return readyBlockNumber; + } + + public byte[] getReadyBlockHash() { + return copy(readyBlockHash); + } + + public long getObservedBlockNumber() { + return observedBlockNumber; + } + + public byte[] getObservedBlockHash() { + return copy(observedBlockHash); + } + + public long getRootLag() { + return lag(readyBlockNumber, observedBlockNumber); + } + + public FailureStage getFailureStage() { + return failureStage; + } + + public FailureKind getFailureKind() { + return failureKind; + } + + public String getFailureType() { + return failureType; + } + + public String getFailureMessage() { + return failureMessage; + } + + public HeaderDiagnostic getHeaderDiagnostic() { + return headerDiagnostic; + } + + public long getHeaderDiagnosticBlockNumber() { + return headerDiagnosticBlockNumber; + } + + public byte[] getHeaderDiagnosticBlockHash() { + return copy(headerDiagnosticBlockHash); + } + } + + @FunctionalInterface + public interface TransitionSink { + + void accept(PathStateBlockTransition transition) throws IOException; + } + + @FunctionalInterface + public interface BaseFlushSink { + + void accept(long blockNumber, byte[] blockHash) throws IOException; + } + + @FunctionalInterface + public interface TransitionPreviewer { + + byte[] prepare(PathStateBlockTransition transition) throws IOException; + } + + @FunctionalInterface + public interface SnapshotDeltaPreparer { + + PathStateSnapshotDelta prepare(BlockSnapshotMeta meta, PathStateBlockTransition transition) + throws IOException; + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateSnapshotDelta.java b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateSnapshotDelta.java new file mode 100644 index 00000000000..ec4a10e2e79 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateSnapshotDelta.java @@ -0,0 +1,252 @@ +package org.tron.core.db2.stateroot; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import org.tron.core.db2.archive.BlockSnapshotMeta; + +/** + * Immutable Snapshot-owned path-state forward delta for one successfully applied block. + * + *

This object contains the actual F/N mutations needed by a future common-checkpoint redo + * payload. It is memory-only in this slice and does not publish CURRENT or write a native Store. + */ +public final class PathStateSnapshotDelta { + + private static final Comparator MUTATION_ORDER = + (left, right) -> compareUnsigned(left.key, right.key); + + private final BlockSnapshotMeta meta; + private final byte[] parentStateRoot; + private final byte[] stateRoot; + private final byte[] transitionPayloadDigest; + private final List stores; + private final List superNodeMutations; + + private PathStateSnapshotDelta(BlockSnapshotMeta meta, byte[] parentStateRoot, + byte[] stateRoot, byte[] transitionPayloadDigest, + List stores, List superNodeMutations) { + this.meta = Objects.requireNonNull(meta, "meta"); + this.parentStateRoot = root(parentStateRoot, "parentStateRoot"); + this.stateRoot = root(stateRoot, "stateRoot"); + this.transitionPayloadDigest = root(transitionPayloadDigest, "transitionPayloadDigest"); + this.stores = Collections.unmodifiableList(new ArrayList<>(stores)); + this.superNodeMutations = immutableMutations(superNodeMutations); + } + + static PathStateSnapshotDelta from(BlockSnapshotMeta meta, + PreparedPathStateTransition prepared, PathStateParticipantScope scope) { + BlockSnapshotMeta admittedMeta = Objects.requireNonNull(meta, "meta"); + PreparedPathStateTransition candidate = Objects.requireNonNull(prepared, "prepared"); + PathStateBlockTransition transition = candidate.getTransition(); + requireSameBlock(admittedMeta, transition); + PathStateParticipantScope admittedScope = Objects.requireNonNull(scope, "scope"); + + Map builders = new LinkedHashMap<>(); + for (PathStateMutation mutation : transition.getMutations()) { + PathStateParticipant participant = admittedScope.require(mutation.getDbName()); + StoreBuilder builder = builders.computeIfAbsent(participant.getStoreId(), + ignored -> new StoreBuilder(participant)); + byte[] secureKey = PathStateCommitmentCodec.storeLeafKey(participant.getStoreId(), + mutation.getPhysicalKey()); + byte[] encodedValue = mutation.isDelete() ? null + : PathStateCommitmentCodec.presentLeafValue(mutation.getPhysicalValue()); + builder.flatMutations.add(new Mutation(secureKey, encodedValue)); + } + + List superMutations = new ArrayList<>(); + for (PreparedPathStateTransition.NodeMutation mutation : candidate.getNodeMutations()) { + Mutation forward = new Mutation(mutation.getPath(), mutation.getEncodedNode()); + if (mutation.getStoreId() == 0) { + superMutations.add(forward); + } else { + PathStateParticipant participant = participant(admittedScope, mutation.getStoreId()); + builders.computeIfAbsent(participant.getStoreId(), + ignored -> new StoreBuilder(participant)).nodeMutations.add(forward); + } + } + + List deltas = new ArrayList<>(); + for (PathStateParticipant participant : admittedScope.getParticipants()) { + StoreBuilder builder = builders.get(participant.getStoreId()); + if (builder != null) { + deltas.add(builder.freeze(candidate.getSnapshot().participantRoot( + participant.getDbName()))); + } + } + return new PathStateSnapshotDelta(admittedMeta, candidate.getParent().getStateRoot(), + candidate.getStateRoot(), transition.getPayloadDigest(), deltas, superMutations); + } + + static PathStateSnapshotDelta fromPhysical(BlockSnapshotMeta meta, + PathStateRootMetadata parent, PathStateBlockTransition transition, + PathStateRoot.Snapshot snapshot, List stores, + List superNodeMutations) { + BlockSnapshotMeta admittedMeta = Objects.requireNonNull(meta, "meta"); + PathStateRootMetadata admittedParent = Objects.requireNonNull(parent, "parent"); + PathStateBlockTransition admittedTransition = Objects.requireNonNull(transition, + "transition"); + PathStateRoot.Snapshot admittedSnapshot = Objects.requireNonNull(snapshot, "snapshot"); + requireSameBlock(admittedMeta, admittedTransition); + return new PathStateSnapshotDelta(admittedMeta, admittedParent.getStateRoot(), + admittedSnapshot.getStateRoot(), admittedTransition.getPayloadDigest(), stores, + superNodeMutations); + } + + public BlockSnapshotMeta getMeta() { + return meta; + } + + public byte[] getParentStateRoot() { + return Arrays.copyOf(parentStateRoot, parentStateRoot.length); + } + + public byte[] getStateRoot() { + return Arrays.copyOf(stateRoot, stateRoot.length); + } + + public byte[] getTransitionPayloadDigest() { + return Arrays.copyOf(transitionPayloadDigest, transitionPayloadDigest.length); + } + + public List getStores() { + return stores; + } + + public List getSuperNodeMutations() { + return superNodeMutations; + } + + private static void requireSameBlock(BlockSnapshotMeta meta, + PathStateBlockTransition transition) { + if (meta.getBlockNumber() != transition.getBlockNumber() + || !Arrays.equals(meta.getBlockHash(), transition.getBlockHash()) + || !Arrays.equals(meta.getParentHash(), transition.getParentHash()) + || meta.getTimestamp() != transition.getTimestamp()) { + throw new IllegalArgumentException( + "path-state delta differs from Snapshot block identity"); + } + } + + private static PathStateParticipant participant(PathStateParticipantScope scope, int storeId) { + for (PathStateParticipant candidate : scope.getParticipants()) { + if (candidate.getStoreId() == storeId) { + return candidate; + } + } + throw new IllegalArgumentException("unknown path-state Store ID: " + storeId); + } + + private static byte[] root(byte[] value, String name) { + byte[] copy = Arrays.copyOf(Objects.requireNonNull(value, name), value.length); + if (copy.length != PathStateCommitmentCodec.ROOT_LENGTH) { + throw new IllegalArgumentException(name + " must contain exactly 32 bytes"); + } + return copy; + } + + private static List immutableMutations(List supplied) { + List copy = new ArrayList<>(Objects.requireNonNull(supplied, "mutations")); + copy.sort(MUTATION_ORDER); + for (int index = 1; index < copy.size(); index++) { + if (Arrays.equals(copy.get(index - 1).key, copy.get(index).key)) { + throw new IllegalArgumentException("duplicate path-state forward mutation key"); + } + } + return Collections.unmodifiableList(copy); + } + + private static int compareUnsigned(byte[] left, byte[] right) { + for (int index = 0; index < Math.min(left.length, right.length); index++) { + int compared = Integer.compare(left[index] & 0xff, right[index] & 0xff); + if (compared != 0) { + return compared; + } + } + return Integer.compare(left.length, right.length); + } + + public static final class StoreDelta { + + private final int storeId; + private final String dbName; + private final byte[] storeRoot; + private final List flatMutations; + private final List nodeMutations; + + StoreDelta(PathStateParticipant participant, byte[] storeRoot, + List flatMutations, List nodeMutations) { + this.storeId = participant.getStoreId(); + this.dbName = participant.getDbName(); + this.storeRoot = root(storeRoot, "storeRoot"); + this.flatMutations = immutableMutations(flatMutations); + this.nodeMutations = immutableMutations(nodeMutations); + if (this.flatMutations.isEmpty()) { + throw new IllegalArgumentException("changed path-state Store has no flat mutations"); + } + } + + public int getStoreId() { + return storeId; + } + + public String getDbName() { + return dbName; + } + + public byte[] getStoreRoot() { + return Arrays.copyOf(storeRoot, storeRoot.length); + } + + public List getFlatMutations() { + return flatMutations; + } + + public List getNodeMutations() { + return nodeMutations; + } + } + + public static final class Mutation { + + private final byte[] key; + private final byte[] value; + + Mutation(byte[] key, byte[] value) { + this.key = Arrays.copyOf(Objects.requireNonNull(key, "key"), key.length); + this.value = value == null ? null : Arrays.copyOf(value, value.length); + } + + public byte[] getKey() { + return Arrays.copyOf(key, key.length); + } + + public byte[] getValue() { + return value == null ? null : Arrays.copyOf(value, value.length); + } + + public boolean isDelete() { + return value == null; + } + } + + private static final class StoreBuilder { + + private final PathStateParticipant participant; + private final List flatMutations = new ArrayList<>(); + private final List nodeMutations = new ArrayList<>(); + + private StoreBuilder(PathStateParticipant participant) { + this.participant = participant; + } + + private StoreDelta freeze(byte[] storeRoot) { + return new StoreDelta(participant, storeRoot, flatMutations, nodeMutations); + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateSnapshotHead.java b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateSnapshotHead.java new file mode 100644 index 00000000000..8d9d4aaff8e --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateSnapshotHead.java @@ -0,0 +1,193 @@ +package org.tron.core.db2.stateroot; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Objects; +import org.tron.core.db2.archive.BlockSnapshotMeta; + +/** In-process snapshot authority that advances only with the durable block-final CURRENT head. */ +public final class PathStateSnapshotHead implements PathStateHead { + + private final PathStateStoreManifest manifest; + private final PathStateLayerLimits limits; + private PathStateRootMetadata head; + private PathStateRoot.Snapshot snapshot; + private boolean failed; + + private PathStateSnapshotHead(PathStateStoreManifest manifest, PathStateLayerLimits limits, + PathStateRootMetadata head, PathStateRoot.Snapshot snapshot) { + this.manifest = manifest; + this.limits = limits; + this.head = head; + this.snapshot = snapshot; + } + + /** Restores and verifies the exact durable CURRENT root before owning its detached snapshot. */ + public static PathStateSnapshotHead open(PathStateStoreManifest manifest, + PathStateLayerLimits limits) throws IOException { + PathStateStoreManifest admitted = Objects.requireNonNull(manifest, "manifest"); + PathStateLayerLimits admittedLimits = Objects.requireNonNull(limits, "limits"); + new PathStateBaseCompaction(admitted, admittedLimits).recover(); + PathStateRootMetadata current = new PathStateCurrentStore(admitted).current(); + try (PathStateNodeStoreSet stores = PathStateNodeStoreSet.openPublished(admitted, current)) { + PathStateRoot root = stores.createRoot(); + PathStateRoot.Snapshot restored = root.snapshot(); + if (!Arrays.equals(restored.getStateRoot(), current.getStateRoot())) { + throw new IOException("path-state snapshot head restore root mismatch"); + } + return new PathStateSnapshotHead(admitted, admittedLimits, current, restored); + } catch (IllegalArgumentException | IllegalStateException failure) { + throw new IOException("path-state snapshot head restore failed", failure); + } + } + + /** Applies one exact child and publishes its snapshot only after durable layer commit succeeds. */ + public synchronized PathStateRootMetadata advance(PathStateBlockTransition transition) + throws IOException { + return advancePrepared(prepare(transition)); + } + + /** Switches the owned durable CURRENT and snapshot to an exact reversible ancestor. */ + public synchronized PathStateRootMetadata rewindTo(long blockNumber, byte[] blockHash) + throws IOException { + requireHealthy(); + PathStateRootMetadata previous = head; + try { + PathStateCurrentStore currentStore = new PathStateCurrentStore(manifest); + PathStateRootMetadata target = currentStore.findAncestor(blockNumber, blockHash, limits); + PathStateRootMetadata switched = new PathStateLayerRetirement(manifest, limits) + .switchToAncestor(target); + PathStateSnapshotHead restored = open(manifest, limits); + if (!same(switched, restored.head) + || restored.head.getBlockNumber() != blockNumber + || !Arrays.equals(restored.head.getBlockHash(), blockHash)) { + failed = true; + throw new IOException("path-state rewound snapshot identity mismatch"); + } + head = restored.head; + snapshot = restored.snapshot; + return head; + } catch (IOException | RuntimeException failure) { + failIfAuthorityMoved(previous, failure); + throw failure; + } + } + + /** Compacts the exact Chainbase-flushed prefix while retaining this newer reversible head. */ + public synchronized PathStateRootMetadata flushBaseThrough(long blockNumber, byte[] blockHash) + throws IOException { + requireHealthy(); + PathStateRootMetadata previous = head; + try { + PathStateRootMetadata base = new PathStateBaseCompaction(manifest, limits) + .compactThrough(blockNumber, blockHash); + if (!same(previous, new PathStateCurrentStore(manifest).current())) { + failed = true; + throw new IOException("path-state base flush changed the owned head"); + } + return base; + } catch (IOException | RuntimeException failure) { + failIfAuthorityMoved(previous, failure); + throw failure; + } + } + + /** Computes an immutable candidate without opening or changing durable path-state storage. */ + public synchronized PreparedPathStateTransition prepare(PathStateBlockTransition transition) + throws IOException { + requireHealthy(); + PathStateBlockTransition admitted = Objects.requireNonNull(transition, "transition"); + requireChild(admitted); + return PreparedPathStateTransition.prepare(head, snapshot, admitted); + } + + @Override + public synchronized byte[] preview(PathStateBlockTransition transition) throws IOException { + return prepare(transition).getStateRoot(); + } + + @Override + public synchronized PathStateSnapshotDelta prepareSnapshotDelta(BlockSnapshotMeta meta, + PathStateBlockTransition transition) throws IOException { + return prepare(transition).toSnapshotDelta(Objects.requireNonNull(meta, "meta")); + } + + /** Publishes one exact prepared child and adopts it only after CURRENT confirms durability. */ + public synchronized PathStateRootMetadata advancePrepared( + PreparedPathStateTransition prepared) throws IOException { + requireHealthy(); + PreparedPathStateTransition admitted = Objects.requireNonNull(prepared, "prepared"); + if (!admitted.extendsParent(head)) { + throw new IOException("path-state prepared transition does not extend owned head"); + } + PathStateRootMetadata previous = head; + PathStateRoot.Snapshot candidateSnapshot = admitted.getSnapshot(); + PathStateRootMetadata committed; + PathStateBlockTransition transition = admitted.getTransition(); + try (PathStateLayer layer = PathStateLayer.beginPrepared(manifest, previous, admitted, limits)) { + committed = layer.commit(); + } catch (IOException | RuntimeException failure) { + failIfAuthorityMoved(previous, failure); + throw failure; + } + if (!same(committed, new PathStateCurrentStore(manifest).current()) + || committed.getBlockNumber() != transition.getBlockNumber() + || !Arrays.equals(committed.getBlockHash(), transition.getBlockHash()) + || !Arrays.equals(committed.getParentHash(), transition.getParentHash()) + || !Arrays.equals(committed.getPayloadDigest(), transition.getPayloadDigest()) + || !Arrays.equals(committed.getStateRoot(), candidateSnapshot.getStateRoot())) { + failed = true; + throw new IOException("path-state committed snapshot identity mismatch"); + } + head = committed; + snapshot = candidateSnapshot; + return committed; + } + + public synchronized PathStateRootMetadata getHead() throws IOException { + requireHealthy(); + return head; + } + + public synchronized PathStateRoot.Snapshot getSnapshot() throws IOException { + requireHealthy(); + return snapshot; + } + + public synchronized boolean isFailed() { + return failed; + } + + @Override + public void close() { + // The legacy head opens native stores only inside bounded operations. + } + + private void requireChild(PathStateBlockTransition transition) throws IOException { + if (transition.getBlockNumber() != head.getBlockNumber() + 1 + || !Arrays.equals(transition.getParentHash(), head.getBlockHash())) { + throw new IOException("path-state snapshot transition does not extend owned head"); + } + } + + private void failIfAuthorityMoved(PathStateRootMetadata previous, Throwable failure) { + try { + if (!same(previous, new PathStateCurrentStore(manifest).current())) { + failed = true; + } + } catch (IOException currentFailure) { + failed = true; + failure.addSuppressed(currentFailure); + } + } + + private void requireHealthy() throws IOException { + if (failed) { + throw new IOException("path-state snapshot head is failed"); + } + } + + private static boolean same(PathStateRootMetadata left, PathStateRootMetadata right) { + return Arrays.equals(left.encode(), right.encode()); + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateStackTrie.java b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateStackTrie.java new file mode 100644 index 00000000000..e3de4abc1aa --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateStackTrie.java @@ -0,0 +1,386 @@ +package org.tron.core.db2.stateroot; + +import java.io.ByteArrayOutputStream; +import java.util.Arrays; +import java.util.Objects; +import org.tron.common.crypto.Hash; + +/** + * Ascending-key MPT builder that commits completed subtrees and retains only the right frontier. + * + *

This follows the memory boundary of Geth's StackTrie: once ascending input moves beyond a + * subtree, that subtree is encoded, emitted to the node sink, replaced by its reference, and its + * children are released. Keys must be fixed secure keys in strict unsigned order. + */ +final class PathStateStackTrie { + + private static final int EMPTY = 0; + private static final int BRANCH = 1; + private static final int EXTENSION = 2; + private static final int LEAF = 3; + private static final int HASHED = 4; + private static final byte[] EMPTY_RLP_ITEM = new byte[]{(byte) 0x80}; + + private final NodeSink sink; + private final Node root = new Node(); + private byte[] previousKey; + private boolean finished; + private long emittedNodes; + + PathStateStackTrie(NodeSink sink) { + this.sink = Objects.requireNonNull(sink, "sink"); + } + + void update(byte[] secureKey, byte[] encodedValue) { + requireOpen(); + byte[] key = secureKey(secureKey); + byte[] value = nonEmpty(encodedValue, "encodedValue"); + if (previousKey != null && compareUnsigned(previousKey, key) >= 0) { + throw new IllegalArgumentException("path-state stack trie keys must be strictly ascending"); + } + previousKey = key; + insert(root, toNibbles(key), value, new byte[0]); + } + + byte[] rootHash() { + if (!finished) { + hash(root, new byte[0]); + finished = true; + } + return root.type == EMPTY + ? Arrays.copyOf(Hash.EMPTY_TRIE_HASH, Hash.EMPTY_TRIE_HASH.length) + : Arrays.copyOf(root.value, root.value.length); + } + + long getEmittedNodes() { + return emittedNodes; + } + + int retainedNodes() { + return retainedNodes(root); + } + + private void requireOpen() { + if (finished) { + throw new IllegalStateException("path-state stack trie is already finished"); + } + } + + private void insert(Node node, byte[] key, byte[] value, byte[] path) { + switch (node.type) { + case EMPTY: + node.becomeLeaf(key, value); + return; + case BRANCH: + int branch = key[0] & 0xff; + for (int sibling = branch - 1; sibling >= 0; sibling--) { + if (node.children[sibling] != null) { + if (node.children[sibling].type != HASHED) { + hash(node.children[sibling], append(path, (byte) sibling)); + } + break; + } + } + if (node.children[branch] == null) { + node.children[branch] = Node.leaf(slice(key, 1), value); + } else { + insert(node.children[branch], slice(key, 1), value, append(path, key[0])); + } + return; + case EXTENSION: + splitExtension(node, key, value, path); + return; + case LEAF: + splitLeaf(node, key, value, path); + return; + case HASHED: + throw new IllegalStateException("path-state stack trie inserted into committed subtree"); + default: + throw new IllegalStateException("unknown path-state stack trie node"); + } + } + + private void splitExtension(Node node, byte[] key, byte[] value, byte[] path) { + int shared = commonPrefix(node.key, key); + if (shared == node.key.length) { + insert(node.children[0], slice(key, shared), value, + concatenate(path, slice(key, 0, shared))); + return; + } + Node oldChild; + if (shared < node.key.length - 1) { + oldChild = Node.extension(slice(node.key, shared + 1), node.children[0]); + hash(oldChild, concatenate(path, slice(node.key, 0, shared + 1))); + } else { + oldChild = node.children[0]; + hash(oldChild, concatenate(path, node.key)); + } + byte oldBranch = node.key[shared]; + byte newBranch = key[shared]; + Node branch = new Node(); + branch.type = BRANCH; + branch.children = new Node[16]; + branch.children[oldBranch & 0xff] = oldChild; + branch.children[newBranch & 0xff] = Node.leaf(slice(key, shared + 1), value); + if (shared == 0) { + node.copyFrom(branch); + } else { + node.type = EXTENSION; + node.key = slice(node.key, 0, shared); + node.value = null; + node.children = new Node[1]; + node.children[0] = branch; + } + } + + private void splitLeaf(Node node, byte[] key, byte[] value, byte[] path) { + int shared = commonPrefix(node.key, key); + if (shared >= node.key.length || shared >= key.length) { + throw new IllegalArgumentException("path-state stack trie duplicate or prefixed key"); + } + Node branch = new Node(); + branch.type = BRANCH; + branch.children = new Node[16]; + byte oldBranch = node.key[shared]; + byte newBranch = key[shared]; + Node oldLeaf = Node.leaf(slice(node.key, shared + 1), node.value); + hash(oldLeaf, concatenate(path, slice(node.key, 0, shared + 1))); + branch.children[oldBranch & 0xff] = oldLeaf; + branch.children[newBranch & 0xff] = Node.leaf(slice(key, shared + 1), value); + if (shared == 0) { + node.copyFrom(branch); + } else { + node.type = EXTENSION; + node.key = slice(node.key, 0, shared); + node.value = null; + node.children = new Node[]{branch}; + } + } + + private void hash(Node node, byte[] path) { + if (node.type == HASHED) { + return; + } + byte[] encoded; + switch (node.type) { + case EMPTY: + node.type = HASHED; + node.key = null; + node.children = null; + node.value = Arrays.copyOf(Hash.EMPTY_TRIE_HASH, Hash.EMPTY_TRIE_HASH.length); + return; + case BRANCH: + byte[][] children = new byte[17][]; + for (int index = 0; index < 16; index++) { + Node child = node.children[index]; + if (child == null) { + children[index] = EMPTY_RLP_ITEM; + } else { + hash(child, append(path, (byte) index)); + children[index] = nodeReference(child.value); + node.children[index] = null; + } + } + children[16] = EMPTY_RLP_ITEM; + encoded = rlpList(children); + break; + case EXTENSION: + hash(node.children[0], concatenate(path, node.key)); + encoded = rlpList(rlpItem(compactPath(node.key, false)), + nodeReference(node.children[0].value)); + node.children[0] = null; + break; + case LEAF: + encoded = rlpList(rlpItem(compactPath(node.key, true)), rlpItem(node.value)); + break; + default: + throw new IllegalStateException("unknown path-state stack trie node"); + } + node.type = HASHED; + node.key = null; + node.children = null; + node.value = encoded.length < 32 && path.length != 0 + ? encoded : Hash.sha3(encoded); + // Path-state addresses nodes by trie path, so persist embedded nodes as well as hashed nodes. + // The parent reference still follows canonical MPT embedding rules. + sink.put(path, encoded); + emittedNodes++; + } + + private static byte[] nodeReference(byte[] value) { + return value.length < 32 ? Arrays.copyOf(value, value.length) : rlpItem(value); + } + + private static byte[] compactPath(byte[] nibbles, boolean leaf) { + int odd = nibbles.length & 1; + int flags = (leaf ? 2 : 0) + odd; + byte[] encoded = new byte[1 + nibbles.length / 2]; + int source = 0; + if (odd == 1) { + encoded[0] = (byte) ((flags << 4) | nibbles[source++]); + } else { + encoded[0] = (byte) (flags << 4); + } + int target = 1; + while (source < nibbles.length) { + encoded[target++] = (byte) ((nibbles[source++] << 4) | nibbles[source++]); + } + return encoded; + } + + private static byte[] rlpItem(byte[] raw) { + byte[] value = Objects.requireNonNull(raw, "raw"); + if (value.length == 1 && (value[0] & 0xff) < 0x80) { + return Arrays.copyOf(value, value.length); + } + byte[] prefix = rlpLength(value.length, 0x80, 0xb7); + return concatenate(prefix, value); + } + + private static byte[] rlpList(byte[]... items) { + int length = 0; + for (byte[] item : items) { + length = Math.addExact(length, item.length); + } + byte[] prefix = rlpLength(length, 0xc0, 0xf7); + ByteArrayOutputStream output = new ByteArrayOutputStream(prefix.length + length); + output.write(prefix, 0, prefix.length); + for (byte[] item : items) { + output.write(item, 0, item.length); + } + return output.toByteArray(); + } + + private static byte[] rlpLength(int length, int shortOffset, int longOffset) { + if (length <= 55) { + return new byte[]{(byte) (shortOffset + length)}; + } + int bytes = Integer.BYTES - Integer.numberOfLeadingZeros(length) / Byte.SIZE; + byte[] encoded = new byte[bytes + 1]; + encoded[0] = (byte) (longOffset + bytes); + for (int index = bytes; index > 0; index--) { + encoded[index] = (byte) length; + length >>>= Byte.SIZE; + } + return encoded; + } + + private static int retainedNodes(Node node) { + if (node == null) { + return 0; + } + int count = 1; + if (node.children != null) { + for (Node child : node.children) { + count += retainedNodes(child); + } + } + return count; + } + + private static int commonPrefix(byte[] left, byte[] right) { + int length = Math.min(left.length, right.length); + int index = 0; + while (index < length && left[index] == right[index]) { + index++; + } + return index; + } + + private static byte[] toNibbles(byte[] key) { + byte[] nibbles = new byte[key.length * 2]; + for (int index = 0; index < key.length; index++) { + nibbles[index * 2] = (byte) ((key[index] >>> 4) & 0x0f); + nibbles[index * 2 + 1] = (byte) (key[index] & 0x0f); + } + return nibbles; + } + + private static byte[] append(byte[] value, byte suffix) { + byte[] result = Arrays.copyOf(value, value.length + 1); + result[value.length] = suffix; + return result; + } + + private static byte[] concatenate(byte[] left, byte[] right) { + byte[] result = Arrays.copyOf(left, left.length + right.length); + System.arraycopy(right, 0, result, left.length, right.length); + return result; + } + + private static byte[] slice(byte[] value, int from) { + return slice(value, from, value.length); + } + + private static byte[] slice(byte[] value, int from, int to) { + return Arrays.copyOfRange(value, from, to); + } + + private static int compareUnsigned(byte[] left, byte[] right) { + for (int index = 0; index < Math.min(left.length, right.length); index++) { + int compared = Integer.compare(left[index] & 0xff, right[index] & 0xff); + if (compared != 0) { + return compared; + } + } + return Integer.compare(left.length, right.length); + } + + private static byte[] secureKey(byte[] value) { + byte[] key = Arrays.copyOf(Objects.requireNonNull(value, "secureKey"), value.length); + if (key.length != PathMerkleTrie.SECURE_KEY_LENGTH) { + throw new IllegalArgumentException("secureKey must contain exactly 32 bytes"); + } + return key; + } + + private static byte[] nonEmpty(byte[] value, String name) { + byte[] copy = Arrays.copyOf(Objects.requireNonNull(value, name), value.length); + if (copy.length == 0) { + throw new IllegalArgumentException(name + " must not be empty"); + } + return copy; + } + + @FunctionalInterface + interface NodeSink { + + void put(byte[] path, byte[] encodedNode); + } + + private static final class Node { + + private int type; + private byte[] key; + private byte[] value; + private Node[] children; + + private static Node leaf(byte[] key, byte[] value) { + Node node = new Node(); + node.becomeLeaf(key, value); + return node; + } + + private static Node extension(byte[] key, Node child) { + Node node = new Node(); + node.type = EXTENSION; + node.key = key; + node.children = new Node[]{child}; + return node; + } + + private void becomeLeaf(byte[] nextKey, byte[] nextValue) { + type = LEAF; + key = Arrays.copyOf(nextKey, nextKey.length); + value = Arrays.copyOf(nextValue, nextValue.length); + children = null; + } + + private void copyFrom(Node source) { + type = source.type; + key = source.key; + value = source.value; + children = source.children; + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateStoreManifest.java b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateStoreManifest.java new file mode 100644 index 00000000000..676308a0c31 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateStoreManifest.java @@ -0,0 +1,283 @@ +package org.tron.core.db2.stateroot; + +import com.google.common.hash.Hashing; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.util.Arrays; +import java.util.Locale; +import java.util.UUID; +import org.tron.core.db2.stateroot.PathStateParticipantDescriptor.StoreIdentity; + +/** Durable format admission anchor for an enabled current path-state backend. */ +public final class PathStateStoreManifest { + + public static final String MANIFEST_FILE = "MANIFEST"; + public static final String BASE_DIRECTORY = "base"; + public static final String LAYERS_DIRECTORY = "layers"; + + private static final int MAGIC = 0x50534d46; // PSMF + private static final short VERSION = 1; + private static final int MAX_LENGTH = 1024 * 1024; + + private final Path directory; + private final Engine engine; + private final byte[] identityDigest; + + private PathStateStoreManifest(Path directory, Engine engine) { + this.directory = directory; + this.engine = engine; + this.identityDigest = Hashing.sha256().hashBytes(encode(engine)).asBytes(); + } + + /** Creates a new exact-format manifest or validates an existing one without rewriting it. */ + public static PathStateStoreManifest createOrOpen(Path directory, Engine engine) + throws IOException { + Path root = directory.toAbsolutePath().normalize(); + Engine selected = requireEngine(engine); + rejectSymbolicLink(root); + Files.createDirectories(root); + requireDirectory(root, "path-state root"); + + byte[] expected = encode(selected); + Path manifest = root.resolve(MANIFEST_FILE); + if (Files.exists(manifest, LinkOption.NOFOLLOW_LINKS)) { + validateExisting(manifest, expected); + } else { + publish(manifest, expected); + } + ensureChildDirectory(root.resolve(BASE_DIRECTORY)); + ensureChildDirectory(root.resolve(LAYERS_DIRECTORY)); + return new PathStateStoreManifest(root, selected); + } + + /** Validates an existing manifest without creating or modifying filesystem entries. */ + public static PathStateStoreManifest validateExisting(Path directory, Engine engine) + throws IOException { + Path root = directory.toAbsolutePath().normalize(); + Engine selected = requireEngine(engine); + rejectSymbolicLink(root); + requireDirectory(root, "path-state root"); + Path manifest = root.resolve(MANIFEST_FILE); + validateExisting(manifest, encode(selected)); + Path base = root.resolve(BASE_DIRECTORY); + if (!Files.isDirectory(base, LinkOption.NOFOLLOW_LINKS)) { + requireBaseReplacementRecoveryLayout(root); + } + requireDirectory(root.resolve(LAYERS_DIRECTORY), "path-state layers"); + return new PathStateStoreManifest(root, selected); + } + + private static void requireBaseReplacementRecoveryLayout(Path root) throws IOException { + Path intent = root.resolve(PathStateBaseCompaction.INTENT_FILE); + if (!Files.isRegularFile(intent, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("path-state base is missing outside a durable replacement"); + } + requireDirectory(root.resolve(PathStateBaseCompaction.NEXT_DIRECTORY), + "path-state next base"); + requireDirectory(root.resolve(PathStateBaseCompaction.PREVIOUS_DIRECTORY), + "path-state previous base"); + } + + public Path getDirectory() { + return directory; + } + + public Path getBaseDirectory() { + return directory.resolve(BASE_DIRECTORY); + } + + public Path getLayersDirectory() { + return directory.resolve(LAYERS_DIRECTORY); + } + + public Path getLayerDirectory(long blockNumber, byte[] blockHash) { + if (blockNumber < 0) { + throw new IllegalArgumentException("blockNumber must not be negative"); + } + byte[] hash = Arrays.copyOf(blockHash, blockHash.length); + if (hash.length != PathStateRootMetadata.DIGEST_LENGTH) { + throw new IllegalArgumentException("blockHash must be exactly 32 bytes"); + } + return getLayersDirectory().resolve(String.format(Locale.ROOT, "%020d-%s", + blockNumber, hex(hash))); + } + + public Engine getEngine() { + return engine; + } + + public byte[] getIdentityDigest() { + return Arrays.copyOf(identityDigest, identityDigest.length); + } + + private static byte[] encode(Engine engine) { + try { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + DataOutputStream output = new DataOutputStream(bytes); + output.writeInt(MAGIC); + output.writeShort(VERSION); + output.writeShort(0); + output.writeInt(0); + writeString(output, PathStateParticipantDescriptor.SCOPE_ID); + output.writeByte(engine.tag); + output.writeShort(PathStateCommitmentCodec.FORMAT_VERSION); + PathStateParticipantDescriptor descriptor = PathStateParticipantDescriptor.current(); + PathStateCanonicalizer canonicalizer = new PathStateCanonicalizer(); + output.writeInt(descriptor.getStores().size()); + for (StoreIdentity store : descriptor.getStores()) { + PathStateCanonicalizer.StoreFormat format = + canonicalizer.requireFormat(store.getDbName()); + output.writeInt(store.getStoreId()); + writeString(output, store.getDbName()); + writeString(output, store.getComparatorId()); + output.writeInt(format.getStoreFormatVersion()); + writeString(output, format.getCodecId()); + } + output.flush(); + byte[] payload = bytes.toByteArray(); + int length = payload.length + Integer.BYTES; + if (length > MAX_LENGTH) { + throw new IllegalArgumentException("path-state manifest is too large"); + } + ByteBuffer.wrap(payload).putInt(8, length); + bytes.reset(); + output = new DataOutputStream(bytes); + output.write(payload); + output.writeInt(Hashing.crc32c().hashBytes(payload).asInt()); + output.flush(); + return bytes.toByteArray(); + } catch (IOException impossible) { + throw new IllegalStateException("in-memory path-state manifest encoding failed", impossible); + } + } + + private static void validateExisting(Path manifest, byte[] expected) throws IOException { + if (!Files.isRegularFile(manifest, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("path-state manifest is missing or not a regular file"); + } + long size = Files.size(manifest); + if (size <= Integer.BYTES || size > MAX_LENGTH) { + throw new IOException("path-state manifest length is invalid"); + } + byte[] actual = Files.readAllBytes(manifest); + decodeHeader(actual); + if (!Arrays.equals(expected, actual)) { + throw new IOException("path-state manifest identity mismatch"); + } + } + + private static void decodeHeader(byte[] encoded) throws IOException { + if (encoded.length <= Integer.BYTES || encoded.length > MAX_LENGTH) { + throw new IOException("path-state manifest length is invalid"); + } + byte[] payload = Arrays.copyOf(encoded, encoded.length - Integer.BYTES); + int checksum = ByteBuffer.wrap(encoded, payload.length, Integer.BYTES).getInt(); + if (checksum != Hashing.crc32c().hashBytes(payload).asInt()) { + throw new IOException("path-state manifest checksum mismatch"); + } + try (DataInputStream input = new DataInputStream(new ByteArrayInputStream(encoded))) { + if (input.readInt() != MAGIC || input.readShort() != VERSION || input.readShort() != 0 + || input.readInt() != encoded.length) { + throw new IOException("unsupported path-state manifest header"); + } + } + } + + private static void publish(Path manifest, byte[] encoded) throws IOException { + Path directory = manifest.getParent(); + Path temporary = directory.resolve(".MANIFEST-" + UUID.randomUUID()); + try { + try (FileChannel channel = FileChannel.open(temporary, StandardOpenOption.CREATE_NEW, + StandardOpenOption.WRITE)) { + writeFully(channel, ByteBuffer.wrap(encoded)); + channel.force(true); + } + try { + Files.move(temporary, manifest, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException unsupported) { + throw new IOException("path-state manifest requires atomic publication", unsupported); + } + syncDirectory(directory); + } finally { + Files.deleteIfExists(temporary); + } + } + + private static void ensureChildDirectory(Path path) throws IOException { + rejectSymbolicLink(path); + Files.createDirectories(path); + requireDirectory(path, "path-state child"); + } + + private static void rejectSymbolicLink(Path path) throws IOException { + if (Files.isSymbolicLink(path)) { + throw new IOException("path-state path must not be a symbolic link: " + path); + } + } + + private static void requireDirectory(Path path, String name) throws IOException { + if (!Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException(name + " is missing or not a directory: " + path); + } + } + + private static Engine requireEngine(Engine engine) { + if (engine == null) { + throw new IllegalArgumentException("path-state engine must not be null"); + } + return engine; + } + + private static void writeString(DataOutputStream output, String value) throws IOException { + byte[] encoded = value.getBytes(StandardCharsets.UTF_8); + if (encoded.length == 0 || encoded.length > 1024) { + throw new IllegalArgumentException("path-state manifest string is invalid"); + } + output.writeShort(encoded.length); + output.write(encoded); + } + + private static void writeFully(FileChannel channel, ByteBuffer buffer) throws IOException { + while (buffer.hasRemaining()) { + channel.write(buffer); + } + } + + private static void syncDirectory(Path directory) throws IOException { + try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) { + channel.force(true); + } + } + + private static String hex(byte[] value) { + StringBuilder encoded = new StringBuilder(value.length * 2); + for (byte current : value) { + encoded.append(Character.forDigit(current >>> 4 & 0xf, 16)); + encoded.append(Character.forDigit(current & 0xf, 16)); + } + return encoded.toString(); + } + + public enum Engine { + LEVELDB(1), + ROCKSDB(2); + + private final int tag; + + Engine(int tag) { + this.tag = tag; + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateStoreTrieBuilder.java b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateStoreTrieBuilder.java new file mode 100644 index 00000000000..6435a115968 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateStoreTrieBuilder.java @@ -0,0 +1,182 @@ +package org.tron.core.db2.stateroot; + +import java.io.Closeable; +import java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import org.tron.core.db2.stateroot.PathStateNativeNodeStore.BatchMutation; +import org.tron.core.db2.stateroot.PathStateStoreManifest.Engine; + +/** Disk-sorted, bounded-memory builder for one participant Store trie. */ +final class PathStateStoreTrieBuilder implements Closeable { + + static final int DEFAULT_WRITE_BATCH_ROWS = 4096; + private static final long BUILD_PROGRESS_ROWS = 1L << 20; + + private final PathStateNativeNodeStore spool; + private final PathStateStackTrie.NodeSink nodeSink; + private final LeafSink leafSink; + private final BuildProgress buildProgress; + private final byte[] generationPrefix; + private final List pendingRows = new ArrayList<>(DEFAULT_WRITE_BATCH_ROWS); + private boolean built; + private long inputRows; + private long sortedRows; + + PathStateStoreTrieBuilder(Path spoolDirectory, Engine engine, + PathStateStackTrie.NodeSink nodeSink) throws IOException { + this(spoolDirectory, engine, new byte[0], nodeSink, + (secureKey, encodedValue) -> { }); + } + + PathStateStoreTrieBuilder(Path spoolDirectory, Engine engine, + PathStateStackTrie.NodeSink nodeSink, LeafSink leafSink) throws IOException { + this(spoolDirectory, engine, new byte[0], nodeSink, leafSink); + } + + PathStateStoreTrieBuilder(Path spoolDirectory, Engine engine, byte[] generationPrefix, + PathStateStackTrie.NodeSink nodeSink, LeafSink leafSink) throws IOException { + this(spoolDirectory, engine, generationPrefix, nodeSink, leafSink, + (sortedRows, elapsedMillis) -> { }); + } + + PathStateStoreTrieBuilder(Path spoolDirectory, Engine engine, byte[] generationPrefix, + PathStateStackTrie.NodeSink nodeSink, LeafSink leafSink, + BuildProgress buildProgress) throws IOException { + spool = PathStateNativeNodeStore.open(Objects.requireNonNull(spoolDirectory, "spoolDirectory"), + Objects.requireNonNull(engine, "engine")); + this.nodeSink = Objects.requireNonNull(nodeSink, "nodeSink"); + this.leafSink = Objects.requireNonNull(leafSink, "leafSink"); + this.buildProgress = Objects.requireNonNull(buildProgress, "buildProgress"); + this.generationPrefix = Arrays.copyOf( + Objects.requireNonNull(generationPrefix, "generationPrefix"), generationPrefix.length); + } + + /** + * Spools one canonical leaf by its secure key. Repeated keys overwrite the prior value. + * + *

Only one bounded native write batch is retained in heap; values already flushed remain in + * the temporary database until the sorted build pass reads them. + */ + void put(byte[] secureKey, byte[] encodedValue) { + requireCollecting(); + byte[] key = requireSecureKey(secureKey); + byte[] value = nonEmpty(encodedValue, "encodedValue"); + pendingRows.add(BatchMutation.put(spoolKey(key), value)); + inputRows = Math.addExact(inputRows, 1L); + if (pendingRows.size() >= DEFAULT_WRITE_BATCH_ROWS) { + flushRows(); + } + } + + /** Flushes the spool, streams it in secure-key order, and returns the canonical Store root. */ + byte[] build() throws IOException { + requireCollecting(); + flushRows(); + PathStateStackTrie trie = new PathStateStackTrie(nodeSink); + long startedNanos = System.nanoTime(); + PathStateNativeNodeStore.EntryConsumer consumer = entry -> { + byte[] key = secureKey(entry.getKey()); + byte[] value = entry.getValue(); + trie.update(key, value); + leafSink.put(key, value); + sortedRows = Math.addExact(sortedRows, 1L); + if (sortedRows % BUILD_PROGRESS_ROWS == 0) { + buildProgress.report(sortedRows, elapsedMillis(startedNanos)); + } + }; + if (generationPrefix.length == 0) { + spool.scanAll(consumer); + } else { + spool.scanPrefix(generationPrefix, consumer); + } + if (sortedRows % BUILD_PROGRESS_ROWS != 0) { + buildProgress.report(sortedRows, elapsedMillis(startedNanos)); + } + built = true; + return trie.rootHash(); + } + + private static long elapsedMillis(long startedNanos) { + return java.util.concurrent.TimeUnit.NANOSECONDS.toMillis( + System.nanoTime() - startedNanos); + } + + long getInputRows() { + return inputRows; + } + + long getSortedRows() { + return sortedRows; + } + + int getPendingRows() { + return pendingRows.size(); + } + + private void flushRows() { + if (!pendingRows.isEmpty()) { + spool.writeBatch(new ArrayList<>(pendingRows)); + pendingRows.clear(); + } + } + + private void requireCollecting() { + if (built) { + throw new IllegalStateException("path-state Store trie builder is already built"); + } + } + + private static byte[] requireSecureKey(byte[] value) { + byte[] key = Arrays.copyOf(Objects.requireNonNull(value, "secureKey"), value.length); + if (key.length != PathMerkleTrie.SECURE_KEY_LENGTH) { + throw new IllegalArgumentException("secureKey must contain exactly 32 bytes"); + } + return key; + } + + private byte[] spoolKey(byte[] secureKey) { + byte[] key = Arrays.copyOf(generationPrefix, + generationPrefix.length + secureKey.length); + System.arraycopy(secureKey, 0, key, generationPrefix.length, secureKey.length); + return key; + } + + private byte[] secureKey(byte[] spoolKey) { + if (spoolKey.length != generationPrefix.length + PathMerkleTrie.SECURE_KEY_LENGTH) { + throw new IllegalStateException("path-state Store spool key has invalid length"); + } + return Arrays.copyOfRange(spoolKey, generationPrefix.length, spoolKey.length); + } + + private static byte[] nonEmpty(byte[] value, String name) { + byte[] copy = Arrays.copyOf(Objects.requireNonNull(value, name), value.length); + if (copy.length == 0) { + throw new IllegalArgumentException(name + " must not be empty"); + } + return copy; + } + + @Override + public void close() throws IOException { + if (!built) { + flushRows(); + } + spool.close(); + } + + @FunctionalInterface + interface LeafSink { + + void put(byte[] secureKey, byte[] encodedValue); + } + + @FunctionalInterface + interface BuildProgress { + + void report(long sortedRows, long elapsedMillis); + } +} diff --git a/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateTransitionCollector.java b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateTransitionCollector.java new file mode 100644 index 00000000000..14d3d672fb7 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/stateroot/PathStateTransitionCollector.java @@ -0,0 +1,11 @@ +package org.tron.core.db2.stateroot; + +import java.io.IOException; +import org.tron.core.db2.archive.BlockChangeView; + +/** Builds one immutable path-state transition from the shared block-final change view. */ +@FunctionalInterface +public interface PathStateTransitionCollector { + + PathStateBlockTransition collect(BlockChangeView view) throws IOException; +} diff --git a/chainbase/src/main/java/org/tron/core/db2/stateroot/PreparedPathStateTransition.java b/chainbase/src/main/java/org/tron/core/db2/stateroot/PreparedPathStateTransition.java new file mode 100644 index 00000000000..dd4c8c928ca --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/db2/stateroot/PreparedPathStateTransition.java @@ -0,0 +1,247 @@ +package org.tron.core.db2.stateroot; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import org.tron.common.crypto.Hash; +import org.tron.core.db2.archive.BlockSnapshotMeta; + +/** Immutable, memory-only candidate trie result for one exact block transition. */ +public final class PreparedPathStateTransition { + + private final PathStateRootMetadata parent; + private final PathStateBlockTransition transition; + private final PathStateRoot.Snapshot snapshot; + private final List nodeMutations; + + private PreparedPathStateTransition(PathStateRootMetadata parent, + PathStateBlockTransition transition, PathStateRoot.Snapshot snapshot, + List nodeMutations) { + this.parent = parent; + this.transition = transition; + this.snapshot = snapshot; + this.nodeMutations = Collections.unmodifiableList(new ArrayList<>(nodeMutations)); + } + + /** Computes candidate node changes without opening or writing any native path-state Store. */ + public static PreparedPathStateTransition prepare(PathStateRootMetadata parent, + PathStateRoot.Snapshot parentSnapshot, PathStateBlockTransition transition) { + PathStateRootMetadata admittedParent = Objects.requireNonNull(parent, "parent"); + PathStateRoot.Snapshot admittedSnapshot = Objects.requireNonNull(parentSnapshot, + "parentSnapshot"); + PathStateBlockTransition admittedTransition = Objects.requireNonNull(transition, + "transition"); + requireChild(admittedParent, admittedSnapshot, admittedTransition); + + Map stores = new LinkedHashMap<>(); + PathStateParticipantScope scope = new PathStateCanonicalizer().participantScope(); + PathStateRoot root = PathStateRoot.fromSnapshot(scope, participant -> stores.computeIfAbsent( + participant.getStoreId(), ignored -> new RecordingNodeStore()), + stores.computeIfAbsent(0, ignored -> new RecordingNodeStore()), admittedSnapshot); + if (!admittedTransition.getMutations().isEmpty()) { + root.apply(admittedTransition.getMutations()); + } + PathStateRoot.Snapshot candidate = root.snapshot(); + List mutations = new ArrayList<>(); + for (Map.Entry store : stores.entrySet()) { + store.getValue().appendTo(store.getKey(), mutations); + } + return new PreparedPathStateTransition(admittedParent, admittedTransition, candidate, + mutations); + } + + public byte[] getStateRoot() { + return snapshot.getStateRoot(); + } + + public int getNodeMutationCount() { + return nodeMutations.size(); + } + + /** Freezes this candidate as one immutable Snapshot-owned forward delta. */ + public PathStateSnapshotDelta toSnapshotDelta(BlockSnapshotMeta meta) { + return PathStateSnapshotDelta.from(meta, this, + new PathStateCanonicalizer().participantScope()); + } + + PathStateRootMetadata getParent() { + return parent; + } + + PathStateBlockTransition getTransition() { + return transition; + } + + PathStateRoot.Snapshot getSnapshot() { + return snapshot; + } + + List getNodeMutations() { + return nodeMutations; + } + + boolean extendsParent(PathStateRootMetadata expected) { + return Arrays.equals(parent.encode(), expected.encode()); + } + + /** Validates the immutable candidate without reading any path-state Store. */ + void validatePreparedTransition(PathStateParticipantScope scope) { + PathStateParticipantScope admittedScope = Objects.requireNonNull(scope, "scope"); + if (transition.getBlockNumber() != parent.getBlockNumber() + 1 + || !Arrays.equals(transition.getParentHash(), parent.getBlockHash())) { + throw new IllegalStateException("path-state prepared transition no longer extends parent"); + } + byte[] candidateRoot = snapshot.getStateRoot(); + if (candidateRoot.length != PathStateCommitmentCodec.ROOT_LENGTH) { + throw new IllegalStateException("path-state prepared root must contain exactly 32 bytes"); + } + Set admittedStoreIds = new LinkedHashSet<>(); + admittedStoreIds.add(0); + for (PathStateParticipant participant : admittedScope.getParticipants()) { + admittedStoreIds.add(participant.getStoreId()); + } + Set unique = new LinkedHashSet<>(); + NodeMutation superRoot = null; + for (NodeMutation mutation : nodeMutations) { + if (!admittedStoreIds.contains(mutation.storeId)) { + throw new IllegalStateException("prepared path-state node has unknown Store ID"); + } + if (mutation.path.length > PathMerkleTrie.SECURE_KEY_LENGTH * 2) { + throw new IllegalStateException("prepared path-state node path is too long"); + } + for (byte nibble : mutation.path) { + if (nibble < 0 || nibble > 15) { + throw new IllegalStateException("prepared path-state node path is not nibble encoded"); + } + } + if (!unique.add(new NodeMutationKey(mutation.storeId, mutation.path))) { + throw new IllegalStateException("duplicate prepared path-state node mutation"); + } + if (mutation.encodedNode != null && mutation.encodedNode.length == 0) { + throw new IllegalStateException("prepared path-state node encoding is empty"); + } + if (mutation.storeId == 0 && mutation.path.length == 0) { + superRoot = mutation; + } + } + if (!Arrays.equals(candidateRoot, parent.getStateRoot())) { + if (superRoot == null || superRoot.encodedNode == null + || !Arrays.equals(Hash.sha3(superRoot.encodedNode), candidateRoot)) { + throw new IllegalStateException("prepared path-state super root mutation is inconsistent"); + } + } + } + + private static void requireChild(PathStateRootMetadata parent, + PathStateRoot.Snapshot snapshot, PathStateBlockTransition transition) { + if (!Arrays.equals(parent.getStateRoot(), snapshot.getStateRoot())) { + throw new IllegalArgumentException("path-state prepared parent snapshot root mismatch"); + } + if (transition.getBlockNumber() != parent.getBlockNumber() + 1 + || !Arrays.equals(transition.getParentHash(), parent.getBlockHash())) { + throw new IllegalArgumentException("path-state prepared transition does not extend parent"); + } + } + + static final class NodeMutation { + + private final int storeId; + private final byte[] path; + private final byte[] encodedNode; + + private NodeMutation(int storeId, byte[] path, byte[] encodedNode) { + this.storeId = storeId; + this.path = Arrays.copyOf(path, path.length); + this.encodedNode = encodedNode == null ? null + : Arrays.copyOf(encodedNode, encodedNode.length); + } + + int getStoreId() { + return storeId; + } + + byte[] getPath() { + return Arrays.copyOf(path, path.length); + } + + byte[] getEncodedNode() { + return encodedNode == null ? null : Arrays.copyOf(encodedNode, encodedNode.length); + } + } + + private static final class RecordingNodeStore implements PathNodeStore { + + private final Map changes = new LinkedHashMap<>(); + + @Override + public byte[] get(byte[] path) { + byte[] value = changes.get(new BytesKey(path)); + return value == null ? null : Arrays.copyOf(value, value.length); + } + + @Override + public void put(byte[] path, byte[] encodedNode) { + changes.put(new BytesKey(path), Arrays.copyOf(encodedNode, encodedNode.length)); + } + + @Override + public void delete(byte[] path) { + changes.put(new BytesKey(path), null); + } + + private void appendTo(int storeId, List mutations) { + for (Map.Entry change : changes.entrySet()) { + mutations.add(new NodeMutation(storeId, change.getKey().bytes, change.getValue())); + } + } + } + + private static final class BytesKey { + + private final byte[] bytes; + + private BytesKey(byte[] bytes) { + this.bytes = Arrays.copyOf(Objects.requireNonNull(bytes, "path"), bytes.length); + } + + @Override + public boolean equals(Object other) { + return this == other || other instanceof BytesKey + && Arrays.equals(bytes, ((BytesKey) other).bytes); + } + + @Override + public int hashCode() { + return Arrays.hashCode(bytes); + } + } + + private static final class NodeMutationKey { + + private final int storeId; + private final byte[] path; + + private NodeMutationKey(int storeId, byte[] path) { + this.storeId = storeId; + this.path = Arrays.copyOf(path, path.length); + } + + @Override + public boolean equals(Object other) { + return this == other || other instanceof NodeMutationKey + && storeId == ((NodeMutationKey) other).storeId + && Arrays.equals(path, ((NodeMutationKey) other).path); + } + + @Override + public int hashCode() { + return 31 * storeId + Arrays.hashCode(path); + } + } +} diff --git a/chainbase/src/main/java/org/tron/core/service/RewardViCalService.java b/chainbase/src/main/java/org/tron/core/service/RewardViCalService.java index f88fd02c539..acc45e9cf81 100644 --- a/chainbase/src/main/java/org/tron/core/service/RewardViCalService.java +++ b/chainbase/src/main/java/org/tron/core/service/RewardViCalService.java @@ -3,7 +3,6 @@ import static org.tron.core.store.DelegationStore.DECIMAL_OF_VI_REWARD; import static org.tron.core.store.DelegationStore.REMARK; -import com.google.common.collect.Streams; import com.google.common.primitives.Bytes; import com.google.protobuf.ByteString; import java.math.BigInteger; @@ -14,8 +13,6 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; -import java.util.stream.Collectors; -import java.util.stream.LongStream; import javax.annotation.PreDestroy; import lombok.extern.slf4j.Slf4j; import org.bouncycastle.util.encoders.Hex; @@ -65,6 +62,8 @@ public class RewardViCalService { private final ScheduledExecutorService es = ExecutorServiceManager .newSingleThreadScheduledExecutor("rewardViCalService"); + private volatile boolean stopped; + @Autowired public RewardViCalService(@Autowired DynamicPropertiesStore propertiesStore, @@ -99,6 +98,9 @@ private boolean isDone() { } private void maybeRun() { + if (stopped) { + return; + } try { if (enableNewRewardAlgorithm()) { if (this.newRewardCalStartCycle > 1) { @@ -135,8 +137,10 @@ private void clearUp(boolean isDone) { } @PreDestroy - private void destroy() { + public void stop() { + stopped = true; es.shutdownNow(); + ExecutorServiceManager.shutdownAndAwaitTermination(es, "rewardViCalService"); } @@ -173,11 +177,20 @@ public long getNewRewardAlgorithmReward(long beginCycle, long endCycle, private void calcMerkleRoot() { logger.info("calcMerkleRoot start"); - DBIterator iterator = rewardViStore.iterator(); - iterator.seekToFirst(); - ArrayList ids = Streams.stream(iterator) - .map(this::getHash) - .collect(Collectors.toCollection(ArrayList::new)); + ArrayList ids; + try (DBIterator iterator = rewardViStore.iterator()) { + iterator.seekToFirst(); + ids = new ArrayList<>(); + while (!stopped && iterator.hasNext()) { + ids.add(getHash(iterator.next())); + } + } catch (Exception e) { + throw new TronDBException(e); + } + + if (stopped) { + return; + } Sha256Hash rewardViRootLocal = MerkleRoot.root(ids); if (!Objects.equals(rewardViRoot, rewardViRootLocal)) { @@ -198,9 +211,17 @@ private Sha256Hash getHash(Map.Entry entry) { private void startRewardCal() { logger.info("rewardViCalService start"); rewardViStore.reset(); - DBIterator iterator = (DBIterator) witnessStore.iterator(); - iterator.seekToFirst(); - iterator.forEachRemaining(e -> accumulateWitnessReward(e.getKey())); + try (DBIterator iterator = (DBIterator) witnessStore.iterator()) { + iterator.seekToFirst(); + while (!stopped && iterator.hasNext()) { + accumulateWitnessReward(iterator.next().getKey()); + } + } catch (Exception e) { + throw new TronDBException(e); + } + if (stopped) { + return; + } rewardViStore.put(IS_DONE_KEY, IS_DONE_VALUE); logger.info("rewardViCalService is done"); @@ -208,11 +229,15 @@ private void startRewardCal() { private void accumulateWitnessReward(byte[] witness) { long startCycle = 1; - LongStream.range(startCycle, newRewardCalStartCycle) - .forEach(cycle -> accumulateWitnessVi(cycle, witness)); + for (long cycle = startCycle; cycle < newRewardCalStartCycle && !stopped; cycle++) { + accumulateWitnessVi(cycle, witness); + } } private void accumulateWitnessVi(long cycle, byte[] address) { + if (stopped) { + return; + } BigInteger preVi = getWitnessVi(cycle - 1, address); long voteCount = getWitnessVote(cycle, address); long reward = getReward(cycle, address); @@ -284,4 +309,3 @@ private long getLatestBlockHeaderNumber() { return value == null ? 1 : ByteArray.toLong(value); } } - diff --git a/chainbase/src/main/java/org/tron/core/store/AccountAssetStore.java b/chainbase/src/main/java/org/tron/core/store/AccountAssetStore.java index 6e970a136d3..1b8114b1121 100644 --- a/chainbase/src/main/java/org/tron/core/store/AccountAssetStore.java +++ b/chainbase/src/main/java/org/tron/core/store/AccountAssetStore.java @@ -2,21 +2,109 @@ import com.google.common.primitives.Bytes; import com.google.common.primitives.Longs; +import java.util.HashMap; +import java.util.Map; import org.apache.commons.lang3.ArrayUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; +import org.tron.common.storage.leveldb.LevelDbDataSourceImpl; +import org.tron.common.storage.rocksdb.RocksDbDataSourceImpl; import org.tron.common.utils.ByteArray; import org.tron.core.db.TronDatabase; +import org.tron.core.db2.common.DB; +import org.tron.core.db2.common.LevelDB; +import org.tron.core.db2.common.RocksDB; import org.tron.core.db2.common.WrappedByteArray; +import org.tron.core.db2.core.Chainbase; +import org.tron.core.db2.core.SnapshotManager; +import org.tron.core.db2.core.SnapshotRoot; import org.tron.protos.Protocol; -import java.util.HashMap; -import java.util.Map; - @Component public class AccountAssetStore extends TronDatabase { + private volatile Chainbase snapshots; + private boolean recoveringSnapshots; + + /** Shares the existing native DB; registers before recovery and before any sessions exist. */ + public synchronized void enableSnapshots(SnapshotManager manager) { + enableSnapshots(manager, false); + } + + public synchronized void enableSnapshots(SnapshotManager manager, boolean recovering) { + if (snapshots != null) { + throw new IllegalStateException("AccountAsset Snapshot lane already attached"); + } + DB engine; + if (dbSource instanceof LevelDbDataSourceImpl) { + engine = new LevelDB( + (LevelDbDataSourceImpl) dbSource); + } else if (dbSource instanceof RocksDbDataSourceImpl) { + engine = new RocksDB( + (RocksDbDataSourceImpl) dbSource); + } else { + throw new IllegalStateException("Unsupported AccountAsset Snapshot engine"); + } + Chainbase lane = new Chainbase( + new SnapshotRoot(engine)); + lane.setRegistrationSource(AccountAssetStore.class.getName()); + manager.installP66SnapshotLane(lane, recovering); + recoveringSnapshots = recovering; + snapshots = lane; + } + + public synchronized void finishSnapshotRecovery(SnapshotManager manager) { + manager.finishP66Recovery(); + recoveringSnapshots = false; + } + + @Override + public void close() { + if (snapshots != null) { + snapshots.close(); + } + super.close(); + } + + @Override + public Map prefixQuery(byte[] key) { + return snapshots == null ? super.prefixQuery(key) : snapshots.prefixQuery(key); + } + + @Override + public void updateByBatch(Map rows) { + if (snapshots == null) { + super.updateByBatch(rows); + } else { + rows.forEach((key, value) -> { + if (value == null) { + snapshots.delete(key); + } else { + snapshots.put(key, value); + } + }); + } + } + + @Override + public void updateByBatchSynced(Map rows) { + if (snapshots != null && !recoveringSnapshots) { + throw new IllegalStateException("AccountAsset durability belongs to Common checkpoint"); + } + super.updateByBatchSynced(rows); + } + + @Override + public byte[] getFromRoot(byte[] key) { + return dbSource.getData(key); + } + + @Override + public byte[] getUnchecked(byte[] key) { + return get(key); + } + @Autowired protected AccountAssetStore(@Value("account-asset") String dbName) { super(dbName); @@ -24,22 +112,30 @@ protected AccountAssetStore(@Value("account-asset") String dbName) { @Override public void put(byte[] key, byte[] item) { - dbSource.putData(key, item); + if (snapshots == null) { + dbSource.putData(key, item); + } else { + snapshots.put(key, item); + } } @Override public void delete(byte[] key) { - dbSource.deleteData(key); + if (snapshots == null) { + dbSource.deleteData(key); + } else { + snapshots.delete(key); + } } @Override public byte[] get(byte[] key) { - return dbSource.getData(key); + return snapshots == null ? dbSource.getData(key) : snapshots.getUnchecked(key); } @Override public boolean has(byte[] key) { - return dbSource.getData(key) != null; + return get(key) != null; } public void putAccount(Protocol.Account account) { diff --git a/chainbase/src/main/java/org/tron/core/store/DynamicPropertiesStore.java b/chainbase/src/main/java/org/tron/core/store/DynamicPropertiesStore.java index 0f74f20d379..875c6b84077 100644 --- a/chainbase/src/main/java/org/tron/core/store/DynamicPropertiesStore.java +++ b/chainbase/src/main/java/org/tron/core/store/DynamicPropertiesStore.java @@ -8,7 +8,11 @@ import com.google.protobuf.ByteString; import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; import java.util.Optional; +import java.util.function.LongSupplier; import java.util.stream.IntStream; import lombok.Getter; import lombok.extern.slf4j.Slf4j; @@ -29,6 +33,62 @@ @Component public class DynamicPropertiesStore extends TronStoreWithRevoking { + private static final Map LONG_PROPERTY_DEFAULTS = + createLongPropertyDefaults(); + + private static Map createLongPropertyDefaults() { + // Keep this registry aligned with every typed long getter below that uses Optional.orElse. + Map defaults = new LinkedHashMap<>(); + defaults.put("WITNESS_127_PAY_PER_BLOCK", () -> 16_000_000L); + defaults.put("CURRENT_CYCLE_NUMBER", () -> 0L); + defaults.put("ALLOW_TVM_SHANGHAI", + () -> CommonParameter.getInstance().getAllowTvmShangHai()); + defaults.put("ALLOW_CANCEL_ALL_UNFREEZE_V2", + () -> CommonParameter.getInstance().getAllowCancelAllUnfreezeV2()); + defaults.put("MAX_DELEGATE_LOCK_PERIOD", + () -> DELEGATE_PERIOD / BLOCK_PRODUCED_INTERVAL); + defaults.put("ALLOW_OLD_REWARD_OPT", + () -> CommonParameter.getInstance().getAllowOldRewardOpt()); + defaults.put("ALLOW_ENERGY_ADJUSTMENT", + () -> CommonParameter.getInstance().getAllowEnergyAdjustment()); + defaults.put("MAX_CREATE_ACCOUNT_TX_SIZE", + () -> CommonParameter.getInstance().getMaxCreateAccountTxSize()); + defaults.put("ALLOW_STRICT_MATH", + () -> CommonParameter.getInstance().getAllowStrictMath()); + defaults.put("CONSENSUS_LOGIC_OPTIMIZATION", + () -> CommonParameter.getInstance().getConsensusLogicOptimization()); + defaults.put("ALLOW_TVM_CANCUN", + () -> CommonParameter.getInstance().getAllowTvmCancun()); + defaults.put("ALLOW_TVM_BLOB", + () -> CommonParameter.getInstance().getAllowTvmBlob()); + defaults.put("ALLOW_TVM_SELFDESTRUCT_RESTRICTION", () -> 0L); + defaults.put("PROPOSAL_EXPIRE_TIME", + () -> CommonParameter.getInstance().getProposalExpireTime()); + defaults.put("ALLOW_TVM_OSAKA", () -> 0L); + defaults.put("ALLOW_TVM_PRAGUE", () -> 0L); + defaults.put("BLOCK_HASH_HISTORY_INSTALLED", () -> 0L); + defaults.put("ALLOW_HARDEN_RESOURCE_CALCULATION", () -> 0L); + defaults.put("ALLOW_HARDEN_EXCHANGE_CALCULATION", () -> 0L); + defaults.put("TURKISH_KEY_MIGRATION_DONE", () -> 0L); + return Collections.unmodifiableMap(defaults); + } + + /** + * Resolves the missing-key defaults defined by the typed long getters in this Store. + * Existing values always remain authoritative; callers use this map only for an absent key. + */ + public static Long getLongPropertyDefault(String key) { + LongSupplier supplier = LONG_PROPERTY_DEFAULTS.get(key); + return supplier == null ? null : supplier.getAsLong(); + } + + /** Returns a resolved, immutable snapshot for auditing and completeness tests. */ + public static Map getLongPropertyDefaults() { + Map resolved = new LinkedHashMap<>(); + LONG_PROPERTY_DEFAULTS.forEach((key, supplier) -> resolved.put(key, supplier.getAsLong())); + return Collections.unmodifiableMap(resolved); + } + private static final byte[] LATEST_BLOCK_HEADER_TIMESTAMP = "latest_block_header_timestamp" .getBytes(); private static final byte[] LATEST_BLOCK_HEADER_NUMBER = "latest_block_header_number".getBytes(); @@ -2186,6 +2246,21 @@ public long getLatestBlockHeaderNumberFromDB() { return -1; } + /** + * Gets the latest block hash from the persistent Snapshot root, excluding reversible layers. + */ + public Sha256Hash getLatestBlockHeaderHashFromDB() { + try { + byte[] blockHash = Optional.ofNullable(getFromRoot(LATEST_BLOCK_HEADER_HASH)) + .map(BytesCapsule::getData) + .orElseThrow(() -> new IllegalArgumentException("not found block hash")); + return Sha256Hash.wrap(blockHash); + } catch (ItemNotFoundException | BadItemException | IllegalArgumentException e) { + logger.error("Get header hash from DB, {}.", e.getMessage()); + } + return null; + } + public int getStateFlag() { return Optional.ofNullable(getUnchecked(STATE_FLAG)) .map(BytesCapsule::getData) diff --git a/chainbase/src/main/java/org/tron/core/store/StorageRowKeyCodec.java b/chainbase/src/main/java/org/tron/core/store/StorageRowKeyCodec.java new file mode 100644 index 00000000000..8c29d7aa059 --- /dev/null +++ b/chainbase/src/main/java/org/tron/core/store/StorageRowKeyCodec.java @@ -0,0 +1,55 @@ +package org.tron.core.store; + +import static java.lang.System.arraycopy; + +import java.util.Objects; +import org.tron.common.crypto.Hash; +import org.tron.common.utils.ByteUtil; + +/** Canonical physical-key mapping shared by latest and historical contract storage reads. */ +public final class StorageRowKeyCodec { + + public static final int KEY_BYTES = 32; + private static final int HALF_KEY_BYTES = KEY_BYTES / 2; + + private StorageRowKeyCodec() { + } + + /** + * Maps a logical contract address and slot to the existing 32-byte storage-row key. + * + *

The mapping intentionally preserves the current truncated alias/collision semantics. + */ + public static byte[] physicalKey(byte[] address, byte[] logicalSlot, int contractVersion, + byte[] createTransactionHash) { + return physicalKeyFromAddressHash(addressHash(address, createTransactionHash), logicalSlot, + contractVersion); + } + + /** Returns the address-side hash used by the storage-row key mapping. */ + public static byte[] addressHash(byte[] address, byte[] createTransactionHash) { + Objects.requireNonNull(address, "address"); + if (ByteUtil.isNullOrZeroArray(createTransactionHash)) { + return Hash.sha3(address); + } + return Hash.sha3(ByteUtil.merge(address, createTransactionHash)); + } + + /** Maps a precomputed address hash and logical slot to the existing storage-row key. */ + public static byte[] physicalKeyFromAddressHash(byte[] addressHash, byte[] logicalSlot, + int contractVersion) { + Objects.requireNonNull(addressHash, "addressHash"); + Objects.requireNonNull(logicalSlot, "logicalSlot"); + if (addressHash.length != KEY_BYTES) { + throw new IllegalArgumentException("addressHash must be exactly 32 bytes"); + } + if (logicalSlot.length != KEY_BYTES) { + throw new IllegalArgumentException("logicalSlot must be exactly 32 bytes"); + } + byte[] transformedSlot = contractVersion == 1 ? Hash.sha3(logicalSlot) : logicalSlot; + byte[] physicalKey = new byte[KEY_BYTES]; + arraycopy(addressHash, 0, physicalKey, 0, HALF_KEY_BYTES); + arraycopy(transformedSlot, HALF_KEY_BYTES, physicalKey, HALF_KEY_BYTES, HALF_KEY_BYTES); + return physicalKey; + } +} diff --git a/common/src/main/java/org/tron/common/utils/BlockFile.java b/common/src/main/java/org/tron/common/utils/BlockFile.java new file mode 100644 index 00000000000..d2a06df98f8 --- /dev/null +++ b/common/src/main/java/org/tron/common/utils/BlockFile.java @@ -0,0 +1,317 @@ +package org.tron.common.utils; + +import com.google.protobuf.InvalidProtocolBufferException; +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.Closeable; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.EOFException; +import java.io.IOException; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.util.Arrays; +import java.util.zip.CRC32; +import org.tron.protos.Protocol.Block; + +/** + * Versioned container for a consecutive range of raw protobuf blocks. + */ +public final class BlockFile { + + private static final byte[] MAGIC = new byte[] {'T', 'R', 'O', 'N', 'B', 'L', 'K', '1'}; + private static final int VERSION = 1; + private static final int BLOCK_ID_LENGTH = 32; + private static final int MAX_BLOCK_LENGTH = 64 * 1024 * 1024; + + private BlockFile() { + } + + /** Supplies one source record for the requested height. */ + @FunctionalInterface + public interface RecordSource { + Record get(long height) throws IOException; + } + + /** Writes an inclusive, consecutive block range without keeping the range in memory. */ + public static Header write(Path output, long start, long end, boolean overwrite, + RecordSource source) throws IOException { + validateRange(start, end); + Path absoluteOutput = output.toAbsolutePath().normalize(); + if (Files.exists(absoluteOutput) && !overwrite) { + throw new IOException("Output file already exists: " + absoluteOutput); + } + Path parent = absoluteOutput.getParent(); + if (parent == null) { + throw new IOException("Output file must have a parent directory: " + absoluteOutput); + } + Files.createDirectories(parent); + Path temporary = Files.createTempFile(parent, absoluteOutput.getFileName().toString(), ".tmp"); + boolean completed = false; + Header header = new Header(start, end, end - start + 1); + try { + try (DataOutputStream outputStream = new DataOutputStream(new BufferedOutputStream( + Files.newOutputStream(temporary, StandardOpenOption.TRUNCATE_EXISTING)))) { + writeHeader(outputStream, header); + byte[] previousBlockId = null; + for (long height = start; height <= end; height++) { + Record record = source.get(height); + validateRecord(record, height, previousBlockId); + writeRecord(outputStream, record); + previousBlockId = record.getBlockId(); + if (height == Long.MAX_VALUE) { + break; + } + } + } + move(temporary, absoluteOutput, overwrite); + completed = true; + return header; + } finally { + if (!completed) { + Files.deleteIfExists(temporary); + } + } + } + + /** Opens a streaming reader. The caller must close it. */ + public static Reader open(Path input) throws IOException { + return new Reader(input); + } + + private static void validateRange(long start, long end) { + if (start < 0) { + throw new IllegalArgumentException("Start height must be non-negative"); + } + if (end < start) { + throw new IllegalArgumentException("End height must be greater than or equal to start"); + } + if (end - start == Long.MAX_VALUE) { + throw new IllegalArgumentException("Block range is too large"); + } + } + + private static void writeHeader(DataOutputStream output, Header header) throws IOException { + output.write(MAGIC); + output.writeInt(VERSION); + output.writeLong(header.getStart()); + output.writeLong(header.getEnd()); + output.writeLong(header.getCount()); + } + + private static Header readHeader(DataInputStream input) throws IOException { + byte[] magic = new byte[MAGIC.length]; + input.readFully(magic); + if (!Arrays.equals(MAGIC, magic)) { + throw new IOException("Not a java-tron block file"); + } + int version = input.readInt(); + if (version != VERSION) { + throw new IOException("Unsupported block file version: " + version); + } + long start = input.readLong(); + long end = input.readLong(); + long count = input.readLong(); + try { + validateRange(start, end); + } catch (IllegalArgumentException e) { + throw new IOException("Invalid block file range", e); + } + if (count != end - start + 1) { + throw new IOException("Block file count does not match its range"); + } + return new Header(start, end, count); + } + + private static void writeRecord(DataOutputStream output, Record record) throws IOException { + byte[] blockData = record.getBlockData(); + output.writeLong(record.getHeight()); + output.write(record.getBlockId()); + output.writeInt(blockData.length); + output.write(blockData); + output.writeInt(checksum(blockData)); + } + + private static Record readRecord(DataInputStream input) throws IOException { + long height = input.readLong(); + byte[] blockId = new byte[BLOCK_ID_LENGTH]; + input.readFully(blockId); + int blockLength = input.readInt(); + if (blockLength <= 0 || blockLength > MAX_BLOCK_LENGTH) { + throw new IOException("Invalid block length " + blockLength + " at height " + height); + } + byte[] blockData = new byte[blockLength]; + input.readFully(blockData); + int expectedChecksum = input.readInt(); + if (checksum(blockData) != expectedChecksum) { + throw new IOException("Block checksum mismatch at height " + height); + } + return new Record(height, blockId, blockData); + } + + private static void validateRecord(Record record, long expectedHeight, byte[] previousBlockId) + throws IOException { + if (record == null) { + throw new IOException("Missing block at height " + expectedHeight); + } + if (record.getHeight() != expectedHeight) { + throw new IOException("Expected block " + expectedHeight + " but got " + record.getHeight()); + } + Block block = record.getBlock(); + long protoHeight = block.getBlockHeader().getRawData().getNumber(); + if (protoHeight != expectedHeight) { + throw new IOException("Block protobuf height " + protoHeight + + " does not match record height " + expectedHeight); + } + if (previousBlockId != null && !Arrays.equals(previousBlockId, + block.getBlockHeader().getRawData().getParentHash().toByteArray())) { + throw new IOException("Block parent mismatch at height " + expectedHeight); + } + } + + private static int checksum(byte[] value) { + CRC32 crc32 = new CRC32(); + crc32.update(value); + return (int) crc32.getValue(); + } + + private static void move(Path source, Path target, boolean overwrite) throws IOException { + StandardCopyOption[] atomicOptions = overwrite + ? new StandardCopyOption[] {StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING} + : new StandardCopyOption[] {StandardCopyOption.ATOMIC_MOVE}; + try { + Files.move(source, target, atomicOptions); + } catch (AtomicMoveNotSupportedException e) { + StandardCopyOption[] options = overwrite + ? new StandardCopyOption[] {StandardCopyOption.REPLACE_EXISTING} + : new StandardCopyOption[0]; + Files.move(source, target, options); + } + } + + /** Immutable file header. */ + public static final class Header { + private final long start; + private final long end; + private final long count; + + private Header(long start, long end, long count) { + this.start = start; + this.end = end; + this.count = count; + } + + public long getStart() { + return start; + } + + public long getEnd() { + return end; + } + + public long getCount() { + return count; + } + } + + /** One raw block and the ID used as its source database key. */ + public static final class Record { + private final long height; + private final byte[] blockId; + private final byte[] blockData; + + public Record(long height, byte[] blockId, byte[] blockData) { + if (blockId == null || blockId.length != BLOCK_ID_LENGTH) { + throw new IllegalArgumentException("Block ID must be 32 bytes"); + } + if (blockData == null || blockData.length == 0 || blockData.length > MAX_BLOCK_LENGTH) { + throw new IllegalArgumentException("Block data length is invalid"); + } + this.height = height; + this.blockId = Arrays.copyOf(blockId, blockId.length); + this.blockData = Arrays.copyOf(blockData, blockData.length); + } + + public long getHeight() { + return height; + } + + public byte[] getBlockId() { + return Arrays.copyOf(blockId, blockId.length); + } + + public byte[] getBlockData() { + return Arrays.copyOf(blockData, blockData.length); + } + + public Block getBlock() throws IOException { + try { + return Block.parseFrom(blockData); + } catch (InvalidProtocolBufferException e) { + throw new IOException("Invalid block protobuf at height " + height, e); + } + } + } + + /** Streaming block reader with structural and chain-continuity validation. */ + public static final class Reader implements Closeable { + private final DataInputStream input; + private final Header header; + private long recordsRead; + private byte[] previousBlockId; + private boolean endChecked; + + private Reader(Path inputFile) throws IOException { + input = new DataInputStream(new BufferedInputStream(Files.newInputStream(inputFile))); + try { + header = readHeader(input); + } catch (IOException e) { + input.close(); + throw e; + } + } + + public Header getHeader() { + return header; + } + + public boolean hasNext() throws IOException { + if (recordsRead < header.getCount()) { + return true; + } + if (!endChecked) { + endChecked = true; + if (input.read() != -1) { + throw new IOException("Unexpected trailing data after block records"); + } + } + return false; + } + + public Record next() throws IOException { + if (!hasNext()) { + throw new EOFException("No more block records"); + } + long expectedHeight = header.getStart() + recordsRead; + Record record; + try { + record = readRecord(input); + } catch (EOFException e) { + throw new IOException("Truncated block file at height " + expectedHeight, e); + } + validateRecord(record, expectedHeight, previousBlockId); + previousBlockId = record.getBlockId(); + recordsRead++; + return record; + } + + @Override + public void close() throws IOException { + input.close(); + } + } +} diff --git a/common/src/main/java/org/tron/core/config/args/Storage.java b/common/src/main/java/org/tron/core/config/args/Storage.java index 16dd8295be1..364779f85c2 100644 --- a/common/src/main/java/org/tron/core/config/args/Storage.java +++ b/common/src/main/java/org/tron/core/config/args/Storage.java @@ -85,6 +85,116 @@ public class Storage { @Setter private boolean checkpointSync; + @Getter + @Setter + private boolean stateArchiveEnabled; + + @Getter + @Setter + private String stateArchiveDirectory; + + @Getter + @Setter + private long stateArchiveMaxSegmentSize; + + @Getter + @Setter + private int stateArchiveQueueCapacity; + + @Getter + @Setter + private String stateArchiveServingIndexEngine; + + @Getter + @Setter + private StorageConfig.NativeDbConfig stateArchiveServingIndexDbSettings; + + @Getter + @Setter + private StorageConfig.StateArchiveHotStoreConfig stateArchiveHotStoreSettings; + + @Getter + @Setter + private StorageConfig.StateArchiveAppendFileConfig stateArchiveAppendFileSettings; + + @Getter + @Setter + private boolean commonCheckpointEnabled; + + @Getter + @Setter + private boolean p66SnapshotEnabled; + + @Getter + @Setter + private String commonCheckpointDirectory; + + @Getter + @Setter + private boolean pathStateRootEnabled; + + @Getter + @Setter + private String pathStateRootMode; + + @Getter + @Setter + private String pathStateRootDirectory; + + @Getter + @Setter + private String pathStateRootEngine; + + @Getter + @Setter + private int pathStateRootFormatVersion; + + @Getter + @Setter + private int pathStateRootReversibleLayerLimit; + + @Getter + @Setter + private long pathStateRootReversibleLayerBytes; + + @Getter + @Setter + private long pathStateRootWriteBufferBytes; + + @Getter + @Setter + private long pathStateRootNodeCacheBytes; + + @Getter + @Setter + private int pathStateRootParticipantThreads; + + @Getter + @Setter + private int pathStateRootBranchThreads; + + @Getter + @Setter + private boolean pathStateRootRebuildFromGenesis; + + @Getter + @Setter + private boolean pathStateRootVerifyEveryBlock; + + /** Benchmark-only: advance the path-state head in memory and persist no per-block state. */ + @Getter + @Setter + private boolean pathStateRootVolatileSnapshotBenchmark; + + /** Benchmark-only: compute ordered PathState deltas on a bounded background worker. */ + @Getter + @Setter + private boolean pathStateRootAsyncPrepareBenchmark; + + @Getter + @Setter + private StorageConfig.PathStateDbSettingsConfig pathStateRootDbSettings; + private Options defaultDbOptions; @Getter diff --git a/common/src/main/java/org/tron/core/config/args/StorageConfig.java b/common/src/main/java/org/tron/core/config/args/StorageConfig.java index 2c6c3e60a41..46ab3134125 100644 --- a/common/src/main/java/org/tron/core/config/args/StorageConfig.java +++ b/common/src/main/java/org/tron/core/config/args/StorageConfig.java @@ -27,6 +27,9 @@ public class StorageConfig { private BalanceConfig balance = new BalanceConfig(); private CheckpointConfig checkpoint = new CheckpointConfig(); private SnapshotConfig snapshot = new SnapshotConfig(); + private StateArchiveConfig stateArchive = new StateArchiveConfig(); + private CommonCheckpointConfig commonCheckpoint = new CommonCheckpointConfig(); + private PathStateRootConfig pathStateRoot = new PathStateRootConfig(); private TxCacheConfig txCache = new TxCacheConfig(); // ConfigBeanFactory requires all bean fields present per item, so we parse manually. @Setter(lombok.AccessLevel.NONE) @@ -141,6 +144,254 @@ void postProcess() { } } + @Getter + @Setter + public static class StateArchiveConfig { + + private boolean enabled = false; + private String directory = "state-archive"; + private long maxSegmentSize = 1073741824L; + private int queueCapacity = 256; + private String servingIndexEngine = "ROCKSDB"; + private NativeDbConfig servingIndex = NativeDbConfig.large(); + private StateArchiveHotStoreConfig hotStore = new StateArchiveHotStoreConfig(); + private StateArchiveAppendFileConfig appendFile = new StateArchiveAppendFileConfig(); + + void postProcess() { + if (directory == null || directory.trim().isEmpty()) { + throw new IllegalArgumentException("stateArchive.directory must not be empty"); + } + if (maxSegmentSize <= BlockHistoryLimits.MIN_SEGMENT_SIZE) { + throw new IllegalArgumentException( + "stateArchive.maxSegmentSize must be greater than 64 MiB"); + } + if (queueCapacity <= 0 || queueCapacity > 65536) { + throw new IllegalArgumentException( + "stateArchive.queueCapacity must be in [1, 65536]"); + } + servingIndexEngine = normalizeAuxiliaryEngine(servingIndexEngine, + "storage.stateArchive.servingIndexEngine"); + servingIndex.validate("storage.stateArchive.servingIndex"); + hotStore.postProcess(); + appendFile.postProcess(); + } + } + + /** Independent, default-off five-lane append-file v3 prototype settings. */ + @Getter + @Setter + public static class StateArchiveAppendFileConfig { + + private boolean enabled = false; + private int formatVersion = 3; + private int appendBufferBytes = 2097152; + private int maxBlockFrameBytes = 67108864; + private long segmentTargetBytes = 2000000000L; + private int shardMaxSegments = 1024; + + void postProcess() { + if (formatVersion != 3 || appendBufferBytes != 2097152 + || maxBlockFrameBytes != 67108864 || shardMaxSegments != 1024) { + throw new IllegalArgumentException( + "stateArchive.appendFile v3 fixed format settings differ"); + } + if (segmentTargetBytes <= 512) { + throw new IllegalArgumentException( + "stateArchive.appendFile.segmentTargetBytes must exceed the segment header"); + } + } + } + + /** Independent, default-off Hot DB limits and native database options. */ + @Getter + @Setter + public static class StateArchiveHotStoreConfig { + + private boolean enabled = false; + private String engine = "ROCKSDB"; + private long maxBlocks = 10000L; + private long maxEncodedBytes = 2147483648L; + private int maxFrozenGenerations = 8; + private int yellowFrozenGenerations = 4; + private int redFrozenGenerations = 7; + private NativeDbConfig dbSettings = NativeDbConfig.large(); + + void postProcess() { + validate(); + } + + public void validate() { + engine = normalizeAuxiliaryEngine(engine, "storage.stateArchive.hotStore.engine"); + if (maxBlocks <= 0 || maxEncodedBytes <= 0) { + throw new IllegalArgumentException( + "stateArchive.hotStore rotation limits must be positive"); + } + if (maxFrozenGenerations <= 0 || yellowFrozenGenerations <= 0 + || redFrozenGenerations <= yellowFrozenGenerations + || redFrozenGenerations > maxFrozenGenerations) { + throw new IllegalArgumentException( + "stateArchive.hotStore frozen watermarks must satisfy 0 < yellow < red <= max"); + } + dbSettings.validate("storage.stateArchive.hotStore.dbSettings"); + } + } + + @Getter + @Setter + public static class CommonCheckpointConfig { + + private boolean p66SnapshotEnabled = false; + + private boolean enabled = false; + private String directory = "common-checkpoint"; + + void postProcess() { + if (directory == null || directory.trim().isEmpty()) { + throw new IllegalArgumentException("commonCheckpoint.directory must not be empty"); + } + } + } + + @Getter + @Setter + public static class PathStateRootConfig { + + private boolean enabled = false; + private String engine = "ROCKSDB"; + private String mode = "shadow"; + private String directory = "path-state-root"; + private int formatVersion = 1; + private int reversibleLayerLimit = 128; + private long reversibleLayerBytes = 2147483648L; + private long writeBufferBytes = 268435456L; + private long nodeCacheBytes = 268435456L; + private int participantThreads = 4; + private int branchThreads = 8; + private boolean rebuildFromGenesis = false; + private boolean verifyEveryBlock = true; + private boolean volatileSnapshotBenchmark = false; + private boolean asyncPrepareBenchmark = false; + private PathStateDbSettingsConfig dbSettings = new PathStateDbSettingsConfig(); + + void postProcess() { + engine = normalizeAuxiliaryEngine(engine, "storage.pathStateRoot.engine"); + if (!"shadow".equals(mode)) { + throw new IllegalArgumentException("pathStateRoot.mode must be shadow"); + } + if (directory == null || directory.trim().isEmpty()) { + throw new IllegalArgumentException("pathStateRoot.directory must not be empty"); + } + if (formatVersion != 1) { + throw new IllegalArgumentException("pathStateRoot.formatVersion must be 1"); + } + if (reversibleLayerLimit <= 0 || reversibleLayerBytes <= 0 || writeBufferBytes <= 0 + || nodeCacheBytes <= 0) { + throw new IllegalArgumentException("pathStateRoot limits must be positive"); + } + if (participantThreads <= 0 || participantThreads > 64 + || branchThreads <= 0 || branchThreads > 64) { + throw new IllegalArgumentException( + "pathStateRoot prepare threads must be in [1, 64]"); + } + if (rebuildFromGenesis) { + throw new IllegalArgumentException("pathStateRoot.rebuildFromGenesis is not supported"); + } + if (!verifyEveryBlock) { + throw new IllegalArgumentException("pathStateRoot.verifyEveryBlock must remain enabled"); + } + if (asyncPrepareBenchmark && !volatileSnapshotBenchmark) { + throw new IllegalArgumentException( + "pathStateRoot.asyncPrepareBenchmark requires volatileSnapshotBenchmark"); + } + dbSettings.validate(); + } + } + + private static String normalizeAuxiliaryEngine(String engine, String path) { + if (engine == null) { + throw new IllegalArgumentException(path + " must be LEVELDB or ROCKSDB"); + } + String normalized = engine.trim().toUpperCase(java.util.Locale.ROOT); + if (!"LEVELDB".equals(normalized) && !"ROCKSDB".equals(normalized)) { + throw new IllegalArgumentException(path + " must be LEVELDB or ROCKSDB"); + } + return normalized; + } + + /** Engine-neutral native options for one Archive/PathState resource tier. */ + @Getter + @Setter + public static class NativeDbConfig { + + private int blockSize = 4 * 1024; + private int writeBufferSize = 16 * 1024 * 1024; + private long cacheSize = 32L * 1024 * 1024; + private int maxOpenFiles = 100; + private long targetFileSizeBase = 16L * 1024 * 1024; + private long maxBytesForLevelBase = 64L * 1024 * 1024; + private int bloomBitsPerKey = 10; + private int maxWriteBufferNumber = 2; + private int levelNumber = 7; + private double maxBytesForLevelMultiplier = 10.0d; + private int level0FileNumCompactionTrigger = 4; + private int level0SlowdownWritesTrigger = 20; + private int level0StopWritesTrigger = 36; + private int backgroundFlushes = 1; + private int backgroundCompactions = 1; + + public static NativeDbConfig small() { + return new NativeDbConfig(); + } + + public static NativeDbConfig large() { + NativeDbConfig config = new NativeDbConfig(); + config.writeBufferSize = 64 * 1024 * 1024; + config.targetFileSizeBase = 64L * 1024 * 1024; + config.maxBytesForLevelBase = 256L * 1024 * 1024; + return config; + } + + public static NativeDbConfig giant() { + NativeDbConfig config = large(); + config.cacheSize = 64L * 1024 * 1024; + config.targetFileSizeBase = 128L * 1024 * 1024; + config.maxBytesForLevelBase = 512L * 1024 * 1024; + return config; + } + + void validate(String path) { + if (blockSize <= 0 || writeBufferSize <= 0 || cacheSize <= 0 || maxOpenFiles <= 0 + || targetFileSizeBase <= 0 || maxBytesForLevelBase <= 0 + || bloomBitsPerKey <= 0 || maxWriteBufferNumber <= 0 || levelNumber <= 0 + || maxBytesForLevelMultiplier <= 0 || level0FileNumCompactionTrigger <= 0 + || level0SlowdownWritesTrigger < level0FileNumCompactionTrigger + || level0StopWritesTrigger < level0SlowdownWritesTrigger + || backgroundFlushes <= 0 || backgroundCompactions <= 0) { + throw new IllegalArgumentException(path + " native database options are invalid"); + } + } + } + + /** Fixed small/large/giant PathState profile catalog. */ + @Getter + @Setter + public static class PathStateDbSettingsConfig { + + private NativeDbConfig small = NativeDbConfig.small(); + private NativeDbConfig large = NativeDbConfig.large(); + private NativeDbConfig giant = NativeDbConfig.giant(); + + void validate() { + small.validate("storage.pathStateRoot.dbSettings.small"); + large.validate("storage.pathStateRoot.dbSettings.large"); + giant.validate("storage.pathStateRoot.dbSettings.giant"); + } + } + + private static final class BlockHistoryLimits { + private static final long MIN_SEGMENT_SIZE = 64L * 1024 * 1024; + } + @Getter @Setter public static class TxCacheConfig { @@ -184,6 +435,35 @@ public static StorageConfig fromConfig(Config config) { sc.dbSettings.postProcess(); sc.snapshot.postProcess(); + sc.stateArchive.postProcess(); + sc.commonCheckpoint.postProcess(); + if (sc.commonCheckpoint.p66SnapshotEnabled && !sc.commonCheckpoint.enabled) { + throw new IllegalArgumentException("p66SnapshotEnabled requires commonCheckpoint.enabled"); + } + sc.pathStateRoot.postProcess(); + if (sc.commonCheckpoint.enabled + && (!sc.stateArchive.enabled || !sc.pathStateRoot.enabled)) { + throw new IllegalArgumentException( + "commonCheckpoint.enabled requires stateArchive.enabled and pathStateRoot.enabled"); + } + if (sc.stateArchive.hotStore.enabled && !sc.commonCheckpoint.enabled) { + throw new IllegalArgumentException( + "stateArchive.hotStore.enabled requires commonCheckpoint.enabled"); + } + if (sc.stateArchive.appendFile.enabled && !sc.commonCheckpoint.enabled) { + throw new IllegalArgumentException( + "stateArchive.appendFile.enabled requires commonCheckpoint.enabled"); + } + if (sc.stateArchive.appendFile.enabled && sc.stateArchive.hotStore.enabled) { + throw new IllegalArgumentException( + "stateArchive.appendFile.enabled is mutually exclusive with hotStore.enabled"); + } + if (sc.commonCheckpoint.enabled + && (sc.pathStateRoot.volatileSnapshotBenchmark + || sc.pathStateRoot.asyncPrepareBenchmark)) { + throw new IllegalArgumentException( + "commonCheckpoint.enabled is mutually exclusive with PathState benchmark modes"); + } sc.txCache.postProcess(); return sc; } diff --git a/common/src/main/java/org/tron/core/db2/ISession.java b/common/src/main/java/org/tron/core/db2/ISession.java index 21445fb4d7d..c9dfa6f6961 100644 --- a/common/src/main/java/org/tron/core/db2/ISession.java +++ b/common/src/main/java/org/tron/core/db2/ISession.java @@ -1,9 +1,16 @@ package org.tron.core.db2; +import org.tron.core.db2.archive.BlockSnapshotMeta; + public interface ISession extends AutoCloseable { void commit(); + /** Commit a successfully applied block and bind its canonical identity to the snapshot. */ + default void commit(BlockSnapshotMeta meta) { + commit(); + } + void revoke(); void merge(); diff --git a/common/src/main/java/org/tron/core/db2/archive/BlockSnapshotMeta.java b/common/src/main/java/org/tron/core/db2/archive/BlockSnapshotMeta.java new file mode 100644 index 00000000000..86bc61fbffa --- /dev/null +++ b/common/src/main/java/org/tron/core/db2/archive/BlockSnapshotMeta.java @@ -0,0 +1,99 @@ +package org.tron.core.db2.archive; + +import java.util.Arrays; +import java.util.Objects; + +/** + * Immutable identity of a successfully applied canonical block snapshot. + */ +public final class BlockSnapshotMeta { + + private static final int HASH_LENGTH = 32; + + private final long epoch; + private final long blockNumber; + private final byte[] blockHash; + private final byte[] parentHash; + private final long timestamp; + + public BlockSnapshotMeta(long epoch, long blockNumber, byte[] blockHash, byte[] parentHash, + long timestamp) { + if (epoch < 0) { + throw new IllegalArgumentException("epoch must not be negative"); + } + if (blockNumber < 0) { + throw new IllegalArgumentException("blockNumber must not be negative"); + } + this.epoch = epoch; + this.blockNumber = blockNumber; + this.blockHash = copyHash(blockHash, "blockHash"); + this.parentHash = copyHash(parentHash, "parentHash"); + this.timestamp = timestamp; + } + + public static BlockSnapshotMeta forBlock(long blockNumber, byte[] blockHash, byte[] parentHash, + long timestamp) { + return new BlockSnapshotMeta(blockNumber, blockNumber, blockHash, parentHash, timestamp); + } + + private static byte[] copyHash(byte[] hash, String name) { + Objects.requireNonNull(hash, name); + if (hash.length != HASH_LENGTH) { + throw new IllegalArgumentException(name + " must be exactly " + HASH_LENGTH + " bytes"); + } + return Arrays.copyOf(hash, hash.length); + } + + public long getEpoch() { + return epoch; + } + + public long getBlockNumber() { + return blockNumber; + } + + public byte[] getBlockHash() { + return Arrays.copyOf(blockHash, blockHash.length); + } + + public byte[] getParentHash() { + return Arrays.copyOf(parentHash, parentHash.length); + } + + public long getTimestamp() { + return timestamp; + } + + @Override + public boolean equals(Object object) { + if (this == object) { + return true; + } + if (!(object instanceof BlockSnapshotMeta)) { + return false; + } + BlockSnapshotMeta that = (BlockSnapshotMeta) object; + return epoch == that.epoch + && blockNumber == that.blockNumber + && timestamp == that.timestamp + && Arrays.equals(blockHash, that.blockHash) + && Arrays.equals(parentHash, that.parentHash); + } + + @Override + public int hashCode() { + int result = Objects.hash(epoch, blockNumber, timestamp); + result = 31 * result + Arrays.hashCode(blockHash); + result = 31 * result + Arrays.hashCode(parentHash); + return result; + } + + @Override + public String toString() { + return "BlockSnapshotMeta{" + + "epoch=" + epoch + + ", blockNumber=" + blockNumber + + ", timestamp=" + timestamp + + '}'; + } +} diff --git a/common/src/main/java/org/tron/core/exception/TronError.java b/common/src/main/java/org/tron/core/exception/TronError.java index 4ee7cdae916..82da5286707 100644 --- a/common/src/main/java/org/tron/core/exception/TronError.java +++ b/common/src/main/java/org/tron/core/exception/TronError.java @@ -50,6 +50,7 @@ public enum ErrCode { SOLID_NODE_INIT(0), PARAMETER_INIT(1), ACTUATOR_REGISTER(1), + STATE_ARCHIVE_INIT(1), JDK_VERSION(1); private final int code; diff --git a/common/src/main/java/org/tron/core/vm/config/VMConfig.java b/common/src/main/java/org/tron/core/vm/config/VMConfig.java index 304ced33698..129f774c920 100644 --- a/common/src/main/java/org/tron/core/vm/config/VMConfig.java +++ b/common/src/main/java/org/tron/core/vm/config/VMConfig.java @@ -46,6 +46,7 @@ public static class Snapshot { public boolean allowTvmSelfdestructRestriction; public boolean allowTvmOsaka; public boolean allowHardenResourceCalculation; + public boolean energyLimitHardFork; } // HEAD / block-processing config, written by the consensus path; read by everyone with no @@ -95,6 +96,7 @@ public static boolean vmTraceCompressed() { public static void initVmHardFork(boolean pass) { CommonParameter.ENERGY_LIMIT_HARD_FORK = pass; + globalSnapshot.energyLimitHardFork = pass; } // The init* setters below mutate the global (HEAD) config in place. They are kept for tests and @@ -205,7 +207,9 @@ public static void initAllowHardenResourceCalculation(long allow) { } public static boolean getEnergyLimitHardFork() { - return CommonParameter.ENERGY_LIMIT_HARD_FORK; + Snapshot local = localSnapshot.get(); + return local == null ? CommonParameter.ENERGY_LIMIT_HARD_FORK + : local.energyLimitHardFork; } public static boolean allowTvmTransferTrc10() { diff --git a/common/src/main/resources/reference.conf b/common/src/main/resources/reference.conf index 25fc4832e55..9537d426459 100644 --- a/common/src/main/resources/reference.conf +++ b/common/src/main/resources/reference.conf @@ -132,6 +132,145 @@ storage { # Number of blocks flushed to db in each batch during node syncing. snapshot.maxFlushCount = 1 + # Experimental block-boundary state history. Disabled by default. + stateArchive.enabled = false + stateArchive.directory = "state-archive" + stateArchive.maxSegmentSize = 1073741824 # 1 GiB + stateArchive.queueCapacity = 256 + # Persistent engine identity for the serving-index DB. A change requires rebuild/resync. + stateArchive.servingIndexEngine = "ROCKSDB" + stateArchive.servingIndex { + blockSize = 4096 + writeBufferSize = 67108864 + cacheSize = 33554432 + maxOpenFiles = 100 + targetFileSizeBase = 67108864 + maxBytesForLevelBase = 268435456 + bloomBitsPerKey = 10 + maxWriteBufferNumber = 2 + levelNumber = 7 + maxBytesForLevelMultiplier = 10 + level0FileNumCompactionTrigger = 4 + level0SlowdownWritesTrigger = 20 + level0StopWritesTrigger = 36 + backgroundFlushes = 1 + backgroundCompactions = 1 + } + # Independent Hot DB candidate. It has no production caller while disabled. + stateArchive.hotStore { + enabled = false + # Persistent engine identity for current/frozen Hot DB generations. + engine = "ROCKSDB" + maxBlocks = 10000 + maxEncodedBytes = 2147483648 # 2 GiB + maxFrozenGenerations = 8 + yellowFrozenGenerations = 4 + redFrozenGenerations = 7 + dbSettings { + blockSize = 4096 + writeBufferSize = 67108864 + cacheSize = 33554432 + maxOpenFiles = 100 + targetFileSizeBase = 67108864 + maxBytesForLevelBase = 268435456 + bloomBitsPerKey = 10 + maxWriteBufferNumber = 2 + levelNumber = 7 + maxBytesForLevelMultiplier = 10 + level0FileNumCompactionTrigger = 4 + level0SlowdownWritesTrigger = 20 + level0StopWritesTrigger = 36 + backgroundFlushes = 1 + backgroundCompactions = 1 + } + } + # Five-lane append-file v3 replacement candidate. Mutually exclusive with hotStore. + stateArchive.appendFile { + enabled = false + formatVersion = 3 + appendBufferBytes = 2097152 + maxBlockFrameBytes = 67108864 + segmentTargetBytes = 2000000000 + shardMaxSegments = 1024 + } + # Three-authority checkpoint. Admits a verified format-v1 PathState baseline. + commonCheckpoint.enabled = false + # P66 physical Snapshot + parallel block artifacts; fresh Common baseline only. + commonCheckpoint.p66SnapshotEnabled = false + commonCheckpoint.directory = "common-checkpoint" + + # Experimental current-only, non-consensus path state root. Disabled by default. + pathStateRoot.enabled = false + # Persistent engine identity for all PathState physical stores. + pathStateRoot.engine = "ROCKSDB" + pathStateRoot.mode = "shadow" + pathStateRoot.directory = "path-state-root" + pathStateRoot.formatVersion = 1 + pathStateRoot.reversibleLayerLimit = 128 + pathStateRoot.reversibleLayerBytes = 2147483648 # 2 GiB + pathStateRoot.writeBufferBytes = 268435456 # 256 MiB + pathStateRoot.nodeCacheBytes = 268435456 # 256 MiB + pathStateRoot.participantThreads = 4 + pathStateRoot.branchThreads = 8 + pathStateRoot.rebuildFromGenesis = false + pathStateRoot.verifyEveryBlock = true + # Benchmark-only. Advances PathState in memory and writes no per-block journal/F/N/CURRENT. + pathStateRoot.volatileSnapshotBenchmark = false + pathStateRoot.asyncPrepareBenchmark = false + pathStateRoot.dbSettings { + small { + blockSize = 4096 + writeBufferSize = 16777216 + cacheSize = 33554432 + maxOpenFiles = 100 + targetFileSizeBase = 16777216 + maxBytesForLevelBase = 67108864 + bloomBitsPerKey = 10 + maxWriteBufferNumber = 2 + levelNumber = 7 + maxBytesForLevelMultiplier = 10 + level0FileNumCompactionTrigger = 4 + level0SlowdownWritesTrigger = 20 + level0StopWritesTrigger = 36 + backgroundFlushes = 1 + backgroundCompactions = 1 + } + large { + blockSize = 4096 + writeBufferSize = 67108864 + cacheSize = 33554432 + maxOpenFiles = 100 + targetFileSizeBase = 67108864 + maxBytesForLevelBase = 268435456 + bloomBitsPerKey = 10 + maxWriteBufferNumber = 2 + levelNumber = 7 + maxBytesForLevelMultiplier = 10 + level0FileNumCompactionTrigger = 4 + level0SlowdownWritesTrigger = 20 + level0StopWritesTrigger = 36 + backgroundFlushes = 1 + backgroundCompactions = 1 + } + giant { + blockSize = 4096 + writeBufferSize = 67108864 + cacheSize = 67108864 + maxOpenFiles = 100 + targetFileSizeBase = 134217728 + maxBytesForLevelBase = 536870912 + bloomBitsPerKey = 10 + maxWriteBufferNumber = 2 + levelNumber = 7 + maxBytesForLevelMultiplier = 10 + level0FileNumCompactionTrigger = 4 + level0SlowdownWritesTrigger = 20 + level0StopWritesTrigger = 36 + backgroundFlushes = 1 + backgroundCompactions = 1 + } + } + # Data root setting, for check data, currently only reward-vi is used. # merkleRoot = { # reward-vi = 9debcb9924055500aaae98cdee10501c5c39d4daa75800a996f4bdda73dbccd8 // main-net diff --git a/common/src/test/java/org/tron/common/utils/BlockFileTest.java b/common/src/test/java/org/tron/common/utils/BlockFileTest.java new file mode 100644 index 00000000000..122ab426a1d --- /dev/null +++ b/common/src/test/java/org/tron/common/utils/BlockFileTest.java @@ -0,0 +1,85 @@ +package org.tron.common.utils; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; + +import com.google.protobuf.ByteString; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tron.protos.Protocol.Block; +import org.tron.protos.Protocol.BlockHeader; + +public class BlockFileTest { + + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void shouldRoundTripConsecutiveBlocks() throws Exception { + Path output = temporaryFolder.getRoot().toPath().resolve("blocks.dat"); + BlockFile.Header written = BlockFile.write(output, 10, 12, false, + height -> record(height)); + + assertEquals(10, written.getStart()); + assertEquals(12, written.getEnd()); + assertEquals(3, written.getCount()); + try (BlockFile.Reader reader = BlockFile.open(output)) { + assertEquals(3, reader.getHeader().getCount()); + for (long height = 10; height <= 12; height++) { + BlockFile.Record record = reader.next(); + assertEquals(height, record.getHeight()); + assertArrayEquals(blockId(height), record.getBlockId()); + assertEquals(height, record.getBlock().getBlockHeader().getRawData().getNumber()); + } + assertFalse(reader.hasNext()); + } + } + + @Test + public void shouldRejectCorruptedBlockData() throws Exception { + Path output = temporaryFolder.getRoot().toPath().resolve("corrupted.dat"); + BlockFile.write(output, 10, 10, false, BlockFileTest::record); + byte[] bytes = Files.readAllBytes(output); + int firstBlockByte = 8 + Integer.BYTES + Long.BYTES * 3 + + Long.BYTES + 32 + Integer.BYTES; + bytes[firstBlockByte] ^= 1; + Files.write(output, bytes); + + try (BlockFile.Reader reader = BlockFile.open(output)) { + assertThrows(IOException.class, reader::next); + } + } + + @Test + public void shouldNotOverwriteByDefault() throws Exception { + Path output = temporaryFolder.newFile("existing.dat").toPath(); + assertThrows(IOException.class, + () -> BlockFile.write(output, 1, 1, false, BlockFileTest::record)); + } + + private static BlockFile.Record record(long height) { + byte[] parent = height == 10 ? new byte[32] : blockId(height - 1); + Block block = Block.newBuilder() + .setBlockHeader(BlockHeader.newBuilder() + .setRawData(BlockHeader.raw.newBuilder() + .setNumber(height) + .setTimestamp(height * 3000) + .setParentHash(ByteString.copyFrom(parent)))) + .build(); + return new BlockFile.Record(height, blockId(height), block.toByteArray()); + } + + private static byte[] blockId(long height) { + byte[] value = new byte[32]; + byte[] heightBytes = ByteArray.fromLong(height); + System.arraycopy(heightBytes, 0, value, 0, heightBytes.length); + value[31] = (byte) height; + return value; + } +} diff --git a/common/src/test/java/org/tron/core/config/args/StorageConfigTest.java b/common/src/test/java/org/tron/core/config/args/StorageConfigTest.java index e3f1925a763..022758ea4e2 100644 --- a/common/src/test/java/org/tron/core/config/args/StorageConfigTest.java +++ b/common/src/test/java/org/tron/core/config/args/StorageConfigTest.java @@ -2,7 +2,9 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotSame; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; import com.typesafe.config.Config; @@ -56,6 +58,230 @@ public void testCheckpointDefaults() { assertTrue(sc.getCheckpoint().isSync()); } + @Test + public void testStateArchiveDefaultsAndOverrides() { + StorageConfig defaults = StorageConfig.fromConfig(withRef()); + assertFalse(defaults.getStateArchive().isEnabled()); + assertEquals("state-archive", defaults.getStateArchive().getDirectory()); + assertEquals(1073741824L, defaults.getStateArchive().getMaxSegmentSize()); + assertEquals(256, defaults.getStateArchive().getQueueCapacity()); + assertEquals("ROCKSDB", defaults.getStateArchive().getServingIndexEngine()); + assertFalse(defaults.getStateArchive().getHotStore().isEnabled()); + assertEquals("ROCKSDB", defaults.getStateArchive().getHotStore().getEngine()); + assertEquals(10000L, defaults.getStateArchive().getHotStore().getMaxBlocks()); + assertEquals(2147483648L, + defaults.getStateArchive().getHotStore().getMaxEncodedBytes()); + assertEquals(8, defaults.getStateArchive().getHotStore().getMaxFrozenGenerations()); + assertEquals(4, defaults.getStateArchive().getHotStore().getYellowFrozenGenerations()); + assertEquals(7, defaults.getStateArchive().getHotStore().getRedFrozenGenerations()); + assertFalse(defaults.getStateArchive().getAppendFile().isEnabled()); + assertEquals(3, defaults.getStateArchive().getAppendFile().getFormatVersion()); + assertEquals(2000000000L, + defaults.getStateArchive().getAppendFile().getSegmentTargetBytes()); + + StorageConfig configured = StorageConfig.fromConfig(withRef( + "storage.stateArchive { enabled = true, directory = archive-test, " + + "maxSegmentSize = 134217728, queueCapacity = 8, " + + "servingIndexEngine = leveldb, hotStore.engine = leveldb }")); + assertTrue(configured.getStateArchive().isEnabled()); + assertEquals("archive-test", configured.getStateArchive().getDirectory()); + assertEquals(134217728L, configured.getStateArchive().getMaxSegmentSize()); + assertEquals(8, configured.getStateArchive().getQueueCapacity()); + assertEquals("LEVELDB", configured.getStateArchive().getServingIndexEngine()); + assertEquals("LEVELDB", configured.getStateArchive().getHotStore().getEngine()); + } + + @Test + public void testArchiveNativeDatabaseProfileDefaultsAndOverrides() { + StorageConfig defaults = StorageConfig.fromConfig(withRef()); + assertEquals(67108864, + defaults.getStateArchive().getServingIndex().getWriteBufferSize()); + assertEquals(33554432L, + defaults.getStateArchive().getServingIndex().getCacheSize()); + assertNotSame(defaults.getStateArchive().getServingIndex(), + defaults.getStateArchive().getHotStore().getDbSettings()); + assertEquals(16777216, + defaults.getPathStateRoot().getDbSettings().getSmall().getWriteBufferSize()); + assertEquals(67108864, + defaults.getPathStateRoot().getDbSettings().getGiant().getWriteBufferSize()); + assertEquals(67108864L, + defaults.getPathStateRoot().getDbSettings().getGiant().getCacheSize()); + + StorageConfig configured = StorageConfig.fromConfig(withRef( + "storage.pathStateRoot.dbSettings.small.cacheSize = 1048576\n" + + "storage.pathStateRoot.dbSettings.giant.maxOpenFiles = 321\n" + + "storage.stateArchive.servingIndex.writeBufferSize = 8388608\n" + + "storage.stateArchive.hotStore.dbSettings.writeBufferSize = 4194304")); + assertEquals(1048576L, + configured.getPathStateRoot().getDbSettings().getSmall().getCacheSize()); + assertEquals(321, + configured.getPathStateRoot().getDbSettings().getGiant().getMaxOpenFiles()); + assertEquals(8388608, + configured.getStateArchive().getServingIndex().getWriteBufferSize()); + assertEquals(4194304, + configured.getStateArchive().getHotStore().getDbSettings().getWriteBufferSize()); + assertEquals(67108864, + defaults.getStateArchive().getHotStore().getDbSettings().getWriteBufferSize()); + } + + @Test(expected = IllegalArgumentException.class) + public void testHotStoreRejectsInvalidFrozenWatermarks() { + StorageConfig.fromConfig(withRef( + "storage.stateArchive.hotStore.yellowFrozenGenerations = 7\n" + + "storage.stateArchive.hotStore.redFrozenGenerations = 7")); + } + + @Test(expected = IllegalArgumentException.class) + public void testArchiveNativeDatabaseProfileRejectsInvalidValues() { + StorageConfig.fromConfig(withRef( + "storage.pathStateRoot.dbSettings.large.maxOpenFiles = 0")); + } + + @Test(expected = IllegalArgumentException.class) + public void testStateArchiveRejectsSmallSegments() { + StorageConfig.fromConfig(withRef("storage.stateArchive.maxSegmentSize = 1024")); + } + + @Test + public void testP66SnapshotRequiresCommonAndAcceptsExplicitOptIn() { + assertThrows(IllegalArgumentException.class, () -> StorageConfig.fromConfig(withRef( + "storage.commonCheckpoint.p66SnapshotEnabled = true"))); + StorageConfig configured = StorageConfig.fromConfig(withRef( + "storage.stateArchive.enabled = true\n" + + "storage.pathStateRoot.enabled = true\n" + + "storage.commonCheckpoint.enabled = true\n" + + "storage.commonCheckpoint.p66SnapshotEnabled = true")); + assertTrue(configured.getCommonCheckpoint().isP66SnapshotEnabled()); + } + + @Test + public void testCommonCheckpointDefaultsAndAdmission() { + StorageConfig defaults = StorageConfig.fromConfig(withRef()); + assertFalse(defaults.getCommonCheckpoint().isEnabled()); + assertFalse(defaults.getCommonCheckpoint().isP66SnapshotEnabled()); + assertEquals("common-checkpoint", defaults.getCommonCheckpoint().getDirectory()); + + StorageConfig configured = StorageConfig.fromConfig(withRef( + "storage.stateArchive.enabled = true\n" + + "storage.pathStateRoot.enabled = true\n" + + "storage.commonCheckpoint { enabled = true, directory = common-test }")); + assertTrue(configured.getCommonCheckpoint().isEnabled()); + assertEquals("common-test", configured.getCommonCheckpoint().getDirectory()); + } + + @Test(expected = IllegalArgumentException.class) + public void testCommonCheckpointRequiresBothAuthorities() { + StorageConfig.fromConfig(withRef("storage.commonCheckpoint.enabled = true")); + } + + @Test(expected = IllegalArgumentException.class) + public void testHotStoreRequiresCommonCheckpoint() { + StorageConfig.fromConfig(withRef( + "storage.stateArchive.enabled = true\n" + + "storage.stateArchive.hotStore.enabled = true")); + } + + @Test + public void testHotStoreAdmitsOnlyWithCommonCheckpointAuthorities() { + StorageConfig configured = StorageConfig.fromConfig(withRef( + "storage.stateArchive.enabled = true\n" + + "storage.stateArchive.hotStore.enabled = true\n" + + "storage.pathStateRoot.enabled = true\n" + + "storage.commonCheckpoint.enabled = true")); + assertTrue(configured.getStateArchive().getHotStore().isEnabled()); + } + + @Test + public void testAppendFileAdmitsOnlyWithCommonCheckpointAuthorities() { + StorageConfig configured = StorageConfig.fromConfig(withRef( + "storage.stateArchive.enabled = true\n" + + "storage.stateArchive.appendFile.enabled = true\n" + + "storage.pathStateRoot.enabled = true\n" + + "storage.commonCheckpoint.enabled = true")); + assertTrue(configured.getStateArchive().getAppendFile().isEnabled()); + } + + @Test(expected = IllegalArgumentException.class) + public void testAppendFileRequiresCommonCheckpoint() { + StorageConfig.fromConfig(withRef( + "storage.stateArchive.enabled = true\n" + + "storage.stateArchive.appendFile.enabled = true")); + } + + @Test(expected = IllegalArgumentException.class) + public void testAppendFileRejectsHotStoreCombination() { + StorageConfig.fromConfig(withRef( + "storage.stateArchive.enabled = true\n" + + "storage.stateArchive.appendFile.enabled = true\n" + + "storage.stateArchive.hotStore.enabled = true\n" + + "storage.pathStateRoot.enabled = true\n" + + "storage.commonCheckpoint.enabled = true")); + } + + @Test(expected = IllegalArgumentException.class) + public void testCommonCheckpointRejectsBenchmarkMode() { + StorageConfig.fromConfig(withRef( + "storage.stateArchive.enabled = true\n" + + "storage.pathStateRoot.enabled = true\n" + + "storage.pathStateRoot.volatileSnapshotBenchmark = true\n" + + "storage.commonCheckpoint.enabled = true")); + } + + @Test + public void testPathStateRootDefaultsAndOverrides() { + StorageConfig defaults = StorageConfig.fromConfig(withRef()); + assertFalse(defaults.getPathStateRoot().isEnabled()); + assertEquals("shadow", defaults.getPathStateRoot().getMode()); + assertEquals("path-state-root", defaults.getPathStateRoot().getDirectory()); + assertEquals("ROCKSDB", defaults.getPathStateRoot().getEngine()); + assertEquals(1, defaults.getPathStateRoot().getFormatVersion()); + assertEquals(128, defaults.getPathStateRoot().getReversibleLayerLimit()); + assertEquals(2147483648L, defaults.getPathStateRoot().getReversibleLayerBytes()); + assertEquals(268435456L, defaults.getPathStateRoot().getWriteBufferBytes()); + assertEquals(268435456L, defaults.getPathStateRoot().getNodeCacheBytes()); + assertEquals(4, defaults.getPathStateRoot().getParticipantThreads()); + assertEquals(8, defaults.getPathStateRoot().getBranchThreads()); + assertFalse(defaults.getPathStateRoot().isRebuildFromGenesis()); + assertTrue(defaults.getPathStateRoot().isVerifyEveryBlock()); + assertFalse(defaults.getPathStateRoot().isVolatileSnapshotBenchmark()); + assertFalse(defaults.getPathStateRoot().isAsyncPrepareBenchmark()); + + StorageConfig configured = StorageConfig.fromConfig(withRef( + "storage.pathStateRoot { enabled = true, engine = leveldb, mode = shadow, " + + "directory = root-test, " + + "formatVersion = 1, reversibleLayerLimit = 8, reversibleLayerBytes = 4096, " + + "writeBufferBytes = 1024, nodeCacheBytes = 2048, participantThreads = 2, " + + "branchThreads = 3, rebuildFromGenesis = false, " + + "verifyEveryBlock = true, volatileSnapshotBenchmark = true, " + + "asyncPrepareBenchmark = true }")); + assertTrue(configured.getPathStateRoot().isEnabled()); + assertEquals("root-test", configured.getPathStateRoot().getDirectory()); + assertEquals("LEVELDB", configured.getPathStateRoot().getEngine()); + assertEquals(8, configured.getPathStateRoot().getReversibleLayerLimit()); + assertEquals(4096L, configured.getPathStateRoot().getReversibleLayerBytes()); + assertEquals(1024L, configured.getPathStateRoot().getWriteBufferBytes()); + assertEquals(2048L, configured.getPathStateRoot().getNodeCacheBytes()); + assertEquals(2, configured.getPathStateRoot().getParticipantThreads()); + assertEquals(3, configured.getPathStateRoot().getBranchThreads()); + assertTrue(configured.getPathStateRoot().isVolatileSnapshotBenchmark()); + assertTrue(configured.getPathStateRoot().isAsyncPrepareBenchmark()); + } + + @Test(expected = IllegalArgumentException.class) + public void testPathStateRootRejectsInvalidPrepareThreads() { + StorageConfig.fromConfig(withRef("storage.pathStateRoot.participantThreads = 0")); + } + + @Test(expected = IllegalArgumentException.class) + public void testPathStateRootRejectsUnsupportedMode() { + StorageConfig.fromConfig(withRef("storage.pathStateRoot.mode = consensus")); + } + + @Test(expected = IllegalArgumentException.class) + public void testRejectsUnsupportedAuxiliaryDatabaseEngine() { + StorageConfig.fromConfig(withRef("storage.stateArchive.servingIndexEngine = memory")); + } + @Test public void testDbSettingsDefaults() { // These defaults must match develop's Args.initRocksDbSettings() fallbacks so that diff --git a/framework/src/main/java/org/tron/common/runtime/RuntimeImpl.java b/framework/src/main/java/org/tron/common/runtime/RuntimeImpl.java index 3dccfc5d146..8091d7109c5 100644 --- a/framework/src/main/java/org/tron/common/runtime/RuntimeImpl.java +++ b/framework/src/main/java/org/tron/common/runtime/RuntimeImpl.java @@ -12,6 +12,7 @@ import org.tron.core.db.TransactionContext; import org.tron.core.exception.ContractExeException; import org.tron.core.exception.ContractValidateException; +import org.tron.core.vm.config.VMConfig; import org.tron.core.vm.program.Program; import org.tron.core.vm.program.Program.BadJumpDestinationException; import org.tron.core.vm.program.Program.IllegalOperationException; @@ -22,6 +23,7 @@ import org.tron.core.vm.program.Program.PrecompiledContractException; import org.tron.core.vm.program.Program.StackTooLargeException; import org.tron.core.vm.program.Program.StackTooSmallException; +import org.tron.core.vm.repository.HistoricalRepositoryProvider; import org.tron.protos.Protocol.Transaction.Contract.ContractType; import org.tron.protos.Protocol.Transaction.Result.contractResult; @@ -44,14 +46,24 @@ public void execute(TransactionContext context) switch (contractType.getNumber()) { case ContractType.TriggerSmartContract_VALUE: case ContractType.CreateSmartContract_VALUE: - actuator2 = new VMActuator(context.isStatic()); + actuator2 = context.getExecutionMode() + == TransactionContext.ExecutionMode.HISTORICAL_CONSTANT + ? new VMActuator(true, HistoricalRepositoryProvider.INSTANCE) + : new VMActuator(context.isStatic()); break; default: actuatorList = ActuatorCreator.getINSTANCE().createActuator(context.getTrxCap()); } if (actuator2 != null) { - actuator2.validate(context); - actuator2.execute(context); + try { + actuator2.validate(context); + actuator2.execute(context); + } finally { + if (context.getExecutionMode() + == TransactionContext.ExecutionMode.HISTORICAL_CONSTANT) { + VMConfig.clearLocalSnapshot(); + } + } } else { for (Actuator act : actuatorList) { act.validate(); @@ -133,4 +145,3 @@ private void setResultCode(ProgramResult result) { } } - diff --git a/framework/src/main/java/org/tron/core/Wallet.java b/framework/src/main/java/org/tron/core/Wallet.java index ac54cb2b7ff..dd6efa16075 100755 --- a/framework/src/main/java/org/tron/core/Wallet.java +++ b/framework/src/main/java/org/tron/core/Wallet.java @@ -166,6 +166,8 @@ import org.tron.core.db.EnergyProcessor; import org.tron.core.db.Manager; import org.tron.core.db.TransactionContext; +import org.tron.core.db.TransactionContext.ExecutionMode; +import org.tron.core.db2.archive.HistoricalQuerySession; import org.tron.core.db2.core.Chainbase; import org.tron.core.db2.core.Chainbase.Cursor; import org.tron.core.exception.AccountResourceInsufficientException; @@ -204,6 +206,7 @@ import org.tron.core.utils.TransactionUtil; import org.tron.core.vm.config.VMConfig; import org.tron.core.vm.program.Program; +import org.tron.core.vm.repository.HistoricalRepositoryProvider; import org.tron.core.zen.ShieldedTRC20ParametersBuilder; import org.tron.core.zen.ShieldedTRC20ParametersBuilder.ShieldedTRC20ParametersType; import org.tron.core.zen.ZenTransactionBuilder; @@ -3153,10 +3156,40 @@ public Transaction callConstantContract(TransactionCapsule trxCap, headBlock = blockCapsuleList.get(0).getInstance(); } - BlockCapsule headBlockCapsule = new BlockCapsule(headBlock); - TransactionContext context = new TransactionContext(headBlockCapsule, trxCap, - StoreFactory.getInstance(), true, false); - VMActuator vmActuator = new VMActuator(true); + return executeConstantContract(trxCap, builder, retBuilder, isEstimating, + new BlockCapsule(headBlock), null); + } + + public Transaction callHistoricalConstantContract(TransactionCapsule trxCap, + Builder builder, Return.Builder retBuilder, HistoricalQuerySession session) + throws ContractValidateException, ContractExeException, HeaderNotFound, VMIllegalException { + if (!Args.getInstance().isSupportConstant()) { + throw new ContractValidateException("this node does not support constant"); + } + BlockCapsule targetBlock; + try { + targetBlock = chainBaseManager.getBlockByNum(session.getTargetBlock()); + } catch (BadItemException | ItemNotFoundException failure) { + throw new HeaderNotFound("historical block not found"); + } + if (targetBlock == null + || !Arrays.equals(targetBlock.getBlockId().getBytes(), session.getTargetBlockHash())) { + throw new HeaderNotFound("historical block identity changed"); + } + session.requirePinnedIdentity(); + return executeConstantContract(trxCap, builder, retBuilder, false, targetBlock, session); + } + + private Transaction executeConstantContract(TransactionCapsule trxCap, + Builder builder, Return.Builder retBuilder, boolean isEstimating, + BlockCapsule blockCapsule, HistoricalQuerySession session) + throws ContractValidateException, ContractExeException, VMIllegalException { + TransactionContext context = session == null + ? new TransactionContext(blockCapsule, trxCap, StoreFactory.getInstance(), true, false) + : new TransactionContext(blockCapsule, trxCap, StoreFactory.getInstance(), true, false, + ExecutionMode.HISTORICAL_CONSTANT, session); + VMActuator vmActuator = session == null + ? new VMActuator(true) : new VMActuator(true, HistoricalRepositoryProvider.INSTANCE); try { vmActuator.validate(context); @@ -4432,6 +4465,30 @@ public BalanceContract.AccountBalanceResponse getAccountBalance( return builder.build(); } + /** Experimental state-archive balance query used for trace-oracle validation. */ + public BalanceContract.AccountBalanceResponse getAccountBalanceFromStateArchive( + BalanceContract.AccountBalanceRequest request) + throws ItemNotFoundException, BadItemException { + BalanceContract.AccountIdentifier accountIdentifier = request.getAccountIdentifier(); + checkAccountIdentifier(accountIdentifier); + BlockBalanceTrace.BlockIdentifier blockIdentifier = request.getBlockIdentifier(); + checkBlockIdentifier(blockIdentifier); + BlockId blockId = chainBaseManager.getBlockIndexStore().get(blockIdentifier.getNumber()); + if (!blockId.getByteString().equals(blockIdentifier.getHash())) { + throw new IllegalArgumentException("number and hash do not match"); + } + org.tron.core.db2.archive.HistoricalAccountBalanceReader.Result result = + dbManager.getArchiveAccountBalance(blockIdentifier.getNumber(), + accountIdentifier.getAddress().toByteArray()); + if (!result.isPresent()) { + throw new ItemNotFoundException("Account is absent at the requested block"); + } + return BalanceContract.AccountBalanceResponse.newBuilder() + .setBlockIdentifier(blockIdentifier) + .setBalance(result.getBalance()) + .build(); + } + public BalanceContract.BlockBalanceTrace getBlockBalance( BlockBalanceTrace.BlockIdentifier request) throws ItemNotFoundException, BadItemException { checkBlockIdentifier(request); diff --git a/framework/src/main/java/org/tron/core/config/args/Args.java b/framework/src/main/java/org/tron/core/config/args/Args.java index 0bca242606e..b6d49516725 100644 --- a/framework/src/main/java/org/tron/core/config/args/Args.java +++ b/framework/src/main/java/org/tron/core/config/args/Args.java @@ -217,6 +217,45 @@ private static void applyStorageConfig(StorageConfig sc) { // contractParse is set in applyConfigParams alongside event config, not here PARAMETER.storage.setCheckpointVersion(sc.getCheckpoint().getVersion()); PARAMETER.storage.setCheckpointSync(sc.getCheckpoint().isSync()); + PARAMETER.storage.setStateArchiveEnabled(sc.getStateArchive().isEnabled()); + PARAMETER.storage.setStateArchiveDirectory(sc.getStateArchive().getDirectory()); + PARAMETER.storage.setStateArchiveMaxSegmentSize(sc.getStateArchive().getMaxSegmentSize()); + PARAMETER.storage.setStateArchiveQueueCapacity(sc.getStateArchive().getQueueCapacity()); + PARAMETER.storage.setStateArchiveServingIndexEngine( + sc.getStateArchive().getServingIndexEngine()); + PARAMETER.storage.setStateArchiveServingIndexDbSettings( + sc.getStateArchive().getServingIndex()); + PARAMETER.storage.setStateArchiveHotStoreSettings(sc.getStateArchive().getHotStore()); + PARAMETER.storage.setStateArchiveAppendFileSettings(sc.getStateArchive().getAppendFile()); + PARAMETER.storage.setCommonCheckpointEnabled(sc.getCommonCheckpoint().isEnabled()); + PARAMETER.storage.setP66SnapshotEnabled(sc.getCommonCheckpoint().isP66SnapshotEnabled()); + PARAMETER.storage.setCommonCheckpointDirectory(sc.getCommonCheckpoint().getDirectory()); + PARAMETER.storage.setPathStateRootEnabled(sc.getPathStateRoot().isEnabled()); + PARAMETER.storage.setPathStateRootMode(sc.getPathStateRoot().getMode()); + PARAMETER.storage.setPathStateRootDirectory(sc.getPathStateRoot().getDirectory()); + PARAMETER.storage.setPathStateRootEngine(sc.getPathStateRoot().getEngine()); + PARAMETER.storage.setPathStateRootFormatVersion(sc.getPathStateRoot().getFormatVersion()); + PARAMETER.storage.setPathStateRootReversibleLayerLimit( + sc.getPathStateRoot().getReversibleLayerLimit()); + PARAMETER.storage.setPathStateRootReversibleLayerBytes( + sc.getPathStateRoot().getReversibleLayerBytes()); + PARAMETER.storage.setPathStateRootWriteBufferBytes( + sc.getPathStateRoot().getWriteBufferBytes()); + PARAMETER.storage.setPathStateRootNodeCacheBytes( + sc.getPathStateRoot().getNodeCacheBytes()); + PARAMETER.storage.setPathStateRootParticipantThreads( + sc.getPathStateRoot().getParticipantThreads()); + PARAMETER.storage.setPathStateRootBranchThreads( + sc.getPathStateRoot().getBranchThreads()); + PARAMETER.storage.setPathStateRootRebuildFromGenesis( + sc.getPathStateRoot().isRebuildFromGenesis()); + PARAMETER.storage.setPathStateRootVerifyEveryBlock( + sc.getPathStateRoot().isVerifyEveryBlock()); + PARAMETER.storage.setPathStateRootVolatileSnapshotBenchmark( + sc.getPathStateRoot().isVolatileSnapshotBenchmark()); + PARAMETER.storage.setPathStateRootAsyncPrepareBenchmark( + sc.getPathStateRoot().isAsyncPrepareBenchmark()); + PARAMETER.storage.setPathStateRootDbSettings(sc.getPathStateRoot().getDbSettings()); // estimatedTransactions / maxFlushCount clamping & validation run inside // TxCacheConfig.postProcess / SnapshotConfig.postProcess during bean load. @@ -1315,4 +1354,3 @@ private static Map getOptionGroup() { return optionGroupMap; } } - diff --git a/framework/src/main/java/org/tron/core/db/Manager.java b/framework/src/main/java/org/tron/core/db/Manager.java index 9d7a7c979b9..62b3564777b 100644 --- a/framework/src/main/java/org/tron/core/db/Manager.java +++ b/framework/src/main/java/org/tron/core/db/Manager.java @@ -15,6 +15,10 @@ import com.google.common.primitives.Longs; import com.google.protobuf.ByteString; import io.prometheus.client.Histogram; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; @@ -25,6 +29,7 @@ import java.util.HashSet; import java.util.LinkedList; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.Optional; @@ -113,8 +118,59 @@ import org.tron.core.db.api.MigrateTurkishKeyHelper; import org.tron.core.db.api.MoveAbiHelper; import org.tron.core.db2.ISession; +import org.tron.core.db2.archive.AccountAssetArchiveProjector; +import org.tron.core.db2.archive.ArchiveFormatAdmissionValidator.Result; +import org.tron.core.db2.archive.ArchiveFormatAdmissionValidator.Status; +import org.tron.core.db2.archive.ArchiveHistoryWriter; +import org.tron.core.db2.archive.ArchivePointSnapshot; +import org.tron.core.db2.archive.ArchiveStoreScope; +import org.tron.core.db2.archive.BlockSnapshotMeta; +import org.tron.core.db2.archive.HistoricalAccountAssetBalanceResolver; +import org.tron.core.db2.archive.HistoricalAccountAssetPrefixResolver; +import org.tron.core.db2.archive.HistoricalAccountBalanceReader; +import org.tron.core.db2.archive.HistoricalQuerySession; +import org.tron.core.db2.archive.LatestStateGenerationAdapter; +import org.tron.core.db2.archive.LatestStateGenerationCoordinatorFactory; +import org.tron.core.db2.archive.OldValue; +import org.tron.core.db2.archive.SnapshotOldValueCollector; +import org.tron.core.db2.archive.SnapshotPathStateTransitionCollector; +import org.tron.core.db2.archive.StateArchiveAppendCheckpointMaterializerV3; +import org.tron.core.db2.archive.StateArchiveCheckpointMaterializer; +import org.tron.core.db2.archive.StateArchiveCheckpointReadSnapshot; +import org.tron.core.db2.archive.StateArchiveFileFormatV3; +import org.tron.core.db2.archive.StateArchiveHotCheckpointMaterializer; +import org.tron.core.db2.archive.StateArchiveHotStore; +import org.tron.core.db2.archive.StateArchiveRuntimeOwner; import org.tron.core.db2.core.Chainbase; +import org.tron.core.db2.core.ChainbaseCheckpointMaterializer; +import org.tron.core.db2.core.CommonCheckpointBaseline; +import org.tron.core.db2.core.CommonCheckpointBaselineFile; +import org.tron.core.db2.core.CommonCheckpointFile; +import org.tron.core.db2.core.CommonCheckpointFormat; +import org.tron.core.db2.core.CommonCheckpointHotRecovery; +import org.tron.core.db2.core.CommonCheckpointMaterializedStore; +import org.tron.core.db2.core.CommonCheckpointRecoveryStateAdapter; +import org.tron.core.db2.core.CommonCheckpointRedoCoordinator; +import org.tron.core.db2.core.CommonCheckpointRuntime; +import org.tron.core.db2.core.CommonCheckpointRuntimeAttachment; +import org.tron.core.db2.core.CommonCheckpointRuntimeOwner; import org.tron.core.db2.core.SnapshotManager; +import org.tron.core.db2.stateroot.PathStateBlockTransition; +import org.tron.core.db2.stateroot.PathStateCanonicalizer; +import org.tron.core.db2.stateroot.PathStateCanonicalizer.P66Phase; +import org.tron.core.db2.stateroot.PathStateCheckpointMaterializer; +import org.tron.core.db2.stateroot.PathStateHead; +import org.tron.core.db2.stateroot.PathStateLayerLimits; +import org.tron.core.db2.stateroot.PathStateNativeSnapshotSource; +import org.tron.core.db2.stateroot.PathStatePhysicalOverlayHead; +import org.tron.core.db2.stateroot.PathStatePhysicalRuntimeAdmission; +import org.tron.core.db2.stateroot.PathStatePhysicalSnapshotHead; +import org.tron.core.db2.stateroot.PathStatePhysicalStoreSet; +import org.tron.core.db2.stateroot.PathStateRebuildCoordinator.SnapshotIdentity; +import org.tron.core.db2.stateroot.PathStateRoot; +import org.tron.core.db2.stateroot.PathStateRootMetadata; +import org.tron.core.db2.stateroot.PathStateRuntimeAttachment; +import org.tron.core.db2.stateroot.PathStateStoreManifest; import org.tron.core.exception.AccountResourceInsufficientException; import org.tron.core.exception.BadBlockException; import org.tron.core.exception.BadItemException; @@ -187,6 +243,24 @@ public class Manager { private static final int SLEEP_TIME_OUT = 50; private static final int TX_ID_CACHE_SIZE = 100_000; private static final int SLEEP_FOR_WAIT_LOCK = 10; + private static final int PATH_STATE_REBUILD_PAGE_SIZE = 4096; + private static final int PATH_STATE_REBUILD_MARKET_ENTRY_LIMIT = 1_000_000; + @Getter + private ArchiveHistoryWriter archiveHistoryWriter; + @Getter + private StateArchiveRuntimeOwner stateArchiveRuntime; + @Getter + private PathStateHead pathStateSnapshotHead; + @Getter + private PathStateRuntimeAttachment pathStateRuntime; + @Getter + private CommonCheckpointRuntimeAttachment commonCheckpointRuntime; + private StateArchiveAppendCheckpointMaterializerV3 stateArchiveAppendMaterializer; + private boolean stateArchiveServingLive; + private StateArchiveRuntimeOwner.ServingIndexFaultHook stateArchiveServingIndexFaultHook = + stage -> { }; + private StateArchiveRuntimeOwner.ReadableStateFaultHook stateArchiveReadableStateFaultHook = + stage -> { }; private static final int NO_BLOCK_WAITING_LOCK = 0; private final int shieldedTransInPendingMaxCounts = Args.getInstance().getShieldedTransInPendingMaxCounts(); @@ -495,6 +569,13 @@ public void init() { accountStateCallBack.setChainBaseManager(chainBaseManager); trieService.setChainBaseManager(chainBaseManager); revokingStore.disable(); + if (Args.getInstance().getStorage().isP66SnapshotEnabled()) { + if (!Args.getInstance().getStorage().isCommonCheckpointEnabled()) { + throw new IllegalStateException("P66 Snapshot requires Common checkpoint"); + } + chainBaseManager.getAccountAssetStore() + .enableSnapshots((SnapshotManager) revokingStore, true); + } revokingStore.check(); transactionCache.initCache(); rewardViCalService.init(); @@ -565,6 +646,13 @@ public void init() { // init liteFullNode initLiteNode(); + if (Args.getInstance().getStorage().isCommonCheckpointEnabled()) { + initCommonCheckpoint(); + } else { + initStateArchive(); + initPathStateRoot(); + } + long headNum = chainBaseManager.getDynamicPropertiesStore().getLatestBlockHeaderNumber(); logger.info("Current headNum is: {}.", headNum); boolean isLite = chainBaseManager.isLiteNode(); @@ -606,6 +694,911 @@ public void init() { maxFlushCount = CommonParameter.getInstance().getStorage().getMaxFlushCount(); } + private void initStateArchive() { + org.tron.core.config.args.Storage storage = Args.getInstance().getStorage(); + Path archiveDirectory = Paths.get(Args.getInstance().getOutputDirectory(), + storage.getStateArchiveDirectory()).normalize(); + Result admission = StateArchiveBasePreflight.requireAdmitted(storage.isStateArchiveEnabled(), + archiveDirectory); + if (!storage.isStateArchiveEnabled()) { + return; + } + if (!(revokingStore instanceof SnapshotManager)) { + throw new IllegalStateException("State archive requires SnapshotManager"); + } + StateArchiveRuntimeOwner recovered = null; + try { + long headNumber = getDynamicPropertiesStore().getLatestBlockHeaderNumber(); + BlockCapsule headBlock = chainBaseManager.getBlockByNum(headNumber); + BlockSnapshotMeta canonicalHead = BlockSnapshotMeta.forBlock(headNumber, + getDynamicPropertiesStore().getLatestBlockHeaderHash().getBytes(), + headBlock.getParentHash().getBytes(), headBlock.getTimeStamp()); + if (admission.getStatus() == Status.EMPTY_NEW) { + recovered = StateArchiveRuntimeOwner.bootstrapAndRecover( + (SnapshotManager) revokingStore, archiveDirectory, + storage.getStateArchiveMaxSegmentSize(), canonicalHead, + stateArchiveServingIndexFaultHook, stateArchiveReadableStateFaultHook); + logger.info("State archive fresh baseline published: directory={}, head={}", + archiveDirectory, headNumber); + } else { + recovered = StateArchiveRuntimeOwner.recover((SnapshotManager) revokingStore, + archiveDirectory, storage.getStateArchiveMaxSegmentSize(), + stateArchiveServingIndexFaultHook, stateArchiveReadableStateFaultHook); + } + BlockSnapshotMeta archiveHead = recovered.getRecoveredHead(); + if (archiveHead != null + && (archiveHead.getBlockNumber() + != getDynamicPropertiesStore().getLatestBlockHeaderNumber() + || !Arrays.equals(archiveHead.getBlockHash(), + getDynamicPropertiesStore().getLatestBlockHeaderHash().getBytes()))) { + throw new IllegalStateException( + "State archive committed head differs from the persisted state root"); + } + java.util.Map + supplementalStores = java.util.Collections.emptyMap(); + SnapshotOldValueCollector archiveCollector = new SnapshotOldValueCollector(); + boolean accountAssetRegistered = ((SnapshotManager) revokingStore).getDbs().stream() + .anyMatch(database -> AccountAssetArchiveProjector.ACCOUNT_ASSET_DB + .equals(database.getDbName())); + if (!accountAssetRegistered) { + AccountAssetStore accountAssetStore = chainBaseManager.getAccountAssetStore(); + if (accountAssetStore == null) { + throw new IllegalStateException("State archive requires account-asset Store"); + } + supplementalStores = java.util.Collections.singletonMap( + AccountAssetArchiveProjector.ACCOUNT_ASSET_DB, + org.tron.core.db2.archive.LatestStateGenerationAdapter.fromDataSource( + AccountAssetArchiveProjector.ACCOUNT_ASSET_DB, + accountAssetStore.getDbSource())); + archiveCollector = new SnapshotOldValueCollector( + new AccountAssetArchiveProjector(), accountAssetStore::prefixQuery, + SnapshotOldValueCollector::resolveTargetAssetOptimization); + } + archiveHistoryWriter = recovered.attachNormalWriter(archiveCollector, + storage.getStateArchiveQueueCapacity(), canonicalHead, + supplementalStores); + stateArchiveRuntime = recovered; + recovered = null; + logger.info("State archive runtime attached: directory={}, head={}, actions={}, engine={}", + archiveDirectory, archiveHead.getBlockNumber(), + stateArchiveRuntime.getStartupRecoveryActionCount(), + configuredAuxiliaryEngine(storage.getStateArchiveServingIndexEngine(), + storage.getDbEngine())); + } catch (java.io.IOException | BadItemException | ItemNotFoundException + | RuntimeException failure) { + if (recovered != null) { + try { + recovered.close(); + } catch (java.io.IOException | RuntimeException closeFailure) { + failure.addSuppressed(closeFailure); + } + } + throw new IllegalStateException("Failed to recover State Archive startup", failure); + } + } + + /** Installs the fresh-format three-authority runtime before block processing is enabled. */ + private void initCommonCheckpoint() { + if (!(revokingStore instanceof SnapshotManager)) { + throw new IllegalStateException("Common checkpoint requires SnapshotManager"); + } + org.tron.core.config.args.Storage storage = Args.getInstance().getStorage(); + SnapshotManager snapshots = (SnapshotManager) revokingStore; + Path pathDirectory = Paths.get(Args.getInstance().getOutputDirectory(), + storage.getPathStateRootDirectory()).normalize(); + Path archiveDirectory = Paths.get(Args.getInstance().getOutputDirectory(), + storage.getStateArchiveDirectory()).normalize(); + Path checkpointDirectory = Paths.get(Args.getInstance().getOutputDirectory(), + storage.getCommonCheckpointDirectory()).normalize(); + byte[] formatIdentity = CommonCheckpointFormat.identity(); + org.tron.core.config.args.StorageConfig.StateArchiveAppendFileConfig appendConfig = + storage.getStateArchiveAppendFileSettings(); + boolean appendEnabled = appendConfig != null && appendConfig.isEnabled(); + Path appendDirectory = archiveDirectory.resolve("history").resolve("v3"); + PathStatePhysicalOverlayHead pathOwner = null; + CommonCheckpointRuntimeAttachment attachment = null; + StateArchiveHotStore hotStore = null; + StateArchiveAppendCheckpointMaterializerV3 appendMaterializer = null; + try { + PathStateStoreManifest.Engine pathEngine = configuredAuxiliaryEngine( + storage.getPathStateRootEngine(), storage.getDbEngine()); + PathStateStoreManifest.Engine servingIndexEngine = configuredAuxiliaryEngine( + storage.getStateArchiveServingIndexEngine(), storage.getDbEngine()); + boolean pathExisted = Files.exists(pathDirectory, LinkOption.NOFOLLOW_LINKS); + boolean modeAdmitted = pathExisted + && PathStateCheckpointMaterializer.isCommonModeAdmitted(pathDirectory, formatIdentity); + CommonCheckpointBaselineFile baselineFile = + new CommonCheckpointBaselineFile(checkpointDirectory); + boolean baselineExists = Files.isRegularFile( + checkpointDirectory.resolve(CommonCheckpointBaselineFile.FILE_NAME), + LinkOption.NOFOLLOW_LINKS); + if (baselineExists && !Arrays.equals(baselineFile.load().getFormatIdentity(), + formatIdentity)) { + throw new IllegalStateException("Common checkpoint Snapshot semantics differ"); + } + if (!baselineExists) { + requireEmptyOrMissing(archiveDirectory, "State Archive"); + if (!pathExisted) { + requireEmptyOrMissing(checkpointDirectory, "common checkpoint"); + baselineFile.beginBootstrap(formatIdentity); + } else if (!baselineFile.hasBootstrapIntent(formatIdentity)) { + requireEmptyOrMissing(checkpointDirectory, "common checkpoint"); + if (!Files.isRegularFile( + pathDirectory.resolve(PathStateCheckpointMaterializer.CURRENT_FILE), + LinkOption.NOFOLLOW_LINKS)) { + throw new IllegalStateException( + "Common checkpoint existing PathState requires one legacy CURRENT"); + } + } + } + if (!pathExisted) { + rebuildPathStateRoot(snapshots, pathDirectory, pathEngine); + } else if (!modeAdmitted && !Files.isRegularFile( + pathDirectory.resolve(PathStateCheckpointMaterializer.CURRENT_FILE), + LinkOption.NOFOLLOW_LINKS) + && !Files.isRegularFile(pathDirectory.resolve( + PathStateCheckpointMaterializer.COMMON_BASELINE_HEAD_FILE), + LinkOption.NOFOLLOW_LINKS)) { + rebuildPathStateRoot(snapshots, pathDirectory, pathEngine); + } + + PathStateLayerLimits limits = new PathStateLayerLimits( + storage.getPathStateRootReversibleLayerLimit(), + storage.getPathStateRootReversibleLayerBytes()); + recoverPendingCommonCheckpoint(snapshots, checkpointDirectory, archiveDirectory, + pathDirectory, pathEngine, servingIndexEngine, + storage.getPathStateRootNodeCacheBytes(), formatIdentity, baselineFile, baselineExists, + modeAdmitted, appendEnabled, appendDirectory, appendConfig); + if (storage.isP66SnapshotEnabled()) { + chainBaseManager.getAccountAssetStore().finishSnapshotRecovery(snapshots); + } + BlockSnapshotMeta canonical = currentCanonicalBlockMeta(); + P66Phase phase = currentPathStatePhase(); + if (modeAdmitted && Files.isRegularFile( + pathDirectory.resolve(PathStateCheckpointMaterializer.CURRENT_FILE), + LinkOption.NOFOLLOW_LINKS)) { + pathOwner = PathStatePhysicalOverlayHead.openCommonCheckpoint(pathDirectory, pathEngine, + limits, storage.getPathStateRootNodeCacheBytes(), + storage.getPathStateRootParticipantThreads(), storage.getPathStateRootBranchThreads(), + formatIdentity, canonical, phase); + } else { + pathOwner = modeAdmitted || Files.isRegularFile(pathDirectory.resolve( + PathStateCheckpointMaterializer.COMMON_BASELINE_HEAD_FILE), + LinkOption.NOFOLLOW_LINKS) + ? PathStatePhysicalOverlayHead.openCommonBaseline(pathDirectory, pathEngine, limits, + storage.getPathStateRootNodeCacheBytes(), + storage.getPathStateRootParticipantThreads(), + storage.getPathStateRootBranchThreads()) + : PathStatePhysicalOverlayHead.open(pathDirectory, pathEngine, limits, + storage.getPathStateRootNodeCacheBytes(), + storage.getPathStateRootParticipantThreads(), + storage.getPathStateRootBranchThreads()); + } + + PathStateRootMetadata initialHead = pathOwner.getHead(); + CommonCheckpointBaseline supplied = new CommonCheckpointBaseline(formatIdentity, + canonical, initialHead.getStateRoot()); + CommonCheckpointBaseline baseline = baselineExists ? baselineFile.load() + : baselineFile.openOrCreate(supplied); + if (!Arrays.equals(baseline.getFormatIdentity(), formatIdentity)) { + throw new IllegalStateException("Common checkpoint baseline format differs"); + } + if (!modeAdmitted) { + if (!baseline.getHead().equals(canonical) + || !Arrays.equals(baseline.getStateRoot(), initialHead.getStateRoot())) { + throw new IllegalStateException( + "Common checkpoint baseline differs from fresh PathState head"); + } + pathOwner.admitFreshCommonBaseline(baseline); + baselineFile.retireBootstrapIntent(); + } + + java.util.Map + supplementalStores = commonCheckpointSupplementalStores(snapshots); + LatestStateGenerationAdapter latest = LatestStateGenerationCoordinatorFactory.createAdapter( + snapshots, supplementalStores); + CommonCheckpointMaterializedStore materializedStore = + new CommonCheckpointMaterializedStore(checkpointDirectory); + PathStateCheckpointMaterializer pathMaterializer = pathOwner.checkpointMaterializer( + formatIdentity, baseline, materializedStore); + org.tron.core.config.args.StorageConfig.StateArchiveHotStoreConfig hotConfig = + storage.getStateArchiveHotStoreSettings(); + boolean hotEnabled = hotConfig != null && hotConfig.isEnabled(); + PathStateStoreManifest.Engine hotEngine = configuredAuxiliaryEngine( + hotConfig == null ? null : hotConfig.getEngine(), storage.getDbEngine()); + PathStateStoreManifest.Engine archiveRuntimeEngine = hotEnabled + ? hotEngine : servingIndexEngine; + Path hotDirectory = archiveDirectory.resolve("hot"); + if (hotEnabled && !Files.exists(hotDirectory, LinkOption.NOFOLLOW_LINKS)) { + requireEmptyOrMissing(archiveDirectory, "State Archive v2"); + if (!baseline.getHead().equals(canonical)) { + throw new IllegalStateException( + "State Archive Hot DB requires a fresh common checkpoint baseline"); + } + } + CommonCheckpointFile checkpointFile = new CommonCheckpointFile(checkpointDirectory); + StateArchiveHotCheckpointMaterializer hotMaterializer = null; + org.tron.core.db2.core.CommonCheckpointMaterializer archiveMaterializer; + if (appendEnabled) { + appendMaterializer = new StateArchiveAppendCheckpointMaterializerV3( + appendDirectory, formatIdentity, archiveRuntimeEngine, baseline.getStateRoot(), + StateArchiveFileFormatV3.COMPRESSION_NONE, appendConfig.getSegmentTargetBytes()); + archiveMaterializer = appendMaterializer; + } else if (hotEnabled) { + hotStore = StateArchiveHotStore.openOrCreate(hotDirectory, formatIdentity, hotEngine, + baseline.getHead().getBlockNumber(), baseline.getHead().getBlockHash(), hotConfig); + hotMaterializer = new StateArchiveHotCheckpointMaterializer(hotStore); + archiveMaterializer = hotMaterializer; + } else { + archiveMaterializer = new StateArchiveCheckpointMaterializer(archiveDirectory, + formatIdentity, baseline, servingIndexEngine, materializedStore); + } + CommonCheckpointRedoCoordinator coordinator = new CommonCheckpointRedoCoordinator( + checkpointFile, + new ChainbaseCheckpointMaterializer(checkpointDirectory, formatIdentity, + snapshots.getDbs(), baseline, materializedStore), + pathMaterializer, archiveMaterializer); + PathStatePhysicalOverlayHead admittedOwner = pathOwner; + StateArchiveHotCheckpointMaterializer admittedHotMaterializer = hotMaterializer; + StateArchiveHotStore admittedHotStore = hotStore; + StateArchiveAppendCheckpointMaterializerV3 admittedAppendMaterializer = appendMaterializer; + attachment = CommonCheckpointRuntimeAttachment.open(true, + () -> { + CommonCheckpointRuntimeOwner owner = new CommonCheckpointRuntimeOwner(coordinator); + if (admittedAppendMaterializer != null) { + return new CommonCheckpointRuntime(owner, snapshots.getDbs(), appendDirectory, + formatIdentity, archiveRuntimeEngine, latest::pin, + admittedOwner::prepareCommonCheckpointRebase, admittedAppendMaterializer); + } + if (admittedHotMaterializer == null) { + return new CommonCheckpointRuntime(owner, snapshots.getDbs(), archiveDirectory, + formatIdentity, archiveRuntimeEngine, latest::pin, + admittedOwner::prepareCommonCheckpointRebase, materializedStore); + } + CommonCheckpointRecoveryStateAdapter recoveryState = + new CommonCheckpointRecoveryStateAdapter(getDynamicPropertiesStore(), + chainBaseManager); + CommonCheckpointHotRecovery recovery = new CommonCheckpointHotRecovery(checkpointFile, + recoveryState, recoveryState, admittedHotMaterializer::reconcilePreparedTail); + return new CommonCheckpointRuntime(owner, snapshots.getDbs(), archiveDirectory, + formatIdentity, archiveRuntimeEngine, latest::pin, + admittedOwner::prepareCommonCheckpointRebase, admittedHotMaterializer, recovery); + }); + + canonical = currentCanonicalBlockMeta(); + if (Files.isRegularFile(pathDirectory.resolve(PathStateCheckpointMaterializer.CURRENT_FILE), + LinkOption.NOFOLLOW_LINKS) + && (initialHead.getBlockNumber() != canonical.getBlockNumber() + || !Arrays.equals(initialHead.getBlockHash(), canonical.getBlockHash()))) { + admittedOwner.synchronizePublishedCheckpoint(formatIdentity, canonical, + currentPathStatePhase()); + } + if (Files.isRegularFile(pathDirectory.resolve(PathStateCheckpointMaterializer.CURRENT_FILE), + LinkOption.NOFOLLOW_LINKS)) { + if (admittedAppendMaterializer != null) { + requireAppendCommonPublishedAuthorities(checkpointDirectory, pathDirectory, + formatIdentity, admittedAppendMaterializer); + } else if (admittedHotStore == null) { + requireCommonPublishedAuthorities(checkpointDirectory, archiveDirectory, pathDirectory, + formatIdentity, servingIndexEngine, materializedStore); + } else { + requireHotCommonPublishedAuthorities(checkpointDirectory, pathDirectory, + formatIdentity, admittedHotStore); + } + } + PathStateRootMetadata recovered = admittedOwner.getHead(); + if (recovered.getBlockNumber() != canonical.getBlockNumber() + || !Arrays.equals(recovered.getBlockHash(), canonical.getBlockHash())) { + throw new IllegalStateException( + "Common checkpoint PathState head differs from recovered Chainbase head"); + } + + snapshots.installCommonCheckpointArchiveCollector(commonCheckpointArchiveCollector()); + pathStateSnapshotHead = admittedOwner; + attachPathStateBlockFinalRuntime(); + snapshots.attachCommonCheckpointRuntime(attachment); + commonCheckpointRuntime = attachment; + stateArchiveAppendMaterializer = admittedAppendMaterializer; + pathOwner = null; + attachment = null; + hotStore = null; + appendMaterializer = null; + logger.info("Common checkpoint runtime attached: checkpoint={}, archive={}, path={}, " + + "head={}, format={}, pathEngine={}, archiveEngine={}", checkpointDirectory, + archiveDirectory, pathDirectory, canonical.getBlockNumber(), CommonCheckpointFormat.ID, + pathEngine, archiveRuntimeEngine); + } catch (java.io.IOException | BadItemException | ItemNotFoundException + | RuntimeException failure) { + if (pathStateRuntime != null) { + try { + snapshots.detachPathStateRuntime(pathStateRuntime); + pathStateRuntime.close(); + } catch (java.io.IOException | RuntimeException closeFailure) { + failure.addSuppressed(closeFailure); + } finally { + pathStateRuntime = null; + } + } + pathStateSnapshotHead = null; + try { + snapshots.clearCommonCheckpointArchiveCollector(); + } catch (RuntimeException cleanupFailure) { + failure.addSuppressed(cleanupFailure); + } + if (attachment != null) { + attachment.close(); + hotStore = null; + appendMaterializer = null; + } + if (hotStore != null) { + try { + hotStore.close(); + } catch (java.io.IOException closeFailure) { + failure.addSuppressed(closeFailure); + } + } + if (appendMaterializer != null) { + try { + appendMaterializer.close(); + } catch (java.io.IOException closeFailure) { + failure.addSuppressed(closeFailure); + } + } + if (pathOwner != null) { + try { + pathOwner.close(); + } catch (java.io.IOException closeFailure) { + failure.addSuppressed(closeFailure); + } + } + throw new IllegalStateException("Failed to recover common checkpoint startup", failure); + } + } + + private void recoverPendingCommonCheckpoint(SnapshotManager snapshots, + Path checkpointDirectory, Path archiveDirectory, Path pathDirectory, + PathStateStoreManifest.Engine pathEngine, + PathStateStoreManifest.Engine archiveEngine, long residentNodeCacheBytes, + byte[] formatIdentity, CommonCheckpointBaselineFile baselineFile, boolean baselineExists, + boolean modeAdmitted, boolean appendEnabled, Path appendDirectory, + org.tron.core.config.args.StorageConfig.StateArchiveAppendFileConfig appendConfig) + throws java.io.IOException { + CommonCheckpointFile checkpointFile = new CommonCheckpointFile(checkpointDirectory); + if (!checkpointFile.isPresent()) { + return; + } + if (!baselineExists || !modeAdmitted) { + throw new java.io.IOException( + "Common checkpoint WAL requires an admitted PathState baseline"); + } + CommonCheckpointBaseline baseline = baselineFile.load(); + CommonCheckpointMaterializedStore materializedStore = + new CommonCheckpointMaterializedStore(checkpointDirectory); + org.tron.core.db2.core.CommonCheckpointMaterializer archiveRecovery = appendEnabled + ? new StateArchiveAppendCheckpointMaterializerV3(appendDirectory, formatIdentity, + archiveEngine, baseline.getStateRoot(), StateArchiveFileFormatV3.COMPRESSION_NONE, + appendConfig.getSegmentTargetBytes()) + : new StateArchiveCheckpointMaterializer(archiveDirectory, formatIdentity, baseline, + archiveEngine, materializedStore); + try (PathStateCheckpointMaterializer.RecoverySession pathRecovery = + PathStateCheckpointMaterializer.openRecovery(pathDirectory, pathEngine, + residentNodeCacheBytes, formatIdentity, baseline, materializedStore); + org.tron.core.db2.core.CommonCheckpointMaterializer admittedArchive = archiveRecovery; + CommonCheckpointRedoCoordinator coordinator = new CommonCheckpointRedoCoordinator( + checkpointFile, + new ChainbaseCheckpointMaterializer(checkpointDirectory, formatIdentity, + snapshots.getDbs(), baseline, materializedStore), + pathRecovery.getMaterializer(), + admittedArchive)) { + CommonCheckpointRedoCoordinator.RecoveryAction action = coordinator.recover(); + logger.info("Common checkpoint startup redo completed before PathState open: action={}", + action); + } + } + + private BlockSnapshotMeta currentCanonicalBlockMeta() + throws BadItemException, ItemNotFoundException { + long number = getDynamicPropertiesStore().getLatestBlockHeaderNumber(); + BlockCapsule block = chainBaseManager.getBlockByNum(number); + return BlockSnapshotMeta.forBlock(number, + getDynamicPropertiesStore().getLatestBlockHeaderHash().getBytes(), + block.getParentHash().getBytes(), block.getTimeStamp()); + } + + private P66Phase currentPathStatePhase() { + long value = getDynamicPropertiesStore().getAllowAccountAssetOptimizationFromRoot(); + if (value != 0L && value != 1L) { + throw new IllegalStateException("Common checkpoint P66 phase is invalid"); + } + return value == 0L ? P66Phase.P66_OFF : P66Phase.P66_ON; + } + + private static void requireCommonPublishedAuthorities(Path checkpointDirectory, + Path archiveDirectory, Path pathDirectory, byte[] formatIdentity, + PathStateStoreManifest.Engine engine, + CommonCheckpointMaterializedStore materializedStore) throws java.io.IOException { + ChainbaseCheckpointMaterializer.PublishedHead chain = + ChainbaseCheckpointMaterializer.loadPublishedHead(checkpointDirectory, formatIdentity); + PathStateCheckpointMaterializer.PublishedHead path = + PathStateCheckpointMaterializer.loadPublishedHead(pathDirectory, formatIdentity); + org.tron.core.db2.core.CommonCheckpointTarget archive = + StateArchiveCheckpointMaterializer.loadPublishedTarget(archiveDirectory, formatIdentity, + engine, materializedStore); + BlockSnapshotMeta last = archive.getLastBlock(); + if (chain.getEpoch() != last.getEpoch() || path.getEpoch() != last.getEpoch() + || chain.getBlockNumber() != last.getBlockNumber() + || path.getBlockNumber() != last.getBlockNumber() + || !Arrays.equals(chain.getBlockHash(), last.getBlockHash()) + || !Arrays.equals(path.getBlockHash(), last.getBlockHash()) + || !Arrays.equals(chain.getPayloadDigest(), archive.getPayloadDigest()) + || !Arrays.equals(path.getPayloadDigest(), archive.getPayloadDigest()) + || !Arrays.equals(chain.getStateRoot(), archive.getStateRoot()) + || !Arrays.equals(path.getStateRoot(), archive.getStateRoot())) { + throw new java.io.IOException("Common checkpoint published authorities differ"); + } + } + + private static void requireHotCommonPublishedAuthorities(Path checkpointDirectory, + Path pathDirectory, byte[] formatIdentity, StateArchiveHotStore hotStore) + throws java.io.IOException { + ChainbaseCheckpointMaterializer.PublishedHead chain = + ChainbaseCheckpointMaterializer.loadPublishedHead(checkpointDirectory, formatIdentity); + PathStateCheckpointMaterializer.PublishedHead path = + PathStateCheckpointMaterializer.loadPublishedHead(pathDirectory, formatIdentity); + Optional hotTarget = hotStore.getPublishedTargetDigest(); + if (!hotTarget.isPresent() + || chain.getEpoch() != path.getEpoch() + || chain.getBlockNumber() != path.getBlockNumber() + || chain.getBlockNumber() != hotStore.getCommittedHead() + || !Arrays.equals(chain.getBlockHash(), path.getBlockHash()) + || !Arrays.equals(chain.getBlockHash(), hotStore.getCommittedHeadHash()) + || !Arrays.equals(chain.getPayloadDigest(), path.getPayloadDigest()) + || !Arrays.equals(chain.getPayloadDigest(), hotTarget.get()) + || !Arrays.equals(chain.getStateRoot(), path.getStateRoot())) { + throw new java.io.IOException("Hot common checkpoint published authorities differ"); + } + } + + private static void requireAppendCommonPublishedAuthorities(Path checkpointDirectory, + Path pathDirectory, byte[] formatIdentity, + StateArchiveAppendCheckpointMaterializerV3 materializer) throws java.io.IOException { + ChainbaseCheckpointMaterializer.PublishedHead chain = + ChainbaseCheckpointMaterializer.loadPublishedHead(checkpointDirectory, formatIdentity); + PathStateCheckpointMaterializer.PublishedHead path = + PathStateCheckpointMaterializer.loadPublishedHead(pathDirectory, formatIdentity); + org.tron.core.db2.core.CommonCheckpointTarget archive = materializer + .loadPublishedTargetIfPresent().orElseThrow(() -> + new java.io.IOException("Append-file Archive readable target is missing")); + BlockSnapshotMeta last = archive.getLastBlock(); + if (chain.getEpoch() != last.getEpoch() || path.getEpoch() != last.getEpoch() + || chain.getBlockNumber() != last.getBlockNumber() + || path.getBlockNumber() != last.getBlockNumber() + || !Arrays.equals(chain.getBlockHash(), last.getBlockHash()) + || !Arrays.equals(path.getBlockHash(), last.getBlockHash()) + || !Arrays.equals(chain.getPayloadDigest(), archive.getPayloadDigest()) + || !Arrays.equals(path.getPayloadDigest(), archive.getPayloadDigest()) + || !Arrays.equals(chain.getStateRoot(), archive.getStateRoot()) + || !Arrays.equals(path.getStateRoot(), archive.getStateRoot())) { + throw new java.io.IOException("Append-file common checkpoint authorities differ"); + } + } + + private static void requireEmptyOrMissing(Path directory, String label) + throws java.io.IOException { + if (!Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) { + return; + } + if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS)) { + throw new java.io.IOException(label + " path is not a directory"); + } + try (java.util.stream.Stream entries = Files.list(directory)) { + if (entries.findAny().isPresent()) { + throw new java.io.IOException(label + " fresh directory is not empty"); + } + } + } + + private static PathStateStoreManifest.Engine configuredAuxiliaryEngine(String configured, + String fallback) { + String selected = configured == null ? fallback : configured; + if (selected == null) { + return PathStateStoreManifest.Engine.ROCKSDB; + } + try { + return PathStateStoreManifest.Engine.valueOf(selected.trim().toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException failure) { + throw new IllegalStateException("Unsupported auxiliary database engine: " + selected, + failure); + } + } + + private java.util.Map + commonCheckpointSupplementalStores(SnapshotManager snapshots) { + if (snapshots.getDbs().stream().anyMatch(database -> + AccountAssetArchiveProjector.ACCOUNT_ASSET_DB.equals(database.getDbName()))) { + return java.util.Collections.emptyMap(); + } + AccountAssetStore accountAssetStore = chainBaseManager.getAccountAssetStore(); + if (accountAssetStore == null) { + throw new IllegalStateException("Common checkpoint requires account-asset Store"); + } + return java.util.Collections.singletonMap(AccountAssetArchiveProjector.ACCOUNT_ASSET_DB, + LatestStateGenerationAdapter.fromDataSource(AccountAssetArchiveProjector.ACCOUNT_ASSET_DB, + accountAssetStore.getDbSource())); + } + + private SnapshotOldValueCollector commonCheckpointArchiveCollector() { + SnapshotManager snapshots = (SnapshotManager) revokingStore; + if (snapshots.getDbs().stream().anyMatch(database -> + AccountAssetArchiveProjector.ACCOUNT_ASSET_DB.equals(database.getDbName()))) { + return new SnapshotOldValueCollector(); + } + AccountAssetStore accountAssetStore = chainBaseManager.getAccountAssetStore(); + if (accountAssetStore == null) { + throw new IllegalStateException("Common checkpoint requires account-asset Store"); + } + return new SnapshotOldValueCollector(new AccountAssetArchiveProjector(), + accountAssetStore::prefixQuery, + SnapshotOldValueCollector::resolveTargetAssetOptimization); + } + + + private void initPathStateRoot() { + org.tron.core.config.args.Storage storage = Args.getInstance().getStorage(); + if (!storage.isPathStateRootEnabled()) { + try { + PathStatePhysicalRuntimeAdmission.inspect(false, null, null); + } catch (java.io.IOException impossible) { + throw new IllegalStateException("Disabled path-state admission failed", impossible); + } + return; + } + Path directory = Paths.get(Args.getInstance().getOutputDirectory(), + storage.getPathStateRootDirectory()).normalize(); + PathStateHead recovered = null; + try { + PathStateStoreManifest.Engine engine = configuredAuxiliaryEngine( + storage.getPathStateRootEngine(), storage.getDbEngine()); + PathStatePhysicalRuntimeAdmission.Result admission = + PathStatePhysicalRuntimeAdmission.inspect(true, directory, engine); + if (admission.getStatus() + == PathStatePhysicalRuntimeAdmission.Status.REBUILD_REQUIRED) { + if (!(revokingStore instanceof SnapshotManager)) { + throw new IllegalStateException("Path-state rebuild requires SnapshotManager"); + } + rebuildPathStateRoot((SnapshotManager) revokingStore, directory, engine); + admission = PathStatePhysicalRuntimeAdmission.inspect(true, directory, engine); + } + if (admission.getStatus() + != PathStatePhysicalRuntimeAdmission.Status.CURRENT_CANDIDATE) { + throw new IllegalStateException( + "Path-state startup requires a completed admitted rebuild"); + } + PathStateLayerLimits limits = new PathStateLayerLimits( + storage.getPathStateRootReversibleLayerLimit(), + storage.getPathStateRootReversibleLayerBytes()); + recovered = storage.isPathStateRootVolatileSnapshotBenchmark() + ? PathStatePhysicalOverlayHead.open(directory, engine, limits, + storage.getPathStateRootNodeCacheBytes(), + storage.getPathStateRootParticipantThreads(), + storage.getPathStateRootBranchThreads()) + : PathStatePhysicalSnapshotHead.open(directory, engine, limits); + PathStateRootMetadata recoveredHead = recovered.getHead(); + if (recoveredHead.getBlockNumber() + != getDynamicPropertiesStore().getLatestBlockHeaderNumber() + || !Arrays.equals(recoveredHead.getBlockHash(), + getDynamicPropertiesStore().getLatestBlockHeaderHash().getBytes())) { + throw new IllegalStateException( + "Path-state CURRENT differs from the persisted Chainbase head"); + } + pathStateSnapshotHead = recovered; + attachPathStateBlockFinalRuntime(); + logger.info("Path-state current root attached: directory={}, head={}, engine={}, " + + "volatileSnapshotBenchmark={}, asyncPrepareBenchmark={}, nodeCacheBytes={}, " + + "participantThreads={}, branchThreads={}", + directory, + recoveredHead.getBlockNumber(), engine, + storage.isPathStateRootVolatileSnapshotBenchmark(), + storage.isPathStateRootAsyncPrepareBenchmark(), + storage.getPathStateRootNodeCacheBytes(), storage.getPathStateRootParticipantThreads(), + storage.getPathStateRootBranchThreads()); + recovered = null; + } catch (java.io.IOException | RuntimeException failure) { + pathStateSnapshotHead = null; + if (recovered != null) { + try { + recovered.close(); + } catch (java.io.IOException closeFailure) { + failure.addSuppressed(closeFailure); + } + } + throw new IllegalStateException("Failed to recover path-state startup", failure); + } + } + + private void attachPathStateBlockFinalRuntime() throws java.io.IOException { + if (!(revokingStore instanceof SnapshotManager)) { + throw new IllegalStateException("Path-state block-final capture requires SnapshotManager"); + } + AccountAssetStore accountAssetStore = chainBaseManager.getAccountAssetStore(); + if (accountAssetStore == null) { + throw new IllegalStateException( + "Path-state block-final capture requires account-asset Store"); + } + org.tron.core.db2.stateroot.PathStateTransitionCollector collector = + Args.getInstance().getStorage().isP66SnapshotEnabled() + ? new org.tron.core.db2.archive.PhysicalSnapshotPathStateCollector() + : new SnapshotPathStateTransitionCollector( + accountAssetStore::prefixQuery, this::scanPathStateActivationAccounts); + org.tron.core.config.args.Storage storage = Args.getInstance().getStorage(); + PathStateRuntimeAttachment attachment = storage.isCommonCheckpointEnabled() + ? PathStateRuntimeAttachment.commonCheckpoint(collector, this::advancePathStateRoot, + transition -> pathStateSnapshotHead.preview(transition), + (meta, transition) -> pathStateSnapshotHead.prepareSnapshotDelta(meta, transition)) + : storage.isPathStateRootAsyncPrepareBenchmark() + ? PathStateRuntimeAttachment.deferred(collector, this::advancePathStateRoot, + this::flushPathStateBaseThrough, + transition -> pathStateSnapshotHead.preview(transition), + (meta, transition) -> pathStateSnapshotHead.prepareSnapshotDelta(meta, transition)) + : new PathStateRuntimeAttachment(collector, this::advancePathStateRoot, + this::flushPathStateBaseThrough, + transition -> pathStateSnapshotHead.preview(transition), + (meta, transition) -> pathStateSnapshotHead.prepareSnapshotDelta(meta, transition)); + attachment.synchronizeReadyHead(pathStateSnapshotHead.getHead()); + ((SnapshotManager) revokingStore).attachPathStateRuntime(attachment); + pathStateRuntime = attachment; + } + + private void scanPathStateActivationAccounts( + SnapshotPathStateTransitionCollector.ActivationAccountConsumer consumer) + throws java.io.IOException { + SnapshotManager snapshotManager = (SnapshotManager) revokingStore; + org.tron.core.db2.core.Chainbase account = snapshotManager.getDbs().stream() + .filter(database -> AccountAssetArchiveProjector.ACCOUNT_DB.equals(database.getDbName())) + .findFirst() + .orElseThrow(() -> new java.io.IOException( + "Path-state P66 activation requires the Account Store")); + java.util.Iterator> iterator = account.iterator(); + while (iterator.hasNext()) { + java.util.Map.Entry entry = iterator.next(); + if (entry.getKey() == null || entry.getValue() == null) { + throw new java.io.IOException("Path-state P66 activation Account scan contains null"); + } + consumer.accept(Arrays.copyOf(entry.getKey(), entry.getKey().length), + Arrays.copyOf(entry.getValue(), entry.getValue().length)); + } + } + + private void advancePathStateRoot(PathStateBlockTransition transition) + throws java.io.IOException { + PathStateHead owner = pathStateSnapshotHead; + if (owner == null) { + throw new java.io.IOException("Path-state snapshot owner is unavailable"); + } + owner.advance(transition); + } + + private void flushPathStateBaseThrough(long blockNumber, byte[] blockHash) + throws java.io.IOException { + PathStateHead owner = pathStateSnapshotHead; + if (owner == null) { + throw new java.io.IOException("Path-state snapshot owner is unavailable"); + } + owner.flushBaseThrough(blockNumber, blockHash); + } + + private void rebuildPathStateRoot(SnapshotManager snapshotManager, Path directory, + PathStateStoreManifest.Engine engine) throws java.io.IOException { + java.util.Map + supplementalStores = java.util.Collections.emptyMap(); + boolean accountAssetRegistered = snapshotManager.getDbs().stream() + .anyMatch(database -> AccountAssetArchiveProjector.ACCOUNT_ASSET_DB + .equals(database.getDbName())); + if (!accountAssetRegistered) { + AccountAssetStore accountAssetStore = chainBaseManager.getAccountAssetStore(); + if (accountAssetStore == null) { + throw new java.io.IOException("Path-state rebuild requires account-asset Store"); + } + supplementalStores = java.util.Collections.singletonMap( + AccountAssetArchiveProjector.ACCOUNT_ASSET_DB, + org.tron.core.db2.archive.LatestStateGenerationAdapter.fromDataSource( + AccountAssetArchiveProjector.ACCOUNT_ASSET_DB, + accountAssetStore.getDbSource())); + } + try (PathStateNativeSnapshotSource source = PathStateNativeSnapshotSource.acquire( + snapshotManager, supplementalStores, this::readPathStateSnapshotIdentity, + PATH_STATE_REBUILD_PAGE_SIZE, PATH_STATE_REBUILD_MARKET_ENTRY_LIMIT)) { + SnapshotIdentity identity = source.identity(); + PathStateRootMetadata metadata; + try (PathStatePhysicalStoreSet stores = PathStatePhysicalStoreSet.open(directory, + new PathStateCanonicalizer().participantScope(), engine)) { + PathStateRoot rebuilt = stores.ingestAndBuild(source); + metadata = PathStateRootMetadata.base(identity.getBlockNumber(), identity.getBlockHash(), + identity.getParentHash(), identity.getTimestamp(), identity.getPhase(), + stores.getFormatDigest(), rebuilt.rootHash(), source.sourceIdentityDigest()); + stores.publishCurrent(metadata); + } + logger.info("Path-state initial root rebuilt: directory={}, head={}, entries={}", + directory, metadata.getBlockNumber(), "exact-27"); + } + } + + private SnapshotIdentity readPathStateSnapshotIdentity() throws java.io.IOException { + DynamicPropertiesStore dynamic = getDynamicPropertiesStore(); + long blockNumber = dynamic.getLatestBlockHeaderNumber(); + try { + BlockCapsule block = chainBaseManager.getBlockByNum(blockNumber); + byte[] blockHash = dynamic.getLatestBlockHeaderHash().getBytes(); + long timestamp = dynamic.getLatestBlockHeaderTimestamp(); + if (block.getNum() != blockNumber + || !Arrays.equals(block.getBlockId().getBytes(), blockHash) + || block.getTimeStamp() != timestamp) { + throw new java.io.IOException( + "Path-state rebuild block differs from persisted Chainbase head"); + } + long allowAssetOptimization = dynamic.getAllowAccountAssetOptimizationFromRoot(); + if (allowAssetOptimization != 0 && allowAssetOptimization != 1) { + throw new java.io.IOException("Path-state rebuild P66 phase is invalid"); + } + P66Phase phase = allowAssetOptimization == 0 ? P66Phase.P66_OFF : P66Phase.P66_ON; + return new SnapshotIdentity(blockNumber, blockHash, block.getParentHash().getBytes(), + timestamp, phase); + } catch (BadItemException | ItemNotFoundException failure) { + throw new java.io.IOException("Path-state rebuild cannot resolve canonical head", failure); + } + } + + public HistoricalAccountBalanceReader.Result getArchiveAccountBalance(long blockNumber, + byte[] address) throws ItemNotFoundException, BadItemException { + CommonCheckpointRuntimeAttachment common = commonCheckpointRuntime; + if (common != null) { + try (StateArchiveCheckpointReadSnapshot snapshot = common.pinPoint(blockNumber)) { + return HistoricalAccountBalanceReader.read(snapshot, address); + } catch (java.io.IOException failure) { + throw new org.tron.core.db2.archive.ArchivePersistenceException( + "Failed to read common-checkpoint historical account snapshot", failure); + } + } + StateArchiveRuntimeOwner runtime = stateArchiveRuntime; + if (runtime == null) { + throw new IllegalStateException("Experimental state archive is disabled"); + } + try (org.tron.core.db2.archive.ArchiveRuntimeQueryGate.Lease lease = + runtime.pinHistoricalState(blockNumber)) { + return HistoricalAccountBalanceReader.read(lease.getSnapshot(), address); + } catch (java.io.IOException failure) { + throw new org.tron.core.db2.archive.ArchivePersistenceException( + "Failed to read request-owned historical account snapshot", failure); + } + } + + public boolean isArchiveHistoricalQueryEnabled() { + return stateArchiveRuntime != null || commonCheckpointRuntime != null; + } + + /** Opens one canonical request-owned exact-27 historical query view. */ + public HistoricalQuerySession openArchiveHistoricalQuery(long blockNumber, + byte[] expectedBlockHash) throws ItemNotFoundException, BadItemException { + Objects.requireNonNull(expectedBlockHash, "expectedBlockHash"); + if (expectedBlockHash.length != 32) { + throw new IllegalArgumentException("expectedBlockHash must be exactly 32 bytes"); + } + StateArchiveRuntimeOwner runtime = stateArchiveRuntime; + CommonCheckpointRuntimeAttachment common = commonCheckpointRuntime; + if (runtime == null && common == null) { + throw new IllegalStateException("Experimental state archive is disabled"); + } + + BlockCapsule beforePin = chainBaseManager.getBlockByNum(blockNumber); + byte[] canonicalHash = beforePin.getBlockId().getBytes(); + if (!Arrays.equals(expectedBlockHash, canonicalHash)) { + throw new IllegalArgumentException("Historical block number and hash do not match"); + } + + HistoricalQuerySession session; + try { + session = common == null + ? HistoricalQuerySession.open(runtime.pinHistoricalState(blockNumber), canonicalHash) + : HistoricalQuerySession.open(common.pinPoint(blockNumber), canonicalHash); + } catch (java.io.IOException failure) { + throw new org.tron.core.db2.archive.ArchivePersistenceException( + "Failed to open request-owned historical query session", failure); + } + + try { + BlockCapsule afterPin = chainBaseManager.getBlockByNum(blockNumber); + if (!Arrays.equals(canonicalHash, afterPin.getBlockId().getBytes())) { + session.close(); + throw new org.tron.core.db2.archive.ArchivePersistenceException( + "Canonical historical block changed while opening query session"); + } + session.requirePinnedIdentity(); + return session; + } catch (ItemNotFoundException | BadItemException | RuntimeException failure) { + session.close(); + throw failure; + } + } + + /** Measures the current experimental exact-only serving index at its readable fixed point. */ + public StateArchiveRuntimeOwner.ServingIndexInspection inspectArchiveServingIndex() { + StateArchiveRuntimeOwner runtime = stateArchiveRuntime; + if (runtime == null) { + throw new IllegalStateException("Experimental state archive is disabled"); + } + try { + return runtime.inspectServingIndex(); + } catch (java.io.IOException failure) { + throw new org.tron.core.db2.archive.ArchivePersistenceException( + "Failed to inspect State Archive serving index", failure); + } + } + + /** Resolves one P66-aware historical TRC10 balance from a single request generation. */ + public HistoricalAccountAssetBalanceResolver.Result getArchiveAccountAssetBalance( + long blockNumber, byte[] address, String tokenId) { + try (ArchivePointSnapshot snapshot = pinArchivePoint(blockNumber)) { + return new HistoricalAccountAssetBalanceResolver().resolve(snapshot, address, tokenId); + } catch (java.io.IOException failure) { + throw new org.tron.core.db2.archive.ArchivePersistenceException( + "Failed to resolve request-owned historical AccountAsset snapshot", failure); + } + } + + /** Resolves one bounded P66-aware historical TRC10 balance prefix per request generation. */ + public HistoricalAccountAssetPrefixResolver.Result getArchiveAccountAssets( + long blockNumber, byte[] address, HistoricalAccountAssetPrefixResolver.Limits limits) { + StateArchiveRuntimeOwner runtime = stateArchiveRuntime; + if (commonCheckpointRuntime != null) { + throw new UnsupportedOperationException( + "Common-checkpoint Archive supports exact point reads only"); + } + if (runtime == null) { + throw new IllegalStateException("Experimental state archive is disabled"); + } + try (org.tron.core.db2.archive.ArchiveRuntimeQueryGate.Lease lease = + runtime.pinHistoricalState(blockNumber)) { + return new HistoricalAccountAssetPrefixResolver().resolve( + lease.getSnapshot(), address, limits); + } catch (java.io.IOException failure) { + throw new org.tron.core.db2.archive.ArchivePersistenceException( + "Failed to resolve request-owned historical AccountAsset prefix", failure); + } + } + + /** Reads one physical key from an exact versioned State Store at a historical block. */ + public OldValue getArchiveStateValue(long blockNumber, String dbName, byte[] physicalRawKey) { + if (!ArchiveStoreScope.isStateDatabase(dbName)) { + throw new IllegalArgumentException("Not a versioned archive state database: " + dbName); + } + Objects.requireNonNull(physicalRawKey, "physicalRawKey"); + try (ArchivePointSnapshot snapshot = pinArchivePoint(blockNumber)) { + return snapshot.get(dbName, physicalRawKey); + } catch (java.io.IOException failure) { + throw new org.tron.core.db2.archive.ArchivePersistenceException( + "Failed to read request-owned historical State Store snapshot", failure); + } + } + + /** Tests one physical key without opening range or cross-Store iteration semantics. */ + public boolean hasArchiveStateValue(long blockNumber, String dbName, byte[] physicalRawKey) { + return getArchiveStateValue(blockNumber, dbName, physicalRawKey).isPresent(); + } + + private ArchivePointSnapshot pinArchivePoint(long blockNumber) throws java.io.IOException { + CommonCheckpointRuntimeAttachment common = commonCheckpointRuntime; + if (common != null) { + return common.pinPoint(blockNumber); + } + StateArchiveRuntimeOwner legacy = stateArchiveRuntime; + if (legacy == null) { + throw new IllegalStateException("Experimental state archive is disabled"); + } + return legacy.pinHistoricalState(blockNumber); + } + /** * init genesis block. */ @@ -1039,6 +2032,7 @@ public void eraseBlock() { logger.info("Start to erase block: {}.", oldHeadBlock); khaosDb.pop(); revokingStore.fastPop(); + rewindPathStateRootAfterPop(); logger.info("End to erase block: {}.", oldHeadBlock); oldHeadBlock.getTransactions().forEach(tc -> poppedTransactions.add(new TransactionCapsule(tc.getInstance()))); @@ -1050,6 +2044,30 @@ public void eraseBlock() { } } + /** Keeps the non-consensus path-state head aligned after Chainbase owns a successful pop. */ + private void rewindPathStateRootAfterPop() { + PathStateHead owner = pathStateSnapshotHead; + if (owner == null) { + return; + } + long canonicalNumber = getDynamicPropertiesStore().getLatestBlockHeaderNumber(); + byte[] canonicalHash = getDynamicPropertiesStore().getLatestBlockHeaderHash().getBytes(); + try { + PathStateRootMetadata rewound = owner.rewindTo(canonicalNumber, canonicalHash); + PathStateRuntimeAttachment runtime = pathStateRuntime; + if (runtime != null) { + runtime.synchronizeReadyHead(rewound); + } + } catch (java.io.IOException | RuntimeException failure) { + PathStateRuntimeAttachment runtime = pathStateRuntime; + if (runtime != null) { + runtime.failAt(PathStateRuntimeAttachment.FailureStage.REORG, + canonicalNumber, canonicalHash, failure); + } + logger.error("Path-state short-reorg rewind failed after canonical block pop", failure); + } + } + private void applyBlock(BlockCapsule block) throws ContractValidateException, ContractExeException, ValidateSignatureException, AccountResourceInsufficientException, TransactionExpirationException, TooBigTransactionException, DupTransactionException, @@ -1065,15 +2083,24 @@ private void applyBlock(BlockCapsule block, List txs) TooBigTransactionException, DupTransactionException, TaposException, ValidateScheduleException, ReceiptCheckErrException, VMIllegalException, TooBigTransactionResultException, ZksnarkException, BadBlockException, EventBloomException { + long startedNanos = System.nanoTime(); processBlock(block, txs); + long processedNanos = System.nanoTime(); chainBaseManager.getBlockStore().put(block.getBlockId().getBytes(), block); chainBaseManager.getBlockIndexStore().put(block.getBlockId()); if (block.getTransactions().size() != 0) { chainBaseManager.getTransactionRetStore() .put(ByteArray.fromLong(block.getNum()), block.getResult()); } + long storedNanos = System.nanoTime(); updateFork(block); + long forkUpdatedNanos = System.nanoTime(); + logger.info("ApplyBlock outer stages: head={}, processMs={}, storeMs={}, forkMs={}, totalMs={}", + block.getNum(), elapsedMillis(startedNanos, processedNanos), + elapsedMillis(processedNanos, storedNanos), + elapsedMillis(storedNanos, forkUpdatedNanos), + elapsedMillis(startedNanos, forkUpdatedNanos)); if (System.currentTimeMillis() - block.getTimeStamp() >= 60_000) { revokingStore.setMaxFlushCount(maxFlushCount); if (Args.getInstance().getShutdownBlockTime() != null @@ -1091,6 +2118,48 @@ private void applyBlock(BlockCapsule block, List txs) } } + private void commitBlockSession(ISession blockSession, BlockCapsule block) { + blockSession.commit(BlockSnapshotMeta.forBlock( + block.getNum(), + block.getBlockId().getBytes(), + block.getParentHash().getBytes(), + block.getTimeStamp())); + diagnosePathStateHeader(block); + } + + private void diagnosePathStateHeader(BlockCapsule block) { + PathStateRuntimeAttachment runtime = pathStateRuntime; + if (runtime == null) { + return; + } + byte[] blockHash = block.getBlockId().getBytes(); + byte[] carriedRoot = block.getStateRoot(); + boolean carriedRootUsable = carriedRoot != null && carriedRoot.length == 32; + boolean ready = runtime.isReadyForHeaderDiagnostic(block.getNum(), blockHash); + byte[] localRoot = null; + boolean ownerRead = false; + long ownerReadStartedNanos = System.nanoTime(); + try { + PathStateHead owner = pathStateSnapshotHead; + if (carriedRootUsable && ready && owner != null) { + ownerRead = true; + PathStateRootMetadata local = owner.getHead(); + if (local.getBlockNumber() == block.getNum() + && Arrays.equals(local.getBlockHash(), blockHash)) { + localRoot = local.getStateRoot(); + } + } + } catch (java.io.IOException | RuntimeException diagnosticFailure) { + logger.warn("Path-state local root unavailable for header diagnostic", diagnosticFailure); + } + long ownerReadMicros = TimeUnit.NANOSECONDS.toMicros( + System.nanoTime() - ownerReadStartedNanos); + runtime.diagnoseHeader(block.getNum(), blockHash, carriedRoot, localRoot); + logger.info("Path-state header diagnostic access: head={}, ownerRead={}, " + + "skippedNotReady={}, carriedRootUsable={}, ownerReadMicros={}", + block.getNum(), ownerRead, !ready, carriedRootUsable, ownerReadMicros); + } + private void switchFork(BlockCapsule newHead) throws ValidateSignatureException, ContractValidateException, ContractExeException, ValidateScheduleException, AccountResourceInsufficientException, TaposException, @@ -1154,7 +2223,7 @@ private void switchFork(BlockCapsule newHead) tx.setVerified(false); } applyBlock(item.getBlk().setSwitch(true)); - tmpSession.commit(); + commitBlockSession(tmpSession, item.getBlk()); } catch (AccountResourceInsufficientException | ValidateSignatureException | ContractValidateException @@ -1192,7 +2261,7 @@ private void switchFork(BlockCapsule newHead) // todo process the exception carefully later try (ISession tmpSession = revokingStore.buildSession()) { applyBlock(khaosBlock.getBlk().setSwitch(true)); - tmpSession.commit(); + commitBlockSession(tmpSession, khaosBlock.getBlk()); } catch (AccountResourceInsufficientException | ValidateSignatureException | ContractValidateException @@ -1280,9 +2349,21 @@ public void pushBlock(final BlockCapsule block) DupTransactionException, TransactionExpirationException, BadNumberBlockException, BadBlockException, NonCommonBlockException, ReceiptCheckErrException, VMIllegalException, ZksnarkException, EventBloomException { + pushBlock(block, false); + } + + /** Saves a block while preserving the explicit network sync/live source transition. */ + public void pushBlock(final BlockCapsule block, boolean syncSource) + throws ValidateSignatureException, ContractValidateException, ContractExeException, + UnLinkedBlockException, ValidateScheduleException, AccountResourceInsufficientException, + TaposException, TooBigTransactionException, TooBigTransactionResultException, + DupTransactionException, TransactionExpirationException, + BadNumberBlockException, BadBlockException, NonCommonBlockException, + ReceiptCheckErrException, VMIllegalException, ZksnarkException, EventBloomException { setBlockWaitLock(true); try { synchronized (this) { + updateStateArchiveServingMode(syncSource); Metrics.histogramObserve(blockedTimer.get()); blockedTimer.remove(); if (Metrics.enabled()) { @@ -1298,7 +2379,9 @@ public void pushBlock(final BlockCapsule block) final Histogram.Timer timer = Metrics.histogramStartTimer( MetricKeys.Histogram.BLOCK_PUSH_LATENCY); long start = System.currentTimeMillis(); + long startedNanos = System.nanoTime(); List txs = getVerifyTxs(block); + long verifiedNanos = System.nanoTime(); logger.info("Block num: {}, re-push-size: {}, pending-size: {}, " + "block-tx-size: {}, verify-tx-size: {}", block.getNum(), rePushTransactions.size(), pendingTransactions.size(), @@ -1386,9 +2469,14 @@ public void pushBlock(final BlockCapsule block) return; } long oldSolidNum = getDynamicPropertiesStore().getLatestSolidifiedBlockNum(); + long applyStartedNanos = System.nanoTime(); + long appliedNanos; + long committedNanos; try (ISession tmpSession = revokingStore.buildSession()) { applyBlock(newBlock, txs); - tmpSession.commit(); + appliedNanos = System.nanoTime(); + commitBlockSession(tmpSession, newBlock); + committedNanos = System.nanoTime(); } catch (Throwable throwable) { logger.error(throwable.getMessage(), throwable); khaosDb.removeBlk(block.getBlockId()); @@ -1397,6 +2485,15 @@ public void pushBlock(final BlockCapsule block) } long newSolidNum = getDynamicPropertiesStore().getLatestSolidifiedBlockNum(); blockTrigger(newBlock, oldSolidNum, newSolidNum); + long triggeredNanos = System.nanoTime(); + logger.info("PushBlock core stages: head={}, verifyMs={}, preApplyMs={}, applyMs={}, " + + "commitMs={}, triggerMs={}, throughTriggerMs={}", newBlock.getNum(), + elapsedMillis(startedNanos, verifiedNanos), + elapsedMillis(verifiedNanos, applyStartedNanos), + elapsedMillis(applyStartedNanos, appliedNanos), + elapsedMillis(appliedNanos, committedNanos), + elapsedMillis(committedNanos, triggeredNanos), + elapsedMillis(startedNanos, triggeredNanos)); } logger.info(SAVE_BLOCK, newBlock); } @@ -1426,6 +2523,10 @@ public void pushBlock(final BlockCapsule block) } } + private static long elapsedMillis(long startedNanos, long completedNanos) { + return TimeUnit.NANOSECONDS.toMillis(completedNanos - startedNanos); + } + void blockTrigger(final BlockCapsule block, long oldSolid, long newSolid) { // post block and logs for jsonrpc try { @@ -1757,9 +2858,10 @@ public BlockCapsule generateBlock(Miner miner, long blockTime, long timeout) { blockCapsule.addAllTransactions(toBePacked); accountStateCallBack.executeGenerateFinish(); + blockCapsule.setMerkleRoot(); + previewGeneratedPathStateRoot(blockCapsule); session.reset(); - blockCapsule.setMerkleRoot(); blockCapsule.sign(miner.getPrivateKey()); BlockCapsule capsule = new BlockCapsule(blockCapsule.getInstance()); @@ -1776,6 +2878,25 @@ public BlockCapsule generateBlock(Miner miner, long blockTime, long timeout) { return capsule; } + private void previewGeneratedPathStateRoot(BlockCapsule block) { + if (pathStateRuntime == null || !(revokingStore instanceof SnapshotManager)) { + return; + } + try { + byte[] targetBlockHash = new BlockCapsule(block.getInstance()).getBlockId().getBytes(); + byte[] root = ((SnapshotManager) revokingStore).previewPathStateRoot( + BlockSnapshotMeta.forBlock(block.getNum(), targetBlockHash, + block.getParentHash().getBytes(), block.getTimeStamp())); + // tag 3 plus a 32-byte length-delimited value adds exactly 34 protobuf bytes. + if (root != null && block.getSerializedSize() <= ChainConstant.BLOCK_SIZE - 34) { + block.setStateRoot(root); + } + } catch (RuntimeException previewFailure) { + logger.warn("Path-state producer preview unavailable; state_root remains absent", + previewFailure); + } + } + private void filterOwnerAddress(TransactionCapsule transactionCapsule, Set result) { byte[] owner = transactionCapsule.getOwnerAddress(); String ownerAddress = ByteArray.toHexString(owner); @@ -1852,6 +2973,7 @@ private void processBlock(BlockCapsule block, List txs) DupTransactionException, TransactionExpirationException, ValidateScheduleException, ReceiptCheckErrException, VMIllegalException, TooBigTransactionResultException, ZksnarkException, BadBlockException, EventBloomException { + long startedNanos = System.nanoTime(); // todo set revoking db max size. // checkWitness @@ -1876,6 +2998,7 @@ private void processBlock(BlockCapsule block, List txs) TransactionRetCapsule transactionRetCapsule = new TransactionRetCapsule(block); HistoryBlockHashUtil.write(this, block); + long transactionStartedNanos = System.nanoTime(); try { merkleContainer.resetCurrentMerkleTree(); accountStateCallBack.preExecute(block); @@ -1905,6 +3028,7 @@ private void processBlock(BlockCapsule block, List txs) } finally { accountStateCallBack.exceptionFinish(); } + long transactionCompletedNanos = System.nanoTime(); merkleContainer.saveCurrentMerkleTreeAsBestMerkleTree(block.getNum()); block.setResult(transactionRetCapsule); if (getDynamicPropertiesStore().getAllowAdaptiveEnergy() == 1) { @@ -1941,6 +3065,12 @@ private void processBlock(BlockCapsule block, List txs) .initBlockSection(transactionRetCapsule); chainBaseManager.getSectionBloomStore().write(block.getNum()); block.setBloom(blockBloom); + long completedNanos = System.nanoTime(); + logger.info("ProcessBlock stages: head={}, preTxMs={}, txLoopMs={}, postTxMs={}, totalMs={}", + block.getNum(), elapsedMillis(startedNanos, transactionStartedNanos), + elapsedMillis(transactionStartedNanos, transactionCompletedNanos), + elapsedMillis(transactionCompletedNanos, completedNanos), + elapsedMillis(startedNanos, completedNanos)); } private void payReward(BlockCapsule block) { @@ -2660,11 +3790,92 @@ public void close() { EventPluginLoader.getInstance().stopPlugin(); stopFilterProcessThread(); stopValidateSignThread(); + rewardViCalService.stop(); + closeCommonCheckpoint(); + closePathStateRoot(); + closeStateArchive(); chainBaseManager.shutdown(); revokingStore.shutdown(); session.reset(); } + private void closeStateArchive() { + StateArchiveRuntimeOwner runtime = stateArchiveRuntime; + if (runtime == null) { + return; + } + try { + runtime.close(); + stateArchiveRuntime = null; + archiveHistoryWriter = null; + } catch (java.io.IOException failure) { + throw new IllegalStateException("Failed to close State Archive runtime", failure); + } + } + + private void closeCommonCheckpoint() { + CommonCheckpointRuntimeAttachment runtime = commonCheckpointRuntime; + if (runtime == null) { + return; + } + if (!(revokingStore instanceof SnapshotManager)) { + throw new IllegalStateException("Common checkpoint runtime lost SnapshotManager ownership"); + } + revokingStore.flushPending(); + ((SnapshotManager) revokingStore).detachCommonCheckpointRuntime(runtime); + runtime.close(); + commonCheckpointRuntime = null; + stateArchiveAppendMaterializer = null; + stateArchiveServingLive = false; + } + + private void updateStateArchiveServingMode(boolean syncSource) { + StateArchiveAppendCheckpointMaterializerV3 materializer = stateArchiveAppendMaterializer; + if (materializer == null || syncSource || stateArchiveServingLive) { + return; + } + java.util.Optional published; + try { + published = materializer.loadPublishedTargetIfPresent(); + } catch (java.io.IOException failure) { + throw new IllegalStateException("State Archive authority verification failed", failure); + } + try { + if (published.isPresent()) { + materializer.completeServingInitialSync(published.get()); + stateArchiveServingLive = true; + } + } catch (java.io.IOException failure) { + logger.error("State Archive serving-index handoff failed; historical queries unavailable", + failure); + } + } + + private void closePathStateRoot() { + PathStateRuntimeAttachment runtime = pathStateRuntime; + if (runtime != null) { + if (!(revokingStore instanceof SnapshotManager)) { + throw new IllegalStateException("Path-state runtime lost SnapshotManager ownership"); + } + ((SnapshotManager) revokingStore).detachPathStateRuntime(runtime); + try { + runtime.close(); + } catch (java.io.IOException failure) { + throw new IllegalStateException("Failed to close PathState runtime", failure); + } + pathStateRuntime = null; + } + PathStateHead owner = pathStateSnapshotHead; + pathStateSnapshotHead = null; + if (owner != null) { + try { + owner.close(); + } catch (java.io.IOException failure) { + throw new IllegalStateException("Failed to close path-state current head", failure); + } + } + } + private static class ValidateSignTask implements Callable { private TransactionCapsule trx; diff --git a/framework/src/main/java/org/tron/core/db/StateArchiveBasePreflight.java b/framework/src/main/java/org/tron/core/db/StateArchiveBasePreflight.java new file mode 100644 index 00000000000..1d0c4604cdc --- /dev/null +++ b/framework/src/main/java/org/tron/core/db/StateArchiveBasePreflight.java @@ -0,0 +1,45 @@ +package org.tron.core.db; + +import java.nio.file.Path; +import java.util.Objects; +import org.tron.core.db2.archive.ArchiveFormatAdmissionValidator; +import org.tron.core.db2.archive.ArchiveFormatAdmissionValidator.Result; +import org.tron.core.db2.archive.ArchiveFormatAdmissionValidator.Status; + +/** Read-only base-format gate that must run before the archive writer opens. */ +final class StateArchiveBasePreflight { + + private StateArchiveBasePreflight() { + } + + static Result requireAdmitted(boolean enabled, Path archiveDirectory) { + if (!enabled) { + return null; + } + Result result = ArchiveFormatAdmissionValidator.inspect( + Objects.requireNonNull(archiveDirectory, "archiveDirectory")); + if (result.getStatus() == Status.EMPTY_NEW || result.getStatus() == Status.CURRENT_BASE) { + return result; + } + throw new IllegalStateException("State archive base requires quarantine: " + + result.getReason() + ": " + result.getDetail()); + } + + /** S1 can recover an existing exact-27 base, but fresh-base bootstrap is not wired yet. */ + static void requireRecoverable(boolean enabled, Path archiveDirectory) { + if (!enabled) { + return; + } + Result result = ArchiveFormatAdmissionValidator.inspect( + Objects.requireNonNull(archiveDirectory, "archiveDirectory")); + if (result.getStatus() == Status.CURRENT_BASE) { + return; + } + if (result.getStatus() == Status.EMPTY_NEW) { + throw new IllegalStateException( + "State archive fresh-base bootstrap is not available in S1 startup recovery"); + } + throw new IllegalStateException("State archive base requires quarantine: " + + result.getReason() + ": " + result.getDetail()); + } +} diff --git a/framework/src/main/java/org/tron/core/db2/stateroot/PathStatePhysicalOracle.java b/framework/src/main/java/org/tron/core/db2/stateroot/PathStatePhysicalOracle.java new file mode 100644 index 00000000000..1700c9e375a --- /dev/null +++ b/framework/src/main/java/org/tron/core/db2/stateroot/PathStatePhysicalOracle.java @@ -0,0 +1,359 @@ +package org.tron.core.db2.stateroot; + +import java.io.IOException; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import org.tron.core.capsule.BytesCapsule; +import org.tron.core.db2.common.DB; +import org.tron.core.trie.TrieImpl; + +/** Independent {@link TrieImpl} verifier for one offline physical-store oracle window. */ +final class PathStatePhysicalOracle { + + private static final int HASH_BATCH_ENTRIES = 4096; + + private PathStatePhysicalOracle() { + } + + static Result verify(PathStatePhysicalStoreSet stores, + PathStatePhysicalOracleWindow window, Path scratchDirectory, int rowsPerFlush) + throws IOException { + PathStatePhysicalStoreSet source = Objects.requireNonNull(stores, "stores"); + PathStatePhysicalOracleWindow input = Objects.requireNonNull(window, "window"); + if (rowsPerFlush <= 0) { + throw new IllegalArgumentException("physical oracle rows per flush must be positive"); + } + Path scratch = Objects.requireNonNull(scratchDirectory, "scratchDirectory") + .toAbsolutePath().normalize(); + if (Files.exists(scratch, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("physical oracle scratch directory must be fresh: " + scratch); + } + Files.createDirectories(scratch); + + List targets = input.targets(); + List journals = input.journals(); + Map> rootsByStore = new LinkedHashMap<>(); + long totalRows = 0; + boolean complete = false; + try { + for (PathStateParticipantDescriptor.StoreIdentity identity + : PathStateParticipantDescriptor.current().getStores()) { + int storeId = identity.getStoreId(); + Path participantScratch = scratch.resolve(String.format("%02d", storeId)); + List roots = new ArrayList<>(targets.size()); + boolean participantComplete = false; + try (NativeTrieDatabase database = new NativeTrieDatabase(participantScratch)) { + TrieImpl trie = new TrieImpl(database); + trie.setAsync(false); + long[] rows = new long[1]; + source.participant(identity.getDbName()).scanFlat(entry -> { + byte[] storedKey = entry.getKey(); + if (storedKey.length != PathStateCommitmentCodec.ROOT_LENGTH + 1 + || storedKey[0] != 'F') { + throw new IOException("physical oracle F key is corrupt: " + + identity.getDbName()); + } + trie.put(Arrays.copyOfRange(storedKey, 1, storedKey.length), entry.getValue()); + rows[0]++; + if (rows[0] % rowsPerFlush == 0) { + flush(trie, database); + } + }); + byte[] currentRoot = rootAndFlush(trie, database); + requireRoot(participantTarget(targets.get(0), storeId).getStoreRoot(), currentRoot, + identity.getDbName(), targets.get(0).getMetadata().getBlockNumber()); + roots.add(currentRoot); + + for (int index = 0; index < journals.size(); index++) { + PathStatePhysicalReverseJournal.StoreReverse reverse = reverseFor( + journals.get(index), storeId); + if (reverse != null) { + for (PathStatePhysicalReverseJournal.Entry entry : reverse.getFlatEntries()) { + byte[] oldValue = entry.getOldValue(); + if (oldValue == null) { + trie.delete(entry.getKey()); + } else { + trie.put(entry.getKey(), oldValue); + } + } + } + byte[] parentRoot = rootAndFlush(trie, database); + PathStatePhysicalGlobalIntent parent = targets.get(index + 1); + requireRoot(participantTarget(parent, storeId).getStoreRoot(), parentRoot, + identity.getDbName(), parent.getMetadata().getBlockNumber()); + roots.add(parentRoot); + } + totalRows = Math.addExact(totalRows, rows[0]); + participantComplete = true; + } catch (ArithmeticException overflow) { + throw new IOException("physical oracle row count overflow", overflow); + } finally { + if (participantComplete) { + deleteOwnedTree(scratch, participantScratch); + } + } + rootsByStore.put(storeId, immutableRoots(roots)); + } + + for (int targetIndex = 0; targetIndex < targets.size(); targetIndex++) { + TrieImpl superTrie = new TrieImpl(); + superTrie.setAsync(false); + for (PathStateParticipantDescriptor.StoreIdentity identity + : PathStateParticipantDescriptor.current().getStores()) { + PathStateParticipant participant = source.participantScope().require( + identity.getDbName()); + byte[] storeRoot = rootsByStore.get(identity.getStoreId()).get(targetIndex); + superTrie.put(PathStateCommitmentCodec.superLeafKey(identity.getStoreId()), + PathStateCommitmentCodec.superLeafValue(identity.getStoreId(), + identity.getDbName(), participant.getStoreFormatVersion(), storeRoot)); + } + PathStatePhysicalGlobalIntent target = targets.get(targetIndex); + requireRoot(target.getSuperRoot(), superTrie.getRootHash(), "super", + target.getMetadata().getBlockNumber()); + } + complete = true; + return new Result(input.getBlockCount(), totalRows, + input.getCurrentMetadata(), input.getOldestMetadata()); + } finally { + if (complete) { + deleteOwnedTree(scratch.getParent(), scratch); + } + } + } + + private static PathStatePhysicalReverseJournal.StoreReverse reverseFor( + PathStatePhysicalReverseJournal journal, int storeId) { + for (PathStatePhysicalReverseJournal.StoreReverse reverse : journal.getStores()) { + if (reverse.getStoreId() == storeId) { + return reverse; + } + } + return null; + } + + private static PathStatePhysicalGlobalIntent.ParticipantTarget participantTarget( + PathStatePhysicalGlobalIntent target, int storeId) throws IOException { + for (PathStatePhysicalGlobalIntent.ParticipantTarget participant + : target.getParticipants()) { + if (participant.getStoreId() == storeId) { + return participant; + } + } + throw new IOException("physical oracle target Store is absent: " + storeId); + } + + private static byte[] rootAndFlush(TrieImpl trie, NativeTrieDatabase database) { + flush(trie, database); + byte[] root = trie.getRootHash(); + return Arrays.copyOf(root, root.length); + } + + private static void flush(TrieImpl trie, NativeTrieDatabase database) { + trie.flush(); + database.flush(); + } + + private static void requireRoot(byte[] expected, byte[] actual, String store, + long blockNumber) throws IOException { + if (!Arrays.equals(expected, actual)) { + throw new IOException("physical oracle root differs: store=" + store + ", block=" + + blockNumber); + } + } + + private static List immutableRoots(List roots) { + List copies = new ArrayList<>(roots.size()); + for (byte[] root : roots) { + copies.add(Arrays.copyOf(root, root.length)); + } + return Collections.unmodifiableList(copies); + } + + private static void deleteOwnedTree(Path owner, Path target) throws IOException { + Path parent = Objects.requireNonNull(owner, "owner").toAbsolutePath().normalize(); + Path child = Objects.requireNonNull(target, "target").toAbsolutePath().normalize(); + if (child.equals(parent) || !child.startsWith(parent) || Files.isSymbolicLink(child)) { + throw new IOException("physical oracle refuses to delete unowned scratch: " + child); + } + if (!Files.exists(child, LinkOption.NOFOLLOW_LINKS)) { + return; + } + Files.walkFileTree(child, new SimpleFileVisitor() { + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) + throws IOException { + Files.delete(file); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult postVisitDirectory(Path directory, IOException failure) + throws IOException { + if (failure != null) { + throw failure; + } + Files.delete(directory); + return FileVisitResult.CONTINUE; + } + }); + } + + static final class Result { + + private final int blockCount; + private final long rowCount; + private final PathStateRootMetadata current; + private final PathStateRootMetadata oldest; + + private Result(int blockCount, long rowCount, PathStateRootMetadata current, + PathStateRootMetadata oldest) { + this.blockCount = blockCount; + this.rowCount = rowCount; + this.current = current; + this.oldest = oldest; + } + + int getBlockCount() { + return blockCount; + } + + long getRowCount() { + return rowCount; + } + + PathStateRootMetadata getCurrent() { + return PathStateRootMetadata.decode(current.encode()); + } + + PathStateRootMetadata getOldest() { + return PathStateRootMetadata.decode(oldest.encode()); + } + } + + private static final class NativeTrieDatabase implements DB, + AutoCloseable { + + private final PathStateNativeNodeStore store; + private final Map pending = new LinkedHashMap<>(); + + private NativeTrieDatabase(Path directory) throws IOException { + store = PathStateNativeNodeStore.open(directory, PathStateStoreManifest.Engine.ROCKSDB); + } + + @Override + public BytesCapsule get(byte[] key) { + Key owned = new Key(key); + if (pending.containsKey(owned)) { + byte[] value = pending.get(owned); + return value == null ? null : new BytesCapsule(value); + } + byte[] value = store.get(key); + return value == null ? null : new BytesCapsule(value); + } + + @Override + public void put(byte[] key, BytesCapsule value) { + pending.put(new Key(key), Arrays.copyOf(Objects.requireNonNull(value, "value").getData(), + value.getData().length)); + flushIfFull(); + } + + @Override + public void remove(byte[] key) { + pending.put(new Key(key), null); + flushIfFull(); + } + + private void flushIfFull() { + if (pending.size() >= HASH_BATCH_ENTRIES) { + flush(); + } + } + + private void flush() { + if (pending.isEmpty()) { + return; + } + List mutations = new ArrayList<>(pending.size()); + for (Map.Entry entry : pending.entrySet()) { + mutations.add(entry.getValue() == null + ? PathStateNativeNodeStore.BatchMutation.delete(entry.getKey().value) + : PathStateNativeNodeStore.BatchMutation.put(entry.getKey().value, + entry.getValue())); + } + store.writeBatchUnsynced(mutations); + pending.clear(); + } + + @Override + public long size() { + return -1; + } + + @Override + public boolean isEmpty() { + return false; + } + + @Override + public Iterator> iterator() { + return Collections.>emptyList().iterator(); + } + + @Override + public void close() { + flush(); + try { + store.close(); + } catch (IOException failure) { + throw new IllegalStateException("failed to close physical oracle scratch", failure); + } + } + + @Override + public String getDbName() { + return "path-state-physical-oracle"; + } + + @Override + public void stat() { + } + + @Override + public DB newInstance() { + return null; + } + } + + private static final class Key { + + private final byte[] value; + + private Key(byte[] value) { + this.value = Arrays.copyOf(Objects.requireNonNull(value, "value"), value.length); + } + + @Override + public boolean equals(Object other) { + return this == other || other instanceof Key + && Arrays.equals(value, ((Key) other).value); + } + + @Override + public int hashCode() { + return Arrays.hashCode(value); + } + } +} diff --git a/framework/src/main/java/org/tron/core/db2/stateroot/PathStatePhysicalOracleTool.java b/framework/src/main/java/org/tron/core/db2/stateroot/PathStatePhysicalOracleTool.java new file mode 100644 index 00000000000..891e06dd8b7 --- /dev/null +++ b/framework/src/main/java/org/tron/core/db2/stateroot/PathStatePhysicalOracleTool.java @@ -0,0 +1,94 @@ +package org.tron.core.db2.stateroot; + +import java.io.IOException; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.LinkedHashMap; +import java.util.Map; +import org.tron.core.db2.stateroot.PathStateStoreManifest.Engine; + +/** Command-line entry for verifying one offline physical-store reverse window. */ +public final class PathStatePhysicalOracleTool { + + private static final int DEFAULT_ROWS_PER_FLUSH = 100_000; + + private PathStatePhysicalOracleTool() { + } + + public static void main(String[] args) throws Exception { + PathStatePhysicalOracle.Result result = run(args); + System.out.println("PATH_STATE_ORACLE_OK" + + " current=" + result.getCurrent().getBlockNumber() + + " oldest=" + result.getOldest().getBlockNumber() + + " blocks=" + result.getBlockCount() + + " rows=" + result.getRowCount()); + } + + static PathStatePhysicalOracle.Result run(String[] args) throws IOException { + Map options = parse(args); + Path root = Paths.get(require(options, "--root")); + Path scratch = Paths.get(require(options, "--scratch")); + int blocks = positiveInt(require(options, "--blocks"), "--blocks"); + int rowsPerFlush = options.containsKey("--rows-per-flush") + ? positiveInt(options.get("--rows-per-flush"), "--rows-per-flush") + : DEFAULT_ROWS_PER_FLUSH; + Engine engine; + try { + engine = Engine.valueOf(options.getOrDefault("--engine", Engine.ROCKSDB.name())); + } catch (IllegalArgumentException invalid) { + throw new IllegalArgumentException("unsupported physical oracle engine", invalid); + } + PathStateParticipantScope scope = new PathStateCanonicalizer().participantScope(); + try (PathStatePhysicalStoreSet stores = PathStatePhysicalStoreSet.openExisting(root, scope, + engine)) { + PathStatePhysicalOracleWindow window = stores.loadOracleWindow(blocks, + PathStateLayerLimits.defaults()); + return PathStatePhysicalOracle.verify(stores, window, scratch, rowsPerFlush); + } + } + + private static Map parse(String[] args) { + if (args == null || args.length == 0 || args.length % 2 != 0) { + throw usage(); + } + Map options = new LinkedHashMap<>(); + for (int index = 0; index < args.length; index += 2) { + String name = args[index]; + if (!"--root".equals(name) && !"--scratch".equals(name) + && !"--blocks".equals(name) && !"--rows-per-flush".equals(name) + && !"--engine".equals(name)) { + throw usage(); + } + if (options.put(name, args[index + 1]) != null) { + throw new IllegalArgumentException("duplicate physical oracle option: " + name); + } + } + return options; + } + + private static String require(Map options, String name) { + String value = options.get(name); + if (value == null || value.isEmpty()) { + throw usage(); + } + return value; + } + + private static int positiveInt(String value, String name) { + try { + int parsed = Integer.parseInt(value); + if (parsed <= 0) { + throw new NumberFormatException("not positive"); + } + return parsed; + } catch (NumberFormatException invalid) { + throw new IllegalArgumentException(name + " must be a positive integer", invalid); + } + } + + private static IllegalArgumentException usage() { + return new IllegalArgumentException("usage: PathStatePhysicalOracleTool" + + " --root --scratch --blocks " + + " [--rows-per-flush ] [--engine ROCKSDB|LEVELDB]"); + } +} diff --git a/framework/src/main/java/org/tron/core/net/TronNetDelegate.java b/framework/src/main/java/org/tron/core/net/TronNetDelegate.java index 23050f5218d..1414ea9afa6 100644 --- a/framework/src/main/java/org/tron/core/net/TronNetDelegate.java +++ b/framework/src/main/java/org/tron/core/net/TronNetDelegate.java @@ -282,7 +282,7 @@ public void processBlock(BlockCapsule block, boolean isSync) throws P2pException MetricKeys.Histogram.LOCK_ACQUIRE_LATENCY, MetricLabels.BLOCK)); Histogram.Timer timer = Metrics.histogramStartTimer( MetricKeys.Histogram.BLOCK_PROCESS_LATENCY, String.valueOf(isSync)); - dbManager.pushBlock(block); + dbManager.pushBlock(block, isSync); Metrics.histogramObserve(timer); freshBlockId.put(blockId, System.currentTimeMillis()); logger.info("Success process block {}", blockId.getString()); diff --git a/framework/src/main/java/org/tron/core/services/http/FullNodeHttpApiService.java b/framework/src/main/java/org/tron/core/services/http/FullNodeHttpApiService.java index 5a3b86cb396..e3096d78f54 100644 --- a/framework/src/main/java/org/tron/core/services/http/FullNodeHttpApiService.java +++ b/framework/src/main/java/org/tron/core/services/http/FullNodeHttpApiService.java @@ -257,6 +257,8 @@ public class FullNodeHttpApiService extends HttpService { @Autowired private GetAccountBalanceServlet getAccountBalanceServlet; + @Autowired + private GetAccountBalanceFromArchiveServlet getAccountBalanceFromArchiveServlet; @Autowired private GetBlockBalanceServlet getBlockBalanceServlet; @@ -488,6 +490,8 @@ protected void addServlet(ServletContextHandler context) { context.addServlet(new ServletHolder(getAccountBalanceServlet), "/wallet/getaccountbalance"); + context.addServlet(new ServletHolder(getAccountBalanceFromArchiveServlet), + "/wallet/getaccountbalancefromarchive"); context.addServlet(new ServletHolder(getBlockBalanceServlet), "/wallet/getblockbalance"); context.addServlet(new ServletHolder(getBurnTrxServlet), "/wallet/getburntrx"); diff --git a/framework/src/main/java/org/tron/core/services/http/GetAccountBalanceFromArchiveServlet.java b/framework/src/main/java/org/tron/core/services/http/GetAccountBalanceFromArchiveServlet.java new file mode 100644 index 00000000000..50b6bcaf845 --- /dev/null +++ b/framework/src/main/java/org/tron/core/services/http/GetAccountBalanceFromArchiveServlet.java @@ -0,0 +1,32 @@ +package org.tron.core.services.http; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.tron.core.Wallet; +import org.tron.protos.contract.BalanceContract; + +/** Experimental single-purpose endpoint for archive-versus-trace balance validation. */ +@Component +@Slf4j(topic = "API") +public class GetAccountBalanceFromArchiveServlet extends RateLimiterServlet { + + @Autowired + private Wallet wallet; + + protected void doPost(HttpServletRequest request, HttpServletResponse response) { + try { + PostParams params = PostParams.getPostParams(request); + BalanceContract.AccountBalanceRequest.Builder builder = + BalanceContract.AccountBalanceRequest.newBuilder(); + JsonFormat.merge(params.getParams(), builder, params.isVisible()); + BalanceContract.AccountBalanceResponse reply = + wallet.getAccountBalanceFromStateArchive(builder.build()); + response.getWriter().println(JsonFormat.printToString(reply, params.isVisible())); + } catch (Exception failure) { + Util.processError(failure, response); + } + } +} diff --git a/framework/src/main/java/org/tron/core/services/jsonrpc/TronJsonRpcImpl.java b/framework/src/main/java/org/tron/core/services/jsonrpc/TronJsonRpcImpl.java index 6be47886117..70ef1026aad 100644 --- a/framework/src/main/java/org/tron/core/services/jsonrpc/TronJsonRpcImpl.java +++ b/framework/src/main/java/org/tron/core/services/jsonrpc/TronJsonRpcImpl.java @@ -63,6 +63,7 @@ import org.tron.core.capsule.TransactionCapsule; import org.tron.core.config.args.Args; import org.tron.core.db.Manager; +import org.tron.core.db2.archive.HistoricalQuerySession; import org.tron.core.db2.core.Chainbase; import org.tron.core.exception.BadItemException; import org.tron.core.exception.ContractExeException; @@ -389,6 +390,28 @@ private void requireLatestBlockTag(String blockNumOrTag) throw new JsonRpcInvalidParamsException(QUANTITY_NOT_SUPPORT_ERROR); } + private HistoricalQuerySession openHistoricalQuery(String blockNumOrTag) + throws JsonRpcInvalidParamsException { + if (JsonRpcApiUtil.isBlockTag(blockNumOrTag)) { + throw new JsonRpcInvalidParamsException(TAG_NOT_SUPPORT_ERROR); + } + long blockNumber = parseBlockNumber(blockNumOrTag); + if (manager == null || !manager.isArchiveHistoricalQueryEnabled()) { + throw new JsonRpcInvalidParamsException(QUANTITY_NOT_SUPPORT_ERROR); + } + Block block = wallet.getBlockByNum(blockNumber); + if (block == null) { + throw new JsonRpcInvalidParamsException(NO_BLOCK_HEADER); + } + byte[] blockHash = new BlockCapsule(block).getBlockId().getBytes(); + try { + return manager.openArchiveHistoricalQuery(blockNumber, blockHash); + } catch (ItemNotFoundException | BadItemException | RuntimeException failure) { + throw new JsonRpcInvalidParamsException( + "historical state unavailable: " + failure.getMessage(), failure); + } + } + private Block getBlockByJsonHash(String blockHash) throws JsonRpcInvalidParamsException { byte[] bHash = hashToByteArray(blockHash); return wallet.getBlockById(ByteString.copyFrom(bHash)); @@ -449,18 +472,20 @@ public String getLatestBlockNum() { @Override public String getTrxBalance(String address, String blockNumOrTag) throws JsonRpcInvalidParamsException { - requireLatestBlockTag(blockNumOrTag); - - byte[] addressData = addressCompatibleToByteArray(address); - - Account account = Account.newBuilder().setAddress(ByteString.copyFrom(addressData)).build(); - Account reply = wallet.getAccount(account); - long balance = 0; - - if (reply != null) { - balance = reply.getBalance(); + if (LATEST_STR.equalsIgnoreCase(blockNumOrTag)) { + byte[] addressData = addressCompatibleToByteArray(address); + Account account = Account.newBuilder().setAddress(ByteString.copyFrom(addressData)).build(); + Account reply = wallet.getAccount(account); + return ByteArray.toJsonHex(reply == null ? 0 : reply.getBalance()); + } + try (HistoricalQuerySession session = openHistoricalQuery(blockNumOrTag)) { + byte[] addressData = addressCompatibleToByteArray(address); + return ByteArray.toJsonHex(session.getAccount(addressData) + .map(Account::getBalance).orElse(0L)); + } catch (RuntimeException failure) { + throw new JsonRpcInvalidParamsException( + "historical state unavailable: " + failure.getMessage(), failure); } - return ByteArray.toJsonHex(balance); } private void callTriggerConstantContract(byte[] ownerAddressByte, byte[] contractAddressByte, @@ -488,6 +513,23 @@ private void callTriggerConstantContract(byte[] ownerAddressByte, byte[] contrac retBuilder.setResult(true).setCode(response_code.SUCCESS); } + private void callTriggerHistoricalConstantContract(byte[] ownerAddressByte, + byte[] contractAddressByte, long value, byte[] data, + TransactionExtention.Builder trxExtBuilder, Return.Builder retBuilder, + HistoricalQuerySession session) + throws ContractValidateException, ContractExeException, HeaderNotFound, VMIllegalException { + TriggerSmartContract triggerContract = triggerCallContract(ownerAddressByte, + contractAddressByte, value, data, 0, null); + TransactionCapsule trxCap = wallet.createTransactionCapsule(triggerContract, + ContractType.TriggerSmartContract); + Transaction trx = wallet.callHistoricalConstantContract(trxCap, trxExtBuilder, retBuilder, + session); + trxExtBuilder.setTransaction(trx); + trxExtBuilder.setTxid(trxCap.getTransactionId().getByteString()); + trxExtBuilder.setResult(retBuilder); + retBuilder.setResult(true).setCode(response_code.SUCCESS); + } + private void estimateEnergy(byte[] ownerAddressByte, byte[] contractAddressByte, long value, byte[] data, TransactionExtention.Builder trxExtBuilder, Return.Builder retBuilder, EstimateEnergyMessage.Builder estimateBuilder) @@ -549,14 +591,25 @@ static String tryDecodeRevertReason(byte[] resData) { */ private String call(byte[] ownerAddressByte, byte[] contractAddressByte, long value, byte[] data) throws JsonRpcInvalidRequestException, JsonRpcInternalException { + return call(ownerAddressByte, contractAddressByte, value, data, null); + } + + private String call(byte[] ownerAddressByte, byte[] contractAddressByte, long value, + byte[] data, HistoricalQuerySession historicalSession) + throws JsonRpcInvalidRequestException, JsonRpcInternalException { TransactionExtention.Builder trxExtBuilder = TransactionExtention.newBuilder(); Return.Builder retBuilder = Return.newBuilder(); TransactionExtention trxExt; try { - callTriggerConstantContract(ownerAddressByte, contractAddressByte, value, data, - trxExtBuilder, retBuilder); + if (historicalSession == null) { + callTriggerConstantContract(ownerAddressByte, contractAddressByte, value, data, + trxExtBuilder, retBuilder); + } else { + callTriggerHistoricalConstantContract(ownerAddressByte, contractAddressByte, value, data, + trxExtBuilder, retBuilder, historicalSession); + } } catch (ContractValidateException | VMIllegalException e) { String errString = CONTRACT_VALIDATE_ERROR; @@ -603,20 +656,23 @@ private String call(byte[] ownerAddressByte, byte[] contractAddressByte, long va @Override public String getStorageAt(String address, String storageIdx, String blockNumOrTag) throws JsonRpcInvalidParamsException { - requireLatestBlockTag(blockNumOrTag); - - if (storageIdx == null || storageIdx.length() > MAX_STORAGE_KEY_HEX_LEN) { - throw new JsonRpcInvalidParamsException("invalid storage key value"); + if (!LATEST_STR.equalsIgnoreCase(blockNumOrTag)) { + try (HistoricalQuerySession session = openHistoricalQuery(blockNumOrTag)) { + byte[] addressByte = addressCompatibleToByteArray(address); + DataWord index = parseStorageIndex(storageIdx); + if (!session.getContract(addressByte).isPresent()) { + return ByteArray.toJsonHex(new byte[32]); + } + byte[] value = session.getStorage(addressByte, index.getData()).orElse(new byte[32]); + return ByteArray.toJsonHex(new DataWord(value).getData()); + } catch (RuntimeException failure) { + throw new JsonRpcInvalidParamsException( + "historical state unavailable: " + failure.getMessage(), failure); + } } byte[] addressByte = addressCompatibleToByteArray(address); - - DataWord index; - try { - index = new DataWord(ByteArray.fromHexString(storageIdx)); - } catch (Exception e) { - throw new JsonRpcInvalidParamsException("invalid storage key value"); - } + DataWord index = parseStorageIndex(storageIdx); // get contract from contractStore BytesMessage.Builder build = BytesMessage.newBuilder(); @@ -635,10 +691,29 @@ public String getStorageAt(String address, String storageIdx, String blockNumOrT return ByteArray.toJsonHex(value == null ? new byte[32] : value.getData()); } + private DataWord parseStorageIndex(String storageIdx) throws JsonRpcInvalidParamsException { + if (storageIdx == null || storageIdx.length() > MAX_STORAGE_KEY_HEX_LEN) { + throw new JsonRpcInvalidParamsException("invalid storage key value"); + } + try { + return new DataWord(ByteArray.fromHexString(storageIdx)); + } catch (Exception failure) { + throw new JsonRpcInvalidParamsException("invalid storage key value", failure); + } + } + @Override public String getABIOfSmartContract(String contractAddress, String blockNumOrTag) throws JsonRpcInvalidParamsException { - requireLatestBlockTag(blockNumOrTag); + if (!LATEST_STR.equalsIgnoreCase(blockNumOrTag)) { + try (HistoricalQuerySession session = openHistoricalQuery(blockNumOrTag)) { + byte[] addressData = addressCompatibleToByteArray(contractAddress); + return session.getCode(addressData).map(ByteArray::toJsonHex).orElse("0x"); + } catch (RuntimeException failure) { + throw new JsonRpcInvalidParamsException( + "historical state unavailable: " + failure.getMessage(), failure); + } + } byte[] addressData = addressCompatibleToByteArray(contractAddress); @@ -1003,6 +1078,7 @@ public String getCall(CallArguments transactionCall, Object blockParamObj) JsonRpcInternalException { String blockNumOrTag; + byte[] requestedBlockHash = null; if (blockParamObj instanceof HashMap) { HashMap paramMap; paramMap = (HashMap) blockParamObj; @@ -1014,6 +1090,11 @@ public String getCall(CallArguments transactionCall, Object blockParamObj) throw new JsonRpcInvalidParamsException(JSON_ERROR); } + if (LATEST_STR.equalsIgnoreCase(blockNumOrTag)) { + return call(addressCompatibleToByteArray(transactionCall.getFrom()), + addressCompatibleToByteArray(transactionCall.getTo()), transactionCall.parseValue(), + ByteArray.fromHexString(transactionCall.resolveData())); + } long blockNumber = parseBlockNumber(blockNumOrTag); if (wallet.getBlockByNum(blockNumber) == null) { @@ -1027,27 +1108,42 @@ public String getCall(CallArguments transactionCall, Object blockParamObj) throw new JsonRpcInvalidParamsException(JSON_ERROR); } - if (getBlockByJsonHash(blockNumOrTag) == null) { + Block block = getBlockByJsonHash(blockNumOrTag); + if (block == null) { throw new JsonRpcInternalException(NO_BLOCK_HEADER_BY_HASH); } + requestedBlockHash = new BlockCapsule(block).getBlockId().getBytes(); + blockNumOrTag = ByteArray.toJsonHex( + block.getBlockHeader().getRawData().getNumber()); } else { throw new JsonRpcInvalidRequestException(JSON_ERROR); } - blockNumOrTag = LATEST_STR; } else if (blockParamObj instanceof String) { blockNumOrTag = (String) blockParamObj; } else { throw new JsonRpcInvalidRequestException(JSON_ERROR); } - requireLatestBlockTag(blockNumOrTag); - - byte[] addressData = addressCompatibleToByteArray(transactionCall.getFrom()); - byte[] contractAddressData = addressCompatibleToByteArray(transactionCall.getTo()); - - return call(addressData, contractAddressData, transactionCall.parseValue(), - ByteArray.fromHexString(transactionCall.resolveData())); + if (LATEST_STR.equalsIgnoreCase(blockNumOrTag)) { + return call(addressCompatibleToByteArray(transactionCall.getFrom()), + addressCompatibleToByteArray(transactionCall.getTo()), transactionCall.parseValue(), + ByteArray.fromHexString(transactionCall.resolveData())); + } + try (HistoricalQuerySession session = openHistoricalQuery(blockNumOrTag)) { + if (requestedBlockHash != null + && !Arrays.equals(requestedBlockHash, session.getTargetBlockHash())) { + throw new JsonRpcInvalidParamsException("historical block is not canonical"); + } + return call(addressCompatibleToByteArray(transactionCall.getFrom()), + addressCompatibleToByteArray(transactionCall.getTo()), transactionCall.parseValue(), + ByteArray.fromHexString(transactionCall.resolveData()), session); + } catch (JsonRpcInvalidParamsException failure) { + throw failure; + } catch (RuntimeException failure) { + throw new JsonRpcInvalidParamsException( + "historical state unavailable: " + failure.getMessage(), failure); + } } @Override diff --git a/framework/src/main/java/org/tron/program/ArchiveStateDiagnostic.java b/framework/src/main/java/org/tron/program/ArchiveStateDiagnostic.java new file mode 100644 index 00000000000..9843acbba34 --- /dev/null +++ b/framework/src/main/java/org/tron/program/ArchiveStateDiagnostic.java @@ -0,0 +1,271 @@ +package org.tron.program; + +import com.google.common.hash.Hashing; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.TreeSet; +import lombok.extern.slf4j.Slf4j; +import org.bouncycastle.util.encoders.Hex; +import org.tron.common.application.TronApplicationContext; +import org.tron.core.db.Manager; +import org.tron.core.db.RevokingDatabase; +import org.tron.core.db.common.DbSourceInter; +import org.tron.core.db2.archive.ArchivePersistenceException; +import org.tron.core.db2.archive.ArchiveStoreScope; +import org.tron.core.db2.archive.HistoricalAccountAssetBalanceResolver; +import org.tron.core.db2.archive.OldValue; +import org.tron.core.db2.archive.P66AccountAssetCodec; +import org.tron.core.db2.archive.P66AccountAssetCodec.DecodedAssetRow; +import org.tron.core.db2.archive.P66AccountAssetCodec.Phase; +import org.tron.core.db2.core.Chainbase; +import org.tron.core.db2.core.SnapshotManager; +import org.tron.core.store.AccountAssetStore; + +/** Opt-in startup diagnostic for request-owned State Archive point reads. */ +@Slf4j(topic = "DB") +public final class ArchiveStateDiagnostic { + + public static final String ENABLE_PROPERTY = "tron.stateArchive.startupDiagnostic"; + private static final String ACCOUNT_ASSET_DATABASE = "account-asset"; + private static final int ABSENT_KEY_ATTEMPTS = 16; + + private ArchiveStateDiagnostic() { + } + + /** Runs after Spring recovery and before FullNode services start. */ + public static void runIfEnabled(TronApplicationContext context) { + if (!Boolean.parseBoolean(System.getProperty(ENABLE_PROPERTY, "false"))) { + return; + } + RevokingDatabase revokingDatabase = context.getBean(RevokingDatabase.class); + if (!(revokingDatabase instanceof SnapshotManager)) { + throw new IllegalStateException("State Archive diagnostic requires SnapshotManager"); + } + Report report = run(context.getBean(Manager.class), (SnapshotManager) revokingDatabase); + logger.info("State archive startup diagnostic complete: block={}, stores={}, present={}, " + + "absent={}, p66Phase={}, p66Balance={}", + report.getBlockNumber(), report.getStoreCount(), report.getPresentCount(), + report.getAbsentCount(), report.getP66Phase(), report.getP66Balance()); + } + + static Report run(Manager manager, SnapshotManager snapshotManager) { + Objects.requireNonNull(manager, "manager"); + Objects.requireNonNull(snapshotManager, "snapshotManager"); + long blockNumber = manager.getDynamicPropertiesStore().getLatestBlockHeaderNumber(); + Map stores = collectStores(manager, snapshotManager); + List expectedStores = new ArrayList<>(ArchiveStoreScope.getStateDatabases()); + Collections.sort(expectedStores); + if (!stores.keySet().equals(new TreeSet<>(expectedStores))) { + throw new ArchivePersistenceException( + "State Archive diagnostic Store scope is not exact-27: " + stores.keySet()); + } + + int presentCount = 0; + int absentCount = 0; + for (String dbName : expectedStores) { + Sample sample = stores.get(dbName).sample(dbName); + OldValue historical = manager.getArchiveStateValue(blockNumber, dbName, sample.key); + OldValue current = OldValue.fromNullable(sample.value); + if (!current.equals(historical)) { + throw new ArchivePersistenceException( + "State Archive diagnostic mismatch at block " + blockNumber + " Store " + dbName); + } + if (historical.isPresent()) { + presentCount++; + } else { + absentCount++; + } + logger.info("State archive diagnostic point: block={}, store={}, sample={}, key={}, " + + "present={}, valueSha256={}", + blockNumber, dbName, sample.presentSample ? "present" : "absent", + Hex.toHexString(sample.key), historical.isPresent(), digest(historical)); + } + + AccountAssetStore accountAssetStore = manager.getAccountAssetStore(); + Sample assetSample = StoreView.from(accountAssetStore.getDbSource()) + .requirePresentSample(ACCOUNT_ASSET_DATABASE); + DecodedAssetRow decoded = new P66AccountAssetCodec() + .decodePresentAssetRow(assetSample.key, assetSample.value); + HistoricalAccountAssetBalanceResolver.Result logical = + manager.getArchiveAccountAssetBalance(blockNumber, decoded.getAccountAddress(), + decoded.getTokenId()); + if (!logical.isAccountPresent() || logical.getPhase() != Phase.P66_ON + || logical.getBalance() != decoded.getBalance()) { + throw new ArchivePersistenceException( + "State Archive diagnostic P66 AccountAsset mismatch at block " + blockNumber); + } + logger.info("State archive diagnostic P66: block={}, address={}, tokenId={}, phase={}, " + + "balance={}", + blockNumber, Hex.toHexString(decoded.getAccountAddress()), decoded.getTokenId(), + logical.getPhase(), logical.getBalance()); + + return new Report(blockNumber, expectedStores.size(), presentCount, absentCount, + logical.getPhase(), logical.getBalance()); + } + + private static Map collectStores(Manager manager, + SnapshotManager snapshotManager) { + Map stores = new java.util.TreeMap<>(); + for (Chainbase database : snapshotManager.getDbs()) { + if (!ArchiveStoreScope.isStateDatabase(database.getDbName())) { + continue; + } + if (stores.put(database.getDbName(), StoreView.from(database)) != null) { + throw new ArchivePersistenceException( + "Duplicate State Archive diagnostic Store " + database.getDbName()); + } + } + if (!stores.containsKey(ACCOUNT_ASSET_DATABASE)) { + AccountAssetStore accountAssetStore = manager.getAccountAssetStore(); + if (accountAssetStore == null) { + throw new ArchivePersistenceException( + "State Archive diagnostic requires account-asset Store"); + } + stores.put(ACCOUNT_ASSET_DATABASE, StoreView.from(accountAssetStore.getDbSource())); + } + return stores; + } + + private static String digest(OldValue value) { + return value.isPresent() ? Hashing.sha256().hashBytes(value.getValue()).toString() : "absent"; + } + + private interface StoreView { + + Iterator> iterator(); + + byte[] get(byte[] key); + + default Sample sample(String dbName) { + Iterator> iterator = iterator(); + try { + if (iterator.hasNext()) { + Map.Entry entry = iterator.next(); + return new Sample(entry.getKey(), entry.getValue(), true); + } + } finally { + close(iterator, dbName); + } + for (int attempt = 0; attempt < ABSENT_KEY_ATTEMPTS; attempt++) { + byte[] key = Hashing.sha256().hashString( + "state-archive-diagnostic\u0000" + dbName + "\u0000" + attempt, + StandardCharsets.UTF_8).asBytes(); + if (get(key) == null) { + return new Sample(key, null, false); + } + } + throw new ArchivePersistenceException( + "Unable to construct absent State Archive diagnostic key for " + dbName); + } + + default Sample requirePresentSample(String dbName) { + Sample sample = sample(dbName); + if (!sample.presentSample) { + throw new ArchivePersistenceException( + "State Archive diagnostic requires a present sample for " + dbName); + } + return sample; + } + + static StoreView from(Chainbase database) { + return new StoreView() { + @Override + public Iterator> iterator() { + return database.iterator(); + } + + @Override + public byte[] get(byte[] key) { + return database.getUnchecked(key); + } + }; + } + + static StoreView from(DbSourceInter database) { + return new StoreView() { + @Override + public Iterator> iterator() { + return database.iterator(); + } + + @Override + public byte[] get(byte[] key) { + return database.getData(key); + } + }; + } + + static void close(Iterator iterator, String dbName) { + if (!(iterator instanceof AutoCloseable)) { + return; + } + try { + ((AutoCloseable) iterator).close(); + } catch (Exception failure) { + throw new ArchivePersistenceException( + "Failed to close State Archive diagnostic iterator for " + dbName, failure); + } + } + } + + private static final class Sample { + private final byte[] key; + private final byte[] value; + private final boolean presentSample; + + private Sample(byte[] key, byte[] value, boolean presentSample) { + this.key = Arrays.copyOf(Objects.requireNonNull(key, "key"), key.length); + this.value = value == null ? null : Arrays.copyOf(value, value.length); + this.presentSample = presentSample; + } + } + + static final class Report { + private final long blockNumber; + private final int storeCount; + private final int presentCount; + private final int absentCount; + private final Phase p66Phase; + private final long p66Balance; + + private Report(long blockNumber, int storeCount, int presentCount, int absentCount, + Phase p66Phase, long p66Balance) { + this.blockNumber = blockNumber; + this.storeCount = storeCount; + this.presentCount = presentCount; + this.absentCount = absentCount; + this.p66Phase = p66Phase; + this.p66Balance = p66Balance; + } + + long getBlockNumber() { + return blockNumber; + } + + int getStoreCount() { + return storeCount; + } + + int getPresentCount() { + return presentCount; + } + + int getAbsentCount() { + return absentCount; + } + + Phase getP66Phase() { + return p66Phase; + } + + long getP66Balance() { + return p66Balance; + } + } +} diff --git a/framework/src/main/java/org/tron/program/BlockReplay.java b/framework/src/main/java/org/tron/program/BlockReplay.java new file mode 100644 index 00000000000..c1d05edd6a6 --- /dev/null +++ b/framework/src/main/java/org/tron/program/BlockReplay.java @@ -0,0 +1,346 @@ +package org.tron.program; + +import com.beust.jcommander.JCommander; +import com.beust.jcommander.Parameter; +import com.beust.jcommander.ParameterException; +import java.io.IOException; +import java.io.PrintStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.support.DefaultListableBeanFactory; +import org.tron.common.application.TronApplicationContext; +import org.tron.common.log.LogService; +import org.tron.common.prometheus.Metrics; +import org.tron.common.utils.BlockFile; +import org.tron.core.ChainBaseManager; +import org.tron.core.capsule.BlockCapsule; +import org.tron.core.config.DefaultConfig; +import org.tron.core.config.args.Args; +import org.tron.core.consensus.ConsensusService; +import org.tron.core.db.RevokingDatabase; +import org.tron.core.net.TronNetDelegate; + +/** + * Offline block replay entry point for fixed-window database benchmarks. + */ +@Slf4j(topic = "app") +public final class BlockReplay { + + private BlockReplay() { + } + + public static void main(String[] args) { + int exitCode = execute(args, System.out, System.err); + if (exitCode != 0) { + System.exit(exitCode); + } + } + + static int execute(String[] args, PrintStream output, PrintStream error) { + Options options = new Options(); + JCommander commander = JCommander.newBuilder() + .addObject(options) + .programName("BlockReplay") + .build(); + try { + commander.parse(args); + if (options.help) { + commander.usage(); + return 0; + } + options.validate(); + ReplayResult result = options.apply + ? apply(options) + : verify(options); + output.println(result.format()); + return 0; + } catch (ParameterException | IllegalArgumentException e) { + error.println(e.getMessage()); + commander.usage(); + return 2; + } catch (Exception e) { + logger.error("Block replay failed", e); + error.println("Block replay failed: " + e.getMessage()); + return 1; + } finally { + Args.clearParam(); + } + } + + private static ReplayResult verify(Options options) throws Exception { + Args.setParam(new String[] {"-c", options.config}, "config.conf"); + return replayInput(Paths.get(options.input), null, null, options.warmupBlocks, + options.maxBlocks, false); + } + + private static ReplayResult apply(Options options) throws Exception { + Path outputDirectory = Paths.get(options.outputDirectory).toAbsolutePath().normalize(); + if (!Files.isDirectory(outputDirectory)) { + throw new IllegalArgumentException( + "Output directory must be an existing D0 snapshot: " + outputDirectory); + } + Args.setParam(new String[] {"-c", options.config, "-d", outputDirectory.toString(), + "--p2p-disable", "true"}, "config.conf"); + LogService.load(Args.getInstance().getLogbackPath()); + Metrics.init(); + + DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory(); + beanFactory.setAllowCircularReferences(false); + TronApplicationContext context = new TronApplicationContext(beanFactory); + try { + context.register(DefaultConfig.class); + context.refresh(); + startConsensus(context); + ReplayResult result = replayInput(Paths.get(options.input), + context.getBean(TronNetDelegate.class), + context.getBean(ChainBaseManager.class), options.warmupBlocks, + options.maxBlocks, true); + flushPending(context); + return result; + } finally { + context.close(); + } + } + + static void startConsensus(TronApplicationContext context) { + context.getBean(ConsensusService.class).start(); + } + + static void flushPending(TronApplicationContext context) { + context.getBean(RevokingDatabase.class).flushPending(); + } + + static ReplayResult replay(Path input, TronNetDelegate tronNetDelegate, + ChainBaseManager chainBaseManager, + long warmupBlocks, long maxBlocks, boolean apply) throws Exception { + return replay(input, tronNetDelegate, chainBaseManager, warmupBlocks, maxBlocks, apply, null); + } + + private static ReplayResult replay(Path input, TronNetDelegate tronNetDelegate, + ChainBaseManager chainBaseManager, long warmupBlocks, long maxBlocks, boolean apply, + byte[] expectedParentBlockId) throws Exception { + try (BlockFile.Reader reader = BlockFile.open(input)) { + BlockFile.Header header = reader.getHeader(); + long selectedCount = Math.min(header.getCount(), maxBlocks); + if (apply) { + long expectedStart = chainBaseManager.getHeadBlockNum() + 1; + if (header.getStart() != expectedStart) { + throw new IllegalArgumentException("Block file starts at " + header.getStart() + + " but D0 requires block " + expectedStart); + } + } + + long processed = 0; + long measured = 0; + long measuredNanos = 0; + long lastHeight = header.getStart() - 1; + byte[] lastBlockId = expectedParentBlockId; + while (processed < selectedCount && reader.hasNext()) { + BlockFile.Record record = reader.next(); + BlockCapsule block = new BlockCapsule(record.getBlock()); + if (!Arrays.equals(record.getBlockId(), block.getBlockId().getBytes())) { + throw new IOException("Computed block ID mismatch at height " + record.getHeight()); + } + if (processed == 0 && expectedParentBlockId != null && !Arrays.equals( + expectedParentBlockId, block.getParentHash().getBytes())) { + throw new IllegalArgumentException("Block file parent mismatch at " + + record.getHeight()); + } + if (apply && processed == 0 && !block.getParentHash().equals( + chainBaseManager.getHeadBlockId())) { + throw new IllegalArgumentException("First block parent does not match D0 head"); + } + + long startNanos = apply ? System.nanoTime() : 0; + if (apply) { + tronNetDelegate.processBlock(block, true); + if (chainBaseManager.getHeadBlockNum() != record.getHeight() + || !chainBaseManager.getHeadBlockId().equals(block.getBlockId())) { + throw new IllegalStateException( + "D0 head did not advance to block " + record.getHeight()); + } + } + long elapsedNanos = apply ? System.nanoTime() - startNanos : 0; + if (processed >= warmupBlocks) { + measuredNanos += elapsedNanos; + measured++; + } + processed++; + lastHeight = record.getHeight(); + lastBlockId = record.getBlockId(); + } + if (selectedCount == header.getCount()) { + reader.hasNext(); + } + return new ReplayResult(apply, header.getStart(), lastHeight, processed, + Math.min(warmupBlocks, processed), measured, measuredNanos, lastBlockId); + } + } + + static ReplayResult replayInput(Path input, TronNetDelegate tronNetDelegate, + ChainBaseManager chainBaseManager, long warmupBlocks, long maxBlocks, boolean apply) + throws Exception { + if (Files.isRegularFile(input)) { + return replay(input, tronNetDelegate, chainBaseManager, warmupBlocks, maxBlocks, apply); + } + List inputs = listInputFiles(input); + long processed = 0; + long warmup = 0; + long measured = 0; + long measuredNanos = 0; + long start = inputs.get(0).start; + long end = start - 1; + byte[] previousBlockId = null; + for (InputFile inputFile : inputs) { + long remaining = maxBlocks - processed; + if (remaining <= 0) { + break; + } + ReplayResult part = replay(inputFile.path, tronNetDelegate, chainBaseManager, + Math.max(0, warmupBlocks - processed), remaining, apply, previousBlockId); + processed += part.processed; + warmup += part.warmup; + measured += part.measured; + measuredNanos += part.measuredNanos; + end = part.end; + previousBlockId = part.lastBlockId; + } + return new ReplayResult(apply, start, end, processed, warmup, measured, measuredNanos, + previousBlockId); + } + + private static List listInputFiles(Path input) throws IOException { + if (!Files.isDirectory(input)) { + throw new IllegalArgumentException("Block input does not exist: " + input); + } + List paths; + try (Stream stream = Files.list(input)) { + paths = stream.filter(Files::isRegularFile) + .filter(path -> path.getFileName().toString().endsWith(".dat")) + .collect(Collectors.toList()); + } + List inputs = new ArrayList<>(paths.size()); + for (Path path : paths) { + try (BlockFile.Reader reader = BlockFile.open(path)) { + BlockFile.Header header = reader.getHeader(); + inputs.add(new InputFile(path, header.getStart(), header.getEnd())); + } + } + inputs.sort(Comparator.comparingLong(value -> value.start)); + if (inputs.isEmpty()) { + throw new IllegalArgumentException("Block input directory has no .dat files: " + input); + } + for (int i = 1; i < inputs.size(); i++) { + if (inputs.get(i).start != inputs.get(i - 1).end + 1) { + throw new IllegalArgumentException("Block input files are not consecutive between " + + inputs.get(i - 1).path + " and " + inputs.get(i).path); + } + } + return inputs; + } + + static final class Options { + @Parameter(names = {"-i", "--input"}, + description = "Block file or consecutive chunk directory to verify or replay.") + private String input; + + @Parameter(names = {"-d", "--output-directory"}, + description = "Existing stopped-node D0 output directory. Required with --apply.") + private String outputDirectory; + + @Parameter(names = {"-c", "--config"}, description = "Node config file.") + private String config = "config.conf"; + + @Parameter(names = "--apply", + description = "Apply blocks to D0. Without this flag the command only verifies the file.") + private boolean apply; + + @Parameter(names = "--warmup-blocks", + description = "Exclude this many leading blocks from measured time.") + private long warmupBlocks; + + @Parameter(names = "--max-blocks", + description = "Process at most this many blocks from the file.") + private long maxBlocks = Long.MAX_VALUE; + + @Parameter(names = {"-h", "--help"}, help = true) + private boolean help; + + private void validate() { + if (input == null || input.trim().isEmpty()) { + throw new ParameterException("--input is required"); + } + if (!Files.isRegularFile(Paths.get(input)) && !Files.isDirectory(Paths.get(input))) { + throw new ParameterException("Block input does not exist: " + input); + } + if (config == null || config.trim().isEmpty()) { + throw new ParameterException("--config must not be empty"); + } + if (apply && (outputDirectory == null || outputDirectory.trim().isEmpty())) { + throw new ParameterException("--output-directory is required with --apply"); + } + if (warmupBlocks < 0) { + throw new ParameterException("--warmup-blocks must be non-negative"); + } + if (maxBlocks <= 0) { + throw new ParameterException("--max-blocks must be positive"); + } + } + } + + static final class ReplayResult { + private final boolean applied; + private final long start; + private final long end; + private final long processed; + private final long warmup; + private final long measured; + private final long measuredNanos; + private final byte[] lastBlockId; + + private ReplayResult(boolean applied, long start, long end, long processed, + long warmup, long measured, long measuredNanos, byte[] lastBlockId) { + this.applied = applied; + this.start = start; + this.end = end; + this.processed = processed; + this.warmup = warmup; + this.measured = measured; + this.measuredNanos = measuredNanos; + this.lastBlockId = lastBlockId == null ? null + : Arrays.copyOf(lastBlockId, lastBlockId.length); + } + + String format() { + double elapsedMs = measuredNanos / 1_000_000.0; + double blocksPerSecond = measuredNanos == 0 ? 0.0 + : measured * 1_000_000_000.0 / measuredNanos; + return String.format(Locale.ROOT, + "mode=%s range=[%d,%d] processed=%d warmup=%d measured=%d " + + "elapsed_ms=%.3f blocks_per_second=%.3f", + applied ? "apply" : "verify", start, end, processed, warmup, measured, + elapsedMs, blocksPerSecond); + } + } + + private static final class InputFile { + private final Path path; + private final long start; + private final long end; + + private InputFile(Path path, long start, long end) { + this.path = path; + this.start = start; + this.end = end; + } + } +} diff --git a/framework/src/main/java/org/tron/program/FullNode.java b/framework/src/main/java/org/tron/program/FullNode.java index 96b9f73d577..e05416fd133 100644 --- a/framework/src/main/java/org/tron/program/FullNode.java +++ b/framework/src/main/java/org/tron/program/FullNode.java @@ -58,6 +58,7 @@ public static void main(String[] args) { context.register(DefaultConfig.class); context.refresh(); Application appT = ApplicationFactory.create(context); + runArchiveStateDiagnostic(() -> ArchiveStateDiagnostic.runIfEnabled(context)); context.registerShutdownHook(); appT.startup(); if (parameter.isSolidityNode()) { @@ -67,6 +68,15 @@ public static void main(String[] args) { appT.blockUntilShutdown(); } + static void runArchiveStateDiagnostic(Runnable diagnostic) { + try { + diagnostic.run(); + } catch (RuntimeException failure) { + throw new TronError("State Archive startup diagnostic failed", failure, + TronError.ErrCode.STATE_ARCHIVE_INIT); + } + } + private static void checkJdkVersion() { try { Arch.throwIfUnsupportedJavaVersion(); diff --git a/framework/src/main/java/org/tron/program/HttpBlockExport.java b/framework/src/main/java/org/tron/program/HttpBlockExport.java new file mode 100644 index 00000000000..578146dc2d0 --- /dev/null +++ b/framework/src/main/java/org/tron/program/HttpBlockExport.java @@ -0,0 +1,343 @@ +package org.tron.program; + +import com.beust.jcommander.JCommander; +import com.beust.jcommander.Parameter; +import com.beust.jcommander.ParameterException; +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.StreamReadConstraints; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import org.tron.common.utils.BlockFile; +import org.tron.common.utils.ByteArray; +import org.tron.core.capsule.BlockCapsule; +import org.tron.core.capsule.TransactionCapsule; +import org.tron.core.services.http.JsonFormat; +import org.tron.protos.Protocol.Block; +import org.tron.protos.Protocol.BlockHeader; +import org.tron.protos.Protocol.Transaction; + +/** Downloads consecutive blocks from a java-tron HTTP API into resumable block-file chunks. */ +public final class HttpBlockExport { + + static final int MAX_CHUNK_SIZE = 100; + private static final long MAX_RESPONSE_CHARS = 256L * 1024 * 1024; + private static final ObjectMapper HTTP_MAPPER = JsonMapper.builder( + JsonFactory.builder().streamReadConstraints(StreamReadConstraints.builder() + .maxNestingDepth(1000) + .maxTokenCount(10_000_000L) + .build()).build()).build(); + + private HttpBlockExport() { + } + + public static void main(String[] args) { + int exitCode = execute(args, System.out, System.err); + if (exitCode != 0) { + System.exit(exitCode); + } + } + + static int execute(String[] args, java.io.PrintStream output, java.io.PrintStream error) { + Options options = new Options(); + JCommander commander = JCommander.newBuilder() + .addObject(options) + .programName("HttpBlockExport") + .build(); + try { + commander.parse(args); + if (options.help) { + commander.usage(); + return 0; + } + options.validate(); + Files.createDirectories(Paths.get(options.outputDirectory)); + export(options, output); + return 0; + } catch (ParameterException | IllegalArgumentException e) { + error.println(e.getMessage()); + commander.usage(); + return 2; + } catch (Exception e) { + error.println("HTTP block export failed: " + e.getMessage()); + return 1; + } + } + + private static void export(Options options, java.io.PrintStream output) throws IOException { + long next = options.start; + while (next <= options.end) { + long chunkEnd = Math.min(options.end, next + options.chunkSize - 1L); + Path target = Paths.get(options.outputDirectory) + .resolve(String.format("%d-%d.dat", next, chunkEnd)); + if (Files.isRegularFile(target) && !options.overwrite) { + verifyChunk(target, next, chunkEnd); + output.printf("Reused verified chunk [%d, %d] %s%n", next, chunkEnd, target); + } else { + List records = fetchChunk(options, next, chunkEnd); + long chunkStart = next; + BlockFile.write(target, next, chunkEnd, options.overwrite, + height -> records.get(Math.toIntExact(height - chunkStart))); + output.printf("Downloaded chunk [%d, %d] %s%n", next, chunkEnd, target); + } + if (chunkEnd == Long.MAX_VALUE) { + break; + } + next = chunkEnd + 1; + } + } + + static void verifyChunk(Path input, long expectedStart, long expectedEnd) throws IOException { + try (BlockFile.Reader reader = BlockFile.open(input)) { + BlockFile.Header header = reader.getHeader(); + if (header.getStart() != expectedStart || header.getEnd() != expectedEnd) { + throw new IOException("Existing chunk has unexpected range: " + input); + } + while (reader.hasNext()) { + BlockFile.Record record = reader.next(); + BlockCapsule block = new BlockCapsule(record.getBlock()); + if (!Arrays.equals(record.getBlockId(), block.getBlockId().getBytes())) { + throw new IOException("Computed block ID mismatch at height " + record.getHeight()); + } + } + } + } + + private static List fetchChunk(Options options, long start, long end) + throws IOException { + IOException lastFailure = null; + for (int attempt = 1; attempt <= options.retries; attempt++) { + try { + String response = request(options, start, end); + List records = parseResponse(response); + if (records.size() != end - start + 1) { + throw new IOException("Expected " + (end - start + 1) + " blocks but received " + + records.size()); + } + for (int i = 0; i < records.size(); i++) { + if (records.get(i).getHeight() != start + i) { + throw new IOException("Non-consecutive HTTP response at block " + (start + i)); + } + } + return records; + } catch (IOException | RuntimeException e) { + lastFailure = e instanceof IOException ? (IOException) e + : new IOException(e.getMessage(), e); + if (attempt < options.retries) { + try { + Thread.sleep(Math.min(5000L, attempt * 1000L)); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while retrying HTTP request", interrupted); + } + } + } + } + throw new IOException("Failed to download blocks [" + start + ", " + end + "] after " + + options.retries + " attempts: " + lastFailure.getMessage(), lastFailure); + } + + private static String request(Options options, long start, long end) throws IOException { + URL url = new URL(trimTrailingSlash(options.endpoint) + "/wallet/getblockbylimitnext"); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod("POST"); + connection.setConnectTimeout(options.timeoutMillis); + connection.setReadTimeout(options.timeoutMillis); + connection.setDoOutput(true); + connection.setRequestProperty("Content-Type", "application/json"); + byte[] request = ("{\"startNum\":" + start + ",\"endNum\":" + (end + 1) + "}") + .getBytes(StandardCharsets.UTF_8); + connection.setFixedLengthStreamingMode(request.length); + try { + try (OutputStream stream = connection.getOutputStream()) { + stream.write(request); + } + int status = connection.getResponseCode(); + InputStream response = status >= 200 && status < 300 + ? connection.getInputStream() : connection.getErrorStream(); + String body = readAll(response); + if (status < 200 || status >= 300) { + throw new IOException("HTTP " + status + " for blocks [" + start + ", " + end + + "]: " + abbreviate(body)); + } + return body; + } finally { + connection.disconnect(); + } + } + + static List parseResponse(String response) throws IOException { + JsonNode root = HTTP_MAPPER.readTree(response); + JsonNode blocks = root == null ? null : root.get("block"); + if (blocks == null || !blocks.isArray()) { + throw new IOException("HTTP response does not contain a block array: " + + abbreviate(response)); + } + List records = new ArrayList<>(blocks.size()); + for (int i = 0; i < blocks.size(); i++) { + JsonNode source = blocks.get(i); + JsonNode blockIdJson = source.get("blockID"); + JsonNode headerJson = source.get("block_header"); + if (blockIdJson == null || !blockIdJson.isTextual() || headerJson == null + || !headerJson.isObject()) { + throw new IOException("Block response is missing blockID or block_header"); + } + String expectedBlockId = blockIdJson.asText(); + BlockHeader.Builder header = BlockHeader.newBuilder(); + JsonFormat.merge(headerJson.toString(), header, false); + Block.Builder block = Block.newBuilder().setBlockHeader(header); + JsonNode transactions = source.get("transactions"); + if (transactions != null && transactions.isArray()) { + for (int transactionIndex = 0; transactionIndex < transactions.size(); + transactionIndex++) { + JsonNode transactionJson = transactions.get(transactionIndex); + Transaction transaction = parseTransaction(transactionJson, transactionIndex); + JsonNode transactionIdJson = transactionJson.get("txID"); + String expectedTransactionId = transactionIdJson == null ? null + : transactionIdJson.asText(); + if (expectedTransactionId != null && !expectedTransactionId.equalsIgnoreCase( + ByteArray.toHexString(new TransactionCapsule(transaction).getTransactionId() + .getBytes()))) { + throw new IOException("Computed transaction ID mismatch at transaction " + + transactionIndex); + } + block.addTransactions(transaction); + } + } + Block parsed = block.build(); + BlockCapsule capsule = new BlockCapsule(parsed); + byte[] blockId = capsule.getBlockId().getBytes(); + if (!expectedBlockId.equalsIgnoreCase(ByteArray.toHexString(blockId))) { + throw new IOException("Computed block ID mismatch at height " + + parsed.getBlockHeader().getRawData().getNumber()); + } + records.add(new BlockFile.Record( + parsed.getBlockHeader().getRawData().getNumber(), blockId, parsed.toByteArray())); + } + return records; + } + + private static Transaction parseTransaction(JsonNode source, int index) throws IOException { + JsonNode rawDataHexJson = source.get("raw_data_hex"); + if (rawDataHexJson == null || !rawDataHexJson.isTextual()) { + throw new IOException("Transaction " + index + " does not contain raw_data_hex"); + } + try { + String rawDataHex = rawDataHexJson.asText(); + Transaction.raw raw = Transaction.raw.parseFrom(ByteArray.fromHexString(rawDataHex)); + ObjectNode envelope = (ObjectNode) source.deepCopy(); + envelope.remove("txID"); + envelope.remove("raw_data"); + envelope.remove("raw_data_hex"); + envelope.remove("visible"); + Transaction.Builder transaction = Transaction.newBuilder(); + JsonFormat.merge(envelope.toString(), transaction, false); + return transaction.setRawData(raw).build(); + } catch (RuntimeException e) { + throw new IOException("Cannot parse transaction " + index, e); + } + } + + private static String readAll(InputStream input) throws IOException { + if (input == null) { + return ""; + } + StringBuilder result = new StringBuilder(); + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(input, StandardCharsets.UTF_8))) { + char[] buffer = new char[8192]; + int read; + while ((read = reader.read(buffer)) != -1) { + result.append(buffer, 0, read); + if (result.length() > MAX_RESPONSE_CHARS) { + throw new IOException("HTTP response exceeds " + MAX_RESPONSE_CHARS + " characters"); + } + } + } + return result.toString(); + } + + private static String trimTrailingSlash(String endpoint) { + int end = endpoint.length(); + while (end > 0 && endpoint.charAt(end - 1) == '/') { + end--; + } + return endpoint.substring(0, end); + } + + private static String abbreviate(String value) { + return value.length() <= 256 ? value : value.substring(0, 256); + } + + static final class Options { + @Parameter(names = "--endpoint", description = "Base URL of a java-tron HTTP API.") + private String endpoint = "https://api.trongrid.io"; + + @Parameter(names = "--start", description = "First block height, inclusive.") + private long start = -1; + + @Parameter(names = "--end", description = "Last block height, inclusive.") + private long end = -1; + + @Parameter(names = {"-o", "--output-directory"}, + description = "Directory for atomic block-file chunks.") + private String outputDirectory; + + @Parameter(names = "--chunk-size", description = "Blocks per output file, at most 100.") + private int chunkSize = MAX_CHUNK_SIZE; + + @Parameter(names = "--timeout-millis", description = "HTTP connect and read timeout.") + private int timeoutMillis = 120000; + + @Parameter(names = "--retries", description = "Attempts for each HTTP chunk.") + private int retries = 5; + + @Parameter(names = "--overwrite", description = "Replace existing chunks.") + private boolean overwrite; + + @Parameter(names = {"-h", "--help"}, help = true) + private boolean help; + + private void validate() { + if (endpoint == null || endpoint.trim().isEmpty()) { + throw new ParameterException("--endpoint must not be empty"); + } + if (start < 0) { + throw new ParameterException("--start must be non-negative"); + } + if (end < start) { + throw new ParameterException("--end must be greater than or equal to --start"); + } + if (end == Long.MAX_VALUE) { + throw new ParameterException("--end must be less than Long.MAX_VALUE"); + } + if (outputDirectory == null || outputDirectory.trim().isEmpty()) { + throw new ParameterException("--output-directory is required"); + } + if (chunkSize <= 0 || chunkSize > MAX_CHUNK_SIZE) { + throw new ParameterException("--chunk-size must be between 1 and " + MAX_CHUNK_SIZE); + } + if (timeoutMillis <= 0) { + throw new ParameterException("--timeout-millis must be positive"); + } + if (retries <= 0) { + throw new ParameterException("--retries must be positive"); + } + } + } +} diff --git a/framework/src/main/resources/config.conf b/framework/src/main/resources/config.conf index 1176dd46311..750d28b01c6 100644 --- a/framework/src/main/resources/config.conf +++ b/framework/src/main/resources/config.conf @@ -37,6 +37,42 @@ storage { balance.history.lookup = false + # Experimental block-boundary state history. Keep disabled outside dedicated validation nodes. + stateArchive.enabled = false + stateArchive.directory = "state-archive" + stateArchive.maxSegmentSize = 1073741824 + stateArchive.queueCapacity = 256 + # Independent auxiliary DB engines. Changing one requires rebuilding/resyncing that DB. + stateArchive.servingIndexEngine = "ROCKSDB" + stateArchive.hotStore.engine = "ROCKSDB" + # Default-off five-lane append-file v3 path; requires commonCheckpoint.enabled. + stateArchive.appendFile.enabled = false + stateArchive.appendFile.formatVersion = 3 + stateArchive.appendFile.appendBufferBytes = 2097152 + stateArchive.appendFile.maxBlockFrameBytes = 67108864 + stateArchive.appendFile.segmentTargetBytes = 2000000000 + stateArchive.appendFile.shardMaxSegments = 1024 + commonCheckpoint.enabled = false + commonCheckpoint.directory = "common-checkpoint" + + # Experimental current-only, non-consensus path state root. Keep disabled by default. + pathStateRoot.enabled = false + pathStateRoot.mode = "shadow" + pathStateRoot.directory = "path-state-root" + pathStateRoot.engine = "ROCKSDB" + pathStateRoot.formatVersion = 1 + pathStateRoot.reversibleLayerLimit = 128 + pathStateRoot.reversibleLayerBytes = 2147483648 + pathStateRoot.writeBufferBytes = 268435456 + pathStateRoot.nodeCacheBytes = 268435456 + pathStateRoot.participantThreads = 4 + pathStateRoot.branchThreads = 8 + pathStateRoot.rebuildFromGenesis = false + pathStateRoot.verifyEveryBlock = true + # Benchmark-only. Not restart-safe until common-checkpoint flush is installed. + pathStateRoot.volatileSnapshotBenchmark = false + pathStateRoot.asyncPrepareBenchmark = false + # If true, transaction cache initialization will be faster. Default: false txCache.initOptimization = true } diff --git a/framework/src/test/java/org/tron/common/storage/EngineSourceIdentityFileTest.java b/framework/src/test/java/org/tron/common/storage/EngineSourceIdentityFileTest.java new file mode 100644 index 00000000000..32349d96417 --- /dev/null +++ b/framework/src/test/java/org/tron/common/storage/EngineSourceIdentityFileTest.java @@ -0,0 +1,85 @@ +package org.tron.common.storage; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +public class EngineSourceIdentityFileTest { + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void identityIsStableAcrossReopenAndBoundToEngineAndDbName() throws Exception { + Path database = temporaryFolder.newFolder("stable").toPath(); + String first = EngineSourceIdentityFile.loadOrCreate(database, "rocksdb", "account"); + String reopened = EngineSourceIdentityFile.loadOrCreate(database, "ROCKSDB", "account"); + + assertEquals(first, reopened); + assertTrue(first.startsWith("rocksdb:account:")); + assertThrows(IOException.class, + () -> EngineSourceIdentityFile.loadOrCreate(database, "LEVELDB", "account")); + assertThrows(IOException.class, + () -> EngineSourceIdentityFile.loadOrCreate(database, "ROCKSDB", "properties")); + } + + @Test + public void missingIdentityCreatesANewDurableUuid() throws Exception { + Path database = temporaryFolder.newFolder("recreated").toPath(); + String first = EngineSourceIdentityFile.loadOrCreate(database, "LEVELDB", "account"); + Files.delete(EngineSourceIdentityFile.identityPath(database)); + String recreated = EngineSourceIdentityFile.loadOrCreate(database, "LEVELDB", "account"); + + assertNotEquals(first, recreated); + assertEquals(recreated, + EngineSourceIdentityFile.loadOrCreate(database, "LEVELDB", "account")); + } + + @Test + public void corruptionFailsClosedWithoutReplacingIdentity() throws Exception { + Path database = temporaryFolder.newFolder("corrupt").toPath(); + EngineSourceIdentityFile.loadOrCreate(database, "ROCKSDB", "account"); + Path identity = EngineSourceIdentityFile.identityPath(database); + byte[] corrupt = Files.readAllBytes(identity); + corrupt[corrupt.length - 1] ^= 1; + Files.write(identity, corrupt); + + assertThrows(IOException.class, + () -> EngineSourceIdentityFile.loadOrCreate(database, "ROCKSDB", "account")); + assertEquals(corrupt.length, Files.size(identity)); + } + + @Test + public void concurrentOpenersEstablishOneIdentity() throws Exception { + Path database = temporaryFolder.newFolder("concurrent").toPath(); + ExecutorService executor = Executors.newFixedThreadPool(8); + Set identities = ConcurrentHashMap.newKeySet(); + try { + Future[] opens = new Future[8]; + for (int i = 0; i < opens.length; i++) { + opens[i] = executor.submit(() -> identities.add( + EngineSourceIdentityFile.loadOrCreate(database, "ROCKSDB", "account"))); + } + for (Future open : opens) { + open.get(); + } + } finally { + executor.shutdownNow(); + } + + assertEquals(1, identities.size()); + } +} diff --git a/framework/src/test/java/org/tron/common/storage/leveldb/LevelDbDataSourceImplTest.java b/framework/src/test/java/org/tron/common/storage/leveldb/LevelDbDataSourceImplTest.java index 41e8749e1ec..7c0d6b12391 100644 --- a/framework/src/test/java/org/tron/common/storage/leveldb/LevelDbDataSourceImplTest.java +++ b/framework/src/test/java/org/tron/common/storage/leveldb/LevelDbDataSourceImplTest.java @@ -18,7 +18,9 @@ package org.tron.common.storage.leveldb; +import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; @@ -29,9 +31,15 @@ import ch.qos.logback.core.read.ListAppender; import java.io.File; import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import java.util.stream.Collectors; import org.junit.AfterClass; import org.junit.Assert; @@ -51,6 +59,8 @@ import org.tron.common.utils.ReflectUtils; import org.tron.common.utils.StorageUtils; import org.tron.core.config.args.Args; +import org.tron.core.db2.archive.LatestStateGenerationAdapter.SnapshotCapableStore; +import org.tron.core.db2.archive.LatestStateGenerationAdapter.StoreSnapshot; import org.tron.core.exception.TronError; /** @@ -189,4 +199,119 @@ public void fastOpen() { dbLogger.detachAppender(dbAppender); } } + + @Test + public void nativeSnapshotKeepsOldValueAfterLiveWrite() throws Exception { + LevelDbDataSourceImpl dataSource = new LevelDbDataSourceImpl( + Args.getInstance().getOutputDirectory(), "nativeSnapshotKeepsOldValue"); + dataSource.putData(key1, value1); + try (LevelDbDataSourceImpl.PinnedSnapshot snapshot = dataSource.pinSnapshot()) { + dataSource.putData(key1, "replacement".getBytes()); + assertArrayEquals(value1, snapshot.get(key1)); + assertEquals(dataSource.getSnapshotSourceIdentity(), snapshot.getSourceIdentity()); + } + dataSource.closeDB(); + } + + @Test + public void levelDbWrapperExposesSnapshotCapability() throws Exception { + LevelDbDataSourceImpl dataSource = new LevelDbDataSourceImpl( + Args.getInstance().getOutputDirectory(), "wrapperSnapshotCapability"); + org.tron.core.db2.common.LevelDB wrapper = new org.tron.core.db2.common.LevelDB(dataSource); + wrapper.put(key1, value1); + assertTrue(wrapper instanceof SnapshotCapableStore); + try (StoreSnapshot snapshot = wrapper.pin(1, new byte[32])) { + wrapper.put(key1, "20000".getBytes()); + assertArrayEquals(value1, snapshot.get(key1)); + assertEquals(wrapper.getSourceIdentity(), snapshot.getSourceIdentity()); + } finally { + wrapper.close(); + } + } + + @Test + public void closeWaitsForCrossThreadSnapshotRelease() throws Exception { + LevelDbDataSourceImpl dataSource = new LevelDbDataSourceImpl( + Args.getInstance().getOutputDirectory(), "closeWaitsForSnapshot"); + LevelDbDataSourceImpl.PinnedSnapshot snapshot = dataSource.pinSnapshot(); + ExecutorService executor = Executors.newFixedThreadPool(2); + CountDownLatch closeStarted = new CountDownLatch(1); + try { + Future close = executor.submit(() -> { + closeStarted.countDown(); + dataSource.closeDB(); + }); + assertTrue(closeStarted.await(5, TimeUnit.SECONDS)); + assertThrows(TimeoutException.class, () -> close.get(100, TimeUnit.MILLISECONDS)); + executor.submit(() -> { + snapshot.close(); + return null; + }).get(5, TimeUnit.SECONDS); + close.get(5, TimeUnit.SECONDS); + } finally { + snapshot.close(); + executor.shutdownNow(); + dataSource.closeDB(); + } + } + + @Test + public void resetWaitsForPinAndChangesEngineSourceIdentity() throws Exception { + LevelDbDataSourceImpl dataSource = new LevelDbDataSourceImpl( + Args.getInstance().getOutputDirectory(), "resetWaitsForSnapshot"); + String originalIdentity = dataSource.getSnapshotSourceIdentity(); + LevelDbDataSourceImpl.PinnedSnapshot snapshot = dataSource.pinSnapshot(); + ExecutorService executor = Executors.newSingleThreadExecutor(); + CountDownLatch resetStarted = new CountDownLatch(1); + try { + Future reset = executor.submit(() -> { + resetStarted.countDown(); + dataSource.resetDb(); + }); + assertTrue(resetStarted.await(5, TimeUnit.SECONDS)); + assertThrows(TimeoutException.class, () -> reset.get(100, TimeUnit.MILLISECONDS)); + snapshot.close(); + reset.get(5, TimeUnit.SECONDS); + assertNotEquals(originalIdentity, dataSource.getSnapshotSourceIdentity()); + assertThrows(IllegalStateException.class, () -> snapshot.get(key1)); + } finally { + snapshot.close(); + executor.shutdownNow(); + dataSource.closeDB(); + } + } + + @Test + public void sourceIdentityPersistsAcrossProcessStyleReopen() { + String name = "sourceIdentityPersistsAcrossReopen"; + LevelDbDataSourceImpl first = new LevelDbDataSourceImpl( + Args.getInstance().getOutputDirectory(), name); + String identity = first.getSnapshotSourceIdentity(); + first.closeDB(); + + LevelDbDataSourceImpl reopened = new LevelDbDataSourceImpl( + Args.getInstance().getOutputDirectory(), name); + try { + assertEquals(identity, reopened.getSnapshotSourceIdentity()); + } finally { + reopened.closeDB(); + } + } + + @Test + public void corruptSourceIdentityFailsBeforeDatabaseReopen() throws IOException { + String name = "corruptSourceIdentityFailsClosed"; + LevelDbDataSourceImpl first = new LevelDbDataSourceImpl( + Args.getInstance().getOutputDirectory(), name); + Path identityPath = first.getDbPath().resolve(".archive-engine.identity"); + first.closeDB(); + + byte[] corrupted = Files.readAllBytes(identityPath); + corrupted[corrupted.length - 1] ^= 1; + Files.write(identityPath, corrupted); + + assertThrows(TronError.class, () -> new LevelDbDataSourceImpl( + Args.getInstance().getOutputDirectory(), name)); + assertArrayEquals(corrupted, Files.readAllBytes(identityPath)); + } } diff --git a/framework/src/test/java/org/tron/common/storage/rocksdb/RocksDbDataSourceImplTest.java b/framework/src/test/java/org/tron/common/storage/rocksdb/RocksDbDataSourceImplTest.java index b0f13eb9154..02fce193c0a 100644 --- a/framework/src/test/java/org/tron/common/storage/rocksdb/RocksDbDataSourceImplTest.java +++ b/framework/src/test/java/org/tron/common/storage/rocksdb/RocksDbDataSourceImplTest.java @@ -1,14 +1,24 @@ package org.tron.common.storage.rocksdb; +import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; import static org.tron.common.TestConstants.TEST_CONF; import static org.tron.common.TestConstants.assumeLevelDbAvailable; import java.io.File; import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import org.junit.AfterClass; import org.junit.Assert; import org.junit.BeforeClass; @@ -24,6 +34,8 @@ import org.tron.common.utils.PropUtil; import org.tron.common.utils.StorageUtils; import org.tron.core.config.args.Args; +import org.tron.core.db2.archive.LatestStateGenerationAdapter.SnapshotCapableStore; +import org.tron.core.db2.archive.LatestStateGenerationAdapter.StoreSnapshot; import org.tron.core.exception.TronError; /** @@ -139,6 +151,118 @@ public void backupAndDelete() throws RocksDBException { dataSource.closeDB(); } + @Test + public void nativeSnapshotKeepsOldValueAfterLiveWrite() { + RocksDbDataSourceImpl dataSource = new RocksDbDataSourceImpl( + Args.getInstance().getOutputDirectory(), "nativeSnapshotKeepsOldValue"); + dataSource.putData(key1, value1); + try (RocksDbDataSourceImpl.PinnedSnapshot snapshot = dataSource.pinSnapshot()) { + dataSource.putData(key1, "replacement".getBytes()); + assertArrayEquals(value1, snapshot.get(key1)); + assertEquals(dataSource.getSnapshotSourceIdentity(), snapshot.getSourceIdentity()); + } + dataSource.closeDB(); + } + + @Test + public void rocksDbWrapperExposesSnapshotCapability() throws Exception { + RocksDbDataSourceImpl dataSource = new RocksDbDataSourceImpl( + Args.getInstance().getOutputDirectory(), "wrapperSnapshotCapability"); + org.tron.core.db2.common.RocksDB wrapper = new org.tron.core.db2.common.RocksDB(dataSource); + wrapper.put(key1, value1); + assertTrue(wrapper instanceof SnapshotCapableStore); + try (StoreSnapshot snapshot = wrapper.pin(1, new byte[32])) { + wrapper.put(key1, "20000".getBytes()); + assertArrayEquals(value1, snapshot.get(key1)); + assertEquals(wrapper.getSourceIdentity(), snapshot.getSourceIdentity()); + } finally { + wrapper.close(); + } + } + + @Test + public void closeWaitsForCrossThreadSnapshotRelease() throws Exception { + RocksDbDataSourceImpl dataSource = new RocksDbDataSourceImpl( + Args.getInstance().getOutputDirectory(), "closeWaitsForSnapshot"); + RocksDbDataSourceImpl.PinnedSnapshot snapshot = dataSource.pinSnapshot(); + ExecutorService executor = Executors.newFixedThreadPool(2); + CountDownLatch closeStarted = new CountDownLatch(1); + try { + Future close = executor.submit(() -> { + closeStarted.countDown(); + dataSource.closeDB(); + }); + assertTrue(closeStarted.await(5, TimeUnit.SECONDS)); + assertThrows(TimeoutException.class, () -> close.get(100, TimeUnit.MILLISECONDS)); + executor.submit(snapshot::close).get(5, TimeUnit.SECONDS); + close.get(5, TimeUnit.SECONDS); + } finally { + snapshot.close(); + executor.shutdownNow(); + dataSource.closeDB(); + } + } + + @Test + public void resetWaitsForPinAndChangesEngineSourceIdentity() throws Exception { + RocksDbDataSourceImpl dataSource = new RocksDbDataSourceImpl( + Args.getInstance().getOutputDirectory(), "resetWaitsForSnapshot"); + String originalIdentity = dataSource.getSnapshotSourceIdentity(); + RocksDbDataSourceImpl.PinnedSnapshot snapshot = dataSource.pinSnapshot(); + ExecutorService executor = Executors.newSingleThreadExecutor(); + CountDownLatch resetStarted = new CountDownLatch(1); + try { + Future reset = executor.submit(() -> { + resetStarted.countDown(); + dataSource.resetDb(); + }); + assertTrue(resetStarted.await(5, TimeUnit.SECONDS)); + assertThrows(TimeoutException.class, () -> reset.get(100, TimeUnit.MILLISECONDS)); + snapshot.close(); + reset.get(5, TimeUnit.SECONDS); + assertNotEquals(originalIdentity, dataSource.getSnapshotSourceIdentity()); + assertThrows(IllegalStateException.class, () -> snapshot.get(key1)); + } finally { + snapshot.close(); + executor.shutdownNow(); + dataSource.closeDB(); + } + } + + @Test + public void sourceIdentityPersistsAcrossProcessStyleReopen() { + String name = "sourceIdentityPersistsAcrossReopen"; + RocksDbDataSourceImpl first = new RocksDbDataSourceImpl( + Args.getInstance().getOutputDirectory(), name); + String identity = first.getSnapshotSourceIdentity(); + first.closeDB(); + + RocksDbDataSourceImpl reopened = new RocksDbDataSourceImpl( + Args.getInstance().getOutputDirectory(), name); + try { + assertEquals(identity, reopened.getSnapshotSourceIdentity()); + } finally { + reopened.closeDB(); + } + } + + @Test + public void corruptSourceIdentityFailsBeforeDatabaseReopen() throws IOException { + String name = "corruptSourceIdentityFailsClosed"; + RocksDbDataSourceImpl first = new RocksDbDataSourceImpl( + Args.getInstance().getOutputDirectory(), name); + Path identityPath = first.getDbPath().resolve(".archive-engine.identity"); + first.closeDB(); + + byte[] corrupted = Files.readAllBytes(identityPath); + corrupted[corrupted.length - 1] ^= 1; + Files.write(identityPath, corrupted); + + assertThrows(RuntimeException.class, () -> new RocksDbDataSourceImpl( + Args.getInstance().getOutputDirectory(), name)); + assertArrayEquals(corrupted, Files.readAllBytes(identityPath)); + } + private void makeExceptionDb(String dbName) { RocksDbDataSourceImpl dataSource = new RocksDbDataSourceImpl( Args.getInstance().getOutputDirectory(), "test_initDb"); diff --git a/framework/src/test/java/org/tron/common/utils/DBKeyComparatorTest.java b/framework/src/test/java/org/tron/common/utils/DBKeyComparatorTest.java index e9eb0f23ad3..ccc18b7290b 100644 --- a/framework/src/test/java/org/tron/common/utils/DBKeyComparatorTest.java +++ b/framework/src/test/java/org/tron/common/utils/DBKeyComparatorTest.java @@ -49,6 +49,20 @@ public void pairKeyIsEqual() { Assert.assertFalse(MarketUtils.pairKeyIsEqual(pairPriceKey1, pairPriceKey2)); } + @Test + public void shortInternalKeysUseDeterministicUnsignedOrdering() { + byte[] empty = new byte[0]; + byte[] shortKey = new byte[]{1, (byte) 0xff}; + byte[] valid = MarketUtils.createPairPriceKey( + ByteArray.fromString("100"), ByteArray.fromString("200"), 1000L, 2000L); + + Assert.assertEquals(0, MarketComparator.comparePriceKey(empty, empty)); + Assert.assertEquals(-1, MarketComparator.comparePriceKey(empty, shortKey)); + Assert.assertEquals(1, MarketComparator.comparePriceKey(shortKey, empty)); + Assert.assertTrue(MarketComparator.comparePriceKey(shortKey, valid) < 0); + Assert.assertTrue(MarketComparator.comparePriceKey(valid, shortKey) > 0); + } + } diff --git a/framework/src/test/java/org/tron/core/BandwidthProcessorTest.java b/framework/src/test/java/org/tron/core/BandwidthProcessorTest.java index cf652af3650..e4ec5071085 100755 --- a/framework/src/test/java/org/tron/core/BandwidthProcessorTest.java +++ b/framework/src/test/java/org/tron/core/BandwidthProcessorTest.java @@ -723,6 +723,10 @@ public void sameTokenNameOpenConsumeSuccess() { // V2 chainBaseManager.getAssetIssueV2Store().put(assetIssueCapsule.createDbV2Key(), assetIssueCapsule); + AssetIssueCapsule legacyBefore = + chainBaseManager.getAssetIssueStore().get(assetIssueCapsule.createDbKey()); + byte[] legacyBytesBefore = legacyBefore == null + ? null : legacyBefore.getInstance().toByteArray(); AccountCapsule ownerCapsule = new AccountCapsule( @@ -777,6 +781,13 @@ public void sameTokenNameOpenConsumeSuccess() { chainBaseManager.getAssetIssueV2Store().get(assetIssueCapsule.createDbV2Key()); Assert.assertNotNull(assetIssueCapsuleV2); Assert.assertEquals(assetIssueCapsuleV2.getPublicFreeAssetNetUsage(), byteSize); + AssetIssueCapsule legacyAfter = + chainBaseManager.getAssetIssueStore().get(assetIssueCapsule.createDbKey()); + if (legacyBytesBefore == null) { + Assert.assertNull(legacyAfter); + } else { + Assert.assertArrayEquals(legacyBytesBefore, legacyAfter.getInstance().toByteArray()); + } AccountCapsule fromAccount = chainBaseManager.getAccountStore().get(ByteArray.fromHexString(OWNER_ADDRESS)); diff --git a/framework/src/test/java/org/tron/core/capsule/BlockCapsuleTest.java b/framework/src/test/java/org/tron/core/capsule/BlockCapsuleTest.java index b258fbf99a1..88d4d7feca4 100644 --- a/framework/src/test/java/org/tron/core/capsule/BlockCapsuleTest.java +++ b/framework/src/test/java/org/tron/core/capsule/BlockCapsuleTest.java @@ -4,6 +4,7 @@ import static org.mockito.Mockito.when; import com.google.protobuf.ByteString; +import com.google.protobuf.util.JsonFormat; import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; @@ -155,6 +156,43 @@ public void testGetData() { } + @Test + public void stateRootRoundTripsOutsideBlockIdentityAndSignature() throws Exception { + String key = PublicMethod.getRandomPrivateKey(); + byte[] witnessAddress = PublicMethod.getAddressByteByPrivateKey(key); + BlockCapsule block = new BlockCapsule(5, + Sha256Hash.wrap(ByteString.copyFrom(ByteArray.fromHexString( + "9938a342238077182498b464ac0292229938a342238077182498b464ac029222"))), + 6789, + ByteString.copyFrom(witnessAddress)); + block.setMerkleRoot(); + block.sign(ByteArray.fromHexString(key)); + byte[] rawData = block.getInstance().getBlockHeader().getRawData().toByteArray(); + byte[] blockId = block.getBlockId().getBytes(); + byte[] signature = block.getInstance().getBlockHeader().getWitnessSignature().toByteArray(); + byte[] root = Sha256Hash.of(true, "path-state".getBytes()).getBytes(); + + block.setStateRoot(root); + + Assert.assertArrayEquals(root, block.getStateRoot()); + Assert.assertArrayEquals(rawData, + block.getInstance().getBlockHeader().getRawData().toByteArray()); + Assert.assertArrayEquals(blockId, block.getBlockId().getBytes()); + Assert.assertArrayEquals(signature, + block.getInstance().getBlockHeader().getWitnessSignature().toByteArray()); + BlockCapsule restored = new BlockCapsule(block.getData()); + Assert.assertArrayEquals(root, restored.getStateRoot()); + Assert.assertEquals(3, BlockHeader.getDescriptor().findFieldByName("state_root").getNumber()); + Assert.assertTrue(JsonFormat.printer().print(restored.getInstance().getBlockHeader()) + .contains("\"stateRoot\"")); + + DynamicPropertiesStore dps = mock(DynamicPropertiesStore.class); + when(dps.getAllowMultiSign()).thenReturn(0L); + Assert.assertTrue(restored.validateSignature(dps, mock(AccountStore.class))); + Assert.assertThrows(IllegalArgumentException.class, + () -> block.setStateRoot(new byte[31])); + } + @Test public void testValidate() { diff --git a/framework/src/test/java/org/tron/core/config/args/ArgsTest.java b/framework/src/test/java/org/tron/core/config/args/ArgsTest.java index 076a8ab5387..b5a57d33883 100644 --- a/framework/src/test/java/org/tron/core/config/args/ArgsTest.java +++ b/framework/src/test/java/org/tron/core/config/args/ArgsTest.java @@ -311,6 +311,67 @@ public void testCliOverridesStorageConfig() { Args.clearParam(); } + @Test + public void testPathStateRootStorageConfigMapping() { + Map override = new HashMap<>(); + override.put("storage.db.directory", "database"); + override.put("storage.pathStateRoot.enabled", "true"); + override.put("storage.pathStateRoot.engine", "LEVELDB"); + override.put("storage.pathStateRoot.directory", "root-mapped"); + override.put("storage.pathStateRoot.reversibleLayerLimit", "9"); + override.put("storage.pathStateRoot.reversibleLayerBytes", "8192"); + override.put("storage.pathStateRoot.volatileSnapshotBenchmark", "true"); + override.put("storage.pathStateRoot.nodeCacheBytes", "1073741824"); + override.put("storage.pathStateRoot.participantThreads", "2"); + override.put("storage.pathStateRoot.branchThreads", "3"); + override.put("storage.pathStateRoot.asyncPrepareBenchmark", "true"); + Config config = ConfigFactory.parseMap(override) + .withFallback(ConfigFactory.defaultReference()); + + Args.applyConfigParams(config); + + Storage storage = Args.getInstance().getStorage(); + Assert.assertTrue(storage.isPathStateRootEnabled()); + Assert.assertEquals("shadow", storage.getPathStateRootMode()); + Assert.assertEquals("root-mapped", storage.getPathStateRootDirectory()); + Assert.assertEquals("LEVELDB", storage.getPathStateRootEngine()); + Assert.assertEquals(1, storage.getPathStateRootFormatVersion()); + Assert.assertEquals(9, storage.getPathStateRootReversibleLayerLimit()); + Assert.assertEquals(8192L, storage.getPathStateRootReversibleLayerBytes()); + Assert.assertEquals(268435456L, storage.getPathStateRootWriteBufferBytes()); + Assert.assertEquals(1073741824L, storage.getPathStateRootNodeCacheBytes()); + Assert.assertEquals(2, storage.getPathStateRootParticipantThreads()); + Assert.assertEquals(3, storage.getPathStateRootBranchThreads()); + Assert.assertFalse(storage.isPathStateRootRebuildFromGenesis()); + Assert.assertTrue(storage.isPathStateRootVerifyEveryBlock()); + Assert.assertTrue(storage.isPathStateRootVolatileSnapshotBenchmark()); + Assert.assertTrue(storage.isPathStateRootAsyncPrepareBenchmark()); + Args.clearParam(); + } + + @Test + public void testAuxiliaryDatabaseEnginesMapIndependentlyFromChainbase() { + Map override = new HashMap<>(); + override.put("storage.db.engine", "LEVELDB"); + override.put("storage.stateArchive.servingIndexEngine", "ROCKSDB"); + override.put("storage.stateArchive.hotStore.engine", "LEVELDB"); + override.put("storage.stateArchive.appendFile.segmentTargetBytes", "123456789"); + override.put("storage.pathStateRoot.engine", "ROCKSDB"); + Config config = ConfigFactory.parseMap(override) + .withFallback(ConfigFactory.defaultReference()); + + Args.applyConfigParams(config); + + Storage storage = Args.getInstance().getStorage(); + Assert.assertEquals("LEVELDB", storage.getDbEngine()); + Assert.assertEquals("ROCKSDB", storage.getStateArchiveServingIndexEngine()); + Assert.assertEquals("LEVELDB", storage.getStateArchiveHotStoreSettings().getEngine()); + Assert.assertEquals(123456789L, + storage.getStateArchiveAppendFileSettings().getSegmentTargetBytes()); + Assert.assertEquals("ROCKSDB", storage.getPathStateRootEngine()); + Args.clearParam(); + } + /** * Verify that event.subscribe.enable = false from config is read correctly. */ diff --git a/framework/src/test/java/org/tron/core/config/args/StorageTest.java b/framework/src/test/java/org/tron/core/config/args/StorageTest.java index c6b954838ca..f0b78fb8a66 100644 --- a/framework/src/test/java/org/tron/core/config/args/StorageTest.java +++ b/framework/src/test/java/org/tron/core/config/args/StorageTest.java @@ -71,6 +71,24 @@ public void getDirectory() { Assert.assertEquals("database", storage.getDbDirectory()); } + @Test + public void archiveDatabaseProfilesAreBridgedFromConfiguration() { + Assert.assertEquals("ROCKSDB", storage.getPathStateRootEngine()); + Assert.assertNotNull(storage.getPathStateRootDbSettings()); + Assert.assertEquals(16 * 1024 * 1024, + storage.getPathStateRootDbSettings().getSmall().getWriteBufferSize()); + Assert.assertEquals(64 * 1024 * 1024L, + storage.getPathStateRootDbSettings().getGiant().getCacheSize()); + Assert.assertNotNull(storage.getStateArchiveServingIndexDbSettings()); + Assert.assertEquals("ROCKSDB", storage.getStateArchiveServingIndexEngine()); + Assert.assertEquals(64 * 1024 * 1024, + storage.getStateArchiveServingIndexDbSettings().getWriteBufferSize()); + Assert.assertNotNull(storage.getStateArchiveHotStoreSettings()); + Assert.assertFalse(storage.getStateArchiveHotStoreSettings().isEnabled()); + Assert.assertEquals("ROCKSDB", storage.getStateArchiveHotStoreSettings().getEngine()); + Assert.assertEquals(10_000L, storage.getStateArchiveHotStoreSettings().getMaxBlocks()); + } + @Test public void getPath() { Assert.assertEquals("storage_directory_test", StorageUtils.getPathByDbName("account")); diff --git a/framework/src/test/java/org/tron/core/db/StateArchiveBasePreflightTest.java b/framework/src/test/java/org/tron/core/db/StateArchiveBasePreflightTest.java new file mode 100644 index 00000000000..d631bb54ece --- /dev/null +++ b/framework/src/test/java/org/tron/core/db/StateArchiveBasePreflightTest.java @@ -0,0 +1,150 @@ +package org.tron.core.db; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; + +import com.google.common.hash.Hashing; +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tron.core.db2.archive.ArchiveHistoryWriter; +import org.tron.core.db2.archive.ArchiveStoreScope; +import org.tron.core.db2.archive.BlockReverseDiff; +import org.tron.core.db2.archive.BlockSnapshotMeta; + +public class StateArchiveBasePreflightTest { + + private static final int MANIFEST_MAGIC = 0x54414d46; + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void disabledControlDoesNotInspectOrCreateArchivePath() throws Exception { + Path missingManifest = temporaryFolder.newFolder("disabled").toPath(); + byte[] evidence = new byte[]{1, 2, 3}; + Files.write(missingManifest.resolve("history.bin"), evidence); + + StateArchiveBasePreflight.requireAdmitted(false, missingManifest); + + assertArrayEquals(evidence, Files.readAllBytes(missingManifest.resolve("history.bin"))); + assertFalse(Files.exists(missingManifest.resolve("MANIFEST"))); + } + + @Test + public void absentAndEmptyArchivesPassWithoutPreflightWrites() throws Exception { + Path absent = temporaryFolder.getRoot().toPath().resolve("absent"); + StateArchiveBasePreflight.requireAdmitted(true, absent); + assertFalse(Files.exists(absent)); + + Path empty = temporaryFolder.newFolder("empty").toPath(); + StateArchiveBasePreflight.requireAdmitted(true, empty); + try (java.util.stream.Stream entries = Files.list(empty)) { + assertFalse(entries.findAny().isPresent()); + } + } + + @Test + public void recoverableGateRejectsFreshBaseWithoutCreatingBootstrapArtifacts() throws Exception { + Path absent = temporaryFolder.getRoot().toPath().resolve("fresh-recovery"); + + assertThrows(IllegalStateException.class, + () -> StateArchiveBasePreflight.requireRecoverable(true, absent)); + + assertFalse(Files.exists(absent)); + } + + @Test + public void currentManifestPassesWithoutRewrite() throws Exception { + Path archive = temporaryFolder.newFolder("current").toPath(); + try (ArchiveHistoryWriter writer = new ArchiveHistoryWriter(archive, + 128L * 1024 * 1024, ArchiveStoreScope.getStateDatabases())) { + writer.accept(new BlockReverseDiff( + new BlockSnapshotMeta(1, 1, hash(1), hash(0), 1_000L), Collections.emptyList())); + } + byte[] before = Files.readAllBytes(archive.resolve("MANIFEST")); + + StateArchiveBasePreflight.requireAdmitted(true, archive); + + assertArrayEquals(before, Files.readAllBytes(archive.resolve("MANIFEST"))); + } + + @Test + public void missingManifestFailsWithoutCreatingOrChangingEvidence() throws Exception { + Path archive = temporaryFolder.newFolder("missing-manifest").toPath(); + byte[] evidence = new byte[]{4, 5, 6}; + Path history = archive.resolve("commit.log"); + Files.write(history, evidence); + + assertThrows(IllegalStateException.class, + () -> StateArchiveBasePreflight.requireAdmitted(true, archive)); + + assertArrayEquals(evidence, Files.readAllBytes(history)); + assertFalse(Files.exists(archive.resolve("MANIFEST"))); + } + + @Test + public void staleExact26ScopeFailsWithoutManifestRewrite() throws Exception { + Path archive = temporaryFolder.newFolder("stale-exact-26").toPath(); + List participants = new ArrayList<>(ArchiveStoreScope.getStateDatabases()); + participants.remove("abi"); + byte[] stale = manifest("archive-state/exact-26-abi-tombstone/v1", participants); + Path path = archive.resolve("MANIFEST"); + Files.write(path, stale); + + assertThrows(IllegalStateException.class, + () -> StateArchiveBasePreflight.requireAdmitted(true, archive)); + + assertArrayEquals(stale, Files.readAllBytes(path)); + } + + private static byte[] manifest(String scopeIdentity, List participants) + throws Exception { + List sorted = new ArrayList<>(participants); + Collections.sort(sorted); + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + DataOutputStream output = new DataOutputStream(bytes); + output.writeInt(MANIFEST_MAGIC); + output.writeShort(2); + output.writeShort(0); + output.writeInt(0); + writeString(output, scopeIdentity); + output.writeLong(0); + output.write(new byte[32]); + output.writeInt(sorted.size()); + for (String participant : sorted) { + writeString(output, participant); + } + output.flush(); + byte[] payload = bytes.toByteArray(); + ByteBuffer.wrap(payload).putInt(8, payload.length + Integer.BYTES); + bytes.reset(); + output = new DataOutputStream(bytes); + output.write(payload); + output.writeInt(Hashing.crc32c().hashBytes(payload).asInt()); + output.flush(); + return bytes.toByteArray(); + } + + private static void writeString(DataOutputStream output, String value) throws Exception { + byte[] encoded = value.getBytes(StandardCharsets.UTF_8); + output.writeInt(encoded.length); + output.write(encoded); + } + + private static byte[] hash(long suffix) { + byte[] hash = new byte[32]; + hash[31] = (byte) suffix; + return hash; + } +} diff --git a/framework/src/test/java/org/tron/core/db2/SnapshotImplTest.java b/framework/src/test/java/org/tron/core/db2/SnapshotImplTest.java index 3ee61065d1f..be27be0c6c7 100644 --- a/framework/src/test/java/org/tron/core/db2/SnapshotImplTest.java +++ b/framework/src/test/java/org/tron/core/db2/SnapshotImplTest.java @@ -2,14 +2,24 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.Collections; import org.junit.Test; import org.tron.common.BaseMethodTest; +import org.tron.core.db2.archive.BlockReverseDiff; +import org.tron.core.db2.archive.BlockSnapshotMeta; import org.tron.core.db2.core.Snapshot; import org.tron.core.db2.core.SnapshotImpl; import org.tron.core.db2.core.SnapshotManager; import org.tron.core.db2.core.SnapshotRoot; +import org.tron.core.db2.stateroot.PathStateSnapshotDelta; public class SnapshotImplTest extends BaseMethodTest { private RevokingDbWithCacheNewValueTest.TestRevokingTronStore tronDatabase; @@ -161,6 +171,28 @@ public void testMergeOverride() throws Exception { assertEquals(new String("value4".getBytes()), new String(s4)); } + @Test + public void testAttachBlockArtifactsRequiresOneSnapshotIdentity() throws Exception { + SnapshotRoot root = new SnapshotRoot(tronDatabase.getDb()); + SnapshotImpl layer = getSnapshotImplIns(root); + BlockSnapshotMeta meta = BlockSnapshotMeta.forBlock(1, hash(1), hash(0), 3_000L); + PathStateSnapshotDelta delta = mock(PathStateSnapshotDelta.class); + when(delta.getMeta()).thenReturn(meta); + + attachBlockArtifacts(layer, meta, null, delta); + + assertEquals(meta, layer.getBlockSnapshotMeta()); + assertSame(delta, layer.getPreparedPathStateDelta()); + + PathStateSnapshotDelta wrong = mock(PathStateSnapshotDelta.class); + when(wrong.getMeta()).thenReturn( + BlockSnapshotMeta.forBlock(2, hash(2), hash(1), 6_000L)); + InvocationTargetException failure = assertThrows(InvocationTargetException.class, + () -> attachBlockArtifacts(layer, meta, null, wrong)); + assertEquals(IllegalArgumentException.class, failure.getCause().getClass()); + assertSame(delta, layer.getPreparedPathStateDelta()); + } + /** * The constructor of SnapshotImpl is not public * so reflection is used to construct the object here. @@ -172,4 +204,20 @@ private SnapshotImpl getSnapshotImplIns(Snapshot snapshot) throws Exception { return (SnapshotImpl) constructor.newInstance(snapshot); } + private void attachBlockArtifacts(SnapshotImpl snapshot, BlockSnapshotMeta meta, + BlockReverseDiff reverseDiff, PathStateSnapshotDelta delta) throws Exception { + Method method = SnapshotImpl.class.getDeclaredMethod("attachBlockArtifacts", + BlockSnapshotMeta.class, BlockReverseDiff.class, PathStateSnapshotDelta.class); + method.setAccessible(true); + method.invoke(snapshot, meta, reverseDiff, delta); + } + + private static byte[] hash(int seed) { + byte[] hash = new byte[32]; + for (int index = 0; index < hash.length; index++) { + hash[index] = (byte) (seed + index); + } + return hash; + } + } diff --git a/framework/src/test/java/org/tron/core/db2/SnapshotManagerTest.java b/framework/src/test/java/org/tron/core/db2/SnapshotManagerTest.java index ae16776a7c6..c9df6547a69 100644 --- a/framework/src/test/java/org/tron/core/db2/SnapshotManagerTest.java +++ b/framework/src/test/java/org/tron/core/db2/SnapshotManagerTest.java @@ -1,14 +1,24 @@ package org.tron.core.db2; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import com.google.common.collect.Maps; import com.google.common.primitives.Longs; import com.google.protobuf.ByteString; +import java.io.IOException; +import java.lang.reflect.Field; import java.util.Arrays; import java.util.List; import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; import org.junit.Assert; @@ -18,8 +28,12 @@ import org.tron.core.capsule.BlockCapsule; import org.tron.core.db2.RevokingDbWithCacheNewValueTest.TestRevokingTronStore; import org.tron.core.db2.SnapshotRootTest.ProtoCapsuleTest; +import org.tron.core.db2.archive.OldValueCollector; import org.tron.core.db2.core.Chainbase; +import org.tron.core.db2.core.CommonCheckpointRuntimeAttachment; import org.tron.core.db2.core.SnapshotManager; +import org.tron.core.db2.stateroot.PathStateRuntimeAttachment; +import org.tron.core.db2.stateroot.PathStateTransitionCollector; import org.tron.core.exception.BadItemException; import org.tron.core.exception.ItemNotFoundException; import org.tron.core.exception.TronError; @@ -95,6 +109,34 @@ public synchronized void testClose() { } + @Test + public synchronized void testFlushPendingBelowBatchThreshold() { + while (revokingDatabase.size() != 0) { + revokingDatabase.pop(); + } + + revokingDatabase.setUnChecked(false); + revokingDatabase.setMaxSize(5); + revokingDatabase.setMaxFlushCount(20); + ProtoCapsuleTest key = new ProtoCapsuleTest("flush-pending".getBytes()); + for (int i = 1; i <= 12; i++) { + try (ISession session = revokingDatabase.buildSession()) { + tronDatabase.put(key.getData(), new ProtoCapsuleTest(("value" + i).getBytes())); + session.commit(); + } + } + + Assert.assertFalse(revokingDatabase.shouldBeRefreshed()); + revokingDatabase.setMaxFlushCount(1); + Assert.assertTrue(revokingDatabase.shouldBeRefreshed()); + revokingDatabase.setMaxFlushCount(20); + + revokingDatabase.flushPending(); + + revokingDatabase.setMaxFlushCount(1); + Assert.assertFalse(revokingDatabase.shouldBeRefreshed()); + } + @Test public void testCheckError() { SnapshotManager manager = spy(new SnapshotManager("")); @@ -112,4 +154,131 @@ public void testFlushError() { TronError thrown = Assert.assertThrows(TronError.class, manager::flush); Assert.assertEquals(TronError.ErrCode.DB_FLUSH, thrown.getErrCode()); } + + @Test + public void commonCheckpointFlushIsExclusiveAndResetsPrefix() throws Exception { + SnapshotManager manager = new SnapshotManager(""); + CommonCheckpointRuntimeAttachment common = mock(CommonCheckpointRuntimeAttachment.class); + when(common.isEnabled()).thenReturn(true); + manager.installCommonCheckpointArchiveCollector(mock(OldValueCollector.class)); + PathStateRuntimeAttachment path = PathStateRuntimeAttachment.commonCheckpoint( + mock(PathStateTransitionCollector.class), transition -> { }, null, null); + manager.attachPathStateRuntime(path); + manager.attachCommonCheckpointRuntime(common); + manager.setUnChecked(false); + manager.setMaxFlushCount(1); + Field count = SnapshotManager.class.getDeclaredField("flushCount"); + count.setAccessible(true); + count.setInt(manager, 1); + + manager.flush(); + + verify(common).checkpointAndRebase(1); + Assert.assertFalse(manager.shouldBeRefreshed()); + Assert.assertSame(common, manager.detachCommonCheckpointRuntime(common)); + Assert.assertSame(path, manager.detachPathStateRuntime(path)); + } + + @Test + public void archiveStateBarrierBlocksSessionAdvanceAndFlush() throws Exception { + SnapshotManager manager = new SnapshotManager(""); + manager.enable(); + ExecutorService executor = Executors.newFixedThreadPool(3); + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + ISession acquired = null; + try { + Future barrier = executor.submit(() -> { + manager.withArchiveStateBarrier(() -> { + entered.countDown(); + try { + if (!release.await(5, TimeUnit.SECONDS)) { + throw new IOException("Timed out waiting to release archive state barrier"); + } + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted inside archive state barrier", interrupted); + } + }); + return null; + }); + Assert.assertTrue(entered.await(5, TimeUnit.SECONDS)); + + Future session = executor.submit(() -> manager.buildSession()); + Future flush = executor.submit(manager::flush); + Assert.assertThrows(TimeoutException.class, + () -> session.get(100, TimeUnit.MILLISECONDS)); + Assert.assertThrows(TimeoutException.class, + () -> flush.get(100, TimeUnit.MILLISECONDS)); + + release.countDown(); + barrier.get(5, TimeUnit.SECONDS); + acquired = session.get(5, TimeUnit.SECONDS); + flush.get(5, TimeUnit.SECONDS); + } finally { + release.countDown(); + if (acquired != null) { + acquired.close(); + } + executor.shutdownNow(); + } + } + + @Test + public void archiveStateBarrierReleasesMonitorAfterFailure() throws Exception { + SnapshotManager manager = new SnapshotManager(""); + manager.enable(); + + IOException failure = Assert.assertThrows(IOException.class, + () -> manager.withArchiveStateBarrier(() -> { + throw new IOException("injected barrier failure"); + })); + Assert.assertEquals("injected barrier failure", failure.getMessage()); + try (ISession ignored = manager.buildSession()) { + Assert.assertEquals(1, manager.size()); + } + Assert.assertEquals(0, manager.size()); + } + + @Test + public void archiveStateBarrierBlocksNestedSessionMerge() throws Exception { + SnapshotManager manager = new SnapshotManager(""); + manager.enable(); + ISession parent = manager.buildSession(); + ISession child = manager.buildSession(); + ExecutorService executor = Executors.newFixedThreadPool(2); + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + try { + Future barrier = executor.submit(() -> { + manager.withArchiveStateBarrier(() -> { + entered.countDown(); + try { + if (!release.await(5, TimeUnit.SECONDS)) { + throw new IOException("Timed out waiting to release archive state barrier"); + } + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted inside archive state barrier", interrupted); + } + }); + return null; + }); + Assert.assertTrue(entered.await(5, TimeUnit.SECONDS)); + Future merge = executor.submit(child::merge); + Assert.assertThrows(TimeoutException.class, + () -> merge.get(100, TimeUnit.MILLISECONDS)); + + release.countDown(); + barrier.get(5, TimeUnit.SECONDS); + merge.get(5, TimeUnit.SECONDS); + Assert.assertEquals(1, manager.size()); + } finally { + release.countDown(); + child.close(); + parent.close(); + executor.shutdownNow(); + } + Assert.assertEquals(0, manager.size()); + } } diff --git a/framework/src/test/java/org/tron/core/db2/archive/ArchiveAuthorityHandleSourcesTest.java b/framework/src/test/java/org/tron/core/db2/archive/ArchiveAuthorityHandleSourcesTest.java new file mode 100644 index 00000000000..d9d43f12792 --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/archive/ArchiveAuthorityHandleSourcesTest.java @@ -0,0 +1,211 @@ +package org.tron.core.db2.archive; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tron.core.db2.archive.ArchiveReadSnapshot.PinnedLatestState; + +public class ArchiveAuthorityHandleSourcesTest { + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void persistentAndReadOnlyHandlesProduceReadyAndReleaseEveryPin() throws Exception { + try (Fixture fixture = new Fixture(temporaryFolder.newFolder("ready").toPath(), false)) { + ArchiveAuthoritySnapshotCollector collector = new ArchiveAuthoritySnapshotCollector( + fixture.sources, fixture.sources, fixture.sources, fixture.sources); + + ArchiveFormatAdmissionValidator.Result result = ArchiveFormatAdmissionValidator.inspect( + fixture.archive, collector.collect()); + + assertEquals(ArchiveFormatAdmissionValidator.Status.CURRENT_READY, result.getStatus()); + assertEquals(2, fixture.latestOpened.get()); + assertEquals(2, fixture.latestClosed.get()); + assertEquals(0, fixture.catalog.getReferenceCount( + fixture.catalog.getCurrentGenerationId())); + } + } + + @Test + public void latestIdentityFailureReleasesLatestAndCatalogPins() throws Exception { + try (Fixture fixture = new Fixture(temporaryFolder.newFolder("bad-latest").toPath(), true)) { + ArchiveAuthoritySnapshotCollector collector = new ArchiveAuthoritySnapshotCollector( + fixture.sources, fixture.sources, fixture.sources, fixture.sources); + + assertThrows(ArchivePersistenceException.class, collector::collect); + + assertEquals(1, fixture.latestOpened.get()); + assertEquals(1, fixture.latestClosed.get()); + assertEquals(0, fixture.catalog.getReferenceCount( + fixture.catalog.getCurrentGenerationId())); + } + } + + private static final class Fixture implements AutoCloseable { + private final Path archive; + private final HistorySegmentStore bodies; + private final HistoryIndexStore index; + private final HistoryCommitStore history; + private final PersistentServingKeyIndexCatalog catalog; + private final AtomicInteger latestOpened = new AtomicInteger(); + private final AtomicInteger latestClosed = new AtomicInteger(); + private final ArchiveAuthorityHandleSources sources; + + private Fixture(Path root, boolean wrongLatestHead) throws Exception { + archive = root.resolve("archive"); + List participants = ArchiveParticipantDescriptor.current().getParticipants(); + ArchiveBaseManifest manifest = new ArchiveBaseManifest(archive, participants); + manifest.ensureBase(meta(1)); + bodies = new HistorySegmentStore(archive, new BlockHistoryCodec(), 4096); + index = new HistoryIndexStore(archive, new HistoryIndexCodec()); + history = new HistoryCommitStore(archive, new HistoryCommitMarkerCodec()); + BlockReverseDiff diff = new BlockReverseDiff(meta(1), Collections.emptyList()); + HistoryLocation body = bodies.append(diff); + HistoryIndexLocation indexLocation = index.append(HistoryIndexRecord.from(diff, body)); + HistoryCommitMarker marker = new HistoryCommitMarker(diff.getMeta(), 0, body, + indexLocation, digest16(41), participants); + bodies.sync(); + index.sync(); + history.commit(marker); + + ArchiveProgressEnvelope checkpoint = progress( + ArchiveProgressEnvelope.Kind.APPLY_CHECKPOINT, null, marker); + ArchiveProgressEnvelope reader = progress( + ArchiveProgressEnvelope.Kind.READER_VISIBLE, null, marker); + Path checkpointPath = archive.resolve("progress/checkpoint.progress"); + Path readerPath = archive.resolve("progress/reader.progress"); + ArchiveProgressEnvelopeCodec progressCodec = new ArchiveProgressEnvelopeCodec(); + new ArchiveProgressFile(checkpointPath, progressCodec).store(checkpoint); + new ArchiveProgressFile(readerPath, progressCodec).store(reader); + + Map participantSources = new LinkedHashMap<>(); + for (String participant : participants) { + ArchiveProgressEnvelope participantProgress = progress( + ArchiveProgressEnvelope.Kind.PARTICIPANT_PROGRESS, participant, marker); + participantSources.put(participant, () -> participantProgress); + } + + Path shadow = root.resolve("serving-shadow"); + try (PersistentServingKeyIndexGeneration generation = + PersistentServingKeyIndexGeneration.build(shadow, "generation-1", 0, hash(0), + Collections.singletonList(marker), index::read, participants, digest(90))) { + assertEquals(1, generation.getIndexedThrough()); + } + catalog = PersistentServingKeyIndexCatalog.create(root.resolve("catalog"), shadow, reader); + ArchiveReadSnapshot.PinnedLatestStateFactory latestFactory = serving -> { + latestOpened.incrementAndGet(); + return new TestLatestPin(serving, wrongLatestHead, latestClosed); + }; + sources = new ArchiveAuthorityHandleSources(history, checkpointPath, participantSources, + readerPath, catalog, latestFactory); + } + + @Override + public void close() throws Exception { + IOException failure = null; + try { + catalog.close(); + } catch (IOException closeFailure) { + failure = closeFailure; + } + history.close(); + index.close(); + bodies.close(); + if (failure != null) { + throw failure; + } + } + } + + private static final class TestLatestPin implements PinnedLatestState { + private final long block; + private final byte[] hash; + private final byte[] sourceDigest; + private final AtomicInteger closed; + private boolean released; + + private TestLatestPin(PersistentServingKeyIndexGeneration serving, boolean wrongHead, + AtomicInteger closed) { + block = serving.getIndexedThrough(); + hash = wrongHead ? hash(2) : serving.getHeadHash(); + sourceDigest = serving.getLatestSourceIdentityDigest(); + this.closed = closed; + } + + @Override + public long getBlockNumber() { + return block; + } + + @Override + public byte[] getBlockHash() { + return Arrays.copyOf(hash, hash.length); + } + + @Override + public byte[] getSourceIdentityDigest() { + return Arrays.copyOf(sourceDigest, sourceDigest.length); + } + + @Override + public OldValue get(String dbName, byte[] physicalRawKey) { + throw new AssertionError("Admission must not read latest business data"); + } + + @Override + public List range(String dbName, byte[] lowerInclusive, + byte[] upperExclusive, int maxEntries) { + throw new AssertionError("Admission must not scan latest business data"); + } + + @Override + public void close() { + assertTrue(!released); + released = true; + closed.incrementAndGet(); + } + } + + private static ArchiveProgressEnvelope progress(ArchiveProgressEnvelope.Kind kind, + String participant, HistoryCommitMarker marker) { + return new ArchiveProgressEnvelope(kind, participant, marker.getMeta().getEpoch(), + marker.getMeta().getBlockHash(), marker.getBatchId(), + marker.getHistoryLocation().getBodyDigest(), + ArchiveParticipantDescriptor.current().getParticipants()); + } + + private static BlockSnapshotMeta meta(long epoch) { + return new BlockSnapshotMeta(epoch, epoch, hash(epoch), hash(epoch - 1), epoch * 1_000L); + } + + private static byte[] hash(long suffix) { + byte[] value = new byte[32]; + value[31] = (byte) suffix; + return value; + } + + private static byte[] digest(int value) { + byte[] digest = new byte[32]; + Arrays.fill(digest, (byte) value); + return digest; + } + + private static byte[] digest16(int value) { + byte[] digest = new byte[16]; + Arrays.fill(digest, (byte) value); + return digest; + } +} diff --git a/framework/src/test/java/org/tron/core/db2/archive/ArchiveAuthoritySnapshotCollectorTest.java b/framework/src/test/java/org/tron/core/db2/archive/ArchiveAuthoritySnapshotCollectorTest.java new file mode 100644 index 00000000000..a36b17e25fb --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/archive/ArchiveAuthoritySnapshotCollectorTest.java @@ -0,0 +1,210 @@ +package org.tron.core.db2.archive; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.Map; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tron.core.db2.archive.ArchiveAuthoritySnapshotCollector.HistorySource; +import org.tron.core.db2.archive.ArchiveAuthoritySnapshotCollector.LatestSource; +import org.tron.core.db2.archive.ArchiveAuthoritySnapshotCollector.ProgressSource; +import org.tron.core.db2.archive.ArchiveAuthoritySnapshotCollector.ServingSource; + +public class ArchiveAuthoritySnapshotCollectorTest { + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void stableSourcesProduceOneReadyBundle() throws Exception { + Path archive = temporaryFolder.newFolder("stable").toPath(); + ArchiveBaseManifest manifest = new ArchiveBaseManifest(archive, + ArchiveParticipantDescriptor.current().getParticipants()); + manifest.ensureBase(meta(1)); + FakeSources sources = new FakeSources(Drift.NONE); + ArchiveAuthoritySnapshotCollector collector = collector(sources); + + ArchiveFormatAdmissionValidator.Result result = ArchiveFormatAdmissionValidator.inspect( + archive, collector.collect()); + + assertEquals(ArchiveFormatAdmissionValidator.Status.CURRENT_READY, result.getStatus()); + assertEquals(2, sources.headReads); + assertEquals(2, sources.planReads); + assertEquals(2, sources.readerReads); + assertEquals(2, sources.servingReads); + assertEquals(2, sources.coverageReads); + assertEquals(2, sources.latestReads); + } + + @Test + public void missingAuthorityAndSourceFailureFailClosed() throws Exception { + FakeSources missing = new FakeSources(Drift.NONE); + missing.checkpoint = null; + assertThrows(ArchivePersistenceException.class, () -> collector(missing).collect()); + + FakeSources failing = new FakeSources(Drift.NONE); + failing.failHead = true; + assertThrows(IOException.class, () -> collector(failing).collect()); + } + + @Test + public void everyMutableBoundaryReplacementRejectsTheWholeSnapshot() { + for (Drift drift : Arrays.asList(Drift.PLAN, Drift.HEAD, Drift.READER, Drift.SERVING, + Drift.COVERAGE, Drift.LATEST)) { + FakeSources sources = new FakeSources(drift); + assertThrows(ArchivePersistenceException.class, () -> collector(sources).collect()); + } + } + + private static ArchiveAuthoritySnapshotCollector collector(FakeSources sources) { + return new ArchiveAuthoritySnapshotCollector(sources, sources, sources, sources); + } + + private enum Drift { + NONE, + PLAN, + HEAD, + READER, + SERVING, + COVERAGE, + LATEST + } + + private static final class FakeSources + implements HistorySource, ProgressSource, ServingSource, LatestSource { + private final Drift drift; + private final HistoryCommitMarker first = marker(1); + private final HistoryCommitMarker head = marker(2); + private ArchiveProgressEnvelope checkpoint; + private final Map participants = new LinkedHashMap<>(); + private final ArchiveProgressEnvelope reader; + private boolean failHead; + private int headReads; + private int planReads; + private int readerReads; + private int servingReads; + private int coverageReads; + private int latestReads; + + private FakeSources(Drift drift) { + this.drift = drift; + checkpoint = progress(ArchiveProgressEnvelope.Kind.APPLY_CHECKPOINT, null, head); + for (String participant : ArchiveParticipantDescriptor.current().getParticipants()) { + participants.put(participant, progress( + ArchiveProgressEnvelope.Kind.PARTICIPANT_PROGRESS, participant, head)); + } + reader = progress(ArchiveProgressEnvelope.Kind.READER_VISIBLE, null, head); + } + + @Override + public HistoryCoverage coverage() { + coverageReads++; + return drift == Drift.COVERAGE && coverageReads == 2 + ? new HistoryCoverage(1, 3, 3, hash(3)) + : new HistoryCoverage(1, 2, 2, hash(2)); + } + + @Override + public HistoryCommitMarker first() { + return first; + } + + @Override + public HistoryCommitMarker head() throws IOException { + headReads++; + if (failHead) { + throw new IOException("history source unavailable"); + } + return drift == Drift.HEAD && headReads == 2 ? marker(3) : head; + } + + @Override + public boolean mutationPlanPresent() { + planReads++; + return drift == Drift.PLAN && planReads == 2; + } + + @Override + public ArchiveProgressEnvelope applyCheckpoint() { + return checkpoint; + } + + @Override + public Map participantProgress() { + return participants; + } + + @Override + public ArchiveProgressEnvelope readerVisible() { + readerReads++; + return drift == Drift.READER && readerReads == 2 + ? progress(ArchiveProgressEnvelope.Kind.READER_VISIBLE, null, first) : reader; + } + + @Override + public ArchiveAuthoritySourceBundle.ServingGenerationSnapshot current() { + servingReads++; + return serving(drift == Drift.SERVING && servingReads == 2 ? digest(81) : digest(80), + digest(90)); + } + + @Override + public byte[] sourceIdentityDigest() { + latestReads++; + return digest(drift == Drift.LATEST && latestReads == 2 ? 91 : 90); + } + } + + private static ArchiveAuthoritySourceBundle.ServingGenerationSnapshot serving( + byte[] prefixDigest, byte[] latestDigest) { + return new ArchiveAuthoritySourceBundle.ServingGenerationSnapshot( + ArchiveParticipantDescriptor.FORMAT_ID, + ArchiveParticipantDescriptor.current().getParticipants(), 0, 2, hash(2), prefixDigest, + latestDigest); + } + + private static ArchiveProgressEnvelope progress(ArchiveProgressEnvelope.Kind kind, + String participant, HistoryCommitMarker marker) { + return new ArchiveProgressEnvelope(kind, participant, marker.getMeta().getEpoch(), + marker.getMeta().getBlockHash(), marker.getBatchId(), + marker.getHistoryLocation().getBodyDigest(), + ArchiveParticipantDescriptor.current().getParticipants()); + } + + private static HistoryCommitMarker marker(long epoch) { + return new HistoryCommitMarker( + new BlockSnapshotMeta(epoch, epoch, hash(epoch), hash(epoch - 1), epoch * 1_000L), + epoch - 1, new HistoryLocation(0, epoch * 100, 100, (int) epoch, + digest(20 + (int) epoch)), + new HistoryIndexLocation(epoch * 50, 50, digest(30 + (int) epoch)), + digest16(40 + (int) epoch), ArchiveParticipantDescriptor.current().getParticipants()); + } + + private static BlockSnapshotMeta meta(long epoch) { + return new BlockSnapshotMeta(epoch, epoch, hash(epoch), new byte[32], epoch * 1_000L); + } + + private static byte[] hash(long suffix) { + byte[] value = new byte[32]; + value[31] = (byte) suffix; + return value; + } + + private static byte[] digest(int value) { + byte[] digest = new byte[32]; + Arrays.fill(digest, (byte) value); + return digest; + } + + private static byte[] digest16(int value) { + byte[] digest = new byte[16]; + Arrays.fill(digest, (byte) value); + return digest; + } +} diff --git a/framework/src/test/java/org/tron/core/db2/archive/ArchiveBootstrapAnchorTest.java b/framework/src/test/java/org/tron/core/db2/archive/ArchiveBootstrapAnchorTest.java new file mode 100644 index 00000000000..ece6a0385b4 --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/archive/ArchiveBootstrapAnchorTest.java @@ -0,0 +1,116 @@ +package org.tron.core.db2.archive; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; + +import java.nio.ByteBuffer; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tron.core.db2.archive.ArchiveProgressEnvelope.Kind; +import org.tron.core.db2.core.SnapshotManager; + +public class ArchiveBootstrapAnchorTest { + + private static final int MAGIC = 0x54414241; + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void independentAnchorSurvivesSecondRestartWithoutLegacyPaths() throws Exception { + Path archive = bootstrap("second-restart", 123); + byte[] encoded = Files.readAllBytes(archive.resolve("bootstrap.anchor")); + + assertEquals(MAGIC, ByteBuffer.wrap(encoded).getInt()); + assertFalse(Files.exists(archive.resolve("participants"))); + assertFalse(Files.exists(archive.resolve("progress"))); + assertNoAnchorTemporary(archive); + for (int restart = 0; restart < 2; restart++) { + try (StateArchiveRuntimeOwner owner = StateArchiveRuntimeOwner.recover( + new SnapshotManager(""), archive, 4096)) { + assertEquals(123, owner.getRecoveredHead().getEpoch()); + } + assertArrayEquals(encoded, Files.readAllBytes(archive.resolve("bootstrap.anchor"))); + } + } + + @Test + public void checksumCorruptionAndSubstitutedMarkerFailClosed() throws Exception { + Path corrupt = bootstrap("corrupt", 123); + byte[] bytes = Files.readAllBytes(corrupt.resolve("bootstrap.anchor")); + bytes[bytes.length - 1] ^= 1; + Files.write(corrupt.resolve("bootstrap.anchor"), bytes); + assertThrows(ArchivePersistenceException.class, + () -> openHistory(corrupt)); + + Path target = bootstrap("target", 123); + Path foreign = bootstrap("foreign", 124); + Files.copy(foreign.resolve("bootstrap.anchor"), target.resolve("bootstrap.anchor"), + java.nio.file.StandardCopyOption.REPLACE_EXISTING); + assertThrows(ArchivePersistenceException.class, + () -> openHistory(target)); + } + + @Test + public void rejectsLegacyProgressEnvelopeAndWrongStoreScope() throws Exception { + Path archive = bootstrap("legacy", 123); + HistoryCommitMarker marker; + try (ArchiveHistoryWriter writer = openHistory(archive)) { + marker = writer.committedHead(); + } + List stores = stores(); + ArchiveProgressEnvelope legacy = new ArchiveProgressEnvelope(Kind.READER_VISIBLE, null, + marker.getMeta().getEpoch(), marker.getMeta().getBlockHash(), marker.getBatchId(), + marker.getHistoryLocation().getBodyDigest(), new byte[32], stores); + Files.write(archive.resolve("bootstrap.anchor"), + new ArchiveProgressEnvelopeCodec().encode(legacy)); + assertThrows(ArchivePersistenceException.class, + () -> openHistory(archive)); + + List incomplete = new ArrayList<>(stores); + incomplete.remove(incomplete.size() - 1); + assertThrows(ArchivePersistenceException.class, + () -> ArchiveBootstrapAnchor.store(archive, marker, incomplete)); + } + + private Path bootstrap(String name, int epoch) throws Exception { + Path archive = temporaryFolder.newFolder(name).toPath().resolve("state-archive"); + BlockSnapshotMeta head = BlockSnapshotMeta.forBlock(epoch, hash(epoch), hash(epoch - 1), + epoch * 1_000L); + try (StateArchiveRuntimeOwner ignored = StateArchiveRuntimeOwner.bootstrapAndRecover( + new SnapshotManager(""), archive, 4096, head)) { + return archive; + } + } + + private static ArchiveHistoryWriter openHistory(Path archive) throws Exception { + return new ArchiveHistoryWriter(archive, 4096, ArchiveStoreScope.getStateDatabases()); + } + + private static void assertNoAnchorTemporary(Path archive) throws Exception { + try (java.util.stream.Stream entries = Files.list(archive)) { + assertFalse(entries.anyMatch(path -> path.getFileName().toString() + .startsWith(".bootstrap.anchor-"))); + } + } + + private static List stores() { + List stores = new ArrayList<>(ArchiveStoreScope.getStateDatabases()); + Collections.sort(stores); + return stores; + } + + private static byte[] hash(int suffix) { + byte[] hash = new byte[32]; + hash[31] = (byte) suffix; + return hash; + } +} diff --git a/framework/src/test/java/org/tron/core/db2/archive/ArchiveCheckpointFileRecoveryTest.java b/framework/src/test/java/org/tron/core/db2/archive/ArchiveCheckpointFileRecoveryTest.java new file mode 100644 index 00000000000..cb5f756a720 --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/archive/ArchiveCheckpointFileRecoveryTest.java @@ -0,0 +1,205 @@ +package org.tron.core.db2.archive; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertThrows; +import static org.mockito.Mockito.mock; + +import java.nio.file.Path; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.springframework.context.ApplicationContext; +import org.tron.common.parameter.CommonParameter; +import org.tron.core.config.args.Storage; +import org.tron.core.config.args.StorageConfig; +import org.tron.core.db2.core.SnapshotManager; +import org.tron.core.store.CheckPointV2Store; +import org.tron.core.store.CheckTmpStore; + +public class ArchiveCheckpointFileRecoveryTest { + + static { + org.rocksdb.RocksDB.loadLibrary(); + } + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void v1ReopensTheSameBindingAcrossSecondRestart() throws Exception { + Path output = temporaryFolder.newFolder("checkpoint-v1-restart").toPath(); + ArchiveWalBinding binding = binding(10); + withStorage(output, 1, () -> { + writeV1(bindingBytes(binding)); + assertRecoveredV1(binding); + assertRecoveredV1(binding); + }); + } + + @Test + public void v1CorruptBindingFailsDuringPreflight() throws Exception { + Path output = temporaryFolder.newFolder("checkpoint-v1-corrupt").toPath(); + withStorage(output, 1, () -> { + byte[] corrupt = bindingBytes(binding(10)); + corrupt[corrupt.length - 1] ^= 1; + writeV1(corrupt); + try (CheckTmpStore checkpoint = checkpointV1()) { + SnapshotManager snapshots = snapshots(1, checkpoint); + try { + assertThrows(ArchivePersistenceException.class, snapshots::check); + } finally { + snapshots.shutdown(); + } + } + }); + } + + @Test + public void v2SelectsLatestBindingAndIgnoresEmptyBoundariesAcrossRestart() throws Exception { + Path output = temporaryFolder.newFolder("checkpoint-v2-selection").toPath(); + ArchiveWalBinding older = binding(10); + ArchiveWalBinding latest = binding(11); + withStorage(output, 2, () -> { + writeV2("1000", null); // pre-enable/before-force boundary + writeV2("2000", bindingBytes(older)); + writeV2("3000", null); // empty boundary must not erase older authority + writeV2("4000", bindingBytes(latest)); + writeV2("5000", null); // after-force empty boundary must not erase latest authority + assertRecoveredV2(latest); + assertRecoveredV2(latest); + }); + } + + @Test + public void v2CorruptLatestBindingFailsInsteadOfFallingBackToOlderAuthority() + throws Exception { + Path output = temporaryFolder.newFolder("checkpoint-v2-corrupt").toPath(); + ArchiveWalBinding older = binding(10); + withStorage(output, 2, () -> { + writeV2("1000", bindingBytes(older)); + byte[] corrupt = bindingBytes(binding(11)); + corrupt[corrupt.length - 1] ^= 1; + writeV2("2000", corrupt); + try (CheckTmpStore checkpoint = checkpointV1()) { + SnapshotManager snapshots = snapshots(2, checkpoint); + try { + assertThrows(ArchivePersistenceException.class, snapshots::check); + assertArrayEquals(older.getBatchDigest(), + snapshots.getRecoveredArchiveWalBinding().getBatchDigest()); + } finally { + snapshots.shutdown(); + } + } + }); + } + + private static void assertRecoveredV1(ArchiveWalBinding expected) throws Exception { + try (CheckTmpStore checkpoint = checkpointV1()) { + SnapshotManager snapshots = snapshots(1, checkpoint); + try { + snapshots.check(); + assertArrayEquals(expected.getBatchDigest(), + snapshots.getRecoveredArchiveWalBinding().getBatchDigest()); + } finally { + snapshots.shutdown(); + } + } + } + + private static void assertRecoveredV2(ArchiveWalBinding expected) throws Exception { + try (CheckTmpStore checkpoint = checkpointV1()) { + SnapshotManager snapshots = snapshots(2, checkpoint); + try { + snapshots.check(); + assertArrayEquals(expected.getBatchDigest(), + snapshots.getRecoveredArchiveWalBinding().getBatchDigest()); + } finally { + snapshots.shutdown(); + } + } + } + + private static SnapshotManager snapshots(int version, CheckTmpStore checkpoint) { + CommonParameter.getInstance().getStorage().setCheckpointVersion(version); + SnapshotManager snapshots = new SnapshotManager(""); + snapshots.setCheckTmpStore(checkpoint); + snapshots.init(); + return snapshots; + } + + private static void writeV1(byte[] encoded) throws Exception { + try (CheckTmpStore checkpoint = checkpointV1()) { + checkpoint.updateByBatch(Collections.singletonMap( + ArchiveWalBinding.getCheckpointKey(), encoded)); + } + } + + private static CheckTmpStore checkpointV1() { + return new CheckTmpStore(mock(ApplicationContext.class)); + } + + private static void writeV2(String name, byte[] encoded) { + try (CheckPointV2Store checkpoint = new CheckPointV2Store("checkpoint/" + name)) { + Map batch = new HashMap<>(); + if (encoded != null) { + batch.put(ArchiveWalBinding.getCheckpointKey(), encoded); + } + checkpoint.updateByBatch(batch); + } + } + + private static byte[] bindingBytes(ArchiveWalBinding binding) { + return new ArchiveWalBindingCodec().encode(binding); + } + + private static ArchiveWalBinding binding(int epoch) { + BlockSnapshotMeta meta = new BlockSnapshotMeta(epoch, epoch, hash(epoch), + hash(epoch - 1), epoch * 1_000L); + return new ArchiveWalBinding(meta, meta, epoch - 1L, hash(epoch - 1), + digest(epoch), digest(epoch + 10), digest(epoch + 20), digest(epoch + 30)); + } + + private static byte[] hash(int suffix) { + byte[] value = new byte[32]; + value[31] = (byte) suffix; + return value; + } + + private static byte[] digest(int seed) { + byte[] value = new byte[32]; + for (int index = 0; index < value.length; index++) { + value[index] = (byte) (seed + index); + } + return value; + } + + private static void withStorage(Path output, int version, ThrowingRunnable action) + throws Exception { + CommonParameter parameters = CommonParameter.getInstance(); + Storage oldStorage = parameters.getStorage(); + Storage storage = new Storage(); + storage.setDefaultDbOptions(new StorageConfig()); + String oldOutput = parameters.outputDirectory; + try { + parameters.storage = storage; + parameters.outputDirectory = output.toString(); + storage.setDbDirectory("database"); + storage.setDbEngine("LEVELDB"); + storage.setDbSync(true); + storage.setCheckpointVersion(version); + storage.setCheckpointSync(true); + action.run(); + } finally { + parameters.outputDirectory = oldOutput; + parameters.storage = oldStorage; + } + } + + @FunctionalInterface + private interface ThrowingRunnable { + void run() throws Exception; + } +} diff --git a/framework/src/test/java/org/tron/core/db2/archive/ArchiveFormatAdmissionValidatorTest.java b/framework/src/test/java/org/tron/core/db2/archive/ArchiveFormatAdmissionValidatorTest.java new file mode 100644 index 00000000000..47d52c1e7b7 --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/archive/ArchiveFormatAdmissionValidatorTest.java @@ -0,0 +1,312 @@ +package org.tron.core.db2.archive; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import com.google.common.hash.Hashing; +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tron.core.db2.archive.ArchiveFormatAdmissionValidator.Reason; +import org.tron.core.db2.archive.ArchiveFormatAdmissionValidator.Result; +import org.tron.core.db2.archive.ArchiveFormatAdmissionValidator.Status; + +public class ArchiveFormatAdmissionValidatorTest { + + private static final int MANIFEST_MAGIC = 0x54414d46; + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void absentAndEmptyDirectoriesRemainEmptyNewWithoutWrites() throws Exception { + Path absent = temporaryFolder.getRoot().toPath().resolve("absent"); + Result absentResult = ArchiveFormatAdmissionValidator.inspect(absent); + assertEquals(Status.EMPTY_NEW, absentResult.getStatus()); + assertEquals(Reason.NONE, absentResult.getReason()); + assertFalse(Files.exists(absent)); + + Path empty = temporaryFolder.newFolder("empty").toPath(); + Result emptyResult = ArchiveFormatAdmissionValidator.inspect(empty); + assertEquals(Status.EMPTY_NEW, emptyResult.getStatus()); + try (java.util.stream.Stream entries = Files.list(empty)) { + assertFalse(entries.findAny().isPresent()); + } + } + + @Test + public void nonemptyDirectoryWithoutManifestRequiresQuarantineWithoutMutation() + throws Exception { + Path archive = temporaryFolder.newFolder("missing-manifest").toPath(); + Path committed = archive.resolve("commits"); + byte[] evidence = new byte[]{1, 2, 3}; + Files.write(committed, evidence); + + Result result = ArchiveFormatAdmissionValidator.inspect(archive); + + assertEquals(Status.QUARANTINE_REQUIRED, result.getStatus()); + assertEquals(Reason.NONEMPTY_WITHOUT_MANIFEST, result.getReason()); + assertArrayEquals(evidence, Files.readAllBytes(committed)); + assertFalse(Files.exists(archive.resolve("MANIFEST"))); + } + + @Test + public void currentExact27ManifestIsCurrentBaseButNotStartupReady() throws Exception { + Path archive = temporaryFolder.newFolder("current-base").toPath(); + ArchiveBaseManifest manifest = new ArchiveBaseManifest(archive, + ArchiveParticipantDescriptor.current().getParticipants()); + manifest.ensureBase(meta(1)); + byte[] before = Files.readAllBytes(archive.resolve("MANIFEST")); + + Result result = ArchiveFormatAdmissionValidator.inspect(archive); + + assertEquals(Status.CURRENT_BASE, result.getStatus()); + assertEquals(Reason.NONE, result.getReason()); + assertArrayEquals(before, Files.readAllBytes(archive.resolve("MANIFEST"))); + } + + @Test + public void staleExact26ManifestRequiresQuarantineWithoutRewrite() throws Exception { + Path archive = temporaryFolder.newFolder("stale-exact-26").toPath(); + List exact26 = new ArrayList<>( + ArchiveParticipantDescriptor.current().getParticipants()); + assertTrue(exact26.remove("abi")); + byte[] stale = manifest("archive-state/exact-26-abi-tombstone/v1", exact26); + Path path = archive.resolve("MANIFEST"); + Files.write(path, stale); + + Result result = ArchiveFormatAdmissionValidator.inspect(archive); + + assertEquals(Status.QUARANTINE_REQUIRED, result.getStatus()); + assertEquals(Reason.UNSUPPORTED_OR_CORRUPT_MANIFEST, result.getReason()); + assertArrayEquals(stale, Files.readAllBytes(path)); + } + + @Test + public void completeExact27AuthorityBundleIsCurrentReadyWithoutWrites() throws Exception { + Path archive = currentArchive("current-ready"); + byte[] before = Files.readAllBytes(archive.resolve("MANIFEST")); + + Result result = ArchiveFormatAdmissionValidator.inspect(archive, readyBundle(false)); + + assertEquals(Status.CURRENT_READY, result.getStatus()); + assertEquals(Reason.NONE, result.getReason()); + assertArrayEquals(before, Files.readAllBytes(archive.resolve("MANIFEST"))); + } + + @Test + public void activePlanOrIncompleteProgressRequiresQuarantine() throws Exception { + Path archive = currentArchive("incomplete-authorities"); + ArchiveAuthoritySourceBundle complete = readyBundle(false); + Map incomplete = new LinkedHashMap<>( + complete.getParticipantProgress()); + incomplete.remove("abi"); + ArchiveAuthoritySourceBundle missingAbi = bundle(false, complete.getApplyCheckpoint(), + incomplete, complete.getReaderVisible(), complete.getServingGeneration(), + coverage(1, 2, 2), digest(90)); + + for (ArchiveAuthoritySourceBundle candidate : Arrays.asList(readyBundle(true), missingAbi, + bundle(false, null, complete.getParticipantProgress(), complete.getReaderVisible(), + complete.getServingGeneration(), coverage(1, 2, 2), digest(90)))) { + Result result = ArchiveFormatAdmissionValidator.inspect(archive, candidate); + assertEquals(Status.QUARANTINE_REQUIRED, result.getStatus()); + assertEquals(Reason.INCOMPLETE_OR_INCONSISTENT_AUTHORITIES, result.getReason()); + } + } + + @Test + public void staleReaderOrServingSourceRequiresQuarantine() throws Exception { + Path archive = currentArchive("mismatched-authorities"); + ArchiveAuthoritySourceBundle complete = readyBundle(false); + HistoryCommitMarker first = marker(1); + ArchiveProgressEnvelope staleReader = progress(ArchiveProgressEnvelope.Kind.READER_VISIBLE, + null, first, null); + ArchiveAuthoritySourceBundle staleReaderBundle = bundle(false, + complete.getApplyCheckpoint(), complete.getParticipantProgress(), staleReader, + complete.getServingGeneration(), coverage(1, 2, 2), digest(90)); + ArchiveAuthoritySourceBundle.ServingGenerationSnapshot wrongSource = serving(digest(91)); + ArchiveAuthoritySourceBundle wrongSourceBundle = bundle(false, + complete.getApplyCheckpoint(), complete.getParticipantProgress(), + complete.getReaderVisible(), wrongSource, coverage(1, 2, 2), digest(90)); + + for (ArchiveAuthoritySourceBundle candidate + : Arrays.asList(staleReaderBundle, wrongSourceBundle)) { + Result result = ArchiveFormatAdmissionValidator.inspect(archive, candidate); + assertEquals(Status.QUARANTINE_REQUIRED, result.getStatus()); + assertEquals(Reason.INCOMPLETE_OR_INCONSISTENT_AUTHORITIES, result.getReason()); + } + } + + @Test + public void nonContiguousOrMisalignedHistoryCoverageRequiresQuarantine() throws Exception { + Path archive = currentArchive("bad-history-coverage"); + ArchiveAuthoritySourceBundle complete = readyBundle(false); + + for (HistoryCoverage invalid : Arrays.asList( + coverage(1, 1, 2), coverage(1, 2, 3), coverage(1, 2, 2, hash(3)))) { + ArchiveAuthoritySourceBundle candidate = bundle(false, complete.getApplyCheckpoint(), + complete.getParticipantProgress(), complete.getReaderVisible(), + complete.getServingGeneration(), invalid, digest(90)); + Result result = ArchiveFormatAdmissionValidator.inspect(archive, candidate); + assertEquals(Status.QUARANTINE_REQUIRED, result.getStatus()); + assertEquals(Reason.INCOMPLETE_OR_INCONSISTENT_AUTHORITIES, result.getReason()); + } + } + + @Test + public void servingPrefixDigestIsNotAHistoryCoverageAuthority() throws Exception { + Path archive = currentArchive("serving-prefix-not-coverage"); + ArchiveAuthoritySourceBundle complete = readyBundle(false); + ArchiveAuthoritySourceBundle.ServingGenerationSnapshot differentInternalDigest = + new ArchiveAuthoritySourceBundle.ServingGenerationSnapshot( + ArchiveParticipantDescriptor.FORMAT_ID, + ArchiveParticipantDescriptor.current().getParticipants(), 0, 2, hash(2), + digest(81), digest(90)); + ArchiveAuthoritySourceBundle candidate = bundle(false, complete.getApplyCheckpoint(), + complete.getParticipantProgress(), complete.getReaderVisible(), + differentInternalDigest, coverage(1, 2, 2), digest(90)); + + Result result = ArchiveFormatAdmissionValidator.inspect(archive, candidate); + + assertEquals(Status.CURRENT_READY, result.getStatus()); + } + + private static byte[] manifest(String scopeIdentity, List participants) throws Exception { + List sorted = new ArrayList<>(participants); + Collections.sort(sorted); + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + DataOutputStream output = new DataOutputStream(bytes); + output.writeInt(MANIFEST_MAGIC); + output.writeShort(2); + output.writeShort(0); + output.writeInt(0); + writeString(output, scopeIdentity); + output.writeLong(0); + output.write(new byte[32]); + output.writeInt(sorted.size()); + for (String participant : sorted) { + writeString(output, participant); + } + output.flush(); + byte[] payload = bytes.toByteArray(); + ByteBuffer.wrap(payload).putInt(8, payload.length + Integer.BYTES); + bytes.reset(); + output = new DataOutputStream(bytes); + output.write(payload); + output.writeInt(Hashing.crc32c().hashBytes(payload).asInt()); + output.flush(); + return bytes.toByteArray(); + } + + private static void writeString(DataOutputStream output, String value) throws Exception { + byte[] encoded = value.getBytes(StandardCharsets.UTF_8); + output.writeInt(encoded.length); + output.write(encoded); + } + + private static BlockSnapshotMeta meta(long epoch) { + byte[] hash = new byte[32]; + hash[31] = (byte) epoch; + return new BlockSnapshotMeta(epoch, epoch, hash, new byte[32], epoch * 1_000L); + } + + private Path currentArchive(String name) throws Exception { + Path archive = temporaryFolder.newFolder(name).toPath(); + ArchiveBaseManifest manifest = new ArchiveBaseManifest(archive, + ArchiveParticipantDescriptor.current().getParticipants()); + manifest.ensureBase(meta(1)); + return archive; + } + + private static ArchiveAuthoritySourceBundle readyBundle(boolean activePlan) { + HistoryCommitMarker head = marker(2); + byte[] planDigest = null; + ArchiveProgressEnvelope checkpoint = progress(ArchiveProgressEnvelope.Kind.APPLY_CHECKPOINT, + null, head, planDigest); + Map participantProgress = new LinkedHashMap<>(); + for (String participant : ArchiveParticipantDescriptor.current().getParticipants()) { + participantProgress.put(participant, progress( + ArchiveProgressEnvelope.Kind.PARTICIPANT_PROGRESS, participant, head, planDigest)); + } + return bundle(activePlan, checkpoint, participantProgress, + progress(ArchiveProgressEnvelope.Kind.READER_VISIBLE, null, head, planDigest), + serving(digest(90)), coverage(1, 2, 2), digest(90)); + } + + private static ArchiveAuthoritySourceBundle bundle(boolean activePlan, + ArchiveProgressEnvelope checkpoint, + Map participantProgress, + ArchiveProgressEnvelope readerVisible, + ArchiveAuthoritySourceBundle.ServingGenerationSnapshot serving, + HistoryCoverage coverage, byte[] latestSourceDigest) { + return new ArchiveAuthoritySourceBundle(activePlan, coverage, marker(1), marker(2), + checkpoint, participantProgress, readerVisible, serving, latestSourceDigest); + } + + private static HistoryCoverage coverage(long firstEpoch, long recordCount, long headEpoch) { + return coverage(firstEpoch, recordCount, headEpoch, hash(headEpoch)); + } + + private static HistoryCoverage coverage(long firstEpoch, long recordCount, long headEpoch, + byte[] headHash) { + return new HistoryCoverage(firstEpoch, recordCount, headEpoch, headHash); + } + + private static ArchiveAuthoritySourceBundle.ServingGenerationSnapshot serving( + byte[] sourceDigest) { + return new ArchiveAuthoritySourceBundle.ServingGenerationSnapshot( + ArchiveParticipantDescriptor.FORMAT_ID, + ArchiveParticipantDescriptor.current().getParticipants(), 0, 2, hash(2), digest(80), + sourceDigest); + } + + private static ArchiveProgressEnvelope progress(ArchiveProgressEnvelope.Kind kind, + String participant, HistoryCommitMarker marker, byte[] planDigest) { + return new ArchiveProgressEnvelope(kind, participant, marker.getMeta().getEpoch(), + marker.getMeta().getBlockHash(), marker.getBatchId(), + marker.getHistoryLocation().getBodyDigest(), planDigest, + ArchiveParticipantDescriptor.current().getParticipants()); + } + + private static HistoryCommitMarker marker(long epoch) { + return new HistoryCommitMarker( + new BlockSnapshotMeta(epoch, epoch, hash(epoch), hash(epoch - 1), epoch * 1_000L), + epoch - 1, new HistoryLocation(0, epoch * 100, 100, (int) epoch, + digest(20 + (int) epoch)), + new HistoryIndexLocation(epoch * 50, 50, digest(30 + (int) epoch)), + digest16(40 + (int) epoch), + ArchiveParticipantDescriptor.current().getParticipants()); + } + + private static byte[] hash(long suffix) { + byte[] value = new byte[32]; + value[31] = (byte) suffix; + return value; + } + + private static byte[] digest(int value) { + byte[] digest = new byte[32]; + Arrays.fill(digest, (byte) value); + return digest; + } + + private static byte[] digest16(int value) { + byte[] digest = new byte[16]; + Arrays.fill(digest, (byte) value); + return digest; + } +} diff --git a/framework/src/test/java/org/tron/core/db2/archive/ArchiveHistoryTruncatorTest.java b/framework/src/test/java/org/tron/core/db2/archive/ArchiveHistoryTruncatorTest.java new file mode 100644 index 00000000000..1e2d71162da --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/archive/ArchiveHistoryTruncatorTest.java @@ -0,0 +1,143 @@ +package org.tron.core.db2.archive; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tron.core.db2.archive.ArchiveHistoryTruncator.Stage; +import org.tron.core.db2.archive.BlockReverseDiff.DbGroup; +import org.tron.core.db2.archive.BlockReverseDiff.Entry; + +public class ArchiveHistoryTruncatorTest { + + private static final List PARTICIPANTS = Collections.singletonList("account"); + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void everyStageCrashKeepsCommitAuthoritativeAndSecondRecoveryConverges() + throws Exception { + for (Stage failedStage : Stage.values()) { + Path archive = temporaryFolder.newFolder("truncate-" + failedStage).toPath(); + initialize(archive); + + try (Stores stores = new Stores(archive)) { + ArchiveHistoryTruncator truncator = new ArchiveHistoryTruncator(stores.commits, + stores.index, stores.bodies, stage -> { + if (stage == failedStage) { + throw new IOException("injected after " + stage); + } + }); + assertThrows(IOException.class, () -> truncator.truncateAfter(10)); + } + + try (Stores afterCrash = new Stores(archive)) { + assertEquals(10, commitHead(afterCrash)); + assertNull(afterCrash.commits.get(11)); + assertEquals(failedStage == Stage.COMMIT_AUTHORITY ? 12 : 10, + indexHead(afterCrash)); + assertEquals(failedStage == Stage.HISTORY_BODY ? 10 : 12, + bodyHead(afterCrash)); + new ArchiveHistoryTruncator(afterCrash.commits, afterCrash.index, + afterCrash.bodies).truncateAfter(10); + } + + try (Stores recovered = new Stores(archive)) { + assertEquals(10, commitHead(recovered)); + assertEquals(10, indexHead(recovered)); + assertEquals(10, bodyHead(recovered)); + new ArchiveHistoryTruncator(recovered.commits, recovered.index, + recovered.bodies).truncateAfter(10); + assertEquals(3, recovered.commits.size()); + } + } + } + + @Test + public void rejectsUnknownTargetBeforeShrinkingAnyStore() throws Exception { + Path archive = temporaryFolder.newFolder("unknown-target").toPath(); + initialize(archive); + try (Stores stores = new Stores(archive)) { + assertThrows(ArchivePersistenceException.class, + () -> new ArchiveHistoryTruncator(stores.commits, stores.index, + stores.bodies).truncateAfter(7)); + assertEquals(12, commitHead(stores)); + assertEquals(12, indexHead(stores)); + assertEquals(12, bodyHead(stores)); + } + } + + private static void initialize(Path archive) throws Exception { + try (HistorySegmentStore bodies = new HistorySegmentStore( + archive, new BlockHistoryCodec(), 4096); + HistoryIndexStore index = new HistoryIndexStore(archive, new HistoryIndexCodec()); + HistoryCommitStore commits = new HistoryCommitStore( + archive, new HistoryCommitMarkerCodec())) { + List markers = new ArrayList<>(); + for (long epoch = 8; epoch <= 12; epoch++) { + BlockReverseDiff diff = diff(epoch); + HistoryLocation body = bodies.append(diff); + HistoryIndexLocation indexLocation = index.append(HistoryIndexRecord.from(diff, body)); + markers.add(new HistoryCommitMarker(diff.getMeta(), epoch - 1, body, indexLocation, + bytes(16, (int) epoch + 40), PARTICIPANTS)); + } + bodies.sync(); + index.sync(); + commits.commitAll(markers); + } + } + + private static long commitHead(Stores stores) { + return stores.commits.head().getMeta().getEpoch(); + } + + private static long indexHead(Stores stores) { + return stores.index.getScanResult().getHead().getRecord().getMeta().getEpoch(); + } + + private static long bodyHead(Stores stores) { + return stores.bodies.getScanResult().getHead().getDiff().getMeta().getEpoch(); + } + + private static BlockReverseDiff diff(long epoch) { + return new BlockReverseDiff(new BlockSnapshotMeta(epoch, epoch, + bytes(32, (int) epoch), bytes(32, (int) epoch - 1), epoch * 1_000), + Collections.singletonList(new DbGroup("account", Collections.singletonList( + new Entry(bytes(8, (int) epoch), OldValue.present(bytes(12, (int) epoch))))))); + } + + private static byte[] bytes(int length, int value) { + byte[] bytes = new byte[length]; + Arrays.fill(bytes, (byte) value); + return bytes; + } + + private static final class Stores implements AutoCloseable { + private final HistorySegmentStore bodies; + private final HistoryIndexStore index; + private final HistoryCommitStore commits; + + private Stores(Path archive) throws IOException { + bodies = new HistorySegmentStore(archive, new BlockHistoryCodec(), 4096); + index = new HistoryIndexStore(archive, new HistoryIndexCodec()); + commits = new HistoryCommitStore(archive, new HistoryCommitMarkerCodec()); + } + + @Override + public void close() throws IOException { + commits.close(); + index.close(); + bodies.close(); + } + } +} diff --git a/framework/src/test/java/org/tron/core/db2/archive/ArchiveHistoryWriterTest.java b/framework/src/test/java/org/tron/core/db2/archive/ArchiveHistoryWriterTest.java new file mode 100644 index 00000000000..afe154d4205 --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/archive/ArchiveHistoryWriterTest.java @@ -0,0 +1,595 @@ +package org.tron.core.db2.archive; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import com.google.protobuf.ByteString; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tron.core.db2.archive.ArchiveHistoryWriter.Stage; +import org.tron.core.db2.archive.BlockReverseDiff.DbGroup; +import org.tron.core.db2.archive.BlockReverseDiff.Entry; +import org.tron.protos.Protocol.Account; + +public class ArchiveHistoryWriterTest { + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void makesHistoryVisibleOnlyAfterOrderedDurabilityStages() throws Exception { + Path archive = temporaryFolder.newFolder("writer").toPath(); + List stages = new ArrayList<>(); + try (ArchiveHistoryWriter writer = new ArchiveHistoryWriter(archive, 4096, + databases(), (stage, meta) -> stages.add(stage))) { + writer.accept(diff(1)); + assertEquals(Arrays.asList(Stage.APPEND_BODY, Stage.APPEND_INDEX, Stage.SYNC_BODY, + Stage.SYNC_INDEX, Stage.COMMIT_MARKER), stages); + assertEquals(1, writer.committedHead().getMeta().getEpoch()); + assertEquals(0, writer.committedHead().getPreviousEpoch()); + assertEquals(Arrays.asList("account", "properties"), + writer.committedHead().getDatabases()); + assertEquals(16, writer.committedHead().getBatchId().length); + assertEquals(diff(1).getMeta(), writer.readCommitted(1).getMeta()); + + writer.accept(diff(2)); + assertEquals(2, writer.committedHead().getMeta().getEpoch()); + writer.revert(diff(2).getMeta()); + assertEquals(1, writer.committedHead().getMeta().getEpoch()); + assertThrows(IllegalArgumentException.class, () -> writer.readCommitted(2)); + } + + try (ArchiveHistoryWriter reopened = new ArchiveHistoryWriter( + archive, 4096, databases())) { + assertEquals(1, reopened.committedHead().getMeta().getEpoch()); + assertEquals(diff(1).getMeta(), reopened.readCommitted(1).getMeta()); + } + } + + @Test + public void rollsBackShortP66OnTailWithoutLosingActivationReverseDeletion() throws Exception { + Path archive = temporaryFolder.newFolder("p66-short-rollback").toPath(); + byte[] address = new byte[21]; + address[0] = 0x41; + address[20] = 7; + byte[] directKey = new P66AccountAssetCodec().assetPhysicalKey(address, "1000007"); + BlockReverseDiff off = new BlockReverseDiff(diff(1).getMeta(), Collections.emptyList()); + BlockReverseDiff activation = new BlockReverseDiff(diff(2).getMeta(), Arrays.asList( + new DbGroup("account", Collections.singletonList( + new Entry(address, OldValue.present(account(address, 20L))))), + new DbGroup("account-asset", Collections.singletonList( + new Entry(directKey, OldValue.absent()))))); + BlockReverseDiff on = new BlockReverseDiff(diff(3).getMeta(), Collections.singletonList( + new DbGroup("account-asset", Collections.singletonList( + new Entry(directKey, OldValue.present(java.nio.ByteBuffer.allocate(Long.BYTES) + .putLong(30L).array())))))); + Set p66Databases = new java.util.LinkedHashSet<>( + Arrays.asList("account", "account-asset", "properties")); + + try (ArchiveHistoryWriter writer = new ArchiveHistoryWriter( + archive, 4096, p66Databases)) { + writer.acceptAll(Arrays.asList(off, activation, on)); + writer.revert(on.getMeta()); + assertEquals(activation.getMeta(), writer.committedHeadMeta()); + Entry reverseDeletion = writer.readCommitted(2).getGroups().stream() + .filter(group -> "account-asset".equals(group.getDbName())) + .findFirst().orElseThrow(AssertionError::new).getEntries().get(0); + assertArrayEquals(directKey, reverseDeletion.getKey()); + assertFalse(reverseDeletion.getOldValue().isPresent()); + } + + try (ArchiveHistoryWriter reopened = new ArchiveHistoryWriter( + archive, 4096, p66Databases)) { + assertEquals(activation.getMeta(), reopened.committedHeadMeta()); + assertThrows(IllegalArgumentException.class, () -> reopened.readCommitted(3)); + Entry reverseDeletion = reopened.readCommitted(2).getGroups().stream() + .filter(group -> "account-asset".equals(group.getDbName())) + .findFirst().orElseThrow(AssertionError::new).getEntries().get(0); + assertFalse(reverseDeletion.getOldValue().isPresent()); + } + } + + @Test + public void rollsBackPreparedSuffixAtEveryPreCommitFailure() throws Exception { + for (Stage failedStage : Stage.values()) { + Path archive = temporaryFolder.newFolder("failure-" + failedStage).toPath(); + ArchiveHistoryWriter.DurabilityHook hook = (stage, meta) -> { + if (stage == failedStage) { + throw new java.io.IOException("injected " + stage); + } + }; + try (ArchiveHistoryWriter writer = new ArchiveHistoryWriter(archive, 4096, + databases(), hook)) { + assertThrows(ArchivePersistenceException.class, () -> writer.accept(diff(1))); + assertNull(writer.committedHead()); + } + try (ArchiveHistoryWriter reopened = new ArchiveHistoryWriter( + archive, 4096, databases())) { + assertNull(reopened.committedHead()); + assertThrows(IllegalArgumentException.class, () -> reopened.readCommitted(1)); + } + } + } + + @Test + public void truncatesCrashLeftPreparedBodyAndIndexOnOpen() throws Exception { + Path archive = temporaryFolder.newFolder("prepared").toPath(); + BlockReverseDiff diff = diff(1); + try (HistoryBodyStore bodies = new PartitionedHistoryBodyStore( + archive, new BlockHistoryCodec(), 4096, null); + HistoryIndexStore index = new HistoryIndexStore(archive, new HistoryIndexCodec())) { + HistoryLocation body = bodies.append(diff); + index.append(HistoryIndexRecord.from(diff, body)); + bodies.sync(); + index.sync(); + } + + try (ArchiveHistoryWriter writer = new ArchiveHistoryWriter(archive, 4096, databases())) { + assertNull(writer.committedHead()); + writer.accept(diff); + assertEquals(1, writer.committedHead().getMeta().getEpoch()); + } + } + + @Test + public void rejectsNonContiguousCanonicalInput() throws Exception { + Path archive = temporaryFolder.newFolder("continuity").toPath(); + try (ArchiveHistoryWriter writer = new ArchiveHistoryWriter(archive, 4096, databases())) { + writer.accept(diff(1)); + BlockReverseDiff gap = new BlockReverseDiff(new BlockSnapshotMeta( + 3, 3, hash(3), hash(2), 3L), Collections.emptyList()); + assertThrows(ArchivePersistenceException.class, () -> writer.accept(gap)); + assertEquals(1, writer.committedHead().getMeta().getEpoch()); + } + } + + @Test + public void readsAccountHistoryWithoutLegacyAccountIndexAcrossRestart() throws Exception { + Path archive = temporaryFolder.newFolder("account-index").toPath(); + byte[] address = new byte[21]; + address[0] = 0x41; + address[20] = 7; + BlockReverseDiff created = accountDiff(1, address, OldValue.absent()); + BlockReverseDiff changed = accountDiff(2, address, + OldValue.present(account(address, 10))); + BlockReverseDiff unchanged = new BlockReverseDiff(new BlockSnapshotMeta( + 3, 3, hash(3), hash(2), 9_000), Collections.emptyList()); + byte[] committedAccount = account(address, 20); + + try (ArchiveHistoryWriter writer = new ArchiveHistoryWriter(archive, 4096, databases())) { + writer.acceptAll(Arrays.asList(created, changed, unchanged)); + assertFalse(writer.readAccountAt(0, address, committedAccount).isPresent()); + assertEquals(10, Account.parseFrom( + writer.readAccountAt(1, address, committedAccount).getValue()).getBalance()); + assertEquals(20, Account.parseFrom( + writer.readAccountAt(2, address, committedAccount).getValue()).getBalance()); + } + + try (ArchiveHistoryWriter reopened = new ArchiveHistoryWriter(archive, 4096, databases())) { + assertEquals(20, Account.parseFrom( + reopened.readAccountAt(2, address, committedAccount).getValue()).getBalance()); + try (java.util.stream.Stream files = Files.list(archive.resolve("commits"))) { + assertEquals(1, files.count()); + } + } + assertFalse(Files.exists(archive.resolve("account-change-index"))); + } + + @Test + public void truncatesIncompleteCommitLogTailOnRestart() throws Exception { + Path archive = temporaryFolder.newFolder("temporary-marker").toPath(); + try (ArchiveHistoryWriter writer = new ArchiveHistoryWriter(archive, 4096, databases())) { + writer.accept(diff(1)); + } + Files.write(archive.resolve("commits").resolve("commit.log"), new byte[]{1, 2, 3}, + java.nio.file.StandardOpenOption.APPEND); + try (ArchiveHistoryWriter writer = new ArchiveHistoryWriter(archive, 4096, databases())) { + assertEquals(1, writer.committedHead().getMeta().getEpoch()); + assertEquals(diff(1).getMeta(), writer.readCommitted(1).getMeta()); + } + } + + @Test + public void persistsBatchedPrefixWithoutPerBlockFilesAndResumes() throws Exception { + Path archive = temporaryFolder.newFolder("batched-prefix").toPath(); + try (ArchiveHistoryWriter writer = new ArchiveHistoryWriter(archive, 4096, databases())) { + for (int start = 1; start <= 1_000; start += 100) { + List batch = new ArrayList<>(100); + for (int number = start; number < start + 100; number++) { + batch.add(diff(number)); + } + writer.acceptAll(batch); + } + assertEquals(1_000, writer.committedHead().getMeta().getEpoch()); + } + + try (java.util.stream.Stream commits = Files.list(archive.resolve("commits")); + java.util.stream.Stream segments = Files.list( + archive.resolve("history/state-archive"))) { + assertEquals(1, commits.count()); + assertTrue(segments.count() < 1_000); + } + try (ArchiveHistoryWriter reopened = new ArchiveHistoryWriter(archive, 4096, databases())) { + assertEquals(1_000, reopened.committedHead().getMeta().getEpoch()); + assertEquals(3, reopened.getStartupScannedRecords()); + assertEquals(diff(500).getMeta(), reopened.readCommitted(500).getMeta()); + reopened.accept(diff(1_001)); + assertEquals(1_001, reopened.committedHead().getMeta().getEpoch()); + } + } + + @Test + public void scansOnlyTailAfterAStaleHistoryScanAnchor() throws Exception { + Path archive = temporaryFolder.newFolder("stale-checkpoint").toPath(); + byte[] checkpointAtOne; + try (ArchiveHistoryWriter writer = new ArchiveHistoryWriter(archive, 4096, databases())) { + writer.accept(diff(1)); + checkpointAtOne = Files.readAllBytes(archive.resolve("history.scan-anchor")); + writer.accept(diff(2)); + } + Files.write(archive.resolve("history.scan-anchor"), checkpointAtOne); + + try (ArchiveHistoryWriter reopened = new ArchiveHistoryWriter(archive, 4096, databases())) { + assertEquals(2, reopened.committedHead().getMeta().getEpoch()); + assertEquals(6, reopened.getStartupScannedRecords()); + assertEquals(diff(2).getMeta(), reopened.readCommitted(2).getMeta()); + } + } + + @Test + public void isolatesLegacyRestartCheckpointFromNewScanAnchor() throws Exception { + Path archive = temporaryFolder.newFolder("legacy-checkpoint-isolation").toPath(); + HistoryCommitMarkerCodec codec = new HistoryCommitMarkerCodec(); + try (ArchiveHistoryWriter writer = new ArchiveHistoryWriter(archive, 4096, databases())) { + writer.accept(diff(1)); + } + ArchiveHistoryScanAnchor anchor = ArchiveHistoryScanAnchor.load(archive, codec); + ArchiveRestartCheckpoint.persist(archive, anchor.getFirstEpoch(), anchor.getRecordCount(), + anchor.getCommitRecordLength(), anchor.getMarker(), codec); + Files.delete(archive.resolve("history.scan-anchor")); + + assertTrue(Files.exists(archive.resolve("restart.checkpoint"))); + assertNull(ArchiveHistoryScanAnchor.load(archive, codec)); + + try (ArchiveHistoryWriter writer = new ArchiveHistoryWriter(archive, 4096, databases())) { + assertEquals(1, writer.committedHead().getMeta().getEpoch()); + } + assertTrue(Files.exists(archive.resolve("restart.checkpoint"))); + assertTrue(Files.exists(archive.resolve("history.scan-anchor"))); + } + + @Test + public void truncatesInvalidBodyAndIndexTailWithoutRescanningPrefix() throws Exception { + Path archive = temporaryFolder.newFolder("invalid-data-tail").toPath(); + try (ArchiveHistoryWriter writer = new ArchiveHistoryWriter(archive, 4096, databases())) { + List batch = new ArrayList<>(1_000); + for (int number = 1; number <= 1_000; number++) { + batch.add(diff(number)); + } + writer.acceptAll(batch); + } + Path lastSegment; + try (java.util.stream.Stream segments = Files.list( + archive.resolve("history/state-archive"))) { + lastSegment = segments.sorted().reduce((left, right) -> right) + .orElseThrow(AssertionError::new); + } + Files.write(lastSegment, new byte[]{1, 2, 3}, + java.nio.file.StandardOpenOption.APPEND); + Files.write(archive.resolve("state_history.idx"), new byte[]{1, 2, 3}, + java.nio.file.StandardOpenOption.APPEND); + + try (ArchiveHistoryWriter reopened = new ArchiveHistoryWriter(archive, 4096, databases())) { + assertEquals(1_000, reopened.committedHead().getMeta().getEpoch()); + assertEquals(3, reopened.getStartupScannedRecords()); + reopened.accept(diff(1_001)); + assertEquals(1_001, reopened.committedHead().getMeta().getEpoch()); + } + } + + @Test + public void boundsPreparedTailAcrossAnOversizedFlushFailure() throws Exception { + Path archive = temporaryFolder.newFolder("bounded-large-flush").toPath(); + List batch = new ArrayList<>(1_500); + for (int number = 1; number <= 1_500; number++) { + batch.add(diff(number)); + } + ArchiveHistoryWriter.DurabilityHook failSecondChunk = (stage, meta) -> { + if (stage == Stage.APPEND_BODY && meta.getEpoch() == 1_025) { + throw new java.io.IOException("injected second chunk failure"); + } + }; + try (ArchiveHistoryWriter writer = new ArchiveHistoryWriter(archive, 4096, databases(), + failSecondChunk)) { + assertThrows(ArchivePersistenceException.class, () -> writer.acceptAll(batch)); + assertEquals(1_024, writer.committedHead().getMeta().getEpoch()); + } + + try (ArchiveHistoryWriter reopened = new ArchiveHistoryWriter(archive, 4096, databases())) { + assertEquals(1_024, reopened.committedHead().getMeta().getEpoch()); + assertEquals(3, reopened.getStartupScannedRecords()); + } + } + + @Test + public void failsClosedOnCorruptHistoryScanAnchor() throws Exception { + Path archive = temporaryFolder.newFolder("corrupt-checkpoint").toPath(); + try (ArchiveHistoryWriter writer = new ArchiveHistoryWriter(archive, 4096, databases())) { + writer.accept(diff(1)); + } + Path checkpoint = archive.resolve("history.scan-anchor"); + byte[] encoded = Files.readAllBytes(checkpoint); + encoded[encoded.length - 1] ^= 1; + Files.write(checkpoint, encoded); + + assertThrows(ArchivePersistenceException.class, + () -> new ArchiveHistoryWriter(archive, 4096, databases())); + } + + @Test + public void completesPreparedTruncationBeforeLoadingHistoryScanAnchor() throws Exception { + Path archive = temporaryFolder.newFolder("writer-truncation-recovery").toPath(); + initializeHistory(archive, 3); + prepareTruncation(archive, 2); + + try (ArchiveHistoryWriter reopened = new ArchiveHistoryWriter( + archive, 4096, databases())) { + assertEquals(2, reopened.committedHead().getMeta().getEpoch()); + assertEquals(diff(2).getMeta(), reopened.readCommitted(2).getMeta()); + assertFalse(Files.exists(archive.resolve("truncation.intent"))); + assertEquals(3, reopened.getStartupScannedRecords()); + } + } + + @Test + public void recoveryDoesNotCreateLegacyAccountIndex() throws Exception { + Path archive = temporaryFolder.newFolder("writer-index-ahead").toPath(); + try (ArchiveHistoryWriter writer = new ArchiveHistoryWriter(archive, 4096, databases())) { + writer.acceptAll(Arrays.asList(diff(1), diff(2), diff(3))); + } + prepareTruncation(archive, 2); + + try (ArchiveHistoryWriter reopened = new ArchiveHistoryWriter( + archive, 4096, databases())) { + assertEquals(2, reopened.committedHead().getMeta().getEpoch()); + } + ArchiveHistoryScanAnchor checkpoint = ArchiveHistoryScanAnchor.load( + archive, new HistoryCommitMarkerCodec()); + assertEquals(2, checkpoint.getMarker().getMeta().getEpoch()); + assertFalse(Files.exists(archive.resolve("account-change-index"))); + assertFalse(Files.exists(archive.resolve("truncation.intent"))); + } + + @Test + public void placesFourHotStoresInDedicatedStateArchiveLibraries() throws Exception { + Path archive = temporaryFolder.newFolder("dedicated-history-libraries").toPath(); + BlockSnapshotMeta meta = new BlockSnapshotMeta(1, 1, hash(1), hash(0), 3_000L); + List groups = new ArrayList<>(); + for (String store : PartitionedHistoryBodyStore.DEDICATED_STORES) { + groups.add(new DbGroup(store, Collections.singletonList( + new Entry(bytes(store), OldValue.present(bytes("old-" + store)))))); + } + groups.add(new DbGroup("properties", Collections.singletonList( + new Entry(bytes("property"), OldValue.present(bytes("old-property")))))); + BlockReverseDiff expected = new BlockReverseDiff(meta, groups); + + try (ArchiveHistoryWriter writer = new ArchiveHistoryWriter( + archive, 4096, exactDatabases())) { + writer.accept(expected); + assertEquals(expected.getGroups().size(), writer.readCommitted(1).getGroups().size()); + } + + Path history = archive.resolve("history"); + assertTrue(Files.isDirectory(history.resolve("state-archive"))); + for (String store : PartitionedHistoryBodyStore.DEDICATED_STORES) { + Path library = history.resolve(PartitionedHistoryBodyStore.libraryName(store)); + assertTrue(Files.isDirectory(library)); + assertTrue(Files.size(library.resolve("history.000000.dat")) > 0); + } + assertFalse(Files.exists(archive.resolve("account-change-index"))); + } + + @Test + public void rejectsLegacyAccountIndexWithoutCreatingNewHistoryLibraries() throws Exception { + Path archive = temporaryFolder.newFolder("legacy-account-index").toPath(); + Files.createDirectory(archive.resolve("account-change-index")); + + ArchivePersistenceException failure = assertThrows(ArchivePersistenceException.class, + () -> new ArchiveHistoryWriter(archive, 4096, databases())); + + assertTrue(failure.getMessage().contains("explicit offline removal")); + assertFalse(Files.exists(archive.resolve("history"))); + } + + @Test + public void rejectsLegacySharedHistoryWithoutCreatingNewLibraries() throws Exception { + Path archive = temporaryFolder.newFolder("legacy-shared-history").toPath(); + Path history = Files.createDirectory(archive.resolve("history")); + Files.createFile(history.resolve("history.000000.dat")); + + ArchivePersistenceException failure = assertThrows(ArchivePersistenceException.class, + () -> new ArchiveHistoryWriter(archive, 4096, databases())); + + assertTrue(failure.getMessage().contains("explicit offline migration")); + assertFalse(Files.exists(history.resolve("state-archive"))); + } + + @Test + public void buildsPersistentServingGenerationFromCommittedWriterPrefix() throws Exception { + Path archive = temporaryFolder.newFolder("writer-serving-generation").toPath(); + byte[] key = bytes("key-1"); + try (ArchiveHistoryWriter writer = new ArchiveHistoryWriter(archive, 4096, databases())) { + writer.acceptAll(Arrays.asList(diff(1), diff(2))); + try (PersistentServingKeyIndexGeneration generation = writer.buildServingGeneration( + archive.resolve("serving-shadow"), "generation-2")) { + assertEquals(2, generation.getIndexedThrough()); + assertEquals(1, generation.firstChangeAfter("account", key, 0, 2).getAsLong()); + assertFalse(generation.firstChangeAfter("properties", key, 0, 2).isPresent()); + } + } + } + + @Test + public void plansServingIncrementOnlyFromDurableIThroughCommittedH() throws Exception { + Path archive = temporaryFolder.newFolder("writer-serving-increment").toPath(); + try (ArchiveHistoryWriter writer = new ArchiveHistoryWriter( + archive, 4096, exactDatabases())) { + writer.acceptAll(Arrays.asList(diff(1), diff(2), diff(3))); + + ServingIndexIncrementalPlan increment = writer.planServingIncrement(2, hash(2)); + assertEquals(2, increment.getIndexedFrom()); + assertEquals(3, increment.getIndexedThrough()); + assertArrayEquals(hash(3), increment.getHeadHash()); + assertEquals(27, increment.getChangesByDatabase().size()); + assertEquals(1, increment.getChanges("account").size()); + assertArrayEquals(bytes("key-3"), + increment.getChanges("account").get(0).getRawKey()); + assertTrue(increment.getChanges("properties").isEmpty()); + + ServingIndexIncrementalPlan zeroAction = writer.planServingIncrement(3, hash(3)); + assertEquals(3, zeroAction.getIndexedFrom()); + assertEquals(3, zeroAction.getIndexedThrough()); + assertTrue(zeroAction.getChangesByDatabase().values().stream().allMatch(List::isEmpty)); + + assertThrows(ArchivePersistenceException.class, + () -> writer.planServingIncrement(2, hash(99))); + assertThrows(ArchivePersistenceException.class, + () -> writer.planServingIncrement(4, hash(4))); + } + } + + @Test + public void exposesImmutableContiguousHistoryCoverageAcrossTailChanges() throws Exception { + Path archive = temporaryFolder.newFolder("history-coverage").toPath(); + initializeHistory(archive, 3); + + try (HistoryCommitStore commits = new HistoryCommitStore( + archive, new HistoryCommitMarkerCodec())) { + assertCoverage(commits.coverage(), 1, 3, 3, hash(3)); + byte[] exposedHash = commits.coverage().getHeadHash(); + exposedHash[31] = 99; + assertArrayEquals(hash(3), commits.coverage().getHeadHash()); + + commits.truncateAfter(2); + assertCoverage(commits.coverage(), 1, 2, 2, hash(2)); + HistoryCommitMarker second = commits.head(); + commits.removeHead(second.getMeta()); + assertCoverage(commits.coverage(), 1, 1, 1, hash(1)); + commits.removeHead(commits.head().getMeta()); + assertNull(commits.coverage()); + } + } + + @Test + public void commitLogForceBoundaryFailurePreservesRecordAsUncertain() throws Exception { + Path archive = temporaryFolder.newFolder("uncertain-marker").toPath(); + HistoryCommitMarker marker = new HistoryCommitMarker(diff(1).getMeta(), 0, + new HistoryLocation(0, 0, 100, 17, new byte[32]), + new HistoryIndexLocation(0, 100, new byte[32]), new byte[16], + new ArrayList<>(databases())); + HistoryCommitStore.DirectorySync directorySync = directory -> { + throw new java.io.IOException("injected directory sync failure"); + }; + try (HistoryCommitStore commits = new HistoryCommitStore( + archive, new HistoryCommitMarkerCodec(), directorySync)) { + assertThrows(java.io.IOException.class, () -> commits.commit(marker)); + assertNull(commits.head()); + assertTrue(commits.mayContain(1)); + assertTrue(Files.size(archive.resolve("commits").resolve("commit.log")) > 0); + assertThrows(IllegalStateException.class, + () -> commits.removeHead(marker.getMeta())); + } + } + + private static Set databases() { + return new java.util.LinkedHashSet<>(Arrays.asList("account", "properties")); + } + + private static Set exactDatabases() { + return new java.util.LinkedHashSet<>(ArchiveStoreScope.getStateDatabases()); + } + + private static void assertCoverage(HistoryCoverage coverage, long firstEpoch, + long recordCount, long headEpoch, byte[] headHash) { + assertEquals(firstEpoch, coverage.getFirstEpoch()); + assertEquals(recordCount, coverage.getRecordCount()); + assertEquals(headEpoch, coverage.getHeadEpoch()); + assertArrayEquals(headHash, coverage.getHeadHash()); + } + + private static void initializeHistory(Path archive, int lastEpoch) throws Exception { + try (HistoryBodyStore bodies = new PartitionedHistoryBodyStore( + archive, new BlockHistoryCodec(), 4096, null); + HistoryIndexStore index = new HistoryIndexStore(archive, new HistoryIndexCodec()); + HistoryCommitStore commits = new HistoryCommitStore( + archive, new HistoryCommitMarkerCodec())) { + List markers = new ArrayList<>(); + for (int epoch = 1; epoch <= lastEpoch; epoch++) { + BlockReverseDiff diff = diff(epoch); + HistoryLocation body = bodies.append(diff); + HistoryIndexLocation indexLocation = index.append(HistoryIndexRecord.from(diff, body)); + markers.add(new HistoryCommitMarker(diff.getMeta(), epoch - 1L, body, indexLocation, + new byte[16], new ArrayList<>(databases()))); + } + bodies.sync(); + index.sync(); + commits.commitAll(markers); + ArchiveHistoryScanAnchor.persist(archive, commits.firstEpoch(), commits.size(), + commits.getRecordLength(), commits.head(), new HistoryCommitMarkerCodec()); + } + } + + private static void prepareTruncation(Path archive, long targetEpoch) throws Exception { + ArchiveHistoryScanAnchor checkpoint = ArchiveHistoryScanAnchor.load(archive, + new HistoryCommitMarkerCodec()); + try (HistoryBodyStore bodies = new PartitionedHistoryBodyStore( + archive, new BlockHistoryCodec(), 4096, checkpoint); + HistoryIndexStore index = new HistoryIndexStore( + archive, new HistoryIndexCodec(), checkpoint); + HistoryCommitStore commits = new HistoryCommitStore( + archive, new HistoryCommitMarkerCodec(), checkpoint)) { + ArchiveTruncationIntent.prepare(archive, commits, index, bodies, targetEpoch, + new HistoryCommitMarkerCodec()); + } + } + + private static BlockReverseDiff diff(int number) { + return new BlockReverseDiff(new BlockSnapshotMeta(number, number, hash(number), + hash(number - 1), number * 3_000L), Collections.singletonList( + new DbGroup("account", Collections.singletonList( + new Entry(bytes("key-" + number), OldValue.present(bytes("value-" + number))))))); + } + + private static BlockReverseDiff accountDiff(int number, byte[] address, OldValue oldValue) { + return new BlockReverseDiff(new BlockSnapshotMeta(number, number, hash(number), + hash(number - 1), number * 3_000L), Collections.singletonList( + new DbGroup("account", Collections.singletonList(new Entry(address, oldValue))))); + } + + private static byte[] account(byte[] address, long balance) { + return Account.newBuilder().setAddress(ByteString.copyFrom(address)).setBalance(balance) + .build().toByteArray(); + } + + private static byte[] hash(int suffix) { + byte[] hash = new byte[32]; + hash[31] = (byte) suffix; + return hash; + } + + private static byte[] bytes(String value) { + return value.getBytes(java.nio.charset.StandardCharsets.UTF_8); + } +} diff --git a/framework/src/test/java/org/tron/core/db2/archive/ArchiveParticipantDescriptorTest.java b/framework/src/test/java/org/tron/core/db2/archive/ArchiveParticipantDescriptorTest.java new file mode 100644 index 00000000000..333e7250316 --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/archive/ArchiveParticipantDescriptorTest.java @@ -0,0 +1,91 @@ +package org.tron.core.db2.archive; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import com.google.common.hash.Hashing; +import java.nio.ByteBuffer; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +public class ArchiveParticipantDescriptorTest { + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void definesExact27WithAbiAndBothAssetIssueStores() { + ArchiveParticipantDescriptor descriptor = ArchiveParticipantDescriptor.current(); + + assertEquals(27, descriptor.getParticipants().size()); + assertEquals(ArchiveParticipantDescriptor.ABI_STORE_ID, + descriptor.getStoreId("abi")); + assertEquals(6, descriptor.getStoreId("asset-issue")); + assertEquals(7, descriptor.getStoreId("asset-issue-v2")); + assertTrue(descriptor.getTombstonesByStoreId().isEmpty()); + assertTrue(descriptor.getParticipants().contains("asset-issue")); + assertTrue(descriptor.getParticipants().contains("asset-issue-v2")); + assertTrue(descriptor.getParticipants().contains("abi")); + assertEquals("archive-state/exact-27-abi-retained/v1", + ArchiveParticipantDescriptor.FORMAT_ID); + } + + @Test + public void rejectsAbiExcludedExact26AndV2OnlyExact25ParticipantSets() { + ArchiveParticipantDescriptor descriptor = ArchiveParticipantDescriptor.current(); + List exact26 = new ArrayList<>(descriptor.getParticipants()); + exact26.remove("abi"); + List exact25 = new ArrayList<>(descriptor.getParticipants()); + exact25.remove("abi"); + exact25.remove("asset-issue"); + + assertThrows(ArchivePersistenceException.class, + () -> descriptor.requireExactParticipants(exact26)); + assertThrows(ArchivePersistenceException.class, + () -> descriptor.requireExactParticipants(exact25)); + descriptor.requireExactParticipants(descriptor.getParticipants()); + } + + @Test + public void manifestBindsApprovedScopeAndRejectsLegacyVersions() throws Exception { + List participants = ArchiveParticipantDescriptor.current().getParticipants(); + Path archive = temporaryFolder.newFolder("exact-27-manifest").toPath(); + ArchiveBaseManifest manifest = new ArchiveBaseManifest(archive, participants); + manifest.ensureBase(meta(1)); + + byte[] encoded = Files.readAllBytes(archive.resolve("MANIFEST")); + assertEquals(3, ByteBuffer.wrap(encoded, Integer.BYTES, Short.BYTES).getShort()); + new ArchiveBaseManifest(archive, participants); + + List abiExcludedExact26 = new ArrayList<>(participants); + abiExcludedExact26.remove("abi"); + assertThrows(ArchivePersistenceException.class, + () -> new ArchiveBaseManifest(archive, abiExcludedExact26)); + + ByteBuffer.wrap(encoded).putShort(Integer.BYTES, (short) 2); + byte[] payload = Arrays.copyOf(encoded, encoded.length - Integer.BYTES); + ByteBuffer.wrap(encoded, encoded.length - Integer.BYTES, Integer.BYTES) + .putInt(Hashing.crc32c().hashBytes(payload).asInt()); + Files.write(archive.resolve("MANIFEST"), encoded); + assertThrows(ArchivePersistenceException.class, + () -> new ArchiveBaseManifest(archive, participants)); + } + + private static BlockSnapshotMeta meta(long epoch) { + return new BlockSnapshotMeta(epoch, epoch, hash((int) epoch), + hash((int) epoch - 1), epoch * 1_000L); + } + + private static byte[] hash(int suffix) { + byte[] hash = new byte[32]; + hash[31] = (byte) suffix; + return hash; + } +} diff --git a/framework/src/test/java/org/tron/core/db2/archive/ArchiveProgressEnvelopeTest.java b/framework/src/test/java/org/tron/core/db2/archive/ArchiveProgressEnvelopeTest.java new file mode 100644 index 00000000000..7f4516a7834 --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/archive/ArchiveProgressEnvelopeTest.java @@ -0,0 +1,188 @@ +package org.tron.core.db2.archive; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import com.google.common.hash.Hashing; +import java.nio.ByteBuffer; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.List; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tron.core.db2.archive.ArchiveProgressEnvelope.Kind; + +public class ArchiveProgressEnvelopeTest { + + private static final List PARTICIPANTS = Arrays.asList( + "account", "account-asset", "storage-row"); + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void deterministicallyRoundTripsCheckpointParticipantAndReaderProgress() { + ArchiveProgressEnvelopeCodec codec = new ArchiveProgressEnvelopeCodec(); + ArchiveProgressEnvelope checkpoint = checkpoint(10, 1); + byte[] first = codec.encode(checkpoint); + byte[] second = codec.encode(checkpoint(10, 1)); + assertArrayEquals(first, second); + assertEnvelope(checkpoint, codec.decode(first)); + + ArchiveProgressEnvelope progress = progress("account-asset", 10, 1); + assertEnvelope(progress, codec.decode(codec.encode(progress))); + + ArchiveProgressEnvelope reader = reader(10, 1); + assertEnvelope(reader, codec.decode(codec.encode(reader))); + + ArchiveProgressEnvelope bound = new ArchiveProgressEnvelope(Kind.APPLY_CHECKPOINT, null, + 10, bytes(32, 1), bytes(16, 2), bytes(32, 3), bytes(32, 4), PARTICIPANTS); + assertEnvelope(bound, codec.decode(codec.encode(bound))); + assertEquals(ArchiveParticipantDescriptor.scopeIdentity(PARTICIPANTS), + codec.decode(first).getScopeIdentity()); + + List approvedParticipants = ArchiveParticipantDescriptor.current().getParticipants(); + ArchiveProgressEnvelope approved = new ArchiveProgressEnvelope(Kind.READER_VISIBLE, null, + 10, bytes(32, 1), bytes(16, 2), bytes(32, 3), approvedParticipants); + assertEquals(ArchiveParticipantDescriptor.FORMAT_ID, + codec.decode(codec.encode(approved)).getScopeIdentity()); + } + + @Test + public void rejectsLegacyVersionAndSameParticipantsWithDifferentScope() { + ArchiveProgressEnvelopeCodec codec = new ArchiveProgressEnvelopeCodec(); + byte[] legacy = codec.encode(checkpoint(10, 1)); + ByteBuffer.wrap(legacy).putShort(4, (short) 2); + refreshChecksum(legacy); + assertThrows(IllegalArgumentException.class, () -> codec.decode(legacy)); + + ArchiveProgressEnvelope substituted = new ArchiveProgressEnvelope( + Kind.APPLY_CHECKPOINT, null, 10, bytes(32, 1), bytes(16, 2), bytes(32, 3), null, + PARTICIPANTS, "experimental/substituted-scope"); + assertThrows(IllegalArgumentException.class, + () -> codec.decode(codec.encode(substituted))); + assertThrows(ArchivePersistenceException.class, + () -> substituted.requireIdentity(Kind.APPLY_CHECKPOINT, null, 10, bytes(32, 1), + bytes(16, 2), bytes(32, 3), PARTICIPANTS)); + } + + @Test + public void rejectsCorruptionTruncationAndInvalidIdentity() { + ArchiveProgressEnvelopeCodec codec = new ArchiveProgressEnvelopeCodec(); + byte[] encoded = codec.encode(checkpoint(10, 1)); + encoded[encoded.length - 1] ^= 1; + assertThrows(IllegalArgumentException.class, () -> codec.decode(encoded)); + assertThrows(IllegalArgumentException.class, + () -> codec.decode(Arrays.copyOf(encoded, 20))); + + assertThrows(IllegalArgumentException.class, () -> new ArchiveProgressEnvelope( + Kind.PARTICIPANT_PROGRESS, "contract", 10, bytes(32, 1), bytes(16, 2), + bytes(32, 3), PARTICIPANTS)); + assertThrows(IllegalArgumentException.class, () -> new ArchiveProgressEnvelope( + Kind.APPLY_CHECKPOINT, null, 10, bytes(31, 1), bytes(16, 2), bytes(32, 3), + PARTICIPANTS)); + } + + @Test + public void rejectsEveryExpectedIdentityMismatch() { + ArchiveProgressEnvelope progress = progress("account-asset", 10, 1); + progress.requireIdentity(Kind.PARTICIPANT_PROGRESS, "account-asset", 10, + bytes(32, 1), bytes(16, 2), bytes(32, 3), PARTICIPANTS); + + assertThrows(ArchivePersistenceException.class, + () -> progress.requireIdentity(Kind.PARTICIPANT_PROGRESS, "storage-row", 10, + bytes(32, 1), bytes(16, 2), bytes(32, 3), PARTICIPANTS)); + assertThrows(ArchivePersistenceException.class, + () -> progress.requireIdentity(Kind.PARTICIPANT_PROGRESS, "account-asset", 10, + bytes(32, 9), bytes(16, 2), bytes(32, 3), PARTICIPANTS)); + assertThrows(ArchivePersistenceException.class, + () -> progress.requireIdentity(Kind.PARTICIPANT_PROGRESS, "account-asset", 10, + bytes(32, 1), bytes(16, 9), bytes(32, 3), PARTICIPANTS)); + assertThrows(ArchivePersistenceException.class, + () -> progress.requireIdentity(Kind.PARTICIPANT_PROGRESS, "account-asset", 10, + bytes(32, 1), bytes(16, 2), bytes(32, 9), PARTICIPANTS)); + assertThrows(ArchivePersistenceException.class, + () -> progress.requireIdentity(Kind.PARTICIPANT_PROGRESS, "account-asset", 11, + bytes(32, 1), bytes(16, 2), bytes(32, 3), PARTICIPANTS)); + assertThrows(ArchivePersistenceException.class, + () -> progress.requireIdentity(Kind.PARTICIPANT_PROGRESS, "account-asset", 10, + bytes(32, 1), bytes(16, 2), bytes(32, 3), + Arrays.asList("account", "account-asset"))); + + ArchiveProgressEnvelope bound = new ArchiveProgressEnvelope(Kind.PARTICIPANT_PROGRESS, + "account-asset", 10, bytes(32, 1), bytes(16, 2), bytes(32, 3), bytes(32, 4), + PARTICIPANTS); + bound.requireIdentity(Kind.PARTICIPANT_PROGRESS, "account-asset", 10, + bytes(32, 1), bytes(16, 2), bytes(32, 3), bytes(32, 4), PARTICIPANTS); + assertThrows(ArchivePersistenceException.class, + () -> bound.requireIdentity(Kind.PARTICIPANT_PROGRESS, "account-asset", 10, + bytes(32, 1), bytes(16, 2), bytes(32, 3), bytes(32, 5), PARTICIPANTS)); + } + + @Test + public void preservesOldAuthorityWhenCrashOccursBeforeAtomicReplace() throws Exception { + Path directory = temporaryFolder.newFolder("progress-file").toPath(); + Path path = directory.resolve("checkpoint.progress"); + ArchiveProgressEnvelopeCodec codec = new ArchiveProgressEnvelopeCodec(); + new ArchiveProgressFile(path, codec).store(checkpoint(9, 1)); + + ArchiveProgressFile failing = new ArchiveProgressFile(path, codec, temporary -> { + throw new java.io.IOException("injected after temporary force"); + }); + assertThrows(java.io.IOException.class, () -> failing.store(checkpoint(10, 2))); + assertTrue(Files.exists(failing.getTemporaryPath())); + assertEquals(9, new ArchiveProgressFile(path, codec).load().getEpoch()); + + new ArchiveProgressFile(path, codec).store(checkpoint(10, 2)); + assertEquals(10, new ArchiveProgressFile(path, codec).load().getEpoch()); + byte[] corrupt = Files.readAllBytes(path); + corrupt[corrupt.length - 1] ^= 1; + Files.write(path, corrupt); + assertThrows(ArchivePersistenceException.class, + () -> new ArchiveProgressFile(path, codec).load()); + } + + private static ArchiveProgressEnvelope checkpoint(long epoch, int seed) { + return new ArchiveProgressEnvelope(Kind.APPLY_CHECKPOINT, null, epoch, bytes(32, seed), + bytes(16, seed + 1), bytes(32, seed + 2), PARTICIPANTS); + } + + private static ArchiveProgressEnvelope progress(String participant, long epoch, int seed) { + return new ArchiveProgressEnvelope(Kind.PARTICIPANT_PROGRESS, participant, epoch, + bytes(32, seed), bytes(16, seed + 1), bytes(32, seed + 2), PARTICIPANTS); + } + + private static ArchiveProgressEnvelope reader(long epoch, int seed) { + return new ArchiveProgressEnvelope(Kind.READER_VISIBLE, null, epoch, bytes(32, seed), + bytes(16, seed + 1), bytes(32, seed + 2), PARTICIPANTS); + } + + private static byte[] bytes(int length, int value) { + byte[] bytes = new byte[length]; + Arrays.fill(bytes, (byte) value); + return bytes; + } + + private static void assertEnvelope(ArchiveProgressEnvelope expected, + ArchiveProgressEnvelope actual) { + assertEquals(expected.getKind(), actual.getKind()); + assertEquals(expected.getParticipant(), actual.getParticipant()); + assertEquals(expected.getEpoch(), actual.getEpoch()); + assertArrayEquals(expected.getBlockHash(), actual.getBlockHash()); + assertArrayEquals(expected.getBatchId(), actual.getBatchId()); + assertArrayEquals(expected.getPayloadDigest(), actual.getPayloadDigest()); + assertArrayEquals(expected.getMutationPlanDigest(), actual.getMutationPlanDigest()); + assertEquals(expected.getScopeIdentity(), actual.getScopeIdentity()); + assertEquals(expected.getParticipants(), actual.getParticipants()); + } + + private static void refreshChecksum(byte[] encoded) { + int payloadLength = encoded.length - Integer.BYTES; + int checksum = Hashing.crc32c().hashBytes(encoded, 0, payloadLength).asInt(); + ByteBuffer.wrap(encoded, payloadLength, Integer.BYTES).putInt(checksum); + } +} diff --git a/framework/src/test/java/org/tron/core/db2/archive/ArchiveReadSnapshotTest.java b/framework/src/test/java/org/tron/core/db2/archive/ArchiveReadSnapshotTest.java new file mode 100644 index 00000000000..adf6aafd7b4 --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/archive/ArchiveReadSnapshotTest.java @@ -0,0 +1,965 @@ +package org.tron.core.db2.archive; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.google.protobuf.ByteString; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.stream.Collectors; +import org.bouncycastle.util.encoders.Hex; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tron.common.parameter.CommonParameter; +import org.tron.common.runtime.vm.DataWord; +import org.tron.common.utils.ByteArray; +import org.tron.core.actuator.VMActuator; +import org.tron.core.capsule.BlockCapsule; +import org.tron.core.capsule.TransactionCapsule; +import org.tron.core.db.TransactionContext; +import org.tron.core.db.TransactionContext.ExecutionMode; +import org.tron.core.db2.archive.ArchiveReadContext.HistoricalStore; +import org.tron.core.db2.archive.ArchiveReadContext.StoreAdapter; +import org.tron.core.db2.archive.ArchiveReadSnapshot.PinnedLatestState; +import org.tron.core.db2.archive.BlockReverseDiff.DbGroup; +import org.tron.core.db2.archive.BlockReverseDiff.Entry; +import org.tron.core.db2.archive.HistoricalRangeOverlay.KeyRange; +import org.tron.core.db2.archive.HistoricalRangeOverlay.Limits; +import org.tron.core.db2.archive.HistoryIndexRecord.KeyGroup; +import org.tron.core.db2.archive.P66AccountAssetCodec.Phase; +import org.tron.core.exception.ContractValidateException; +import org.tron.core.store.DynamicPropertiesStore; +import org.tron.core.store.StorageRowKeyCodec; +import org.tron.core.vm.HistoricalCapabilityException; +import org.tron.core.vm.HistoricalExecutionGuard; +import org.tron.core.vm.OperationActions; +import org.tron.core.vm.PrecompiledContracts; +import org.tron.core.vm.config.ConfigLoader; +import org.tron.core.vm.config.VMConfig; +import org.tron.core.vm.program.Program; +import org.tron.core.vm.repository.HistoricalRepositoryProvider; +import org.tron.core.vm.repository.Repository; +import org.tron.core.vm.repository.RepositoryImpl; +import org.tron.protos.Protocol; +import org.tron.protos.Protocol.Account; +import org.tron.protos.Protocol.AccountType; +import org.tron.protos.contract.SmartContractOuterClass.CreateSmartContract; +import org.tron.protos.contract.SmartContractOuterClass.SmartContract; +import org.tron.protos.contract.SmartContractOuterClass.TriggerSmartContract; + +public class ArchiveReadSnapshotTest { + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void readsPointAndRangeFromOnePinnedPhysicalKeyGeneration() throws Exception { + try (Fixture fixture = new Fixture(temporaryFolder.newFolder("snapshot").toPath())) { + fixture.append(diff(1, entry("p/c", OldValue.absent()))); + fixture.append(diff(2, + entry("p/a", "a1"), entry("p/b", "b1"), entry("p/e", OldValue.absent()), + entry("p/f", OldValue.present(new byte[0])))); + fixture.append(diff(3, entry("p/a", "a2"))); + fixture.sync(); + + Map latestValues = new HashMap<>(); + latestValues.put("p/a", bytes("a3")); + latestValues.put("p/c", bytes("c1")); + latestValues.put("p/e", bytes("e2")); + InMemoryLatest latest = new InMemoryLatest(3, hash(3), latestValues); + try (ArchiveReadSnapshot snapshot = fixture.snapshot(1, latest)) { + assertValue(snapshot.get("account", bytes("p/a")), "a1"); + assertValue(snapshot.get("account", bytes("p/b")), "b1"); + assertValue(snapshot.get("account", bytes("p/c")), "c1"); + assertFalse(snapshot.get("account", bytes("p/e")).isPresent()); + assertArrayEquals(new byte[0], + snapshot.get("account", bytes("p/f")).getValue()); + + List range = snapshot.range("account", + KeyRange.prefix(bytes("p/")), new Limits(10, 10, 10)); + assertEquals(Arrays.asList("p/a", "p/b", "p/c", "p/f"), keys(range)); + } + assertTrue(latest.closed); + } + } + + @Test + public void rejectsMixedGenerationOrCoverageBeforeReading() throws Exception { + try (Fixture fixture = new Fixture(temporaryFolder.newFolder("identity").toPath())) { + fixture.append(diff(1, entry("key", "old"))); + fixture.sync(); + ServingKeyIndexGeneration serving = fixture.serving(); + CommittedHistoryReader history = fixture.history(); + InMemoryLatest wrongHash = new InMemoryLatest(1, hash(99), Collections.emptyMap()); + + assertThrows(IllegalArgumentException.class, () -> ArchiveReadSnapshot.pin( + 0, 1, hash(1), serving, wrongHash, history)); + assertTrue(wrongHash.closed); + + CommittedHistoryReader secondHistory = fixture.history(); + InMemoryLatest wrongCoverage = new InMemoryLatest(0, hash(0), Collections.emptyMap()); + assertThrows(IllegalArgumentException.class, () -> ArchiveReadSnapshot.pin( + 0, 0, hash(0), serving, wrongCoverage, secondHistory)); + assertTrue(wrongCoverage.closed); + } + } + + @Test + public void rejectsWrongKeyBetweenAuthoritativeIndexAndBody() throws Exception { + try (Fixture fixture = new Fixture(temporaryFolder.newFolder("wrong-key").toPath())) { + BlockReverseDiff body = diff(1, entry("actual", "old")); + HistoryLocation location = fixture.bodies.append(body); + HistoryIndexRecord wrongIndex = new HistoryIndexRecord(body.getMeta(), location, + Collections.singletonList(new KeyGroup("account", + Collections.singletonList(bytes("indexed"))))); + HistoryIndexLocation indexLocation = fixture.index.append(wrongIndex); + fixture.markers.add(marker(body.getMeta(), location, indexLocation)); + fixture.sync(); + + try (ArchiveReadSnapshot snapshot = fixture.snapshot(0, + new InMemoryLatest(1, hash(1), Collections.emptyMap()))) { + assertThrows(ArchivePersistenceException.class, + () -> snapshot.get("account", bytes("indexed"))); + } + } + } + + @Test + public void rejectsDigestMismatchAndMissingSegment() throws Exception { + assertUnreadableBody("digest", location -> new HistoryLocation(location.getSegmentId(), + location.getOffset(), location.getRecordLength(), location.getBodyChecksum(), hash(99)), + IllegalArgumentException.class); + assertUnreadableBody("missing", location -> new HistoryLocation(99, location.getOffset(), + location.getRecordLength(), location.getBodyChecksum(), location.getBodyDigest()), + IOException.class); + } + + @Test + public void releasesHistoryPinWhenCommittedPrefixValidationFails() throws Exception { + try (Fixture fixture = new Fixture(temporaryFolder.newFolder("failed-history-pin").toPath())) { + fixture.append(diff(1, entry("key", "old"))); + fixture.sync(); + AtomicBoolean released = new AtomicBoolean(); + ServingKeyIndexGeneration.AuthoritativeIndexReader failingIndex = location -> { + throw new IOException("injected index failure"); + }; + + assertThrows(IOException.class, () -> new CommittedHistoryReader(0, hash(0), + fixture.markers, failingIndex, fixture.bodies::read, () -> released.set(true))); + assertTrue(released.get()); + } + } + + @Test + public void bindsEveryVersionedPhysicalStoreToOneRequestSnapshot() throws Exception { + try (Fixture fixture = new Fixture(temporaryFolder.newFolder("read-context").toPath())) { + fixture.append(diff(1, entry("key", "old"))); + fixture.sync(); + Map latestValues = new HashMap<>(); + latestValues.put("key", bytes("new")); + InMemoryLatest latest = new InMemoryLatest(1, hash(1), latestValues); + AdapterSet adapterSet = rawAdapters(); + + try (ArchiveReadContext context = ArchiveReadContext.open( + fixture.snapshot(0, latest), adapterSet.adapters)) { + HistoricalStore account = context.store(adapterSet.account); + assertArrayEquals(bytes("old"), account.get(bytes("key")).orElseThrow(AssertionError::new)); + assertFalse(account.has(bytes("missing"))); + assertEquals(0, context.getTargetBlock()); + assertEquals(1, context.getPinnedBlock()); + assertTrue(context.getAdapterDbNames().contains("account-asset")); + assertFalse(context.getAdapterDbNames().contains("accountTrie")); + } + assertTrue(latest.closed); + } + } + + @Test + public void contextResolvesHistoricalAccountAssetBeforePinnedHead() throws Exception { + byte[] address = address(41); + String tokenId = "1000001"; + byte[] account = account(address, false, tokenId, 17L); + byte[] directKey = new P66AccountAssetCodec().assetPhysicalKey(address, tokenId); + try (Fixture fixture = new Fixture( + temporaryFolder.newFolder("historical-account-asset-context").toPath())) { + fixture.append(diff(1, + new DbGroup("properties", Collections.singletonList(new Entry( + HistoricalAccountAssetBalanceResolver.proposal66PhysicalKey(), + OldValue.present(ByteArray.fromLong(0L))))), + new DbGroup("account", Collections.singletonList( + new Entry(address, OldValue.present(account)))), + new DbGroup("account-asset", Collections.singletonList( + new Entry(directKey, OldValue.absent()))))); + fixture.sync(); + InMemoryLatest latest = new InMemoryLatest(1, hash(1), Collections.emptyMap()); + + try (ArchiveReadContext context = ArchiveReadContext.open( + fixture.snapshot(0, latest), rawAdapters().adapters)) { + HistoricalAccountAssetBalanceResolver.Result result = + context.resolveAccountAsset(address, tokenId); + assertEquals(0L, result.getBlockNumber()); + assertEquals(Phase.P66_OFF, result.getPhase()); + assertEquals(17L, result.getBalance()); + assertArrayEquals(account, result.getAccountValue()); + byte[] callerCopy = result.getAccountValue(); + callerCopy[0] ^= 1; + assertArrayEquals(account, result.getAccountValue()); + + HistoricalAccountAssetPrefixResolver.Result all = context.resolveAccountAssets(address, + new HistoricalAccountAssetPrefixResolver.Limits(10, 10, 10, 64, 8, 1_000)); + assertEquals(1, all.getBalances().size()); + assertEquals(tokenId, all.getBalances().get(0).getTokenId()); + assertEquals(17L, all.getBalances().get(0).getBalance()); + } + assertTrue(latest.closed); + } + } + + @Test + public void resolvesFixedP66TransitionVectorsAcrossCommittedSnapshots() throws Exception { + byte[] address = address(43); + String tokenId = "1000007"; + byte[] directKey = new P66AccountAssetCodec().assetPhysicalKey(address, tokenId); + byte[] offAtZero = account(address, false, tokenId, 10L); + byte[] offAtOne = account(address, false, tokenId, 20L); + byte[] activationAtTwo = optimizedAccount(address, 2_000L); + byte[] onAtThree = optimizedAccount(address, 3_000L); + byte[] onAtFour = optimizedAccount(address, 4_000L); + HistoricalAccountAssetPrefixResolver.Limits limits = + new HistoricalAccountAssetPrefixResolver.Limits(10, 10, 10, 64, 8, 1_000); + + try (Fixture fixture = new Fixture( + temporaryFolder.newFolder("p66-transition-vectors").toPath())) { + fixture.append(diff(1, + new DbGroup("account", Collections.singletonList( + new Entry(address, OldValue.present(offAtZero)))))); + BlockReverseDiff activation = diff(2, + new DbGroup("properties", Collections.singletonList(new Entry( + HistoricalAccountAssetBalanceResolver.proposal66PhysicalKey(), + OldValue.present(ByteArray.fromLong(0L))))), + new DbGroup("account", Collections.singletonList( + new Entry(address, OldValue.present(offAtOne)))), + new DbGroup("account-asset", Collections.singletonList( + new Entry(directKey, OldValue.absent())))); + fixture.append(activation); + fixture.append(diff(3, + new DbGroup("account", Collections.singletonList( + new Entry(address, OldValue.present(activationAtTwo)))), + new DbGroup("account-asset", Collections.singletonList( + new Entry(directKey, OldValue.present(ByteArray.fromLong(30L))))))); + fixture.append(diff(4, + new DbGroup("account", Collections.singletonList( + new Entry(address, OldValue.present(onAtThree)))), + new DbGroup("account-asset", Collections.singletonList( + new Entry(directKey, OldValue.present(ByteArray.fromLong(40L))))))); + fixture.sync(); + + Entry activationReverseAsset = activation.getGroups().stream() + .filter(group -> "account-asset".equals(group.getDbName())) + .findFirst().orElseThrow(AssertionError::new).getEntries().get(0); + assertArrayEquals(directKey, activationReverseAsset.getKey()); + assertFalse(activationReverseAsset.getOldValue().isPresent()); + + Map> latestValues = new HashMap<>(); + latestValues.put("properties", Collections.singletonMap( + text(HistoricalAccountAssetBalanceResolver.proposal66PhysicalKey()), + ByteArray.fromLong(1L))); + latestValues.put("account", Collections.singletonMap(text(address), onAtFour)); + latestValues.put("account-asset", Collections.singletonMap( + text(directKey), ByteArray.fromLong(50L))); + + assertAccountAssetVector(fixture, latestValues, 1L, Phase.P66_OFF, offAtOne, + tokenId, 20L, limits); + // The request API reports the activation target as P66_ON because both use the same + // canonical direct-row layout; the durable mutation plan retains P66_ACTIVATION. + assertAccountAssetVector(fixture, latestValues, 2L, Phase.P66_ON, activationAtTwo, + tokenId, 30L, limits); + assertAccountAssetVector(fixture, latestValues, 3L, Phase.P66_ON, onAtThree, + tokenId, 40L, limits); + } + } + + @Test + public void accountAssetContextRejectsForeignAdaptersAndUseAfterClose() throws Exception { + byte[] address = address(42); + String tokenId = "1000001"; + Map latestValues = new HashMap<>(); + latestValues.put(text(HistoricalAccountAssetBalanceResolver.proposal66PhysicalKey()), + ByteArray.fromLong(1L)); + latestValues.put(text(address), account(address, true, null, 0L)); + InMemoryLatest latest = new InMemoryLatest(0, hash(0), latestValues); + AdapterSet adapters = rawAdapters(); + try (Fixture fixture = new Fixture( + temporaryFolder.newFolder("closed-account-asset-context").toPath())) { + ArchiveReadContext context = ArchiveReadContext.open( + fixture.snapshot(0, latest), adapters.adapters); + StoreAdapter foreignAccount = StoreAdapter.define("account", value -> value); + assertThrows(IllegalArgumentException.class, () -> context.store(foreignAccount)); + StoreAdapter foreignAbi = StoreAdapter.define("abi", value -> value); + assertThrows(IllegalArgumentException.class, () -> context.store(foreignAbi)); + assertEquals(0L, context.resolveAccountAsset(address, tokenId).getBalance()); + + context.close(); + assertTrue(latest.closed); + assertThrows(IllegalStateException.class, + () -> context.resolveAccountAsset(address, tokenId)); + assertThrows(IllegalStateException.class, + () -> context.resolveAccountAssets(address, + new HistoricalAccountAssetPrefixResolver.Limits(10, 10, 10, 64, 8, 1_000))); + } + } + + @Test + public void rejectsIncompleteOrDerivedStoreAdaptersAndReleasesSnapshot() throws Exception { + assertThrows(IllegalArgumentException.class, + () -> StoreAdapter.define("accountTrie", value -> value)); + try (Fixture fixture = new Fixture(temporaryFolder.newFolder("adapter-set").toPath())) { + fixture.append(diff(1, entry("key", "old"))); + fixture.sync(); + InMemoryLatest latest = new InMemoryLatest(1, hash(1), Collections.emptyMap()); + AdapterSet adapterSet = rawAdapters(); + adapterSet.adapters.remove(adapterSet.account); + + assertThrows(IllegalArgumentException.class, () -> ArchiveReadContext.open( + fixture.snapshot(0, latest), adapterSet.adapters)); + assertTrue(latest.closed); + } + } + + @Test + public void resolvesLogicalStorageWithHistoricalContractFromTheSameContext() throws Exception { + byte[] address = Hex.decode("410102030405060708090a0b0c0d0e0f1011121314"); + byte[] slot = Hex.decode( + "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"); + byte[] transactionHash = Hex.decode( + "f0e0d0c0b0a090807060504030201000112233445566778899aabbccddeeff00"); + byte[] physicalKey = Hex.decode( + "9397a7a785754542ff19d0968c0f92d4dea5e526567e92b0321816a4e895bd2d"); + SmartContract historicalContract = SmartContract.newBuilder().setVersion(1) + .setTrxHash(ByteString.copyFrom(transactionHash)).build(); + SmartContract latestContract = SmartContract.newBuilder().setVersion(0).build(); + + try (Fixture fixture = new Fixture(temporaryFolder.newFolder("logical-storage").toPath())) { + fixture.append(diff(1, + new DbGroup("contract", Collections.singletonList( + new Entry(address, OldValue.present(historicalContract.toByteArray())))), + new DbGroup("storage-row", Collections.singletonList( + new Entry(physicalKey, OldValue.present(bytes("historical-word"))))))); + fixture.sync(); + Map latestValues = new HashMap<>(); + latestValues.put(text(address), latestContract.toByteArray()); + AdapterSet adapterSet = rawAdapters(); + + try (ArchiveReadContext context = ArchiveReadContext.open( + fixture.snapshot(0, new InMemoryLatest(1, hash(1), latestValues)), + adapterSet.adapters)) { + assertArrayEquals(bytes("historical-word"), + context.getStorage(address, slot).orElseThrow(AssertionError::new)); + } + } + } + + @Test + public void historicalQuerySessionOwnsExactTypedViewAndReleasesGateLease() throws Exception { + byte[] address = address(51); + byte[] slot = Hex.decode( + "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"); + byte[] transactionHash = hash(91); + SmartContract contract = SmartContract.newBuilder().setVersion(1) + .setTrxHash(ByteString.copyFrom(transactionHash)).build(); + byte[] physicalKey = StorageRowKeyCodec.physicalKey(address, slot, + contract.getVersion(), transactionHash); + byte[] historicalAccount = optimizedAccount(address, 77L); + byte[] historicalCode = bytes("historical-code"); + byte[] historicalStorage = hash(17); + + try (Fixture fixture = new Fixture( + temporaryFolder.newFolder("historical-query-session").toPath())) { + fixture.append(diff(1, + new DbGroup("account", Collections.singletonList( + new Entry(address, OldValue.present(historicalAccount)))), + new DbGroup("contract", Collections.singletonList( + new Entry(address, OldValue.present(contract.toByteArray())))), + new DbGroup("code", Collections.singletonList( + new Entry(address, OldValue.present(historicalCode)))), + new DbGroup("storage-row", Collections.singletonList( + new Entry(physicalKey, OldValue.present(historicalStorage)))), + new DbGroup("properties", historicalVmProperties()))); + fixture.sync(); + InMemoryLatest latest = InMemoryLatest.scoped(1, hash(1), Collections.emptyMap()); + ArchiveRuntimeQueryGate gate = new ArchiveRuntimeQueryGate( + target -> fixture.snapshot(target, latest)); + HistoricalQuerySession session = HistoricalQuerySession.open(gate.pin(0), hash(0)); + + assertEquals(1, gate.getActiveLeaseCount()); + assertEquals(0L, session.getTargetBlock()); + assertArrayEquals(hash(0), session.getTargetBlockHash()); + assertEquals(77L, session.getAccount(address).orElseThrow(AssertionError::new) + .getBalance()); + assertEquals(contract, session.getContract(address).orElseThrow(AssertionError::new)); + assertArrayEquals(historicalCode, + session.getCode(address).orElseThrow(AssertionError::new)); + assertArrayEquals(historicalStorage, + session.getStorage(address, slot).orElseThrow(AssertionError::new)); + + session.close(); + assertEquals(0, gate.getActiveLeaseCount()); + assertTrue(latest.closed); + assertThrows(IllegalStateException.class, session::getTargetBlock); + gate.close(); + } + } + + @Test + public void historicalQuerySessionFailsClosedWhenReadBudgetIsExceeded() throws Exception { + byte[] address = address(55); + try (Fixture fixture = new Fixture( + temporaryFolder.newFolder("historical-query-budget").toPath())) { + fixture.append(diff(1, new DbGroup("code", Collections.singletonList( + new Entry(address, OldValue.present(bytes("old-code"))))))); + fixture.sync(); + InMemoryLatest latest = InMemoryLatest.scoped(1, hash(1), Collections.emptyMap()); + ArchiveRuntimeQueryGate gate = new ArchiveRuntimeQueryGate( + target -> fixture.snapshot(target, latest)); + try (HistoricalQuerySession session = HistoricalQuerySession.open(gate.pin(0), hash(0), + new HistoricalQuerySession.Limits(1, 1024, 10_000))) { + assertArrayEquals(bytes("old-code"), + session.getCode(address).orElseThrow(AssertionError::new)); + assertThrows(HistoricalQueryBudgetException.class, () -> session.getCode(address)); + } + assertTrue(latest.closed); + gate.close(); + } + } + + @Test + public void historicalRepositoryUsesOneSourceAndKeepsWritesInOverlay() throws Exception { + byte[] address = address(52); + byte[] slot = hash(31); + byte[] historicalStorage = hash(32); + byte[] overlayStorage = hash(33); + byte[] historicalCode = bytes("historical-repository-code"); + SmartContract contract = SmartContract.newBuilder().setVersion(0).build(); + byte[] physicalKey = StorageRowKeyCodec.physicalKey(address, slot, 0, null); + + try (Fixture fixture = new Fixture( + temporaryFolder.newFolder("historical-repository").toPath())) { + fixture.append(diff(1, + new DbGroup("account", Collections.singletonList( + new Entry(address, OldValue.present(optimizedAccount(address, 81L))))), + new DbGroup("contract", Collections.singletonList( + new Entry(address, OldValue.present(contract.toByteArray())))), + new DbGroup("code", Collections.singletonList( + new Entry(address, OldValue.present(historicalCode)))), + new DbGroup("storage-row", Collections.singletonList( + new Entry(physicalKey, OldValue.present(historicalStorage)))), + new DbGroup("properties", historicalVmProperties()))); + fixture.sync(); + InMemoryLatest latest = InMemoryLatest.scoped(1, hash(1), Collections.emptyMap()); + ArchiveRuntimeQueryGate gate = new ArchiveRuntimeQueryGate( + target -> fixture.snapshot(target, latest)); + + try (HistoricalQuerySession session = HistoricalQuerySession.open(gate.pin(0), hash(0))) { + Repository root = RepositoryImpl.createHistoricalRoot(null, session); + assertTrue(root.isHistorical()); + assertEquals(81L, root.getBalance(address)); + assertArrayEquals(historicalCode, root.getCode(address)); + assertEquals(new DataWord(historicalStorage), + root.getStorageValue(address, new DataWord(slot))); + + Repository child = root.newRepositoryChild(); + child.putStorageValue(address, new DataWord(slot), new DataWord(overlayStorage)); + assertEquals(new DataWord(overlayStorage), + child.getStorageValue(address, new DataWord(slot))); + child.commit(); + assertEquals(new DataWord(overlayStorage), + root.getStorageValue(address, new DataWord(slot))); + ConfigLoader.load(root); + assertTrue(VMConfig.allowTvmConstantinople()); + assertTrue(VMConfig.getEnergyLimitHardFork()); + VMConfig.clearLocalSnapshot(); + assertThrows(HistoricalCapabilityException.class, + root::getDynamicPropertiesStore); + + Program program = mock(Program.class); + when(program.getContractState()).thenReturn(root); + assertThrows(HistoricalCapabilityException.class, + () -> OperationActions.suicideAction(program)); + verify(program, never()).stackPop(); + HistoricalExecutionGuard.requirePrecompileAllowed( + new PrecompiledContracts.Identity()); + assertThrows(HistoricalCapabilityException.class, + () -> HistoricalExecutionGuard.requirePrecompileAllowed( + new PrecompiledContracts.RewardBalance())); + assertThrows(IllegalStateException.class, root::commit); + } + assertTrue(latest.closed); + gate.close(); + } + } + + private static List historicalVmProperties() { + List properties = new ArrayList<>(); + String[] enabled = { + "ALLOW_MULTI_SIGN", "ALLOW_TVM_TRANSFER_TRC10", "ALLOW_TVM_CONSTANTINOPLE", + "ALLOW_TVM_SOLIDITY_059", "ALLOW_SHIELDED_TRC20_TRANSACTION", + "ALLOW_TVM_ISTANBUL", "ALLOW_TVM_FREEZE", "ALLOW_TVM_VOTE", "ALLOW_TVM_LONDON", + "ALLOW_TVM_COMPATIBLE_EVM", "ALLOW_HIGHER_LIMIT_FOR_MAX_CPU_TIME_OF_ONE_TX", + "ALLOW_OPTIMIZED_RETURN_VALUE_OF_CHAIN_ID", "ALLOW_DYNAMIC_ENERGY", + "ALLOW_TVM_SHANGHAI", "ALLOW_ENERGY_ADJUSTMENT", "ALLOW_STRICT_MATH", + "ALLOW_TVM_CANCUN", "CONSENSUS_LOGIC_OPTIMIZATION", "ALLOW_TVM_BLOB", + "ALLOW_TVM_SELFDESTRUCT_RESTRICTION", "ALLOW_TVM_OSAKA", + "ALLOW_HARDEN_RESOURCE_CALCULATION", "ALLOW_CREATION_OF_CONTRACTS" + }; + for (String key : enabled) { + properties.add(new Entry(bytes(key), OldValue.present(ByteArray.fromLong(1L)))); + } + properties.add(new Entry(bytes("latest_block_header_number"), + OldValue.present(ByteArray.fromLong(Long.MAX_VALUE)))); + String[] values = { + "UNFREEZE_DELAY_DAYS", "DYNAMIC_ENERGY_THRESHOLD", + "DYNAMIC_ENERGY_INCREASE_FACTOR", "DYNAMIC_ENERGY_MAX_FACTOR", "ENERGY_FEE", + "MAX_FEE_LIMIT", "MAX_CPU_TIME_OF_ONE_TX", "CURRENT_CYCLE_NUMBER" + }; + for (String key : values) { + properties.add(new Entry(bytes(key), OldValue.present(ByteArray.fromLong(100L)))); + } + return properties; + } + + @Test + public void historicalRepositoryAppliesEveryTypedDynamicPropertyDefault() throws Exception { + Map defaults = DynamicPropertiesStore.getLongPropertyDefaults(); + Set expectedKeys = new java.util.HashSet<>(Arrays.asList( + "WITNESS_127_PAY_PER_BLOCK", "CURRENT_CYCLE_NUMBER", "ALLOW_TVM_SHANGHAI", + "ALLOW_CANCEL_ALL_UNFREEZE_V2", "MAX_DELEGATE_LOCK_PERIOD", + "ALLOW_OLD_REWARD_OPT", "ALLOW_ENERGY_ADJUSTMENT", "MAX_CREATE_ACCOUNT_TX_SIZE", + "ALLOW_STRICT_MATH", "CONSENSUS_LOGIC_OPTIMIZATION", "ALLOW_TVM_CANCUN", + "ALLOW_TVM_BLOB", "ALLOW_TVM_SELFDESTRUCT_RESTRICTION", "PROPOSAL_EXPIRE_TIME", + "ALLOW_TVM_OSAKA", "ALLOW_TVM_PRAGUE", "BLOCK_HASH_HISTORY_INSTALLED", + "ALLOW_HARDEN_RESOURCE_CALCULATION", "ALLOW_HARDEN_EXCHANGE_CALCULATION", + "TURKISH_KEY_MIGRATION_DONE")); + assertEquals(expectedKeys, defaults.keySet()); + + List requiredProperties = historicalVmProperties().stream() + .filter(entry -> !defaults.containsKey( + new String(entry.getKey(), StandardCharsets.UTF_8))) + .collect(Collectors.toList()); + try (Fixture fixture = new Fixture( + temporaryFolder.newFolder("historical-dynamic-defaults").toPath())) { + fixture.append(diff(1, new DbGroup("properties", requiredProperties))); + fixture.sync(); + InMemoryLatest latest = InMemoryLatest.scoped(1, hash(1), Collections.emptyMap()); + ArchiveRuntimeQueryGate gate = new ArchiveRuntimeQueryGate( + target -> fixture.snapshot(target, latest)); + + try (HistoricalQuerySession session = HistoricalQuerySession.open(gate.pin(0), hash(0))) { + Repository root = RepositoryImpl.createHistoricalRoot(null, session); + for (Map.Entry defaultEntry : defaults.entrySet()) { + assertEquals(defaultEntry.getKey(), defaultEntry.getValue().longValue(), + root.getDynamicPropertyLong(defaultEntry.getKey())); + } + assertThrows(IllegalArgumentException.class, + () -> root.getDynamicPropertyLong("UNKNOWN_REQUIRED_PROPERTY")); + + ConfigLoader.load(root); + assertEquals(defaults.get("ALLOW_TVM_OSAKA").longValue(), + VMConfig.allowTvmOsaka() ? 1L : 0L); + assertEquals(defaults.get("ALLOW_HARDEN_RESOURCE_CALCULATION").longValue(), + VMConfig.allowHardenResourceCalculation() ? 1L : 0L); + VMConfig.clearLocalSnapshot(); + } + assertTrue(latest.closed); + gate.close(); + } + } + + @Test + public void historicalVmExecutesTriggerAgainstPinnedState() throws Exception { + byte[] owner = address(53); + byte[] contractAddress = address(54); + byte[] code = Hex.decode("60005460005260206000f3"); + byte[] storageValue = new DataWord(7).getData(); + byte[] physicalStorageKey = StorageRowKeyCodec.physicalKey( + contractAddress, new byte[32], 0, null); + SmartContract contract = SmartContract.newBuilder().setVersion(0).build(); + + try (Fixture fixture = new Fixture( + temporaryFolder.newFolder("historical-vm-trigger").toPath())) { + fixture.append(diff(1, + new DbGroup("account", Collections.singletonList( + new Entry(contractAddress, + OldValue.present(optimizedAccount(contractAddress, 0L))))), + new DbGroup("contract", Collections.singletonList( + new Entry(contractAddress, OldValue.present(contract.toByteArray())))), + new DbGroup("code", Collections.singletonList( + new Entry(contractAddress, OldValue.present(code)))), + new DbGroup("storage-row", Collections.singletonList( + new Entry(physicalStorageKey, OldValue.present(storageValue)))), + new DbGroup("properties", historicalVmProperties()))); + fixture.sync(); + InMemoryLatest latest = InMemoryLatest.scoped(1, hash(1), Collections.emptyMap()); + ArchiveRuntimeQueryGate gate = new ArchiveRuntimeQueryGate( + target -> fixture.snapshot(target, latest)); + + try (HistoricalQuerySession session = HistoricalQuerySession.open(gate.pin(0), hash(0))) { + TriggerSmartContract trigger = TriggerSmartContract.newBuilder() + .setOwnerAddress(ByteString.copyFrom(owner)) + .setContractAddress(ByteString.copyFrom(contractAddress)).build(); + TransactionCapsule transaction = new TransactionCapsule(trigger, + Protocol.Transaction.Contract.ContractType.TriggerSmartContract); + TransactionContext context = new TransactionContext( + new BlockCapsule(Protocol.Block.newBuilder().build()), transaction, null, true, false, + ExecutionMode.HISTORICAL_CONSTANT, session); + VMActuator actuator = new VMActuator(true, HistoricalRepositoryProvider.INSTANCE); + long previousConstantCallTimeoutMs = + CommonParameter.getInstance().getConstantCallTimeoutMs(); + CommonParameter.getInstance().setConstantCallTimeoutMs(5_000L); + try { + actuator.validate(context); + actuator.execute(context); + if (context.getProgramResult().getException() != null) { + throw context.getProgramResult().getException(); + } + assertEquals(new DataWord(7), new DataWord(context.getProgramResult().getHReturn())); + + TransactionCapsule createTransaction = new TransactionCapsule( + CreateSmartContract.getDefaultInstance(), + Protocol.Transaction.Contract.ContractType.CreateSmartContract); + TransactionContext createContext = new TransactionContext( + new BlockCapsule(Protocol.Block.newBuilder().build()), createTransaction, null, + true, false, ExecutionMode.HISTORICAL_CONSTANT, session); + VMActuator createActuator = new VMActuator( + true, HistoricalRepositoryProvider.INSTANCE); + ContractValidateException rejected = assertThrows(ContractValidateException.class, + () -> createActuator.validate(createContext)); + assertEquals("Historical execution only supports TriggerSmartContract", + rejected.getMessage()); + } finally { + CommonParameter.getInstance().setConstantCallTimeoutMs(previousConstantCallTimeoutMs); + VMConfig.clearLocalSnapshot(); + } + } + assertTrue(latest.closed); + gate.close(); + } + } + + @Test + public void logicalStorageFailsClosedForMissingCorruptOrClosedContractContext() + throws Exception { + byte[] address = Hex.decode("410102030405060708090a0b0c0d0e0f1011121314"); + byte[] slot = new byte[32]; + AdapterSet adapterSet = rawAdapters(); + try (Fixture fixture = new Fixture(temporaryFolder.newFolder("missing-contract").toPath())) { + ArchiveReadContext context = ArchiveReadContext.open( + fixture.snapshot(0, new InMemoryLatest(0, hash(0), Collections.emptyMap())), + adapterSet.adapters); + assertThrows(ArchivePersistenceException.class, () -> context.getStorage(address, slot)); + context.close(); + assertThrows(IllegalStateException.class, () -> context.getStorage(address, slot)); + } + + adapterSet = rawAdapters(); + try (Fixture fixture = new Fixture(temporaryFolder.newFolder("corrupt-contract").toPath())) { + fixture.append(diff(1, new DbGroup("contract", Collections.singletonList( + new Entry(address, OldValue.present(new byte[]{(byte) 0x80})))))); + fixture.sync(); + try (ArchiveReadContext context = ArchiveReadContext.open( + fixture.snapshot(0, new InMemoryLatest(1, hash(1), Collections.emptyMap())), + adapterSet.adapters)) { + assertThrows(ArchivePersistenceException.class, () -> context.getStorage(address, slot)); + } + } + } + + private void assertUnreadableBody(String name, LocationMutation mutation, + Class error) throws Exception { + try (Fixture fixture = new Fixture(temporaryFolder.newFolder(name).toPath())) { + BlockReverseDiff body = diff(1, entry("key", "old")); + HistoryLocation actual = fixture.bodies.append(body); + HistoryLocation referenced = mutation.apply(actual); + HistoryIndexLocation indexLocation = fixture.index.append( + new HistoryIndexRecord(body.getMeta(), referenced, + Collections.singletonList(new KeyGroup("account", + Collections.singletonList(bytes("key")))))); + fixture.markers.add(marker(body.getMeta(), referenced, indexLocation)); + fixture.sync(); + + try (ArchiveReadSnapshot snapshot = fixture.snapshot(0, + new InMemoryLatest(1, hash(1), Collections.emptyMap()))) { + assertThrows(error, () -> snapshot.get("account", bytes("key"))); + } + } + } + + private static BlockReverseDiff diff(int block, Entry... entries) { + return new BlockReverseDiff(new BlockSnapshotMeta(block, block, hash(block), + hash(block - 1), block * 3_000L), Collections.singletonList( + new DbGroup("account", Arrays.asList(entries)))); + } + + private static BlockReverseDiff diff(int block, DbGroup... groups) { + return new BlockReverseDiff(new BlockSnapshotMeta(block, block, hash(block), + hash(block - 1), block * 3_000L), Arrays.asList(groups)); + } + + private static Entry entry(String key, String oldValue) { + return entry(key, OldValue.present(bytes(oldValue))); + } + + private static Entry entry(String key, OldValue oldValue) { + return new Entry(bytes(key), oldValue); + } + + private static HistoryCommitMarker marker(BlockSnapshotMeta meta, HistoryLocation body, + HistoryIndexLocation index) { + return new HistoryCommitMarker(meta, meta.getEpoch() - 1, body, index, new byte[16], + new ArrayList<>(ArchiveStoreScope.getStateDatabases())); + } + + private static void assertValue(OldValue value, String expected) { + assertTrue(value.isPresent()); + assertArrayEquals(bytes(expected), value.getValue()); + } + + private static List keys(List entries) { + List result = new ArrayList<>(); + entries.forEach(entry -> result.add(text(entry.getKey()))); + return result; + } + + private static byte[] hash(int suffix) { + byte[] hash = new byte[32]; + hash[31] = (byte) suffix; + return hash; + } + + private static byte[] address(int suffix) { + byte[] address = new byte[21]; + address[0] = 0x41; + address[20] = (byte) suffix; + return address; + } + + private static byte[] account(byte[] address, boolean optimized, String tokenId, long balance) { + Account.Builder builder = Account.newBuilder().setAddress(ByteString.copyFrom(address)) + .setType(AccountType.Normal).setAssetOptimized(optimized); + if (tokenId != null) { + builder.putAsset("asset-name", balance).putAssetV2(tokenId, balance); + } + return builder.build().toByteArray(); + } + + private static byte[] optimizedAccount(byte[] address, long balance) { + return Account.newBuilder().setAddress(ByteString.copyFrom(address)) + .setType(AccountType.Normal).setAssetOptimized(true).setBalance(balance) + .build().toByteArray(); + } + + private static void assertAccountAssetVector(Fixture fixture, + Map> latestValues, long target, Phase expectedPhase, + byte[] expectedAccount, + String tokenId, long expectedBalance, + HistoricalAccountAssetPrefixResolver.Limits limits) throws Exception { + InMemoryLatest latest = InMemoryLatest.scoped(4, hash(4), latestValues); + ArchiveReadSnapshot snapshot = fixture.snapshot(target, latest); + assertArrayEquals(hash(4), snapshot.getPinnedHash()); + snapshot.requirePinnedIdentity(); + try (ArchiveReadContext context = ArchiveReadContext.open( + snapshot, rawAdapters().adapters)) { + assertEquals(target, context.getTargetBlock()); + assertEquals(4L, context.getPinnedBlock()); + HistoricalAccountAssetBalanceResolver.Result exact = + context.resolveAccountAsset(address(43), tokenId); + assertEquals(expectedPhase, exact.getPhase()); + assertArrayEquals(expectedAccount, exact.getAccountValue()); + assertEquals(expectedBalance, exact.getBalance()); + + HistoricalAccountAssetPrefixResolver.Result prefix = + context.resolveAccountAssets(address(43), limits); + assertEquals(expectedPhase, prefix.getPhase()); + assertArrayEquals(expectedAccount, prefix.getAccountValue()); + assertEquals(1, prefix.getBalances().size()); + assertEquals(tokenId, prefix.getBalances().get(0).getTokenId()); + assertEquals(expectedBalance, prefix.getBalances().get(0).getBalance()); + snapshot.requirePinnedIdentity(); + } + assertTrue(latest.closed); + } + + private static byte[] bytes(String value) { + return value.getBytes(StandardCharsets.UTF_8); + } + + private static String text(byte[] value) { + return new String(value, StandardCharsets.UTF_8); + } + + private static AdapterSet rawAdapters() { + List> adapters = new ArrayList<>(); + StoreAdapter account = StoreAdapter.define("account", + value -> Arrays.copyOf(value, value.length)); + for (String dbName : ArchiveStoreScope.getStateDatabases()) { + adapters.add("account".equals(dbName) ? account : StoreAdapter.define(dbName, + value -> Arrays.copyOf(value, value.length))); + } + return new AdapterSet(account, adapters); + } + + @FunctionalInterface + private interface LocationMutation { + HistoryLocation apply(HistoryLocation location); + } + + private static final class AdapterSet { + private final StoreAdapter account; + private final List> adapters; + + private AdapterSet(StoreAdapter account, List> adapters) { + this.account = account; + this.adapters = adapters; + } + } + + private static final class Fixture implements AutoCloseable { + private final HistorySegmentStore bodies; + private final HistoryIndexStore index; + private final List markers = new ArrayList<>(); + + private Fixture(Path archive) throws IOException { + bodies = new HistorySegmentStore(archive, new BlockHistoryCodec(), 4096); + index = new HistoryIndexStore(archive, new HistoryIndexCodec()); + } + + private void append(BlockReverseDiff diff) throws IOException { + HistoryLocation body = bodies.append(diff); + HistoryIndexLocation indexLocation = index.append(HistoryIndexRecord.from(diff, body)); + List databases = new ArrayList<>(ArchiveStoreScope.getStateDatabases()); + markers.add(new HistoryCommitMarker(diff.getMeta(), diff.getMeta().getEpoch() - 1, body, + indexLocation, new byte[16], databases)); + } + + private void sync() throws IOException { + bodies.sync(); + index.sync(); + } + + private ServingKeyIndexGeneration serving() throws IOException { + return ServingKeyIndexGeneration.rebuild( + "read-generation", 0, hash(0), markers, index::read, + new ArrayList<>(ArchiveStoreScope.getStateDatabases()), + ServingKeyIndexGeneration.IndexLayout.prototypeDefaults()); + } + + private CommittedHistoryReader history() throws IOException { + return new CommittedHistoryReader(0, hash(0), markers, index::read, bodies::read, + new ArrayList<>(ArchiveStoreScope.getStateDatabases())); + } + + private ArchiveReadSnapshot snapshot(long target, InMemoryLatest latest) throws IOException { + return ArchiveReadSnapshot.pin(target, markers.size(), hash(markers.size()), serving(), + latest, history()); + } + + @Override + public void close() throws Exception { + index.close(); + bodies.close(); + } + } + + private static final class InMemoryLatest implements PinnedLatestState { + private final long block; + private final byte[] hash; + private final Map values; + private final Map> scopedValues; + private boolean closed; + + private InMemoryLatest(long block, byte[] hash, Map values) { + this.block = block; + this.hash = Arrays.copyOf(hash, hash.length); + this.values = new HashMap<>(); + this.scopedValues = null; + values.forEach((key, value) -> this.values.put(key, Arrays.copyOf(value, value.length))); + } + + private InMemoryLatest(long block, byte[] hash, + Map> scopedValues, boolean scoped) { + this.block = block; + this.hash = Arrays.copyOf(hash, hash.length); + this.values = Collections.emptyMap(); + this.scopedValues = new HashMap<>(); + scopedValues.forEach((dbName, rows) -> { + Map copy = new HashMap<>(); + rows.forEach((key, value) -> copy.put(key, Arrays.copyOf(value, value.length))); + this.scopedValues.put(dbName, copy); + }); + } + + private static InMemoryLatest scoped(long block, byte[] hash, + Map> values) { + return new InMemoryLatest(block, hash, values, true); + } + + @Override + public long getBlockNumber() { + return block; + } + + @Override + public byte[] getBlockHash() { + return Arrays.copyOf(hash, hash.length); + } + + @Override + public OldValue get(String dbName, byte[] physicalRawKey) { + Map rows = scopedValues == null ? values + : scopedValues.getOrDefault(dbName, Collections.emptyMap()); + return OldValue.fromNullable(rows.get(text(physicalRawKey))); + } + + @Override + public List range(String dbName, byte[] lowerInclusive, + byte[] upperExclusive, int maxEntries) { + List result = new ArrayList<>(); + Map rows = scopedValues == null ? values + : scopedValues.getOrDefault(dbName, Collections.emptyMap()); + rows.forEach((key, value) -> { + byte[] rawKey = bytes(key); + if (BlockReverseDiff.compareUnsigned(rawKey, lowerInclusive) >= 0 + && (upperExclusive == null + || BlockReverseDiff.compareUnsigned(rawKey, upperExclusive) < 0)) { + result.add(new HistoricalRangeOverlay.Entry(rawKey, value)); + } + }); + result.sort(Comparator.comparing(HistoricalRangeOverlay.Entry::getKey, + BlockReverseDiff::compareUnsigned)); + return result; + } + + @Override + public void close() { + closed = true; + } + + } +} diff --git a/framework/src/test/java/org/tron/core/db2/archive/ArchiveRuntimeQueryGateTest.java b/framework/src/test/java/org/tron/core/db2/archive/ArchiveRuntimeQueryGateTest.java new file mode 100644 index 00000000000..8a8429dbe43 --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/archive/ArchiveRuntimeQueryGateTest.java @@ -0,0 +1,162 @@ +package org.tron.core.db2.archive; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.Test; +import org.tron.core.db2.archive.ArchiveReadSnapshot.PinnedHistory; +import org.tron.core.db2.archive.ArchiveReadSnapshot.PinnedLatestState; +import org.tron.core.db2.archive.ArchiveRuntimeQueryGate.Lease; +import org.tron.core.db2.archive.ArchiveRuntimeQueryGate.State; + +public class ArchiveRuntimeQueryGateTest { + + @Test + public void quiesceRejectsNewPinsAndCloseRequiresEveryLease() throws Exception { + PinnedSnapshot pinned = snapshot(); + AtomicInteger pinCalls = new AtomicInteger(); + ArchiveRuntimeQueryGate gate = new ArchiveRuntimeQueryGate(target -> { + pinCalls.incrementAndGet(); + return pinned.snapshot; + }); + + Lease lease = gate.pin(0); + + assertSame(pinned.snapshot, lease.getSnapshot()); + assertEquals(1, gate.getActiveLeaseCount()); + assertFalse(gate.isDrained()); + gate.quiesce(); + assertEquals(State.QUIESCING, gate.getState()); + assertThrows(IllegalStateException.class, () -> gate.pin(0)); + assertEquals(1, pinCalls.get()); + assertThrows(IllegalStateException.class, gate::close); + assertSame(pinned.snapshot, lease.getSnapshot()); + + lease.close(); + lease.close(); + assertThrows(IllegalStateException.class, lease::getSnapshot); + + assertEquals(0, gate.getActiveLeaseCount()); + assertTrue(gate.isDrained()); + verify(pinned.history, times(1)).close(); + verify(pinned.latest, times(1)).close(); + verify(pinned.serving, times(1)).close(); + gate.close(); + gate.close(); + assertEquals(State.CLOSED, gate.getState()); + } + + @Test + public void failedPinAndFailedSnapshotCloseDoNotLeakLeaseAccounting() throws Exception { + AtomicInteger calls = new AtomicInteger(); + PinnedSnapshot pinned = snapshot(); + doThrow(new IOException("injected close failure")).when(pinned.history).close(); + ArchiveRuntimeQueryGate gate = new ArchiveRuntimeQueryGate(target -> { + if (calls.getAndIncrement() == 0) { + throw new IOException("injected pin failure"); + } + return pinned.snapshot; + }); + + assertThrows(IOException.class, () -> gate.pin(0)); + assertEquals(0, gate.getActiveLeaseCount()); + Lease lease = gate.pin(0); + assertEquals(1, gate.getActiveLeaseCount()); + + assertThrows(IOException.class, lease::close); + + assertTrue(gate.isDrained()); + verify(pinned.history).close(); + verify(pinned.latest).close(); + verify(pinned.serving).close(); + gate.close(); + } + + @Test + public void inFlightPinCompletesBeforeConcurrentQuiesce() throws Exception { + PinnedSnapshot pinned = snapshot(); + CountDownLatch pinEntered = new CountDownLatch(1); + CountDownLatch allowPin = new CountDownLatch(1); + ArchiveRuntimeQueryGate gate = new ArchiveRuntimeQueryGate(target -> { + pinEntered.countDown(); + try { + if (!allowPin.await(5, TimeUnit.SECONDS)) { + throw new IOException("pin release timed out"); + } + } catch (InterruptedException failure) { + Thread.currentThread().interrupt(); + throw new IOException("pin interrupted", failure); + } + return pinned.snapshot; + }); + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + Future pin = executor.submit(() -> gate.pin(0)); + assertTrue(pinEntered.await(5, TimeUnit.SECONDS)); + Future quiesce = executor.submit(gate::quiesce); + + assertFalse(quiesce.isDone()); + allowPin.countDown(); + Lease lease = pin.get(5, TimeUnit.SECONDS); + quiesce.get(5, TimeUnit.SECONDS); + + assertEquals(State.QUIESCING, gate.getState()); + assertEquals(1, gate.getActiveLeaseCount()); + assertThrows(IllegalStateException.class, () -> gate.pin(0)); + lease.close(); + assertTrue(gate.isDrained()); + gate.close(); + } finally { + executor.shutdownNow(); + } + } + + private static PinnedSnapshot snapshot() throws IOException { + byte[] hash = new byte[32]; + ServingKeyIndex serving = mock(ServingKeyIndex.class); + when(serving.getIndexedFrom()).thenReturn(0L); + when(serving.getIndexedThrough()).thenReturn(0L); + when(serving.getHeadHash()).thenReturn(hash); + when(serving.getAuthoritativePrefixDigest()).thenReturn(new byte[0]); + PinnedLatestState latest = mock(PinnedLatestState.class); + when(latest.getBlockNumber()).thenReturn(0L); + when(latest.getBlockHash()).thenReturn(hash); + PinnedHistory history = mock(PinnedHistory.class); + when(history.getIndexedFrom()).thenReturn(0L); + when(history.getIndexedThrough()).thenReturn(0L); + when(history.getHeadHash()).thenReturn(hash); + when(history.getAuthoritativePrefixDigest()).thenReturn(new byte[0]); + return new PinnedSnapshot( + ArchiveReadSnapshot.pin(0, 0, hash, serving, latest, history), serving, latest, history); + } + + private static final class PinnedSnapshot { + private final ArchiveReadSnapshot snapshot; + private final ServingKeyIndex serving; + private final PinnedLatestState latest; + private final PinnedHistory history; + + private PinnedSnapshot(ArchiveReadSnapshot snapshot, ServingKeyIndex serving, + PinnedLatestState latest, PinnedHistory history) { + this.snapshot = snapshot; + this.serving = serving; + this.latest = latest; + this.history = history; + } + } +} diff --git a/framework/src/test/java/org/tron/core/db2/archive/ArchiveTargetMutationPlanFileTest.java b/framework/src/test/java/org/tron/core/db2/archive/ArchiveTargetMutationPlanFileTest.java new file mode 100644 index 00000000000..f6917319200 --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/archive/ArchiveTargetMutationPlanFileTest.java @@ -0,0 +1,256 @@ +package org.tron.core.db2.archive; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; + +import com.google.common.hash.Hashing; +import com.google.common.io.BaseEncoding; +import com.google.protobuf.ByteString; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tron.core.db2.archive.ArchiveProgressEnvelope.Kind; +import org.tron.core.db2.archive.ArchiveTargetMutationPlanFile.Stage; +import org.tron.core.db2.archive.P66AccountAssetCodec.Phase; +import org.tron.protos.Protocol.Account; +import org.tron.protos.Protocol.AccountType; + +public class ArchiveTargetMutationPlanFileTest { + + private static final List PARTICIPANTS = + Arrays.asList("account", "account-asset"); + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void codecRoundTripsExactPutDeleteAndEmptyValues() { + ArchiveTargetMutationPlan plan = plan(1); + ArchiveTargetMutationPlan decoded = new ArchiveTargetMutationPlanCodec().decode( + new ArchiveTargetMutationPlanCodec().encode(plan)); + + assertEquals(1, decoded.getTarget().getEpoch()); + assertEquals(P66AccountAssetCodec.FORMAT_ID, decoded.getAccountAssetFormatId()); + assertEquals(Phase.P66_ON, decoded.getTargetPhase()); + assertEquals(PARTICIPANTS, decoded.getTarget().getParticipants()); + assertArrayEquals(bytes(3, 1), decoded.getMutations("account").get(0).getKey()); + assertArrayEquals(new byte[0], decoded.getMutations("account").get(0).getValue()); + assertNull(decoded.getMutations("account-asset").get(0).getValue()); + assertArrayEquals(plan.digest(), decoded.digest()); + assertEquals("e4bd9becc28e2afae72429f5630b3bd04bbe55fe9ae651809667bc5d5ad43bbf", + BaseEncoding.base16().lowerCase().encode(plan.digest())); + + Map> substituted = new LinkedHashMap<>( + plan.getMutations()); + substituted.put("account", Collections.singletonList( + ArchiveParticipantMutation.put(bytes(3, 1), bytes(1, 9)))); + ArchiveTargetMutationPlan replacement = new ArchiveTargetMutationPlan( + plan.getTarget(), P66AccountAssetCodec.FORMAT_ID, Phase.P66_ON, substituted); + assertFalse(Arrays.equals(plan.digest(), replacement.digest())); + } + + @Test + public void digestBindsFormatAndPhaseAndLegacyVersionFailsClosed() { + ArchiveTargetMutationPlan canonical = plan(1); + ArchiveTargetMutationPlan activation = new ArchiveTargetMutationPlan( + canonical.getTarget(), P66AccountAssetCodec.FORMAT_ID, Phase.P66_ACTIVATION, + canonical.getMutations()); + ArchiveTargetMutationPlan substitutedFormat = new ArchiveTargetMutationPlan( + canonical.getTarget(), "archive-state/p66-account-asset/legacy", + Phase.P66_ON, canonical.getMutations()); + + assertFalse(Arrays.equals(canonical.digest(), activation.digest())); + assertFalse(Arrays.equals(canonical.digest(), substitutedFormat.digest())); + ArchiveTargetMutationPlanCodec codec = new ArchiveTargetMutationPlanCodec(); + assertEquals(Phase.P66_ACTIVATION, + codec.decode(codec.encode(activation)).getTargetPhase()); + assertThrows(IllegalArgumentException.class, + () -> codec.decode(codec.encode(substitutedFormat))); + + byte[] legacy = codec.encode(canonical); + ByteBuffer.wrap(legacy).putShort(Integer.BYTES, (short) 1); + byte[] payload = Arrays.copyOf(legacy, legacy.length - Integer.BYTES); + ByteBuffer.wrap(legacy, legacy.length - Integer.BYTES, Integer.BYTES) + .putInt(Hashing.crc32c().hashBytes(payload).asInt()); + assertThrows(IllegalArgumentException.class, () -> codec.decode(legacy)); + } + + @Test + public void atomicFaultExposesOnlyOldOrNewPlan() throws Exception { + Path checkpoint = temporaryFolder.newFolder("atomic").toPath().resolve("checkpoint.progress"); + ArchiveTargetMutationPlanFile normal = new ArchiveTargetMutationPlanFile(checkpoint); + normal.store(plan(0)); + + ArchiveTargetMutationPlanFile beforeReplace = new ArchiveTargetMutationPlanFile(checkpoint, + (stage, path) -> { + if (stage == Stage.AFTER_TEMPORARY_FORCE) { + throw new IOException("injected before replace"); + } + }); + assertThrows(IOException.class, () -> beforeReplace.store(plan(1))); + assertEquals(0, normal.loadRequired().getTarget().getEpoch()); + + ArchiveTargetMutationPlanFile afterReplace = new ArchiveTargetMutationPlanFile(checkpoint, + (stage, path) -> { + if (stage == Stage.AFTER_REPLACE) { + throw new IOException("injected after replace"); + } + }); + assertThrows(IOException.class, () -> afterReplace.store(plan(1))); + assertEquals(1, normal.loadRequired().getTarget().getEpoch()); + } + + @Test + public void rejectsChecksumCorruptionAndTruncation() throws Exception { + Path checkpoint = temporaryFolder.newFolder("corrupt").toPath().resolve("checkpoint.progress"); + ArchiveTargetMutationPlanFile file = new ArchiveTargetMutationPlanFile(checkpoint); + file.store(plan(1)); + byte[] encoded = Files.readAllBytes(file.getPath()); + encoded[encoded.length - 1] ^= 1; + Files.write(file.getPath(), encoded); + assertThrows(ArchivePersistenceException.class, file::loadRequired); + + Files.write(file.getPath(), Arrays.copyOf(encoded, 10)); + assertThrows(ArchivePersistenceException.class, file::loadRequired); + } + + @Test + public void canonicalizesContainerOrderAndRejectsDuplicatePhysicalKeys() { + ArchiveProgressEnvelope target = plan(1).getTarget(); + Map> first = new LinkedHashMap<>(); + first.put("account-asset", Collections.singletonList( + ArchiveParticipantMutation.delete(bytes(3, 2)))); + first.put("account", Arrays.asList( + ArchiveParticipantMutation.put(bytes(3, 3), bytes(1, 3)), + ArchiveParticipantMutation.put(bytes(3, 1), bytes(1, 1)))); + Map> second = new LinkedHashMap<>(); + second.put("account", Arrays.asList( + ArchiveParticipantMutation.put(bytes(3, 1), bytes(1, 1)), + ArchiveParticipantMutation.put(bytes(3, 3), bytes(1, 3)))); + second.put("account-asset", Collections.singletonList( + ArchiveParticipantMutation.delete(bytes(3, 2)))); + assertArrayEquals(new ArchiveTargetMutationPlan(target, P66AccountAssetCodec.FORMAT_ID, + Phase.P66_ON, first).digest(), + new ArchiveTargetMutationPlan(target, P66AccountAssetCodec.FORMAT_ID, + Phase.P66_ON, second).digest()); + + second.put("account", Arrays.asList( + ArchiveParticipantMutation.put(bytes(3, 1), bytes(1, 1)), + ArchiveParticipantMutation.delete(bytes(3, 1)))); + assertThrows(IllegalArgumentException.class, + () -> new ArchiveTargetMutationPlan(target, P66AccountAssetCodec.FORMAT_ID, + Phase.P66_ON, second)); + } + + @Test + public void reopensFixedP66TransitionMutationPlanVectors() throws Exception { + byte[] address = accountAddress(7); + String tokenId = "1000007"; + P66AccountAssetCodec codec = new P66AccountAssetCodec(); + byte[] directKey = codec.assetPhysicalKey(address, tokenId); + ArchiveTargetMutationPlan[] vectors = { + transitionPlan(1, Phase.P66_OFF, address, + account(address, false, tokenId, 20L, 1_000L), null, null), + transitionPlan(2, Phase.P66_ACTIVATION, address, + account(address, true, null, 0L, 2_000L), directKey, + ByteBuffer.allocate(Long.BYTES).putLong(30L).array()), + transitionPlan(3, Phase.P66_ON, address, + account(address, true, null, 0L, 3_000L), directKey, + ByteBuffer.allocate(Long.BYTES).putLong(40L).array()) + }; + + for (ArchiveTargetMutationPlan expected : vectors) { + Path checkpoint = temporaryFolder.newFolder( + "p66-plan-" + expected.getTargetPhase().name().toLowerCase()) + .toPath().resolve("checkpoint.progress"); + new ArchiveTargetMutationPlanFile(checkpoint).store(expected); + + ArchiveTargetMutationPlan reopened = + new ArchiveTargetMutationPlanFile(checkpoint).loadRequired(); + assertEquals(expected.getTarget().getEpoch(), reopened.getTarget().getEpoch()); + assertArrayEquals(expected.getTarget().getBlockHash(), + reopened.getTarget().getBlockHash()); + assertArrayEquals(expected.getTarget().getBatchId(), reopened.getTarget().getBatchId()); + assertArrayEquals(expected.getTarget().getPayloadDigest(), + reopened.getTarget().getPayloadDigest()); + assertEquals(expected.getAccountAssetFormatId(), reopened.getAccountAssetFormatId()); + assertEquals(expected.getTargetPhase(), reopened.getTargetPhase()); + assertMutationsEqual(expected.getMutations("account"), + reopened.getMutations("account")); + assertMutationsEqual(expected.getMutations("account-asset"), + reopened.getMutations("account-asset")); + assertArrayEquals(expected.digest(), reopened.digest()); + } + } + + private static ArchiveTargetMutationPlan plan(long epoch) { + ArchiveProgressEnvelope target = new ArchiveProgressEnvelope(Kind.APPLY_CHECKPOINT, null, + epoch, bytes(32, (int) epoch), bytes(16, (int) epoch + 10), + bytes(32, (int) epoch + 20), PARTICIPANTS); + Map> mutations = new LinkedHashMap<>(); + mutations.put("account", Collections.singletonList( + ArchiveParticipantMutation.put(bytes(3, 1), new byte[0]))); + mutations.put("account-asset", Collections.singletonList( + ArchiveParticipantMutation.delete(bytes(3, 2)))); + return new ArchiveTargetMutationPlan(target, P66AccountAssetCodec.FORMAT_ID, + Phase.P66_ON, mutations); + } + + private static ArchiveTargetMutationPlan transitionPlan(long epoch, Phase phase, + byte[] address, byte[] accountValue, byte[] directKey, byte[] directValue) { + ArchiveProgressEnvelope target = new ArchiveProgressEnvelope(Kind.APPLY_CHECKPOINT, null, + epoch, bytes(32, (int) epoch), bytes(16, (int) epoch + 10), + bytes(32, (int) epoch + 20), PARTICIPANTS); + Map> mutations = new LinkedHashMap<>(); + mutations.put("account", Collections.singletonList( + ArchiveParticipantMutation.put(address, accountValue))); + mutations.put("account-asset", directKey == null ? Collections.emptyList() + : Collections.singletonList(ArchiveParticipantMutation.put(directKey, directValue))); + return new ArchiveTargetMutationPlan(target, P66AccountAssetCodec.FORMAT_ID, + phase, mutations); + } + + private static byte[] accountAddress(int suffix) { + byte[] address = new byte[21]; + address[0] = 0x41; + address[20] = (byte) suffix; + return address; + } + + private static byte[] account(byte[] address, boolean optimized, String tokenId, + long assetBalance, long balance) { + Account.Builder builder = Account.newBuilder().setAddress(ByteString.copyFrom(address)) + .setType(AccountType.Normal).setAssetOptimized(optimized).setBalance(balance); + if (tokenId != null) { + builder.putAsset("asset-name", assetBalance).putAssetV2(tokenId, assetBalance); + } + return builder.build().toByteArray(); + } + + private static void assertMutationsEqual(List expected, + List actual) { + assertEquals(expected.size(), actual.size()); + for (int i = 0; i < expected.size(); i++) { + assertArrayEquals(expected.get(i).getKey(), actual.get(i).getKey()); + assertArrayEquals(expected.get(i).getValue(), actual.get(i).getValue()); + } + } + + private static byte[] bytes(int length, int value) { + byte[] bytes = new byte[length]; + Arrays.fill(bytes, (byte) value); + return bytes; + } +} diff --git a/framework/src/test/java/org/tron/core/db2/archive/ArchiveTruncationRecoveryTest.java b/framework/src/test/java/org/tron/core/db2/archive/ArchiveTruncationRecoveryTest.java new file mode 100644 index 00000000000..f2ea3d292cc --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/archive/ArchiveTruncationRecoveryTest.java @@ -0,0 +1,180 @@ +package org.tron.core.db2.archive; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tron.core.db2.archive.ArchiveTruncationRecovery.Stage; +import org.tron.core.db2.archive.BlockReverseDiff.DbGroup; +import org.tron.core.db2.archive.BlockReverseDiff.Entry; + +public class ArchiveTruncationRecoveryTest { + + private static final List PARTICIPANTS = Collections.singletonList("account"); + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void preparedIntentRecoversBeforeCommitShrinkWithoutEpochZeroScan() throws Exception { + Path archive = temporaryFolder.newFolder("before-commit").toPath(); + initialize(archive); + prepare(archive); + assertHeads(archive, 12, 12, 12, 12); + + assertTrue(new ArchiveTruncationRecovery(archive, 4096).recover()); + assertRecovered(archive); + assertFalse(new ArchiveTruncationRecovery(archive, 4096).recover()); + } + + @Test + public void everyPostIntentCrashUsesIntentAndConvergesToTargetCheckpoint() throws Exception { + for (Stage failedStage : Stage.values()) { + Path archive = temporaryFolder.newFolder("intent-" + failedStage).toPath(); + initialize(archive); + prepare(archive); + ArchiveTruncationRecovery recovery = new ArchiveTruncationRecovery(archive, 4096, + stage -> { + if (stage == failedStage) { + throw new IOException("injected after " + stage); + } + }); + assertThrows(IOException.class, recovery::recover); + + ArchiveHistoryScanAnchor checkpoint = ArchiveHistoryScanAnchor.load(archive, + new HistoryCommitMarkerCodec()); + long expectedCheckpoint = failedStage == Stage.COMMIT_SHRUNK ? 12 : 10; + assertEquals(expectedCheckpoint, checkpoint.getMarker().getMeta().getEpoch()); + + assertTrue(new ArchiveTruncationRecovery(archive, 4096).recover()); + assertRecovered(archive); + } + } + + @Test + public void intentPreReplaceCrashNeverShrinksCommittedAuthority() throws Exception { + Path archive = temporaryFolder.newFolder("intent-pre-replace").toPath(); + initialize(archive); + ArchiveHistoryScanAnchor checkpoint = ArchiveHistoryScanAnchor.load(archive, + new HistoryCommitMarkerCodec()); + try (HistoryBodyStore bodies = new PartitionedHistoryBodyStore( + archive, new BlockHistoryCodec(), 4096, checkpoint); + HistoryIndexStore index = new HistoryIndexStore( + archive, new HistoryIndexCodec(), checkpoint); + HistoryCommitStore commits = new HistoryCommitStore( + archive, new HistoryCommitMarkerCodec(), checkpoint)) { + assertThrows(IOException.class, () -> ArchiveTruncationIntent.prepare(archive, + commits, index, bodies, 10, new HistoryCommitMarkerCodec(), temporary -> { + throw new IOException("injected before intent replace"); + })); + } + assertNull(ArchiveTruncationIntent.load(archive, new HistoryCommitMarkerCodec())); + assertFalse(new ArchiveTruncationRecovery(archive, 4096).recover()); + assertHeads(archive, 12, 12, 12, 12); + } + + @Test + public void corruptIntentFailsBeforeCommitShrink() throws Exception { + Path archive = temporaryFolder.newFolder("corrupt-intent").toPath(); + initialize(archive); + prepare(archive); + Path intentPath = archive.resolve("truncation.intent"); + byte[] corrupt = Files.readAllBytes(intentPath); + corrupt[corrupt.length - 1] ^= 1; + Files.write(intentPath, corrupt); + + assertThrows(ArchivePersistenceException.class, + () -> new ArchiveTruncationRecovery(archive, 4096).recover()); + assertHeads(archive, 12, 12, 12, 12); + } + + private static void initialize(Path archive) throws Exception { + HistoryCommitMarker head; + try (HistoryBodyStore bodies = new PartitionedHistoryBodyStore( + archive, new BlockHistoryCodec(), 4096, null); + HistoryIndexStore index = new HistoryIndexStore(archive, new HistoryIndexCodec()); + HistoryCommitStore commits = new HistoryCommitStore( + archive, new HistoryCommitMarkerCodec())) { + List markers = new ArrayList<>(); + for (long epoch = 8; epoch <= 12; epoch++) { + BlockReverseDiff diff = diff(epoch); + HistoryLocation body = bodies.append(diff); + HistoryIndexLocation indexLocation = index.append(HistoryIndexRecord.from(diff, body)); + markers.add(new HistoryCommitMarker(diff.getMeta(), epoch - 1, body, indexLocation, + bytes(16, (int) epoch + 40), PARTICIPANTS)); + } + bodies.sync(); + index.sync(); + commits.commitAll(markers); + head = commits.head(); + ArchiveHistoryScanAnchor.persist(archive, commits.firstEpoch(), commits.size(), + commits.getRecordLength(), head, new HistoryCommitMarkerCodec()); + } + } + + private static void prepare(Path archive) throws Exception { + ArchiveHistoryScanAnchor checkpoint = ArchiveHistoryScanAnchor.load(archive, + new HistoryCommitMarkerCodec()); + try (HistoryBodyStore bodies = new PartitionedHistoryBodyStore( + archive, new BlockHistoryCodec(), 4096, checkpoint); + HistoryIndexStore index = new HistoryIndexStore( + archive, new HistoryIndexCodec(), checkpoint); + HistoryCommitStore commits = new HistoryCommitStore( + archive, new HistoryCommitMarkerCodec(), checkpoint)) { + ArchiveTruncationIntent.prepare(archive, commits, index, bodies, 10, + new HistoryCommitMarkerCodec()); + } + } + + private static void assertRecovered(Path archive) throws Exception { + assertNull(ArchiveTruncationIntent.load(archive, new HistoryCommitMarkerCodec())); + assertHeads(archive, 10, 10, 10, 10); + } + + private static void assertHeads(Path archive, long checkpointEpoch, long commitEpoch, + long indexEpoch, long bodyEpoch) throws Exception { + ArchiveHistoryScanAnchor checkpoint = ArchiveHistoryScanAnchor.load(archive, + new HistoryCommitMarkerCodec()); + assertEquals(checkpointEpoch, checkpoint.getMarker().getMeta().getEpoch()); + try (HistoryBodyStore bodies = new PartitionedHistoryBodyStore( + archive, new BlockHistoryCodec(), 4096, checkpoint); + HistoryIndexStore index = new HistoryIndexStore( + archive, new HistoryIndexCodec(), checkpoint); + HistoryCommitStore commits = new HistoryCommitStore( + archive, new HistoryCommitMarkerCodec(), checkpoint)) { + assertEquals(commitEpoch, commits.head().getMeta().getEpoch()); + assertEquals(indexEpoch, index.getScanResult().getHead().getRecord().getMeta().getEpoch()); + assertEquals(bodyEpoch, bodies.getScanResult().getHead().getDiff().getMeta().getEpoch()); + if (checkpointEpoch == 10 && commitEpoch == 10 && indexEpoch == 10 && bodyEpoch == 10) { + assertEquals(1, commits.getStartupScannedRecords()); + assertEquals(1, index.getStartupScannedRecords()); + assertEquals(1, bodies.getStartupScannedRecords()); + } + } + } + + private static BlockReverseDiff diff(long epoch) { + return new BlockReverseDiff(new BlockSnapshotMeta(epoch, epoch, + bytes(32, (int) epoch), bytes(32, (int) epoch - 1), epoch * 1_000), + Collections.singletonList(new DbGroup("account", Collections.singletonList( + new Entry(bytes(8, (int) epoch), OldValue.present(bytes(12, (int) epoch))))))); + } + + private static byte[] bytes(int length, int value) { + byte[] bytes = new byte[length]; + Arrays.fill(bytes, (byte) value); + return bytes; + } +} diff --git a/framework/src/test/java/org/tron/core/db2/archive/ArchiveWalBindingTest.java b/framework/src/test/java/org/tron/core/db2/archive/ArchiveWalBindingTest.java new file mode 100644 index 00000000000..54eebf1dde5 --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/archive/ArchiveWalBindingTest.java @@ -0,0 +1,64 @@ +package org.tron.core.db2.archive; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThrows; + +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.Test; + +public class ArchiveWalBindingTest { + + private final ArchiveWalBindingCodec codec = new ArchiveWalBindingCodec(); + + @Test + public void roundTripsContiguousHistoryIdentityAndCheckpointEntry() { + ArchiveWalBinding binding = ArchiveWalBinding.fromMarkers(Arrays.asList( + marker(7, 6), marker(8, 7))); + + ArchiveWalBinding decoded = codec.decode(codec.encode(binding)); + + assertEquals(7, decoded.getFirst().getEpoch()); + assertEquals(8, decoded.getLast().getEpoch()); + assertEquals(6, decoded.getPredecessorEpoch()); + assertArrayEquals(hash(6), decoded.getPredecessorHash()); + assertArrayEquals(binding.getBatchDigest(), decoded.getBatchDigest()); + assertArrayEquals(binding.getStoreScopeDigest(), decoded.getStoreScopeDigest()); + assertArrayEquals(binding.getHistoryRefsDigest(), decoded.getHistoryRefsDigest()); + assertArrayEquals(binding.getBlockIndexRefsDigest(), decoded.getBlockIndexRefsDigest()); + + Map checkpoint = new LinkedHashMap<>(); + checkpoint.put(ArchiveWalBinding.getCheckpointKey(), codec.encode(binding)); + assertNotNull(ArchiveWalBinding.fromCheckpointBatch(checkpoint)); + } + + @Test + public void rejectsCorruptionAndNonContiguousMarkers() { + byte[] encoded = codec.encode(ArchiveWalBinding.fromMarkers( + Collections.singletonList(marker(7, 6)))); + encoded[encoded.length - 5] ^= 1; + assertThrows(IllegalArgumentException.class, () -> codec.decode(encoded)); + + List gap = Arrays.asList(marker(7, 6), marker(9, 8)); + assertThrows(IllegalArgumentException.class, () -> ArchiveWalBinding.fromMarkers(gap)); + } + + private static HistoryCommitMarker marker(int epoch, int previousEpoch) { + return new HistoryCommitMarker( + new BlockSnapshotMeta(epoch, epoch, hash(epoch), hash(previousEpoch), epoch * 1_000L), + previousEpoch, new HistoryLocation(0, epoch * 100L, 80, epoch, hash(epoch + 20)), + new HistoryIndexLocation(epoch * 120L, 96, hash(epoch + 40)), + Arrays.copyOf(hash(epoch + 60), 16), Arrays.asList("account", "properties")); + } + + private static byte[] hash(int suffix) { + byte[] value = new byte[32]; + value[31] = (byte) suffix; + return value; + } +} diff --git a/framework/src/test/java/org/tron/core/db2/archive/AsyncArchiveHistorySinkTest.java b/framework/src/test/java/org/tron/core/db2/archive/AsyncArchiveHistorySinkTest.java new file mode 100644 index 00000000000..2ee5dab1c76 --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/archive/AsyncArchiveHistorySinkTest.java @@ -0,0 +1,177 @@ +package org.tron.core.db2.archive; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tron.core.db2.archive.ArchiveHistoryWriter.Stage; +import org.tron.core.db2.archive.BlockReverseDiff.DbGroup; +import org.tron.core.db2.archive.BlockReverseDiff.Entry; + +public class AsyncArchiveHistorySinkTest { + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void waitsForDurabilityAndRevertsCommittedHead() throws Exception { + Path archive = temporaryFolder.newFolder("async").toPath(); + ArchiveHistoryWriter writer = new ArchiveHistoryWriter(archive, 4096, databases()); + try (AsyncArchiveHistorySink sink = new AsyncArchiveHistorySink(writer, 2)) { + sink.accept(diff(1)); + sink.accept(diff(2)); + sink.awaitCommitted(2); + assertEquals(2, writer.committedHead().getMeta().getEpoch()); + sink.revert(diff(2).getMeta()); + assertEquals(1, writer.committedHead().getMeta().getEpoch()); + sink.releaseThrough(1); + } + } + + @Test + public void persistsOneFlushRangeAsOneDurabilityBatch() throws Exception { + Path archive = temporaryFolder.newFolder("async-batch").toPath(); + java.util.List stages = new ArrayList<>(); + ArchiveHistoryWriter writer = new ArchiveHistoryWriter(archive, 4096, databases(), + (stage, meta) -> stages.add(stage)); + try (AsyncArchiveHistorySink sink = new AsyncArchiveHistorySink(writer, 2)) { + sink.acceptAll(Arrays.asList(diff(1), diff(2), diff(3))); + sink.awaitCommitted(3); + assertEquals(3, writer.committedHead().getMeta().getEpoch()); + assertEquals(3, stages.stream().filter(stage -> stage == Stage.APPEND_BODY).count()); + assertEquals(3, stages.stream().filter(stage -> stage == Stage.APPEND_INDEX).count()); + assertEquals(1, stages.stream().filter(stage -> stage == Stage.SYNC_BODY).count()); + assertEquals(1, stages.stream().filter(stage -> stage == Stage.SYNC_INDEX).count()); + assertEquals(3, stages.stream().filter(stage -> stage == Stage.COMMIT_MARKER).count()); + } + } + + @Test + public void createsEvidenceFromTheSameDurableWriterAuthority() throws Exception { + Path archive = temporaryFolder.newFolder("async-evidence").toPath(); + ArchiveHistoryWriter writer = new ArchiveHistoryWriter(archive, 4096, + ArchiveStoreScope.getStateDatabases()); + try (AsyncArchiveHistorySink sink = new AsyncArchiveHistorySink(writer, 1)) { + BlockReverseDiff committed = diff(1); + sink.accept(committed); + sink.awaitCommitted(1); + + List evidence = sink.createMarkerRangeEvidence(1) + .read(Collections.singletonList(committed.getMeta())); + + assertEquals(1, evidence.size()); + assertEquals(committed.getMeta(), evidence.get(0).getMeta()); + } + } + + @Test + public void removesQueuedForkHeadWithoutPublishingIt() throws Exception { + Path archive = temporaryFolder.newFolder("queued-reorg").toPath(); + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + ArchiveHistoryWriter writer = new ArchiveHistoryWriter(archive, 4096, databases(), + (stage, meta) -> { + if (stage == Stage.APPEND_BODY && meta.getEpoch() == 1) { + entered.countDown(); + await(release); + } + }); + try (AsyncArchiveHistorySink sink = new AsyncArchiveHistorySink(writer, 1)) { + sink.accept(diff(1)); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + sink.accept(diff(2)); + sink.revert(diff(2).getMeta()); + release.countDown(); + sink.awaitCommitted(1); + assertEquals(1, writer.committedHead().getMeta().getEpoch()); + } + } + + @Test + public void fullQueueAppliesBackpressureWithoutDroppingBlocks() throws Exception { + Path archive = temporaryFolder.newFolder("backpressure").toPath(); + CountDownLatch release = new CountDownLatch(1); + CountDownLatch entered = new CountDownLatch(1); + ArchiveHistoryWriter writer = new ArchiveHistoryWriter(archive, 4096, databases(), + (stage, meta) -> { + if (stage == Stage.APPEND_BODY && meta.getEpoch() == 1) { + entered.countDown(); + await(release); + } + }); + try (AsyncArchiveHistorySink sink = new AsyncArchiveHistorySink(writer, 1)) { + sink.accept(diff(1)); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + sink.accept(diff(2)); + CountDownLatch thirdAccepted = new CountDownLatch(1); + Thread producer = new Thread(() -> { + sink.accept(diff(3)); + thirdAccepted.countDown(); + }); + producer.start(); + assertFalse(thirdAccepted.await(200, TimeUnit.MILLISECONDS)); + release.countDown(); + assertTrue(thirdAccepted.await(5, TimeUnit.SECONDS)); + sink.awaitCommitted(3); + producer.join(); + assertEquals(3, writer.committedHead().getMeta().getEpoch()); + } + } + + @Test + public void writerFailureBecomesFatalForQueue() throws Exception { + Path archive = temporaryFolder.newFolder("failure").toPath(); + ArchiveHistoryWriter writer = new ArchiveHistoryWriter(archive, 4096, databases(), + (stage, meta) -> { + if (stage == Stage.APPEND_INDEX) { + throw new java.io.IOException("injected"); + } + }); + try (AsyncArchiveHistorySink sink = new AsyncArchiveHistorySink(writer, 1)) { + sink.accept(diff(1)); + assertThrows(ArchivePersistenceException.class, () -> sink.awaitCommitted(1)); + assertThrows(ArchivePersistenceException.class, () -> sink.accept(diff(2))); + } + } + + private static Set databases() { + return new java.util.LinkedHashSet<>(Arrays.asList("account", "properties")); + } + + private static BlockReverseDiff diff(int number) { + return new BlockReverseDiff(new BlockSnapshotMeta(number, number, hash(number), + hash(number - 1), number), Collections.singletonList(new DbGroup("account", + Collections.singletonList(new Entry(bytes("key-" + number), OldValue.absent()))))); + } + + private static byte[] hash(int suffix) { + byte[] hash = new byte[32]; + hash[31] = (byte) suffix; + return hash; + } + + private static byte[] bytes(String value) { + return value.getBytes(java.nio.charset.StandardCharsets.UTF_8); + } + + private static void await(CountDownLatch latch) throws java.io.IOException { + try { + latch.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new java.io.IOException("interrupted", e); + } + } +} diff --git a/framework/src/test/java/org/tron/core/db2/archive/BlockHistoryCodecTest.java b/framework/src/test/java/org/tron/core/db2/archive/BlockHistoryCodecTest.java new file mode 100644 index 00000000000..79576384984 --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/archive/BlockHistoryCodecTest.java @@ -0,0 +1,93 @@ +package org.tron.core.db2.archive; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.util.Arrays; +import java.util.Collections; +import org.junit.Test; +import org.tron.core.db2.archive.BlockReverseDiff.DbGroup; +import org.tron.core.db2.archive.BlockReverseDiff.Entry; + +public class BlockHistoryCodecTest { + + private final BlockHistoryCodec codec = new BlockHistoryCodec(); + + @Test + public void encodesDeterministicallyAndRoundTripsValueStates() { + BlockReverseDiff first = diff(Arrays.asList( + new DbGroup("votes", Arrays.asList( + new Entry(bytes("z"), OldValue.present(bytes("value"))), + new Entry(bytes("a"), OldValue.absent()))), + new DbGroup("account", Collections.singletonList( + new Entry(bytes("empty"), OldValue.present(new byte[0])))))); + BlockReverseDiff reordered = diff(Arrays.asList( + new DbGroup("account", Collections.singletonList( + new Entry(bytes("empty"), OldValue.present(new byte[0])))), + new DbGroup("votes", Arrays.asList( + new Entry(bytes("a"), OldValue.absent()), + new Entry(bytes("z"), OldValue.present(bytes("value"))))))); + + byte[] encoded = codec.encode(first); + assertArrayEquals(encoded, codec.encode(reordered)); + assertEquals(encoded.length, + codec.recordLength(Arrays.copyOf(encoded, BlockHistoryCodec.HEADER_LENGTH))); + + BlockReverseDiff decoded = codec.decode(encoded); + assertEquals(first.getMeta(), decoded.getMeta()); + assertEquals(2, decoded.getGroups().size()); + Entry empty = decoded.getGroups().get(0).getEntries().get(0); + assertTrue(empty.getOldValue().isPresent()); + assertEquals(0, empty.getOldValue().getValue().length); + Entry absent = decoded.getGroups().get(1).getEntries().get(0); + assertFalse(absent.getOldValue().isPresent()); + assertArrayEquals(bytes("value"), + decoded.getGroups().get(1).getEntries().get(1).getOldValue().getValue()); + } + + @Test + public void rejectsCorruptionTruncationAndOversizedRecords() { + byte[] encoded = codec.encode(diff(Collections.singletonList( + new DbGroup("account", Collections.singletonList( + new Entry(bytes("key"), OldValue.present(bytes("value")))))))); + byte[] corrupted = Arrays.copyOf(encoded, encoded.length); + corrupted[BlockHistoryCodec.HEADER_LENGTH] ^= 1; + assertThrows(IllegalArgumentException.class, () -> codec.decode(corrupted)); + assertThrows(IllegalArgumentException.class, + () -> codec.decode(Arrays.copyOf(encoded, encoded.length - 1))); + + BlockHistoryCodec smallCodec = new BlockHistoryCodec(180); + byte[] incompressible = new byte[512]; + new java.util.Random(17L).nextBytes(incompressible); + BlockReverseDiff oversized = diff(Collections.singletonList( + new DbGroup("account", Collections.singletonList( + new Entry(bytes("key"), OldValue.present(incompressible)))))); + assertThrows(IllegalArgumentException.class, () -> smallCodec.encode(oversized)); + } + + @Test + public void preservesNoopBlockMetadata() { + BlockReverseDiff empty = diff(Collections.emptyList()); + BlockReverseDiff decoded = codec.decode(codec.encode(empty)); + assertEquals(empty.getMeta(), decoded.getMeta()); + assertTrue(decoded.getGroups().isEmpty()); + } + + private static BlockReverseDiff diff(java.util.List groups) { + return new BlockReverseDiff(new BlockSnapshotMeta( + 12, 12, hash(12), hash(11), 36_000L), groups); + } + + private static byte[] hash(int suffix) { + byte[] hash = new byte[32]; + hash[31] = (byte) suffix; + return hash; + } + + private static byte[] bytes(String value) { + return value.getBytes(java.nio.charset.StandardCharsets.UTF_8); + } +} diff --git a/framework/src/test/java/org/tron/core/db2/archive/CommittedHistoryAuthorityTest.java b/framework/src/test/java/org/tron/core/db2/archive/CommittedHistoryAuthorityTest.java new file mode 100644 index 00000000000..1d5773419a1 --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/archive/CommittedHistoryAuthorityTest.java @@ -0,0 +1,55 @@ +package org.tron.core.db2.archive; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +import java.nio.file.Path; +import java.util.Collections; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +public class CommittedHistoryAuthorityTest { + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void writerAndStoreExposeTheSameReadOnlyCommittedAuthority() throws Exception { + Path archive = temporaryFolder.newFolder("committed-authority").toPath(); + BlockSnapshotMeta meta = BlockSnapshotMeta.forBlock(1, hash(1), hash(0), 1_000L); + + try (ArchiveHistoryWriter writer = new ArchiveHistoryWriter( + archive, 4096, ArchiveStoreScope.getStateDatabases())) { + writer.accept(new BlockReverseDiff(meta, Collections.emptyList())); + assertAuthority(writer, meta); + + } + + try (HistoryCommitStore store = new HistoryCommitStore( + archive, new HistoryCommitMarkerCodec())) { + assertAuthority(store, meta); + } + } + + private static void assertAuthority(CommittedHistoryAuthority authority, + BlockSnapshotMeta expected) { + assertEquals(1L, authority.firstEpoch()); + assertNotNull(authority.head()); + assertEquals(expected, authority.head().getMeta()); + assertEquals(expected, authority.get(1).getMeta()); + HistoryCoverage coverage = authority.coverage(); + assertNotNull(coverage); + assertEquals(1L, coverage.getFirstEpoch()); + assertEquals(1L, coverage.getRecordCount()); + assertEquals(1L, coverage.getHeadEpoch()); + assertArrayEquals(expected.getBlockHash(), coverage.getHeadHash()); + } + + private static byte[] hash(int suffix) { + byte[] hash = new byte[32]; + hash[31] = (byte) suffix; + return hash; + } +} diff --git a/framework/src/test/java/org/tron/core/db2/archive/DurableHistoryMarkerRangeEvidenceTest.java b/framework/src/test/java/org/tron/core/db2/archive/DurableHistoryMarkerRangeEvidenceTest.java new file mode 100644 index 00000000000..68af89f8f86 --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/archive/DurableHistoryMarkerRangeEvidenceTest.java @@ -0,0 +1,196 @@ +package org.tron.core.db2.archive; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +public class DurableHistoryMarkerRangeEvidenceTest { + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void readsOnlyExactDurableRangeAndReopensWithIdenticalEvidence() throws Exception { + Path archive = temporaryFolder.newFolder("marker-evidence").toPath(); + List expected = Arrays.asList(meta(2), meta(3)); + List encoded = new ArrayList<>(); + try (ArchiveHistoryWriter writer = new ArchiveHistoryWriter( + archive, 4096, new java.util.LinkedHashSet<>(participants()))) { + writer.acceptAll(Arrays.asList(diff(1), diff(2), diff(3), diff(4))); + List evidence = + writer.createMarkerRangeEvidence(2).read(expected); + assertEquals(Arrays.asList(2L, 3L), epochs(evidence)); + HistoryCommitMarkerCodec codec = new HistoryCommitMarkerCodec(); + evidence.forEach(marker -> encoded.add(codec.encode(marker))); + } + + try (ArchiveHistoryWriter reopened = new ArchiveHistoryWriter( + archive, 4096, new java.util.LinkedHashSet<>(participants()))) { + List evidence = + reopened.createMarkerRangeEvidence(2).read(expected); + HistoryCommitMarkerCodec codec = new HistoryCommitMarkerCodec(); + assertArrayEquals(encoded.get(0), codec.encode(evidence.get(0))); + assertArrayEquals(encoded.get(1), codec.encode(evidence.get(1))); + assertEquals(4L, reopened.committedHead().getMeta().getEpoch()); + } + } + + @Test + public void markerPreflightRejectsMissingSubstitutedAndReorderedBeforeBodyRead() { + FakeSource source = new FakeSource(); + source.put(marker(meta(1)), diff(1)); + DurableHistoryMarkerRangeEvidence evidence = + new DurableHistoryMarkerRangeEvidence(source, 2); + List expected = Arrays.asList(meta(1), meta(2)); + + assertThrows(ArchivePersistenceException.class, () -> evidence.read(expected)); + assertEquals(0, source.bodyReads); + + source.putAt(2, marker(meta(3)), diff(2)); + assertThrows(ArchivePersistenceException.class, () -> evidence.read(expected)); + assertEquals(0, source.bodyReads); + + source.putAt(1, marker(meta(2)), diff(1)); + source.putAt(2, marker(meta(1)), diff(2)); + assertThrows(ArchivePersistenceException.class, () -> evidence.read(expected)); + assertEquals(0, source.bodyReads); + + source.clear(); + source.put(marker(meta(1)), diff(1)); + source.put(marker(meta(2)), diff(2)); + assertEquals(Arrays.asList(1L, 2L), epochs(evidence.read(expected))); + assertEquals(2, source.bodyReads); + } + + @Test + public void referenceFailureAndMarkerDriftLeaveEvidenceRetryable() { + FakeSource source = new FakeSource(); + source.put(marker(meta(1)), diff(1)); + source.failBody = true; + DurableHistoryMarkerRangeEvidence evidence = + new DurableHistoryMarkerRangeEvidence(source, 1); + + assertThrows(ArchivePersistenceException.class, + () -> evidence.read(Collections.singletonList(meta(1)))); + source.failBody = false; + assertEquals(1, evidence.read(Collections.singletonList(meta(1))).size()); + + source.bodyReads = 0; + source.driftAfterBody = true; + assertThrows(ArchivePersistenceException.class, + () -> evidence.read(Collections.singletonList(meta(1)))); + source.driftAfterBody = false; + assertEquals(1, evidence.read(Collections.singletonList(meta(1))).size()); + } + + @Test + public void invalidOrOversizedExpectedRangeFailsBeforeSourceAction() { + FakeSource source = new FakeSource(); + DurableHistoryMarkerRangeEvidence evidence = + new DurableHistoryMarkerRangeEvidence(source, 1); + + assertThrows(ArchivePersistenceException.class, + () -> evidence.read(Collections.emptyList())); + assertThrows(ArchivePersistenceException.class, + () -> evidence.read(Arrays.asList(meta(1), meta(2)))); + assertThrows(ArchivePersistenceException.class, + () -> new DurableHistoryMarkerRangeEvidence(source, 2) + .read(Arrays.asList(meta(1), meta(3)))); + assertEquals(0, source.markerReads); + assertEquals(0, source.bodyReads); + } + + private static List epochs(List markers) { + List epochs = new ArrayList<>(); + markers.forEach(marker -> epochs.add(marker.getMeta().getEpoch())); + return epochs; + } + + private static List participants() { + List participants = new ArrayList<>(ArchiveStoreScope.getStateDatabases()); + Collections.sort(participants); + return participants; + } + + private static BlockReverseDiff diff(int epoch) { + return new BlockReverseDiff(meta(epoch), Collections.emptyList()); + } + + private static BlockSnapshotMeta meta(int epoch) { + return BlockSnapshotMeta.forBlock(epoch, hash(epoch), hash(epoch - 1), epoch * 1_000L); + } + + private static HistoryCommitMarker marker(BlockSnapshotMeta meta) { + int epoch = (int) meta.getEpoch(); + return new HistoryCommitMarker(meta, epoch - 1, + new HistoryLocation(0, epoch * 100L, 100, epoch, bytes(32, epoch + 20)), + new HistoryIndexLocation(epoch * 50L, 50, bytes(32, epoch + 30)), + bytes(16, epoch + 40), participants()); + } + + private static byte[] hash(int suffix) { + byte[] hash = new byte[32]; + hash[31] = (byte) suffix; + return hash; + } + + private static byte[] bytes(int length, int value) { + byte[] result = new byte[length]; + Arrays.fill(result, (byte) value); + return result; + } + + private static final class FakeSource implements DurableHistoryMarkerRangeEvidence.Source { + private final Map markers = new LinkedHashMap<>(); + private final Map bodies = new LinkedHashMap<>(); + private int markerReads; + private int bodyReads; + private boolean failBody; + private boolean driftAfterBody; + + private void put(HistoryCommitMarker marker, BlockReverseDiff body) { + putAt(marker.getMeta().getEpoch(), marker, body); + } + + private void putAt(long epoch, HistoryCommitMarker marker, BlockReverseDiff body) { + markers.put(epoch, marker); + bodies.put(epoch, body); + } + + private void clear() { + markers.clear(); + bodies.clear(); + markerReads = 0; + bodyReads = 0; + } + + @Override + public HistoryCommitMarker marker(long epoch) { + markerReads++; + if (driftAfterBody && bodyReads > 0) { + return DurableHistoryMarkerRangeEvidenceTest.marker(meta((int) epoch + 1)); + } + return markers.get(epoch); + } + + @Override + public BlockReverseDiff readCommitted(long epoch) { + bodyReads++; + if (failBody) { + throw new ArchivePersistenceException("injected body/index reference failure"); + } + return bodies.get(epoch); + } + } +} diff --git a/framework/src/test/java/org/tron/core/db2/archive/HistoricalAccountAssetBalanceResolverTest.java b/framework/src/test/java/org/tron/core/db2/archive/HistoricalAccountAssetBalanceResolverTest.java new file mode 100644 index 00000000000..67171d72dbd --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/archive/HistoricalAccountAssetBalanceResolverTest.java @@ -0,0 +1,526 @@ +package org.tron.core.db2.archive; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import com.google.protobuf.ByteString; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.OptionalLong; +import org.junit.Test; +import org.tron.common.utils.ByteArray; +import org.tron.core.db2.archive.HistoricalAccountAssetBalanceResolver.Result; +import org.tron.core.db2.archive.HistoricalAccountAssetPrefixResolver.Balance; +import org.tron.core.db2.archive.HistoricalAccountAssetPrefixResolver.Limits; +import org.tron.core.db2.archive.P66AccountAssetCodec.Phase; +import org.tron.protos.Protocol.Account; +import org.tron.protos.Protocol.AccountType; + +public class HistoricalAccountAssetBalanceResolverTest { + + private static final String TOKEN_ID = "1000001"; + + @Test + public void resolvesP66OffBalanceOnlyFromHistoricalAccountMap() throws Exception { + byte[] address = address(1); + Fixture fixture = new Fixture() + .put("properties", proposal66Key(), ByteArray.fromLong(0L)) + .put("account", address, account(address, false, TOKEN_ID, 17L)); + + try (ArchiveReadSnapshot snapshot = fixture.snapshot()) { + Result result = new HistoricalAccountAssetBalanceResolver() + .resolve(snapshot, address, TOKEN_ID); + assertTrue(result.isAccountPresent()); + assertEquals(17L, result.getBalance()); + assertEquals(Phase.P66_OFF, result.getPhase()); + assertEquals(7L, result.getBlockNumber()); + assertEquals(Arrays.asList("properties", "account", "account-asset"), fixture.reads); + } + } + + @Test + public void resolvesP66OnBalanceOnlyFromExactDirectRow() throws Exception { + byte[] address = address(2); + byte[] directKey = new P66AccountAssetCodec().assetPhysicalKey(address, TOKEN_ID); + Fixture fixture = new Fixture() + .put("properties", proposal66Key(), ByteArray.fromLong(1L)) + .put("account", address, account(address, true, null, 0L)) + .put("account-asset", directKey, ByteArray.fromLong(29L)); + + try (ArchiveReadSnapshot snapshot = fixture.snapshot()) { + Result result = new HistoricalAccountAssetBalanceResolver() + .resolve(snapshot, address, TOKEN_ID); + assertTrue(result.isAccountPresent()); + assertEquals(29L, result.getBalance()); + assertEquals(Phase.P66_ON, result.getPhase()); + } + } + + @Test + public void preservesSemanticAbsenceForMissingAccountAndZeroBalance() throws Exception { + byte[] existing = address(3); + byte[] missing = address(4); + Fixture fixture = new Fixture() + .put("properties", proposal66Key(), ByteArray.fromLong(1L)) + .put("account", existing, account(existing, true, null, 0L)); + + try (ArchiveReadSnapshot snapshot = fixture.snapshot()) { + HistoricalAccountAssetBalanceResolver resolver = + new HistoricalAccountAssetBalanceResolver(); + Result zero = resolver.resolve(snapshot, existing, TOKEN_ID); + assertTrue(zero.isAccountPresent()); + assertEquals(0L, zero.getBalance()); + + Result absent = resolver.resolve(snapshot, missing, TOKEN_ID); + assertFalse(absent.isAccountPresent()); + assertThrows(IllegalStateException.class, absent::getBalance); + } + } + + @Test + public void rejectsMissingOrMalformedHistoricalProposal66Property() throws Exception { + byte[] address = address(5); + Fixture missing = new Fixture().put("account", address, + account(address, false, TOKEN_ID, 1L)); + try (ArchiveReadSnapshot snapshot = missing.snapshot()) { + assertThrows(ArchivePersistenceException.class, + () -> new HistoricalAccountAssetBalanceResolver() + .resolve(snapshot, address, TOKEN_ID)); + } + + for (byte[] invalid : Arrays.asList(new byte[]{1}, ByteArray.fromLong(2L))) { + Fixture malformed = new Fixture() + .put("properties", proposal66Key(), invalid) + .put("account", address, account(address, false, TOKEN_ID, 1L)); + try (ArchiveReadSnapshot snapshot = malformed.snapshot()) { + assertThrows(ArchivePersistenceException.class, + () -> new HistoricalAccountAssetBalanceResolver() + .resolve(snapshot, address, TOKEN_ID)); + } + } + } + + @Test + public void rejectsMixedAndOrphanPhysicalLayouts() throws Exception { + byte[] address = address(6); + byte[] directKey = new P66AccountAssetCodec().assetPhysicalKey(address, TOKEN_ID); + List invalid = Arrays.asList( + new Fixture() + .put("properties", proposal66Key(), ByteArray.fromLong(0L)) + .put("account", address, account(address, false, TOKEN_ID, 3L)) + .put("account-asset", directKey, ByteArray.fromLong(3L)), + new Fixture() + .put("properties", proposal66Key(), ByteArray.fromLong(1L)) + .put("account", address, account(address, false, TOKEN_ID, 3L)), + new Fixture() + .put("properties", proposal66Key(), ByteArray.fromLong(1L)) + .put("account-asset", directKey, ByteArray.fromLong(3L))); + + for (Fixture fixture : invalid) { + try (ArchiveReadSnapshot snapshot = fixture.snapshot()) { + assertThrows(ArchivePersistenceException.class, + () -> new HistoricalAccountAssetBalanceResolver() + .resolve(snapshot, address, TOKEN_ID)); + } + } + } + + @Test + public void rejectsCorruptWrongKeyAndNonCanonicalDirectValues() throws Exception { + byte[] address = address(7); + byte[] directKey = new P66AccountAssetCodec().assetPhysicalKey(address, TOKEN_ID); + List invalid = Arrays.asList( + new Fixture() + .put("properties", proposal66Key(), ByteArray.fromLong(1L)) + .put("account", address, new byte[]{1, 2, 3}), + new Fixture() + .put("properties", proposal66Key(), ByteArray.fromLong(1L)) + .put("account", address, account(address(8), true, null, 0L)), + new Fixture() + .put("properties", proposal66Key(), ByteArray.fromLong(1L)) + .put("account", address, account(address, true, null, 0L)) + .put("account-asset", directKey, new byte[]{1}), + new Fixture() + .put("properties", proposal66Key(), ByteArray.fromLong(1L)) + .put("account", address, account(address, true, null, 0L)) + .put("account-asset", directKey, ByteArray.fromLong(0L))); + + for (Fixture fixture : invalid) { + try (ArchiveReadSnapshot snapshot = fixture.snapshot()) { + assertThrows(ArchivePersistenceException.class, + () -> new HistoricalAccountAssetBalanceResolver() + .resolve(snapshot, address, TOKEN_ID)); + } + } + } + + @Test + public void rejectsUnsupportedTokenIdentitiesBeforeReadingStores() throws Exception { + byte[] address = address(9); + for (String tokenId : Arrays.asList("", "01", "asset-name", "12")) { + Fixture fixture = new Fixture(); + try (ArchiveReadSnapshot snapshot = fixture.snapshot()) { + assertThrows(ArchivePersistenceException.class, + () -> new HistoricalAccountAssetBalanceResolver() + .resolve(snapshot, address, tokenId)); + assertTrue(fixture.reads.isEmpty()); + } + } + } + + @Test + public void failsClosedWhenPinnedGenerationIdentityDriftsDuringResolution() throws Exception { + byte[] address = address(10); + Fixture fixture = new Fixture() + .put("properties", proposal66Key(), ByteArray.fromLong(1L)) + .put("account", address, account(address, true, null, 0L)); + fixture.driftAfterThirdRead = true; + + try (ArchiveReadSnapshot snapshot = fixture.snapshot()) { + assertThrows(IllegalArgumentException.class, + () -> new HistoricalAccountAssetBalanceResolver() + .resolve(snapshot, address, TOKEN_ID)); + } + } + + @Test + public void prefixResolvesP66OffEmbeddedMapInCanonicalTokenOrder() throws Exception { + byte[] address = address(11); + Account account = Account.newBuilder().setAddress(ByteString.copyFrom(address)) + .setType(AccountType.Normal).putAssetV2("1000002", 22L) + .putAssetV2("1000001", 11L).build(); + Fixture fixture = new Fixture() + .put("properties", proposal66Key(), ByteArray.fromLong(0L)) + .put("account", address, account.toByteArray()); + + try (ArchiveReadSnapshot snapshot = fixture.snapshot()) { + HistoricalAccountAssetPrefixResolver.Result result = + new HistoricalAccountAssetPrefixResolver().resolve(snapshot, address, limits()); + assertEquals(Phase.P66_OFF, result.getPhase()); + assertTrue(result.isAccountPresent()); + assertBalances(result.getBalances(), "1000001", 11L, "1000002", 22L); + } + } + + @Test + public void prefixResolvesP66OnExactRowsAndRejectsMixedOrInvalidRows() throws Exception { + byte[] address = address(12); + P66AccountAssetCodec codec = new P66AccountAssetCodec(); + byte[] firstKey = codec.assetPhysicalKey(address, "1000001"); + byte[] secondKey = codec.assetPhysicalKey(address, "1000002"); + Fixture valid = new Fixture() + .put("properties", proposal66Key(), ByteArray.fromLong(1L)) + .put("account", address, account(address, true, null, 0L)) + .put("account-asset", secondKey, ByteArray.fromLong(22L)) + .put("account-asset", firstKey, ByteArray.fromLong(11L)); + try (ArchiveReadSnapshot snapshot = valid.snapshot()) { + HistoricalAccountAssetPrefixResolver.Result result = + new HistoricalAccountAssetPrefixResolver().resolve(snapshot, address, limits()); + assertEquals(Phase.P66_ON, result.getPhase()); + assertBalances(result.getBalances(), "1000001", 11L, "1000002", 22L); + } + + List invalid = Arrays.asList( + new Fixture() + .put("properties", proposal66Key(), ByteArray.fromLong(0L)) + .put("account", address, account(address, false, TOKEN_ID, 1L)) + .put("account-asset", firstKey, ByteArray.fromLong(1L)), + new Fixture() + .put("properties", proposal66Key(), ByteArray.fromLong(1L)) + .put("account-asset", firstKey, ByteArray.fromLong(1L)), + new Fixture() + .put("properties", proposal66Key(), ByteArray.fromLong(1L)) + .put("account", address, account(address, true, null, 0L)) + .put("account-asset", concat(address, "01"), ByteArray.fromLong(1L)), + new Fixture() + .put("properties", proposal66Key(), ByteArray.fromLong(1L)) + .put("account", address, account(address, true, null, 0L)) + .put("account-asset", firstKey, ByteArray.fromLong(0L))); + for (Fixture fixture : invalid) { + try (ArchiveReadSnapshot snapshot = fixture.snapshot()) { + assertThrows(ArchivePersistenceException.class, + () -> new HistoricalAccountAssetPrefixResolver() + .resolve(snapshot, address, limits())); + } + } + } + + @Test + public void prefixRejectsEveryOutputBudgetWithoutReturningPartialResults() throws Exception { + byte[] address = address(13); + P66AccountAssetCodec codec = new P66AccountAssetCodec(); + byte[] firstKey = codec.assetPhysicalKey(address, "1000001"); + byte[] secondKey = codec.assetPhysicalKey(address, "1000002"); + List rejected = Arrays.asList( + new Limits(10, 10, 1, 64, 8, 1_000), + new Limits(10, 10, 10, firstKey.length - 1, 8, 1_000), + new Limits(10, 10, 10, 64, 7, 1_000), + new Limits(10, 10, 10, 64, 8, + (long) firstKey.length + Long.BYTES + secondKey.length + Long.BYTES - 1)); + for (Limits limits : rejected) { + Fixture fixture = new Fixture() + .put("properties", proposal66Key(), ByteArray.fromLong(1L)) + .put("account", address, account(address, true, null, 0L)) + .put("account-asset", firstKey, ByteArray.fromLong(11L)) + .put("account-asset", secondKey, ByteArray.fromLong(22L)); + try (ArchiveReadSnapshot snapshot = fixture.snapshot()) { + assertThrows(ArchiveQueryLimitExceededException.class, + () -> new HistoricalAccountAssetPrefixResolver() + .resolve(snapshot, address, limits)); + } + } + assertThrows(IllegalArgumentException.class, + () -> new Limits(0, 1, 1, 1, 1, 1)); + } + + @Test + public void prefixRejectsForeignUnsortedDuplicateAndGenerationDrift() throws Exception { + byte[] address = address(14); + byte[] firstKey = new P66AccountAssetCodec().assetPhysicalKey(address, "1000001"); + byte[] secondKey = new P66AccountAssetCodec().assetPhysicalKey(address, "1000002"); + Fixture foreign = prefixFixture(address, firstKey, secondKey); + foreign.put("account-asset", + new P66AccountAssetCodec().assetPhysicalKey(address(99), "1000003"), + ByteArray.fromLong(33L)); + foreign.foreignRange = true; + Fixture unsorted = prefixFixture(address, firstKey, secondKey); + unsorted.reverseRange = true; + Fixture duplicate = prefixFixture(address, firstKey, secondKey); + duplicate.duplicateRange = true; + for (Fixture fixture : Arrays.asList(foreign, unsorted, duplicate)) { + try (ArchiveReadSnapshot snapshot = fixture.snapshot()) { + assertThrows(IllegalArgumentException.class, + () -> new HistoricalAccountAssetPrefixResolver() + .resolve(snapshot, address, limits())); + } + } + + Fixture drift = prefixFixture(address, firstKey, secondKey); + drift.driftAfterRange = true; + try (ArchiveReadSnapshot snapshot = drift.snapshot()) { + assertThrows(IllegalArgumentException.class, + () -> new HistoricalAccountAssetPrefixResolver() + .resolve(snapshot, address, limits())); + } + } + + private static byte[] proposal66Key() { + return HistoricalAccountAssetBalanceResolver.proposal66PhysicalKey(); + } + + private static Fixture prefixFixture(byte[] address, byte[] firstKey, byte[] secondKey) { + return new Fixture() + .put("properties", proposal66Key(), ByteArray.fromLong(1L)) + .put("account", address, account(address, true, null, 0L)) + .put("account-asset", firstKey, ByteArray.fromLong(11L)) + .put("account-asset", secondKey, ByteArray.fromLong(22L)); + } + + private static Limits limits() { + return new Limits(10, 10, 10, 64, 8, 1_000); + } + + private static void assertBalances(List balances, String firstToken, + long firstBalance, String secondToken, long secondBalance) { + assertEquals(2, balances.size()); + assertEquals(firstToken, balances.get(0).getTokenId()); + assertEquals(firstBalance, balances.get(0).getBalance()); + assertEquals(secondToken, balances.get(1).getTokenId()); + assertEquals(secondBalance, balances.get(1).getBalance()); + } + + private static byte[] concat(byte[] address, String suffix) { + byte[] token = suffix.getBytes(java.nio.charset.StandardCharsets.US_ASCII); + byte[] key = Arrays.copyOf(address, address.length + token.length); + System.arraycopy(token, 0, key, address.length, token.length); + return key; + } + + private static byte[] account(byte[] address, boolean optimized, String tokenId, long balance) { + Account.Builder builder = Account.newBuilder().setAddress(ByteString.copyFrom(address)) + .setType(AccountType.Normal).setAssetOptimized(optimized); + if (tokenId != null) { + builder.putAsset("asset-name", balance).putAssetV2(tokenId, balance); + } + return builder.build().toByteArray(); + } + + private static byte[] address(int suffix) { + byte[] address = new byte[21]; + address[0] = 0x41; + address[20] = (byte) suffix; + return address; + } + + private static byte[] hash(int suffix) { + byte[] hash = new byte[32]; + hash[31] = (byte) suffix; + return hash; + } + + private static final class Fixture { + private final List values = new ArrayList<>(); + private final List reads = new ArrayList<>(); + private final MutableServing serving = new MutableServing(); + private boolean driftAfterThirdRead; + private boolean driftAfterRange; + private boolean foreignRange; + private boolean reverseRange; + private boolean duplicateRange; + + private Fixture put(String dbName, byte[] key, byte[] value) { + values.add(new Value(dbName, key, value)); + return this; + } + + private ArchiveReadSnapshot snapshot() throws Exception { + ArchiveReadSnapshot.PinnedLatestState latest = new ArchiveReadSnapshot.PinnedLatestState() { + @Override + public long getBlockNumber() { + return 7L; + } + + @Override + public byte[] getBlockHash() { + return hash(7); + } + + @Override + public OldValue get(String dbName, byte[] rawKey) { + reads.add(dbName); + if (driftAfterThirdRead && reads.size() == 3) { + serving.drifted = true; + } + for (Value value : values) { + if (value.dbName.equals(dbName) && Arrays.equals(value.key, rawKey)) { + return OldValue.present(value.value); + } + } + return OldValue.absent(); + } + + @Override + public List range(String dbName, byte[] lower, + byte[] upper, int maxEntries) { + List result = new ArrayList<>(); + for (Value value : values) { + if (value.dbName.equals(dbName) + && (foreignRange || inRange(value.key, lower, upper))) { + result.add(new HistoricalRangeOverlay.Entry(value.key, value.value)); + } + } + result.sort((left, right) -> BlockReverseDiff.compareUnsigned( + left.getKey(), right.getKey())); + if (reverseRange) { + Collections.reverse(result); + } + if (duplicateRange && !result.isEmpty()) { + result.add(result.get(result.size() - 1)); + } + if (driftAfterRange) { + serving.drifted = true; + } + return result; + } + + @Override + public void close() { + } + }; + ArchiveReadSnapshot.PinnedHistory history = new ArchiveReadSnapshot.PinnedHistory() { + @Override + public long getIndexedFrom() { + return 0L; + } + + @Override + public long getIndexedThrough() { + return 7L; + } + + @Override + public byte[] getHeadHash() { + return hash(7); + } + + @Override + public byte[] getAuthoritativePrefixDigest() { + return hash(11); + } + + @Override + public OldValue read(String dbName, byte[] rawKey, long firstChangeBlock) { + throw new AssertionError("Fixture must not read unconfigured history"); + } + + @Override + public void close() { + } + }; + return ArchiveReadSnapshot.pin(7L, 7L, hash(7), serving, latest, history); + } + } + + private static boolean inRange(byte[] key, byte[] lower, byte[] upper) { + return BlockReverseDiff.compareUnsigned(key, lower) >= 0 + && (upper == null || BlockReverseDiff.compareUnsigned(key, upper) < 0); + } + + private static final class MutableServing implements ServingKeyIndex { + private boolean drifted; + + @Override + public String getGenerationId() { + return "account-asset-resolver"; + } + + @Override + public long getIndexedFrom() { + return 0L; + } + + @Override + public long getIndexedThrough() { + return 7L; + } + + @Override + public byte[] getHeadHash() { + return drifted ? hash(8) : hash(7); + } + + @Override + public byte[] getAuthoritativePrefixDigest() { + return hash(11); + } + + @Override + public OptionalLong firstChangeAfter(String dbName, byte[] rawKey, long targetBlock, + long upperBound) { + return OptionalLong.empty(); + } + + @Override + public List changesInRange(String dbName, + byte[] lowerInclusive, byte[] upperExclusive, long targetBlock, long upperBound, + int maxChangedKeys) { + return Collections.emptyList(); + } + } + + private static final class Value { + private final String dbName; + private final byte[] key; + private final byte[] value; + + private Value(String dbName, byte[] key, byte[] value) { + this.dbName = dbName; + this.key = Arrays.copyOf(key, key.length); + this.value = Arrays.copyOf(value, value.length); + } + } +} diff --git a/framework/src/test/java/org/tron/core/db2/archive/HistoricalAccountBalanceReaderTest.java b/framework/src/test/java/org/tron/core/db2/archive/HistoricalAccountBalanceReaderTest.java new file mode 100644 index 00000000000..86e6ef7a804 --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/archive/HistoricalAccountBalanceReaderTest.java @@ -0,0 +1,150 @@ +package org.tron.core.db2.archive; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import com.google.protobuf.ByteString; +import java.util.Arrays; +import java.util.Collections; +import org.junit.Test; +import org.tron.protos.Protocol.Account; +import org.tron.protos.Protocol.AccountType; + +public class HistoricalAccountBalanceReaderTest { + + @Test + public void distinguishesHistoricalBalanceZeroFromAbsentAccount() throws Exception { + byte[] existing = address(1); + byte[] absent = address(2); + ArchiveReadSnapshot snapshot = snapshot(existing, account(existing, 0)); + + HistoricalAccountBalanceReader.Result present = + HistoricalAccountBalanceReader.read(snapshot, existing); + assertTrue(present.isPresent()); + assertEquals(0, present.getBalance()); + assertEquals(7, present.getBlockNumber()); + + HistoricalAccountBalanceReader.Result missing = + HistoricalAccountBalanceReader.read(snapshot, absent); + assertFalse(missing.isPresent()); + assertThrows(IllegalStateException.class, missing::getBalance); + snapshot.close(); + } + + @Test + public void rejectsMalformedOrWrongKeyAccountValues() throws Exception { + byte[] address = address(3); + ArchiveReadSnapshot malformed = snapshot(address, new byte[]{1, 2, 3}); + assertThrows(ArchivePersistenceException.class, + () -> HistoricalAccountBalanceReader.read(malformed, address)); + malformed.close(); + + ArchiveReadSnapshot wrongKey = snapshot(address, account(address(4), 99)); + assertThrows(ArchivePersistenceException.class, + () -> HistoricalAccountBalanceReader.read(wrongKey, address)); + wrongKey.close(); + } + + private static ArchiveReadSnapshot snapshot(byte[] key, byte[] value) throws Exception { + byte[] hash = hash(7); + ServingKeyIndexGeneration serving = ServingKeyIndexGeneration.rebuild( + "account-balance", 0, hash(0), Collections.emptyList(), ignored -> null, + Collections.singletonList("account"), + ServingKeyIndexGeneration.IndexLayout.prototypeDefaults()); + // Empty-prefix coverage ends at the base. Build the pinned fixture at block 0 then expose the + // requested target through a seven-block no-change committed prefix. + java.util.List markers = new java.util.ArrayList<>(); + java.util.Map indexes = new java.util.HashMap<>(); + for (int block = 1; block <= 7; block++) { + BlockSnapshotMeta meta = new BlockSnapshotMeta(block, block, hash(block), hash(block - 1), + block * 3_000L); + HistoryLocation body = new HistoryLocation(0, block * 100L, 10, block, hash(block + 20)); + HistoryIndexRecord index = new HistoryIndexRecord(meta, body, Collections.emptyList()); + HistoryIndexLocation location = new HistoryIndexLocation(block * 80L, 20, + hash(block + 40)); + markers.add(new HistoryCommitMarker(meta, block - 1, body, location, new byte[16], + Collections.singletonList("account"))); + indexes.put(location.getOffset(), index); + } + serving = ServingKeyIndexGeneration.rebuild("account-balance", 0, hash(0), markers, + location -> indexes.get(location.getOffset())); + ServingKeyIndexGeneration finalServing = serving; + ArchiveReadSnapshot.PinnedLatestState latest = new ArchiveReadSnapshot.PinnedLatestState() { + @Override + public long getBlockNumber() { + return 7; + } + + @Override + public byte[] getBlockHash() { + return hash; + } + + @Override + public OldValue get(String dbName, byte[] rawKey) { + return Arrays.equals(key, rawKey) ? OldValue.present(value) : OldValue.absent(); + } + + @Override + public java.util.List range(String dbName, byte[] lower, + byte[] upper, int maxEntries) { + return Collections.emptyList(); + } + + @Override + public void close() { + } + }; + ArchiveReadSnapshot.PinnedHistory history = new ArchiveReadSnapshot.PinnedHistory() { + @Override + public long getIndexedFrom() { + return finalServing.getIndexedFrom(); + } + + @Override + public long getIndexedThrough() { + return 7; + } + + @Override + public byte[] getHeadHash() { + return hash; + } + + @Override + public byte[] getAuthoritativePrefixDigest() { + return finalServing.getAuthoritativePrefixDigest(); + } + + @Override + public OldValue read(String dbName, byte[] rawKey, long firstChangeBlock) { + throw new AssertionError("No key changes are expected in this fixture"); + } + + @Override + public void close() { + } + }; + return ArchiveReadSnapshot.pin(7, 7, hash, serving, latest, history); + } + + private static byte[] account(byte[] address, long balance) { + return Account.newBuilder().setAddress(ByteString.copyFrom(address)) + .setType(AccountType.Normal).setBalance(balance).build().toByteArray(); + } + + private static byte[] address(int suffix) { + byte[] address = new byte[21]; + address[0] = 0x41; + address[20] = (byte) suffix; + return address; + } + + private static byte[] hash(int suffix) { + byte[] hash = new byte[32]; + hash[31] = (byte) suffix; + return hash; + } +} diff --git a/framework/src/test/java/org/tron/core/db2/archive/HistoricalRangeOverlayTest.java b/framework/src/test/java/org/tron/core/db2/archive/HistoricalRangeOverlayTest.java new file mode 100644 index 00000000000..ce523a44bf8 --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/archive/HistoricalRangeOverlayTest.java @@ -0,0 +1,151 @@ +package org.tron.core.db2.archive; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tron.core.db2.archive.HistoricalRangeOverlay.Entry; +import org.tron.core.db2.archive.HistoricalRangeOverlay.KeyRange; +import org.tron.core.db2.archive.HistoricalRangeOverlay.Limits; +import org.tron.core.db2.archive.HistoryIndexRecord.KeyGroup; + +public class HistoricalRangeOverlayTest { + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void overlaysOneStoreWithoutLosingDeletedOrEmptyValues() throws Exception { + ServingKeyIndexGeneration index = buildIndex("overlay"); + List latest = Arrays.asList( + entry("p/a", "a3"), + entry("p/c", "c1"), + entry("p/d", "d1"), + entry("p/e", "e2")); + Map oldValues = new HashMap<>(); + oldValues.put("p/a", OldValue.present(bytes("a1"))); + oldValues.put("p/b", OldValue.present(bytes("b1"))); + oldValues.put("p/e", OldValue.absent()); + oldValues.put("p/f", OldValue.present(new byte[0])); + Map readAt = new HashMap<>(); + + List result = HistoricalRangeOverlay.materialize("account", 1, 3, + KeyRange.prefix(bytes("p/")), latest, index, (dbName, key, firstChange) -> { + assertEquals("account", dbName); + readAt.put(text(key), firstChange); + return oldValues.get(text(key)); + }, new Limits(10, 10, 10)); + + assertEntries(result, "p/a", "a1", "p/b", "b1", "p/c", "c1", "p/d", "d1"); + assertEquals(5, result.size()); + assertArrayEquals(bytes("p/f"), result.get(4).getKey()); + assertArrayEquals(new byte[0], result.get(4).getValue()); + assertEquals(Long.valueOf(2), readAt.get("p/a")); + assertEquals(Long.valueOf(2), readAt.get("p/b")); + assertEquals(Long.valueOf(2), readAt.get("p/e")); + assertEquals(Long.valueOf(2), readAt.get("p/f")); + } + + @Test + public void rejectsEveryBudgetOverflowInsteadOfReturningPartialResults() throws Exception { + ServingKeyIndexGeneration index = buildIndex("limits"); + List latest = Arrays.asList(entry("p/a", "a3"), entry("p/c", "c1")); + HistoricalRangeOverlay.HistoricalValueReader history = + (dbName, key, block) -> OldValue.present(bytes("old")); + + assertThrows(ArchiveQueryLimitExceededException.class, + () -> materialize(index, latest, history, new Limits(3, 10, 10))); + assertThrows(ArchiveQueryLimitExceededException.class, + () -> materialize(index, latest, history, new Limits(10, 4, 10))); + assertThrows(ArchiveQueryLimitExceededException.class, + () -> materialize(index, latest, history, new Limits(10, 10, 3))); + } + + @Test + public void rejectsUnsortedOrOutOfRangeLatestInput() throws Exception { + ServingKeyIndexGeneration index = buildIndex("validation"); + HistoricalRangeOverlay.HistoricalValueReader history = + (dbName, key, block) -> OldValue.absent(); + + assertThrows(IllegalArgumentException.class, + () -> materialize(index, Arrays.asList(entry("p/b", "b"), entry("p/a", "a")), + history, new Limits(10, 10, 10))); + assertThrows(IllegalArgumentException.class, + () -> materialize(index, Collections.singletonList(entry("q/a", "a")), history, + new Limits(10, 10, 10))); + } + + private ServingKeyIndexGeneration buildIndex(String name) throws Exception { + Path archive = temporaryFolder.newFolder(name).toPath(); + List markers = new ArrayList<>(); + try (HistoryIndexStore authoritative = new HistoryIndexStore( + archive, new HistoryIndexCodec())) { + markers.add(append(authoritative, 1, group("account", bytes("p/c")))); + markers.add(append(authoritative, 2, + group("account", bytes("p/a"), bytes("p/b"), bytes("p/e"), bytes("p/f")), + group("other", bytes("p/a")))); + markers.add(append(authoritative, 3, + group("account", bytes("p/a"), bytes("q/z")))); + authoritative.sync(); + return ServingKeyIndexGeneration.rebuild( + "generation-" + name, 0, hash(0), markers, authoritative::read); + } + } + + private static List materialize(ServingKeyIndexGeneration index, List latest, + HistoricalRangeOverlay.HistoricalValueReader history, Limits limits) throws Exception { + return HistoricalRangeOverlay.materialize("account", 1, 3, + KeyRange.prefix(bytes("p/")), latest, index, history, limits); + } + + private static HistoryCommitMarker append(HistoryIndexStore authoritative, int block, + KeyGroup... groups) throws Exception { + BlockSnapshotMeta meta = new BlockSnapshotMeta(block, block, hash(block), hash(block - 1), + block * 3_000L); + HistoryLocation body = new HistoryLocation(0, block * 100L, 80, block, hash(block)); + HistoryIndexLocation location = authoritative.append( + new HistoryIndexRecord(meta, body, Arrays.asList(groups))); + return new HistoryCommitMarker(meta, block - 1L, body, location, new byte[16], + Arrays.asList("account", "other")); + } + + private static KeyGroup group(String dbName, byte[]... keys) { + return new KeyGroup(dbName, Arrays.asList(keys)); + } + + private static Entry entry(String key, String value) { + return new Entry(bytes(key), bytes(value)); + } + + private static void assertEntries(List entries, String... keyValues) { + for (int i = 0; i < keyValues.length; i += 2) { + assertArrayEquals(bytes(keyValues[i]), entries.get(i / 2).getKey()); + assertArrayEquals(bytes(keyValues[i + 1]), entries.get(i / 2).getValue()); + } + } + + private static byte[] hash(int suffix) { + byte[] hash = new byte[32]; + hash[31] = (byte) suffix; + return hash; + } + + private static byte[] bytes(String value) { + return value.getBytes(StandardCharsets.UTF_8); + } + + private static String text(byte[] value) { + return new String(value, StandardCharsets.UTF_8); + } +} diff --git a/framework/src/test/java/org/tron/core/db2/archive/HistoryIndexStoreTest.java b/framework/src/test/java/org/tron/core/db2/archive/HistoryIndexStoreTest.java new file mode 100644 index 00000000000..155fa71e06e --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/archive/HistoryIndexStoreTest.java @@ -0,0 +1,117 @@ +package org.tron.core.db2.archive; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; + +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.Arrays; +import java.util.Collections; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tron.core.db2.archive.BlockReverseDiff.DbGroup; +import org.tron.core.db2.archive.BlockReverseDiff.Entry; + +public class HistoryIndexStoreTest { + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void locatesAndBackReferencesHistoryBody() throws Exception { + Path archive = temporaryFolder.newFolder("index").toPath(); + BlockReverseDiff diff = diff(1); + HistoryLocation bodyLocation; + HistoryIndexLocation indexLocation; + try (HistorySegmentStore bodies = new HistorySegmentStore( + archive, new BlockHistoryCodec(), 4096); + HistoryIndexStore index = new HistoryIndexStore(archive, new HistoryIndexCodec())) { + bodyLocation = bodies.append(diff); + indexLocation = index.append(HistoryIndexRecord.from(diff, bodyLocation)); + bodies.sync(); + index.sync(); + + HistoryIndexRecord decodedIndex = index.read(indexLocation); + assertEquals(diff.getMeta(), decodedIndex.getMeta()); + assertEquals(bodyLocation.getSegmentId(), + decodedIndex.getHistoryLocation().getSegmentId()); + assertArrayEquals(bodyLocation.getBodyDigest(), + decodedIndex.getHistoryLocation().getBodyDigest()); + assertEquals(diff.getMeta(), bodies.read(decodedIndex.getHistoryLocation()).getMeta()); + assertArrayEquals(bytes("key-1"), decodedIndex.getGroups().get(0).getKeys().get(0)); + } + + try (HistoryIndexStore reopened = new HistoryIndexStore(archive, new HistoryIndexCodec())) { + assertNull(reopened.getScanResult().getInvalidTailOffset()); + assertEquals(1, reopened.getScanResult().getRecordCount()); + assertArrayEquals(indexLocation.getDigest(), reopened.getScanResult().getHead() + .getLocation().getDigest()); + } + } + + @Test + public void detectsAndTruncatesCorruptIndexTail() throws Exception { + Path archive = temporaryFolder.newFolder("corrupt-index").toPath(); + BlockReverseDiff diff = diff(1); + HistoryIndexLocation location; + try (HistorySegmentStore bodies = new HistorySegmentStore( + archive, new BlockHistoryCodec(), 4096); + HistoryIndexStore index = new HistoryIndexStore(archive, new HistoryIndexCodec())) { + HistoryLocation body = bodies.append(diff); + location = index.append(HistoryIndexRecord.from(diff, body)); + bodies.sync(); + index.sync(); + } + + Path indexPath = archive.resolve("state_history.idx"); + try (FileChannel channel = FileChannel.open(indexPath, StandardOpenOption.WRITE)) { + channel.position(location.getOffset() + HistoryIndexCodec.FIXED_HEADER_LENGTH); + channel.write(ByteBuffer.wrap(new byte[]{0x7f})); + channel.force(true); + } + try (HistoryIndexStore index = new HistoryIndexStore(archive, new HistoryIndexCodec())) { + assertNotNull(index.getScanResult().getInvalidTailOffset()); + assertEquals(Long.valueOf(0), index.getScanResult().getInvalidTailOffset()); + assertThrows(IllegalStateException.class, + () -> index.append(HistoryIndexRecord.from(diff, + new HistoryLocation(0, 0, 1, 0, new byte[32])))); + index.truncateInvalidTail(); + assertNull(index.getScanResult().getInvalidTailOffset()); + assertEquals(0, index.getScanResult().getRecordCount()); + } + } + + @Test + public void indexEncodingIsDeterministic() { + BlockReverseDiff diff = diff(1); + HistoryLocation location = new HistoryLocation(2, 7, 99, 17, new byte[32]); + HistoryIndexCodec codec = new HistoryIndexCodec(); + byte[] first = codec.encode(HistoryIndexRecord.from(diff, location)); + byte[] second = codec.encode(HistoryIndexRecord.from(diff, location)); + assertArrayEquals(first, second); + assertEquals(first.length, codec.recordLength(Arrays.copyOf(first, 12))); + } + + private static BlockReverseDiff diff(int number) { + return new BlockReverseDiff(new BlockSnapshotMeta(number, number, hash(number), + hash(number - 1), number * 3_000L), Collections.singletonList( + new DbGroup("account", Collections.singletonList( + new Entry(bytes("key-" + number), OldValue.present(bytes("value-" + number))))))); + } + + private static byte[] hash(int suffix) { + byte[] hash = new byte[32]; + hash[31] = (byte) suffix; + return hash; + } + + private static byte[] bytes(String value) { + return value.getBytes(java.nio.charset.StandardCharsets.UTF_8); + } +} diff --git a/framework/src/test/java/org/tron/core/db2/archive/HistorySegmentStoreTest.java b/framework/src/test/java/org/tron/core/db2/archive/HistorySegmentStoreTest.java new file mode 100644 index 00000000000..cbcee3b767d --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/archive/HistorySegmentStoreTest.java @@ -0,0 +1,107 @@ +package org.tron.core.db2.archive; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.Collections; +import java.util.Random; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tron.core.db2.archive.BlockReverseDiff.DbGroup; +import org.tron.core.db2.archive.BlockReverseDiff.Entry; + +public class HistorySegmentStoreTest { + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void appendsRotatesScansAndReadsRecords() throws Exception { + Path archive = temporaryFolder.newFolder("archive").toPath(); + BlockHistoryCodec codec = new BlockHistoryCodec(); + HistoryLocation first; + HistoryLocation second; + try (HistorySegmentStore store = new HistorySegmentStore(archive, codec, 250)) { + first = store.append(diff(1, randomBytes(100, 1))); + second = store.append(diff(2, randomBytes(100, 2))); + store.sync(); + assertEquals(0, first.getSegmentId()); + assertEquals(1, second.getSegmentId()); + assertEquals(2, store.getScanResult().getRecordCount()); + assertEquals(2, store.read(second).getMeta().getBlockNumber()); + } + + try (HistorySegmentStore reopened = new HistorySegmentStore(archive, codec, 250)) { + assertNull(reopened.getScanResult().getInvalidTail()); + assertEquals(2, reopened.getScanResult().getRecordCount()); + assertArrayEquals(second.getBodyDigest(), reopened.getScanResult().getHead() + .getLocation().getBodyDigest()); + } + } + + @Test + public void findsAndTruncatesPartialTailBeforeAppending() throws Exception { + Path archive = temporaryFolder.newFolder("partial").toPath(); + BlockHistoryCodec codec = new BlockHistoryCodec(); + HistoryLocation first; + try (HistorySegmentStore store = new HistorySegmentStore(archive, codec, 4096)) { + first = store.append(diff(1, bytes("one"))); + store.sync(); + } + + Path segment = archive.resolve("history").resolve("history.000000.dat"); + Files.write(segment, new byte[]{0x54, 0x41, 0x52}, StandardOpenOption.APPEND); + try (HistorySegmentStore store = new HistorySegmentStore(archive, codec, 4096)) { + assertNotNull(store.getScanResult().getInvalidTail()); + assertEquals(first.endOffset(), store.getScanResult().getInvalidTail().getOffset()); + assertThrows(IllegalStateException.class, () -> store.append(diff(2, bytes("two")))); + store.truncateInvalidTail(); + assertNull(store.getScanResult().getInvalidTail()); + store.append(diff(2, bytes("two"))); + store.sync(); + assertEquals(2, store.getScanResult().getRecordCount()); + } + } + + @Test + public void rejectsHashOrEpochGaps() throws Exception { + Path archive = temporaryFolder.newFolder("gap").toPath(); + try (HistorySegmentStore store = new HistorySegmentStore( + archive, new BlockHistoryCodec(), 4096)) { + store.append(diff(1, bytes("one"))); + BlockReverseDiff wrongParent = new BlockReverseDiff( + new BlockSnapshotMeta(2, 2, hash(2), hash(99), 2L), Collections.emptyList()); + assertThrows(IllegalArgumentException.class, () -> store.append(wrongParent)); + } + } + + private static BlockReverseDiff diff(int number, byte[] value) { + return new BlockReverseDiff(new BlockSnapshotMeta( + number, number, hash(number), hash(number - 1), number), + Collections.singletonList(new DbGroup("account", Collections.singletonList( + new Entry(bytes("key-" + number), OldValue.present(value)))))); + } + + private static byte[] randomBytes(int length, int seed) { + byte[] bytes = new byte[length]; + new Random(seed).nextBytes(bytes); + return bytes; + } + + private static byte[] hash(int suffix) { + byte[] hash = new byte[32]; + hash[31] = (byte) suffix; + return hash; + } + + private static byte[] bytes(String value) { + return value.getBytes(java.nio.charset.StandardCharsets.UTF_8); + } +} diff --git a/framework/src/test/java/org/tron/core/db2/archive/LatestStateGenerationAdapterTest.java b/framework/src/test/java/org/tron/core/db2/archive/LatestStateGenerationAdapterTest.java new file mode 100644 index 00000000000..d4fca0f2d9d --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/archive/LatestStateGenerationAdapterTest.java @@ -0,0 +1,269 @@ +package org.tron.core.db2.archive; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Collections; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.Test; +import org.tron.core.db2.archive.ArchiveReadSnapshot.PinnedLatestState; +import org.tron.core.db2.archive.LatestStateGenerationAdapter.SnapshotCapableStore; +import org.tron.core.db2.archive.LatestStateGenerationAdapter.StoreSnapshot; +import org.tron.core.db2.common.DB; + +public class LatestStateGenerationAdapterTest { + + private static final List PARTICIPANTS = Arrays.asList("account", "properties"); + + @Test + public void currentDbAbstractionFailsClosedInsteadOfUsingOrdinaryGet() { + Map> databases = new LinkedHashMap<>(); + databases.put("account", new OrdinaryDb("account")); + databases.put("properties", new OrdinaryDb("properties")); + + ArchivePersistenceException failure = assertThrows(ArchivePersistenceException.class, + () -> LatestStateGenerationAdapter.fromDatabases(PARTICIPANTS, databases)); + assertTrue(failure.getMessage().contains("stable snapshot lifecycle")); + assertFalse(((OrdinaryDb) databases.get("account")).read); + assertFalse(((OrdinaryDb) databases.get("properties")).read); + } + + @Test + public void pinsExactGenerationAndSurvivesLiveSourceReplacement() throws Exception { + FakeStore account = new FakeStore("account", "rocksdb:/state/account", bytes("old")); + FakeStore properties = new FakeStore("properties", "leveldb:/state/properties", + bytes("property")); + LatestStateGenerationAdapter adapter = adapter(account, properties); + byte[] expectedDigest = adapter.getSourceIdentityDigest(); + + try (PinnedLatestState pinned = adapter.pin("generation-1", 7, hash(7), PARTICIPANTS)) { + account.replace("rocksdb:/replacement/account", bytes("new")); + assertArrayEquals(bytes("old"), pinned.get("account", bytes("key")).getValue()); + assertArrayEquals(expectedDigest, pinned.getSourceIdentityDigest()); + assertThrows(UnsupportedOperationException.class, + () -> pinned.range("account", new byte[0], null, 1)); + } + assertEquals(1, account.closedSnapshots.get()); + assertEquals(1, properties.closedSnapshots.get()); + } + + @Test + public void releasesPartialAcquireAndRejectsReplacementIdentity() throws Exception { + FakeStore account = new FakeStore("account", "rocksdb:/state/account", bytes("old")); + FakeStore properties = new FakeStore("properties", "leveldb:/state/properties", + bytes("property")); + properties.failPin.set(true); + LatestStateGenerationAdapter adapter = adapter(account, properties); + + assertThrows(IOException.class, + () -> adapter.pin("generation-1", 7, hash(7), PARTICIPANTS)); + assertEquals(1, account.closedSnapshots.get()); + + properties.failPin.set(false); + properties.replaceAfterPin.set(true); + assertThrows(ArchivePersistenceException.class, + () -> adapter.pin("generation-1", 7, hash(7), PARTICIPANTS)); + assertEquals(2, account.closedSnapshots.get()); + assertEquals(1, properties.closedSnapshots.get()); + } + + @Test + public void rejectsParticipantAndSnapshotBlockIdentityMismatch() { + FakeStore account = new FakeStore("account", "rocksdb:/state/account", bytes("old")); + FakeStore properties = new FakeStore("properties", "leveldb:/state/properties", + bytes("property")); + LatestStateGenerationAdapter adapter = adapter(account, properties); + + assertThrows(ArchivePersistenceException.class, + () -> adapter.pin("generation-1", 7, hash(7), Collections.singletonList("account"))); + properties.wrongBlock.set(true); + assertThrows(ArchivePersistenceException.class, + () -> adapter.pin("generation-1", 7, hash(7), PARTICIPANTS)); + assertEquals(1, account.closedSnapshots.get()); + assertEquals(1, properties.closedSnapshots.get()); + } + + @Test + public void rejectsSourceReplacementBeforeAcquiringAnySnapshot() { + FakeStore account = new FakeStore("account", "rocksdb:/state/account", bytes("old")); + FakeStore properties = new FakeStore("properties", "leveldb:/state/properties", + bytes("property")); + LatestStateGenerationAdapter adapter = adapter(account, properties); + account.replace("rocksdb:/replacement/account", bytes("new")); + + assertThrows(ArchivePersistenceException.class, + () -> adapter.pin("generation-1", 7, hash(7), PARTICIPANTS)); + assertEquals(0, account.closedSnapshots.get()); + assertEquals(0, properties.closedSnapshots.get()); + } + + private static LatestStateGenerationAdapter adapter(FakeStore... stores) { + Map indexed = new LinkedHashMap<>(); + for (FakeStore store : stores) { + indexed.put(store.dbName, store); + } + return new LatestStateGenerationAdapter(PARTICIPANTS, indexed); + } + + private static byte[] bytes(String value) { + return value.getBytes(StandardCharsets.UTF_8); + } + + private static byte[] hash(int suffix) { + byte[] hash = new byte[32]; + hash[31] = (byte) suffix; + return hash; + } + + private static final class FakeStore implements SnapshotCapableStore { + private final String dbName; + private final AtomicBoolean failPin = new AtomicBoolean(); + private final AtomicBoolean wrongBlock = new AtomicBoolean(); + private final AtomicBoolean replaceAfterPin = new AtomicBoolean(); + private final AtomicInteger closedSnapshots = new AtomicInteger(); + private String identity; + private byte[] value; + + private FakeStore(String dbName, String identity, byte[] value) { + this.dbName = dbName; + this.identity = identity; + this.value = Arrays.copyOf(value, value.length); + } + + private void replace(String replacementIdentity, byte[] replacementValue) { + identity = replacementIdentity; + value = Arrays.copyOf(replacementValue, replacementValue.length); + } + + @Override + public String getDbName() { + return dbName; + } + + @Override + public String getSourceIdentity() { + return identity; + } + + @Override + public StoreSnapshot pin(long blockNumber, byte[] blockHash) throws IOException { + if (failPin.get()) { + throw new IOException("injected pin failure"); + } + String candidateIdentity = identity; + byte[] pinnedValue = Arrays.copyOf(value, value.length); + if (replaceAfterPin.get()) { + identity = identity + ":replacement"; + candidateIdentity = identity; + } + final String pinnedIdentity = candidateIdentity; + long pinnedBlock = wrongBlock.get() ? blockNumber + 1 : blockNumber; + return new StoreSnapshot() { + private boolean closed; + + @Override + public String getDbName() { + return dbName; + } + + @Override + public String getSourceIdentity() { + return pinnedIdentity; + } + + @Override + public long getBlockNumber() { + return pinnedBlock; + } + + @Override + public byte[] getBlockHash() { + return Arrays.copyOf(blockHash, blockHash.length); + } + + @Override + public byte[] get(byte[] physicalRawKey) { + if (closed) { + throw new IllegalStateException("snapshot is closed"); + } + return Arrays.copyOf(pinnedValue, pinnedValue.length); + } + + @Override + public void close() { + if (!closed) { + closed = true; + closedSnapshots.incrementAndGet(); + } + } + }; + } + } + + private static final class OrdinaryDb implements DB { + private final String dbName; + private boolean read; + + private OrdinaryDb(String dbName) { + this.dbName = dbName; + } + + @Override + public byte[] get(byte[] key) { + read = true; + return null; + } + + @Override + public void put(byte[] key, byte[] value) { + } + + @Override + public long size() { + return 0; + } + + @Override + public boolean isEmpty() { + return true; + } + + @Override + public void remove(byte[] key) { + } + + @Override + public Iterator> iterator() { + return Collections.>emptyList().iterator(); + } + + @Override + public void close() { + } + + @Override + public String getDbName() { + return dbName; + } + + @Override + public void stat() { + } + + @Override + public DB newInstance() { + return this; + } + } +} diff --git a/framework/src/test/java/org/tron/core/db2/archive/LatestStateGenerationCoordinatorFactoryTest.java b/framework/src/test/java/org/tron/core/db2/archive/LatestStateGenerationCoordinatorFactoryTest.java new file mode 100644 index 00000000000..bb282f93410 --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/archive/LatestStateGenerationCoordinatorFactoryTest.java @@ -0,0 +1,357 @@ +package org.tron.core.db2.archive; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.tron.common.TestConstants.TEST_CONF; + +import java.io.UncheckedIOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.After; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tron.common.storage.leveldb.LevelDbDataSourceImpl; +import org.tron.common.storage.rocksdb.RocksDbDataSourceImpl; +import org.tron.core.config.args.Args; +import org.tron.core.db2.archive.ArchiveProgressEnvelope.Kind; +import org.tron.core.db2.archive.LatestStateGenerationAdapter.StoreSnapshot; +import org.tron.core.db2.common.DB; +import org.tron.core.db2.common.LevelDB; +import org.tron.core.db2.common.RocksDB; +import org.tron.core.db2.core.Chainbase; +import org.tron.core.db2.core.SnapshotManager; +import org.tron.core.db2.core.SnapshotRoot; + +public class LatestStateGenerationCoordinatorFactoryTest { + + private final List openRegistries = new ArrayList<>(); + + static { + org.rocksdb.RocksDB.loadLibrary(); + } + + @BeforeClass + public static void initConfiguration() { + Args.setParam(new String[0], TEST_CONF); + } + + @AfterClass + public static void clearConfiguration() { + Args.clearParam(); + } + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @After + public void closeEngines() { + openRegistries.forEach(Registry::close); + openRegistries.clear(); + } + + @Test + public void assemblesExactMixedEnginesAndIgnoresDerivedStore() throws Exception { + Registry registry = registry(true); + Path readerVisible = temporaryFolder.newFile("reader-visible").toPath(); + storeReader(readerVisible, 1, registry.participants); + + try (LatestStateGenerationCoordinator coordinator = + LatestStateGenerationCoordinatorFactory.create(registry.manager, readerVisible); + LatestStateGenerationCoordinator.Candidate candidate = + coordinator.acquire("generation-1")) { + assertEquals(ArchiveStoreScope.getStateDatabases().size(), totalPins(registry)); + } + assertEquals(ArchiveStoreScope.getStateDatabases().size(), totalCloses(registry)); + } + + @Test + public void combinesSnapshotManagerStoresWithSupplementalAccountAsset() throws Exception { + Registry registry = registry(false); + DB accountAsset = registry.engines.get( + AccountAssetArchiveProjector.ACCOUNT_ASSET_DB); + registry.manager.getDbs().removeIf(database -> + AccountAssetArchiveProjector.ACCOUNT_ASSET_DB.equals(database.getDbName())); + ArchiveProgressEnvelope authority = new ArchiveProgressEnvelope(Kind.READER_VISIBLE, null, 1, + hash(1), new byte[16], new byte[32], registry.participants); + + try (LatestStateGenerationCoordinator coordinator = + LatestStateGenerationCoordinatorFactory.create(registry.manager, + java.util.Collections.singletonMap( + AccountAssetArchiveProjector.ACCOUNT_ASSET_DB, + (LatestStateGenerationAdapter.SnapshotCapableStore) accountAsset), + () -> authority); + LatestStateGenerationCoordinator.Candidate candidate = + coordinator.acquire("generation-supplemental")) { + assertEquals(ArchiveStoreScope.getStateDatabases().size(), totalPins(registry)); + } + assertEquals(ArchiveStoreScope.getStateDatabases().size(), totalCloses(registry)); + } + + @Test + public void rejectsMissingDuplicateAndNonCapableStateRoots() throws Exception { + Path readerVisible = temporaryFolder.newFile("invalid-reader-visible").toPath(); + Registry missing = registry(false); + missing.manager.getDbs().removeIf(database -> "witness".equals(database.getDbName())); + assertThrows(ArchivePersistenceException.class, + () -> LatestStateGenerationCoordinatorFactory.create(missing.manager, readerVisible)); + + Registry duplicate = registry(false); + duplicate.manager.getDbs().add(new Chainbase(new SnapshotRoot( + duplicate.engines.get("account")))); + assertThrows(ArchivePersistenceException.class, + () -> LatestStateGenerationCoordinatorFactory.create(duplicate.manager, readerVisible)); + + SnapshotManager nonCapable = new SnapshotManager(""); + for (String participant : sortedParticipants()) { + nonCapable.getDbs().add(new Chainbase(new SnapshotRoot(nonCapable(participant)))); + } + assertThrows(ArchivePersistenceException.class, + () -> LatestStateGenerationCoordinatorFactory.create(nonCapable, readerVisible)); + } + + @Test + public void rejectsReaderSetMismatchAndReleasesPartialAcquire() throws Exception { + Registry registry = registry(false); + Path readerVisible = temporaryFolder.newFile("partial-reader-visible").toPath(); + List unexpectedDerived = new ArrayList<>(registry.participants); + unexpectedDerived.add("accountTrie"); + java.util.Collections.sort(unexpectedDerived); + storeReader(readerVisible, 1, unexpectedDerived); + + try (LatestStateGenerationCoordinator coordinator = + LatestStateGenerationCoordinatorFactory.create(registry.manager, readerVisible)) { + assertThrows(ArchivePersistenceException.class, + () -> coordinator.acquire("generation-mismatch")); + assertEquals(0, totalPins(registry)); + + storeReader(readerVisible, 1, registry.participants); + registry.probes.get("properties").failPin.set(true); + assertThrows(IllegalStateException.class, + () -> coordinator.acquire("generation-partial")); + assertTrue(totalPins(registry) > 0); + assertEquals(totalPins(registry), totalCloses(registry)); + } + AtomicBoolean reentered = new AtomicBoolean(); + registry.manager.withArchiveStateBarrier(() -> reentered.set(true)); + assertTrue(reentered.get()); + } + + @Test + public void readerDriftClosesEveryMixedEngineSnapshot() throws Exception { + Registry registry = registry(false); + Path readerVisible = temporaryFolder.newFile("drifting-reader-visible").toPath(); + storeReader(readerVisible, 1, registry.participants); + AtomicBoolean changed = new AtomicBoolean(); + registry.probes.firstEntry().getValue().onPin = () -> { + if (changed.compareAndSet(false, true)) { + try { + storeReader(readerVisible, 2, registry.participants); + } catch (java.io.IOException failure) { + throw new UncheckedIOException(failure); + } + } + }; + + try (LatestStateGenerationCoordinator coordinator = + LatestStateGenerationCoordinatorFactory.create(registry.manager, readerVisible)) { + assertThrows(ArchivePersistenceException.class, + () -> coordinator.acquire("generation-drift")); + } + assertEquals(ArchiveStoreScope.getStateDatabases().size(), totalPins(registry)); + assertEquals(totalPins(registry), totalCloses(registry)); + } + + private Registry registry(boolean includeDerived) { + SnapshotManager manager = new SnapshotManager(""); + TreeMap probes = new TreeMap<>(); + TreeMap> engines = new TreeMap<>(); + List participants = sortedParticipants(); + for (int index = 0; index < participants.size(); index++) { + String participant = participants.get(index); + Probe probe = new Probe(participant, (index & 1) == 0 ? "leveldb" : "rocksdb"); + DB engine = (index & 1) == 0 + ? new FakeLevelDB(probe) : new FakeRocksDB(probe); + probes.put(participant, probe); + engines.put(participant, engine); + manager.getDbs().add(new Chainbase(new SnapshotRoot(engine))); + } + if (includeDerived) { + manager.getDbs().add(new Chainbase(new SnapshotRoot(nonCapable("accountTrie")))); + } + Registry registry = new Registry(manager, participants, probes, engines); + openRegistries.add(registry); + return registry; + } + + @SuppressWarnings("unchecked") + private static DB nonCapable(String dbName) { + DB database = mock(DB.class); + when(database.getDbName()).thenReturn(dbName); + return database; + } + + private static List sortedParticipants() { + String[] participants = ArchiveStoreScope.getStateDatabases().toArray(new String[0]); + Arrays.sort(participants); + return Arrays.asList(participants); + } + + private static void storeReader(Path path, int epoch, List participants) + throws java.io.IOException { + new ArchiveProgressFile(path, new ArchiveProgressEnvelopeCodec()).store( + new ArchiveProgressEnvelope(Kind.READER_VISIBLE, null, epoch, hash(epoch), new byte[16], + new byte[32], participants)); + } + + private static int totalPins(Registry registry) { + return registry.probes.values().stream().mapToInt(probe -> probe.pins.get()).sum(); + } + + private static int totalCloses(Registry registry) { + return registry.probes.values().stream().mapToInt(probe -> probe.closes.get()).sum(); + } + + private static byte[] hash(int suffix) { + byte[] hash = new byte[32]; + hash[31] = (byte) suffix; + return hash; + } + + private static final class Registry { + private final SnapshotManager manager; + private final List participants; + private final TreeMap probes; + private final Map> engines; + + private Registry(SnapshotManager manager, List participants, + TreeMap probes, Map> engines) { + this.manager = manager; + this.participants = participants; + this.probes = probes; + this.engines = engines; + } + + private void close() { + engines.values().forEach(DB::close); + } + } + + private static final class Probe { + private final String dbName; + private final String sourceIdentity; + private final AtomicBoolean failPin = new AtomicBoolean(); + private final AtomicInteger pins = new AtomicInteger(); + private final AtomicInteger closes = new AtomicInteger(); + private Runnable onPin = () -> { }; + + private Probe(String dbName, String engine) { + this.dbName = dbName; + this.sourceIdentity = engine + ":" + dbName + ":00000000-0000-0000-0000-000000000001"; + } + + private StoreSnapshot pin(long blockNumber, byte[] blockHash) { + if (failPin.get()) { + throw new IllegalStateException("injected engine pin failure: " + dbName); + } + pins.incrementAndGet(); + onPin.run(); + byte[] expectedHash = Arrays.copyOf(blockHash, blockHash.length); + return new StoreSnapshot() { + private boolean closed; + + @Override + public String getDbName() { + return dbName; + } + + @Override + public String getSourceIdentity() { + return sourceIdentity; + } + + @Override + public long getBlockNumber() { + return blockNumber; + } + + @Override + public byte[] getBlockHash() { + return Arrays.copyOf(expectedHash, expectedHash.length); + } + + @Override + public byte[] get(byte[] physicalRawKey) { + return null; + } + + @Override + public void close() { + if (!closed) { + closed = true; + closes.incrementAndGet(); + } + } + }; + } + } + + private static final class FakeLevelDB extends LevelDB { + private final Probe probe; + + private FakeLevelDB(Probe probe) { + super(mock(LevelDbDataSourceImpl.class)); + this.probe = probe; + } + + @Override + public String getDbName() { + return probe.dbName; + } + + @Override + public String getSourceIdentity() { + return probe.sourceIdentity; + } + + @Override + public StoreSnapshot pin(long blockNumber, byte[] blockHash) { + return probe.pin(blockNumber, blockHash); + } + } + + private static final class FakeRocksDB extends RocksDB { + private final Probe probe; + + private FakeRocksDB(Probe probe) { + super(mock(RocksDbDataSourceImpl.class)); + this.probe = probe; + } + + @Override + public String getDbName() { + return probe.dbName; + } + + @Override + public String getSourceIdentity() { + return probe.sourceIdentity; + } + + @Override + public StoreSnapshot pin(long blockNumber, byte[] blockHash) { + return probe.pin(blockNumber, blockHash); + } + } +} diff --git a/framework/src/test/java/org/tron/core/db2/archive/LatestStateGenerationCoordinatorTest.java b/framework/src/test/java/org/tron/core/db2/archive/LatestStateGenerationCoordinatorTest.java new file mode 100644 index 00000000000..937f027fe12 --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/archive/LatestStateGenerationCoordinatorTest.java @@ -0,0 +1,345 @@ +package org.tron.core.db2.archive; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tron.core.db2.archive.ArchiveReadSnapshot.PinnedLatestState; +import org.tron.core.db2.archive.LatestStateGenerationAdapter.SnapshotCapableStore; +import org.tron.core.db2.archive.LatestStateGenerationAdapter.StoreSnapshot; +import org.tron.core.db2.core.SnapshotManager; + +public class LatestStateGenerationCoordinatorTest { + + private static final List PARTICIPANTS = Arrays.asList("account", "properties"); + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void acquiresEveryStoreInsideBarrierAndPublishesBoundGeneration() throws Exception { + Path root = temporaryFolder.newFolder("coordinator").toPath(); + FakeBarrier barrier = new FakeBarrier(); + FakeStore account = new FakeStore("account", "rocksdb:account", bytes("account-v1"), barrier); + FakeStore properties = new FakeStore("properties", "leveldb:properties", + bytes("properties-v1"), barrier); + Map stores = stores(account, properties); + AtomicReference authority = new AtomicReference<>(); + + try (ArchiveHistoryWriter writer = writer(root.resolve("archive")); + LatestStateGenerationCoordinator coordinator = new LatestStateGenerationCoordinator( + PARTICIPANTS, stores, barrier::run, authority::get)) { + writer.accept(diff(1, "account", "key", "old")); + authority.set(reader(writer.committedHead())); + try (LatestStateGenerationCoordinator.Candidate candidate = + coordinator.acquire("generation-1"); + PersistentServingKeyIndexGeneration serving = writer.buildServingGeneration( + root.resolve("generation-1"), "generation-1", + candidate.getSourceIdentityDigest())) { + assertFalse(barrier.active.get()); + assertTrue(coordinator.publish(null, candidate, serving)); + account.value = bytes("account-live-v2"); + try (PinnedLatestState pinned = coordinator.pin(serving)) { + assertArrayEquals(bytes("account-v1"), + pinned.get("account", bytes("key")).getValue()); + assertArrayEquals(candidate.getSourceIdentityDigest(), + pinned.getSourceIdentityDigest()); + assertEquals(1, coordinator.getReferenceCount("generation-1")); + } + } + } + assertEquals(1, account.closedSnapshots.get()); + assertEquals(1, properties.closedSnapshots.get()); + } + + @Test + public void authorityDriftAndPartialAcquireReleaseEverySnapshot() throws Exception { + FakeBarrier barrier = new FakeBarrier(); + FakeStore account = new FakeStore("account", "rocksdb:account", bytes("account"), barrier); + FakeStore properties = new FakeStore("properties", "leveldb:properties", + bytes("properties"), barrier); + AtomicInteger reads = new AtomicInteger(); + LatestStateGenerationCoordinator drifting = new LatestStateGenerationCoordinator( + PARTICIPANTS, stores(account, properties), barrier::run, + () -> reads.getAndIncrement() == 0 ? reader(1) : reader(2)); + try { + assertThrows(ArchivePersistenceException.class, + () -> drifting.acquire("generation-1")); + assertFalse(barrier.active.get()); + assertEquals(1, account.closedSnapshots.get()); + assertEquals(1, properties.closedSnapshots.get()); + } finally { + drifting.close(); + } + + properties.failPin.set(true); + LatestStateGenerationCoordinator partial = new LatestStateGenerationCoordinator( + PARTICIPANTS, stores(account, properties), barrier::run, () -> reader(1)); + try { + assertThrows(IOException.class, () -> partial.acquire("generation-2")); + assertFalse(barrier.active.get()); + assertEquals(2, account.closedSnapshots.get()); + assertEquals(1, properties.closedSnapshots.get()); + } finally { + partial.close(); + } + } + + @Test + public void pinnedOldGenerationSurvivesReplacementUntilLastReaderCloses() throws Exception { + Path root = temporaryFolder.newFolder("replacement").toPath(); + FakeBarrier barrier = new FakeBarrier(); + FakeStore account = new FakeStore("account", "rocksdb:account", bytes("account-v1"), barrier); + FakeStore properties = new FakeStore("properties", "leveldb:properties", + bytes("properties-v1"), barrier); + AtomicReference authority = new AtomicReference<>(); + LatestStateGenerationCoordinator coordinator = new LatestStateGenerationCoordinator( + PARTICIPANTS, stores(account, properties), barrier::run, authority::get); + PinnedLatestState oldPin = null; + PinnedLatestState newPin = null; + try (ArchiveHistoryWriter writer = writer(root.resolve("archive"))) { + writer.accept(diff(1, "account", "key", "old-1")); + authority.set(reader(writer.committedHead())); + try (LatestStateGenerationCoordinator.Candidate first = coordinator.acquire("generation-1"); + PersistentServingKeyIndexGeneration serving1 = writer.buildServingGeneration( + root.resolve("generation-1"), "generation-1", first.getSourceIdentityDigest())) { + assertTrue(coordinator.publish(null, first, serving1)); + oldPin = coordinator.pin(serving1); + } + + account.value = bytes("account-v2"); + properties.value = bytes("properties-v2"); + writer.accept(diff(2, "properties", "key", "old-2")); + authority.set(reader(writer.committedHead())); + try (LatestStateGenerationCoordinator.Candidate second = coordinator.acquire("generation-2"); + PersistentServingKeyIndexGeneration serving2 = writer.buildServingGeneration( + root.resolve("generation-2"), "generation-2", second.getSourceIdentityDigest())) { + assertTrue(coordinator.publish("generation-1", second, serving2)); + newPin = coordinator.pin(serving2); + assertArrayEquals(bytes("account-v1"), + oldPin.get("account", bytes("key")).getValue()); + assertArrayEquals(bytes("account-v2"), + newPin.get("account", bytes("key")).getValue()); + assertThrows(IOException.class, coordinator::close); + } + + oldPin.close(); + oldPin = null; + assertEquals(1, account.closedSnapshots.get()); + assertEquals(1, properties.closedSnapshots.get()); + newPin.close(); + newPin = null; + coordinator.close(); + assertEquals(2, account.closedSnapshots.get()); + assertEquals(2, properties.closedSnapshots.get()); + } finally { + if (oldPin != null) { + oldPin.close(); + } + if (newPin != null) { + newPin.close(); + } + coordinator.close(); + } + } + + @Test + public void snapshotManagerBarrierReleasesPartialAcquireAndMonitor() throws Exception { + SnapshotManager manager = new SnapshotManager(""); + manager.enable(); + SnapshotManagerBarrier barrier = new SnapshotManagerBarrier(manager); + FakeStore account = new FakeStore("account", "rocksdb:account", bytes("account"), + barrier.active); + FakeStore properties = new FakeStore("properties", "leveldb:properties", bytes("properties"), + barrier.active); + properties.failPin.set(true); + + try (LatestStateGenerationCoordinator coordinator = new LatestStateGenerationCoordinator( + PARTICIPANTS, stores(account, properties), barrier::run, () -> reader(1))) { + assertThrows(IOException.class, () -> coordinator.acquire("generation-1")); + assertFalse(barrier.active.get()); + assertEquals(1, account.closedSnapshots.get()); + assertEquals(0, properties.closedSnapshots.get()); + } + try (org.tron.core.db2.ISession ignored = manager.buildSession()) { + assertEquals(1, manager.size()); + } + assertEquals(0, manager.size()); + } + + private static ArchiveHistoryWriter writer(Path archive) throws IOException { + return new ArchiveHistoryWriter(archive, 4096, new LinkedHashSet<>(PARTICIPANTS)); + } + + private static BlockReverseDiff diff(int block, String database, String key, String oldValue) { + return new BlockReverseDiff(new BlockSnapshotMeta(block, block, hash(block), hash(block - 1), + block * 1_000L), Collections.singletonList(new BlockReverseDiff.DbGroup(database, + Collections.singletonList(new BlockReverseDiff.Entry(bytes(key), + OldValue.present(bytes(oldValue))))))); + } + + private static ArchiveProgressEnvelope reader(HistoryCommitMarker marker) { + return new ArchiveProgressEnvelope(ArchiveProgressEnvelope.Kind.READER_VISIBLE, null, + marker.getMeta().getEpoch(), marker.getMeta().getBlockHash(), marker.getBatchId(), + marker.getHistoryLocation().getBodyDigest(), PARTICIPANTS); + } + + private static ArchiveProgressEnvelope reader(int epoch) { + return new ArchiveProgressEnvelope(ArchiveProgressEnvelope.Kind.READER_VISIBLE, null, epoch, + hash(epoch), new byte[16], new byte[32], PARTICIPANTS); + } + + private static Map stores(FakeStore... stores) { + Map indexed = new LinkedHashMap<>(); + for (FakeStore store : stores) { + indexed.put(store.dbName, store); + } + return indexed; + } + + private static byte[] bytes(String value) { + return value.getBytes(StandardCharsets.UTF_8); + } + + private static byte[] hash(int suffix) { + byte[] hash = new byte[32]; + hash[31] = (byte) suffix; + return hash; + } + + private static final class FakeBarrier { + private final AtomicBoolean active = new AtomicBoolean(); + + private void run(ArchiveStateBarrier.ArchiveStateAction action) throws IOException { + if (!active.compareAndSet(false, true)) { + throw new IllegalStateException("barrier is already active"); + } + try { + action.run(); + } finally { + if (!active.compareAndSet(true, false)) { + throw new IllegalStateException("barrier is not active"); + } + } + } + } + + private static final class SnapshotManagerBarrier { + private final SnapshotManager manager; + private final AtomicBoolean active = new AtomicBoolean(); + + private SnapshotManagerBarrier(SnapshotManager manager) { + this.manager = manager; + } + + private void run(ArchiveStateBarrier.ArchiveStateAction action) throws IOException { + manager.withArchiveStateBarrier(() -> { + if (!active.compareAndSet(false, true)) { + throw new IllegalStateException("barrier is already active"); + } + try { + action.run(); + } finally { + if (!active.compareAndSet(true, false)) { + throw new IllegalStateException("barrier is not active"); + } + } + }); + } + } + + private static final class FakeStore implements SnapshotCapableStore { + private final String dbName; + private final String sourceIdentity; + private final AtomicBoolean barrierActive; + private final AtomicBoolean failPin = new AtomicBoolean(); + private final AtomicInteger closedSnapshots = new AtomicInteger(); + private byte[] value; + + private FakeStore(String dbName, String sourceIdentity, byte[] value, FakeBarrier barrier) { + this(dbName, sourceIdentity, value, barrier.active); + } + + private FakeStore(String dbName, String sourceIdentity, byte[] value, + AtomicBoolean barrierActive) { + this.dbName = dbName; + this.sourceIdentity = sourceIdentity; + this.value = value; + this.barrierActive = barrierActive; + } + + @Override + public String getDbName() { + return dbName; + } + + @Override + public String getSourceIdentity() { + return sourceIdentity; + } + + @Override + public StoreSnapshot pin(long blockNumber, byte[] blockHash) throws IOException { + if (!barrierActive.get()) { + throw new AssertionError("Store snapshot acquired outside global barrier"); + } + if (failPin.get()) { + throw new IOException("injected Store pin failure"); + } + byte[] pinnedValue = Arrays.copyOf(value, value.length); + return new StoreSnapshot() { + private boolean closed; + + @Override + public String getDbName() { + return dbName; + } + + @Override + public String getSourceIdentity() { + return sourceIdentity; + } + + @Override + public long getBlockNumber() { + return blockNumber; + } + + @Override + public byte[] getBlockHash() { + return Arrays.copyOf(blockHash, blockHash.length); + } + + @Override + public byte[] get(byte[] physicalRawKey) { + return Arrays.copyOf(pinnedValue, pinnedValue.length); + } + + @Override + public void close() { + if (!closed) { + closed = true; + closedSnapshots.incrementAndGet(); + } + } + }; + } + } +} diff --git a/framework/src/test/java/org/tron/core/db2/archive/P66AccountAssetCodecTest.java b/framework/src/test/java/org/tron/core/db2/archive/P66AccountAssetCodecTest.java new file mode 100644 index 00000000000..ec1a0a8e756 --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/archive/P66AccountAssetCodecTest.java @@ -0,0 +1,170 @@ +package org.tron.core.db2.archive; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import com.google.protobuf.ByteString; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Collections; +import org.junit.Test; +import org.tron.common.utils.ByteArray; +import org.tron.core.db2.archive.BlockChangeView.PostValue; +import org.tron.core.db2.archive.P66AccountAssetCodec.AssetRow; +import org.tron.core.db2.archive.P66AccountAssetCodec.DecodedAssetRow; +import org.tron.core.db2.archive.P66AccountAssetCodec.Phase; +import org.tron.protos.Protocol.Account; + +public class P66AccountAssetCodecTest { + + private final P66AccountAssetCodec codec = new P66AccountAssetCodec(); + + @Test + public void p66OffPreservesExactAccountBytesAndRejectsDirectRows() throws Exception { + byte[] address = address(1); + Account account = account(address).toBuilder() + .putAsset("legacy-name", 7L) + .putAssetV2("1000001", 11L) + .build(); + byte[] raw = account.toByteArray(); + + byte[] canonical = codec.canonicalizeAccount(Phase.P66_OFF, address, raw); + assertArrayEquals(raw, canonical); + assertFalse(Account.parseFrom(canonical).getAssetOptimized()); + codec.requireCanonicalLayout(Phase.P66_OFF, address, canonical, + Collections.emptyList()); + assertThrows(ArchivePersistenceException.class, + () -> codec.encodeAssetRow(Phase.P66_OFF, address, "1000001", 11L)); + + byte[] optimized = account.toBuilder().setAssetOptimized(true).build().toByteArray(); + assertThrows(ArchivePersistenceException.class, + () -> codec.canonicalizeAccount(Phase.P66_OFF, address, optimized)); + } + + @Test + public void activationAndOnCanonicalizeAccountAndRowsDeterministically() throws Exception { + byte[] address = address(2); + Account raw = account(address).toBuilder() + .setBalance(99L) + .putAsset("legacy-name", 5L) + .putAssetV2("1000001", 17L) + .build(); + + byte[] activation = codec.canonicalizeAccount(Phase.P66_ACTIVATION, address, + raw.toByteArray()); + byte[] on = codec.canonicalizeAccount(Phase.P66_ON, address, raw.toByteArray()); + assertArrayEquals(activation, on); + Account canonical = Account.parseFrom(activation); + assertTrue(canonical.getAssetOptimized()); + assertTrue(canonical.getAssetMap().isEmpty()); + assertTrue(canonical.getAssetV2Map().isEmpty()); + assertEquals(99L, canonical.getBalance()); + assertEquals("1a154100000000000000000000000000000000000000022063e00301", + ByteArray.toHexString(activation)); + + AssetRow present = codec.encodeAssetRow(Phase.P66_ACTIVATION, address, "1000001", 17L); + AssetRow absent = codec.encodeAssetRow(Phase.P66_ACTIVATION, address, "1000002", 0L); + assertTrue(present.getPostValue().isPresent()); + assertArrayEquals(ByteBuffer.allocate(Long.BYTES).putLong(17L).array(), + present.getPostValue().getValue()); + assertFalse(absent.getPostValue().isPresent()); + codec.requireCanonicalLayout(Phase.P66_ACTIVATION, address, activation, + Arrays.asList(present, absent)); + codec.requireCanonicalLayout(Phase.P66_ON, address, on, + Arrays.asList(present, absent)); + } + + @Test + public void decodesSignedBalanceAndDefensivelyOwnsBytes() { + byte[] address = address(3); + AssetRow row = codec.encodeAssetRow(Phase.P66_ON, address, "0", -9L); + byte[] key = row.getPhysicalRawKey(); + byte[] value = row.getPostValue().getValue(); + DecodedAssetRow decoded = codec.decodePresentAssetRow(key, value); + assertArrayEquals(address, decoded.getAccountAddress()); + assertEquals("0", decoded.getTokenId()); + assertEquals(-9L, decoded.getBalance()); + + key[0] ^= 1; + value[0] ^= 1; + assertArrayEquals(address, decoded.getAccountAddress()); + assertEquals(-9L, decoded.getBalance()); + assertArrayEquals(address, Arrays.copyOf(row.getPhysicalRawKey(), address.length)); + } + + @Test + public void rejectsMalformedAccountKeyTokenValueAndStoredZero() { + byte[] address = address(4); + byte[] raw = account(address).toByteArray(); + assertThrows(ArchivePersistenceException.class, + () -> codec.canonicalizeAccount(Phase.P66_OFF, new byte[20], raw)); + assertThrows(ArchivePersistenceException.class, + () -> codec.canonicalizeAccount(Phase.P66_OFF, address(5), raw)); + assertThrows(ArchivePersistenceException.class, + () -> codec.canonicalizeAccount(Phase.P66_OFF, address, new byte[]{-1, -1})); + assertThrows(ArchivePersistenceException.class, + () -> codec.encodeAssetRow(Phase.P66_ON, address, "01", 1L)); + assertThrows(ArchivePersistenceException.class, + () -> codec.encodeAssetRow(Phase.P66_ON, address, "1a", 1L)); + assertThrows(ArchivePersistenceException.class, + () -> codec.decodePresentAssetRow(address, new byte[Long.BYTES])); + byte[] key = concat(address, "1000001"); + assertThrows(ArchivePersistenceException.class, + () -> codec.decodePresentAssetRow(key, new byte[7])); + assertThrows(ArchivePersistenceException.class, + () -> codec.decodePresentAssetRow(key, new byte[Long.BYTES])); + } + + @Test + public void canonicalLayoutRejectsMixedUnsortedDuplicateAndForeignRows() { + byte[] address = address(6); + byte[] raw = account(address).toBuilder().putAssetV2("1000001", 1L).build().toByteArray(); + byte[] canonical = codec.canonicalizeAccount(Phase.P66_ON, address, raw); + AssetRow first = codec.encodeAssetRow(Phase.P66_ON, address, "1000001", 1L); + AssetRow second = codec.encodeAssetRow(Phase.P66_ON, address, "1000002", 2L); + + assertThrows(ArchivePersistenceException.class, + () -> codec.requireCanonicalLayout(Phase.P66_ON, address, raw, + Collections.emptyList())); + assertThrows(ArchivePersistenceException.class, + () -> codec.requireCanonicalLayout(Phase.P66_OFF, address, raw, + Collections.singletonList(first))); + assertThrows(ArchivePersistenceException.class, + () -> codec.requireCanonicalLayout(Phase.P66_ON, address, canonical, + Arrays.asList(second, first))); + assertThrows(ArchivePersistenceException.class, + () -> codec.requireCanonicalLayout(Phase.P66_ON, address, canonical, + Arrays.asList(first, first))); + AssetRow foreign = codec.encodeAssetRow(Phase.P66_ON, address(7), "1000003", 3L); + assertThrows(ArchivePersistenceException.class, + () -> codec.requireCanonicalLayout(Phase.P66_ON, address, canonical, + Collections.singletonList(foreign))); + AssetRow malformedAbsent = new AssetRow(concat(address, "01"), PostValue.absent()); + assertThrows(ArchivePersistenceException.class, + () -> codec.requireCanonicalLayout(Phase.P66_ON, address, canonical, + Collections.singletonList(malformedAbsent))); + } + + private static Account account(byte[] address) { + return Account.newBuilder().setAddress(ByteString.copyFrom(address)).build(); + } + + private static byte[] address(int suffix) { + byte[] address = new byte[21]; + address[0] = 0x41; + address[20] = (byte) suffix; + return address; + } + + private static byte[] concat(byte[] address, String token) { + byte[] tokenBytes = token.getBytes(StandardCharsets.US_ASCII); + return ByteBuffer.allocate(address.length + tokenBytes.length) + .put(address) + .put(tokenBytes) + .array(); + } +} diff --git a/framework/src/test/java/org/tron/core/db2/archive/PersistentServingKeyIndexGenerationTest.java b/framework/src/test/java/org/tron/core/db2/archive/PersistentServingKeyIndexGenerationTest.java new file mode 100644 index 00000000000..1b7db89ed41 --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/archive/PersistentServingKeyIndexGenerationTest.java @@ -0,0 +1,736 @@ +package org.tron.core.db2.archive; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import com.google.common.hash.Hashing; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.OptionalLong; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tron.core.db2.archive.ArchiveReadSnapshot.PinnedHistory; +import org.tron.core.db2.archive.ArchiveReadSnapshot.PinnedLatestState; +import org.tron.core.db2.archive.HistoryIndexRecord.KeyGroup; +import org.tron.core.db2.stateroot.PathStateStoreManifest.Engine; + +public class PersistentServingKeyIndexGenerationTest { + + private static final List PARTICIPANTS = Arrays.asList("account", "properties"); + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void persistsExactKeyChangesAndSourceIdentityAcrossReopen() throws Exception { + Path root = temporaryFolder.newFolder("persistent-serving").toPath(); + try (Fixture fixture = new Fixture(root.resolve("authoritative"))) { + fixture.append(1, group("account", bytes("cold"), bytes("hot"))); + fixture.append(2, group("properties", bytes("same"))); + fixture.append(3, group("account", bytes("hot"))); + fixture.sync(); + + Path generationPath = root.resolve("generation-1"); + byte[] expectedDigest = ServingKeyIndexGeneration.rebuild("memory", 0, hash(0), + fixture.markers, fixture.index::read, PARTICIPANTS, + ServingKeyIndexGeneration.IndexLayout.prototypeDefaults()) + .getAuthoritativePrefixDigest(); + try (PersistentServingKeyIndexGeneration generation = + PersistentServingKeyIndexGeneration.build(generationPath, "generation-1", 0, hash(0), + fixture.markers, fixture.index::read, PARTICIPANTS, hash(77))) { + assertEquals(0, generation.getIndexedFrom()); + assertEquals(3, generation.getIndexedThrough()); + assertArrayEquals(hash(3), generation.getHeadHash()); + assertArrayEquals(expectedDigest, generation.getAuthoritativePrefixDigest()); + assertArrayEquals(hash(77), generation.getLatestSourceIdentityDigest()); + assertEquals(ArchiveParticipantDescriptor.scopeIdentity(PARTICIPANTS), + generation.getScopeIdentity()); + assertTrue(generation.isLatestSourceIdentityBound()); + assertEquals(4, generation.getKeyChangeCount()); + assertEquals(1, change(generation, "account", bytes("hot"), 0, 3)); + assertEquals(3, change(generation, "account", bytes("hot"), 1, 3)); + assertEquals(2, change(generation, "properties", bytes("same"), 0, 3)); + assertFalse(generation.firstChangeAfter("account", bytes("missing"), 0, 3) + .isPresent()); + assertThrows(IllegalArgumentException.class, + () -> generation.firstChangeAfter("account-asset", bytes("hot"), 0, 3)); + } + + try (PersistentServingKeyIndexGeneration reopened = + PersistentServingKeyIndexGeneration.open(generationPath)) { + assertTrue(reopened.supportsRangeQueries()); + assertEquals(ArchiveParticipantDescriptor.scopeIdentity(PARTICIPANTS), + reopened.getScopeIdentity()); + assertArrayEquals(hash(77), reopened.getLatestSourceIdentityDigest()); + assertEquals(3, change(reopened, "account", bytes("hot"), 2, 3)); + List changed = reopened.changesInRange( + "account", bytes("h"), bytes("z"), 0, 3, 10); + assertEquals(1, changed.size()); + assertArrayEquals(bytes("hot"), changed.get(0).getKey()); + assertEquals(1, changed.get(0).getFirstChangeBlock()); + assertThrows(ArchiveQueryLimitExceededException.class, + () -> reopened.changesInRange("account", new byte[0], null, 0, 3, 1)); + } + + byte[] legacyRangeManifest = Files.readAllBytes(generationPath.resolve("generation.meta")); + ByteBuffer.wrap(legacyRangeManifest).putShort(4, (short) 3); + refreshChecksum(legacyRangeManifest); + Files.write(generationPath.resolve("generation.meta"), legacyRangeManifest); + try (PersistentServingKeyIndexGeneration legacyRange = + PersistentServingKeyIndexGeneration.open(generationPath)) { + assertFalse(legacyRange.supportsRangeQueries()); + assertThrows(ArchivePersistenceException.class, + () -> legacyRange.changesInRange("account", new byte[0], null, 0, 3, 10)); + } + } + } + + @Test + public void incrementallyPublishesExact27PagesAndCoverageFromCheckpoint() throws Exception { + Path root = temporaryFolder.newFolder("persistent-exact-v5").toPath(); + Path archive = root.resolve("archive"); + Path catalogRoot = root.resolve("catalog"); + byte[] hot = bytes("hot"); + try (ArchiveHistoryWriter writer = new ArchiveHistoryWriter(archive, 4096, + ArchiveStoreScope.getStateDatabases())) { + for (int epoch = 1; epoch <= 6; epoch++) { + writer.accept(exactDiff(epoch, "account", hot)); + } + ServingIndexIncrementalPlan initial = writer.planServingIncrement(0, hash(0)); + Path firstShadow = root.resolve("shadow-1"); + try (PersistentServingKeyIndexGeneration first = + PersistentServingKeyIndexGeneration.buildExact(firstShadow, "exact-1", initial, + hash(77))) { + assertTrue(first.isExactOnlyFormat()); + assertFalse(first.supportsRangeQueries()); + assertEquals(6, first.getKeyChangeCount()); + assertEquals(5, change(first, "account", hot, 4, 6)); + assertThrows(ArchivePersistenceException.class, () -> first.changesInRange( + "account", new byte[0], null, 0, 6, 10)); + assertCoverage(first, "abi", 0, 6, "UNSIGNED_RAW_V1", "exact-1"); + assertCoverage(first, "market_pair_price_to_order", 0, 6, + "MARKET_PRICE_V1", "exact-1"); + PersistentServingKeyIndexGeneration.GenerationStatistics statistics = + first.inspectStatistics(); + assertEquals(27, statistics.getStores().size()); + assertTrue(statistics.getApparentBytes() > 0); + assertTrue(statistics.getAllocatedBytes() > 0); + assertEquals(1, statistics.getStores().get("account").getKeyMetadataCount()); + assertEquals(0, statistics.getStores().get("account").getInlineKeyCount()); + assertEquals(1, statistics.getStores().get("account").getPagedKeyCount()); + assertEquals(1, statistics.getStores().get("account").getPageCount()); + assertEquals(6, statistics.getStores().get("account").getChangeEntryCount()); + assertEquals(0, statistics.getStores().get("abi").getChangeEntryCount()); + assertTrue(statistics.getStores().get("abi").getLogicalBytes() > 0); + if (first.getEngine() == Engine.ROCKSDB) { + assertTrue(statistics.getEngine().getEstimatedLiveDataBytes().isAvailable()); + assertTrue(statistics.getEngine().getTotalSstBytes().isAvailable()); + assertTrue(statistics.getEngine().getPendingCompactionBytes().isAvailable()); + assertTrue(statistics.getEngine().getEstimatedLiveDataBytes().getValue() >= 0); + } else { + assertFalse(statistics.getEngine().getEstimatedLiveDataBytes().isAvailable()); + assertFalse(statistics.getEngine().getTotalSstBytes().isAvailable()); + assertFalse(statistics.getEngine().getPendingCompactionBytes().isAvailable()); + } + PersistentServingKeyIndexGeneration.GenerationStatistics unavailable = + first.inspectStatistics(ignored -> OptionalLong.empty()); + assertFalse(unavailable.getEngine().getEstimatedLiveDataBytes().isAvailable()); + assertFalse(unavailable.getEngine().getTotalSstBytes().isAvailable()); + assertFalse(unavailable.getEngine().getPendingCompactionBytes().isAvailable()); + assertThrows(IllegalStateException.class, + () -> unavailable.getEngine().getTotalSstBytes().getValue()); + assertThrows(ArchivePersistenceException.class, + () -> first.inspectStatistics(ignored -> OptionalLong.of(-1))); + } + + try (PersistentServingKeyIndexCatalog catalog = + PersistentServingKeyIndexCatalog.create(catalogRoot, firstShadow)) { + PersistentServingKeyIndexGeneration pinnedOld = catalog.pin(); + writer.accept(exactDiff(7, "witness", bytes("witness"))); + ServingIndexIncrementalPlan increment = writer.planServingIncrement(6, hash(6)); + + Path failedShadow = root.resolve("shadow-failed"); + assertThrows(IOException.class, () -> pinnedOld.extendExact(failedShadow, + "exact-failed", increment, hash(78), () -> { + throw new IOException("injected disk-full before exact batch"); + })); + assertEquals("exact-1", catalog.getCurrentGenerationId()); + assertFalse(catalog.generationExists("exact-failed")); + + Path replacementShadow = root.resolve("shadow-2"); + try (PersistentServingKeyIndexGeneration replacement = pinnedOld.extendExact( + replacementShadow, "exact-2", increment, hash(78))) { + assertEquals(7, replacement.getIndexedThrough()); + assertEquals(7, replacement.getKeyChangeCount()); + assertEquals(5, change(replacement, "account", hot, 4, 7)); + assertEquals(7, change(replacement, "witness", bytes("witness"), 6, 7)); + assertCoverage(replacement, "abi", 0, 7, "UNSIGNED_RAW_V1", "exact-2"); + PersistentServingKeyIndexGeneration.GenerationStatistics statistics = + replacement.inspectStatistics(); + assertEquals(1, statistics.getStores().get("witness").getInlineKeyCount()); + assertEquals(1, statistics.getStores().get("witness").getChangeEntryCount()); + assertEquals(0, statistics.getStores().get("abi").getChangeEntryCount()); + } + assertTrue(catalog.publish("exact-1", replacementShadow)); + assertEquals("exact-2", catalog.getCurrentGenerationId()); + assertTrue(catalog.generationExists("exact-1")); + assertEquals(5, change(pinnedOld, "account", hot, 4, 6)); + pinnedOld.close(); + assertFalse(catalog.generationExists("exact-1")); + + ServingIndexIncrementalPlan zeroAction = writer.planServingIncrement(7, hash(7)); + assertEquals(7, zeroAction.getIndexedFrom()); + assertEquals(7, zeroAction.getIndexedThrough()); + assertEquals("exact-2", catalog.getCurrentGenerationId()); + } + + try (PersistentServingKeyIndexCatalog reopened = + PersistentServingKeyIndexCatalog.open(catalogRoot); + PersistentServingKeyIndexGeneration current = reopened.pin()) { + assertTrue(current.isExactOnlyFormat()); + assertEquals(7, current.getIndexedThrough()); + assertEquals(7, change(current, "witness", bytes("witness"), 6, 7)); + assertCoverage(current, "market_pair_price_to_order", 0, 7, + "MARKET_PRICE_V1", "exact-2"); + Path rebuiltPath = root.resolve("rebuilt"); + try (PersistentServingKeyIndexGeneration rebuilt = + PersistentServingKeyIndexGeneration.buildExact(rebuiltPath, "rebuilt", + writer.planServingRebuild(), hash(78))) { + assertArrayEquals(rebuilt.getAuthoritativePrefixDigest(), + current.getAuthoritativePrefixDigest()); + } + } + } + } + + @Test + public void rangeIndexPreservesUnsignedBinaryKeyOrderAndPrefixBoundaries() throws Exception { + Path root = temporaryFolder.newFolder("persistent-binary-range").toPath(); + try (Fixture fixture = new Fixture(root.resolve("authoritative"))) { + byte[] zero = new byte[]{0}; + byte[] zeroZero = new byte[]{0, 0}; + byte[] zeroFf = new byte[]{0, (byte) 0xff}; + byte[] one = new byte[]{1}; + fixture.append(1, group("account", zero, zeroZero, zeroFf, one, + new byte[]{(byte) 0xff})); + fixture.sync(); + + try (PersistentServingKeyIndexGeneration generation = + PersistentServingKeyIndexGeneration.build(root.resolve("generation"), "binary", 0, + hash(0), fixture.markers, fixture.index::read, PARTICIPANTS)) { + List changed = generation.changesInRange( + "account", zero, one, 0, 1, 10); + assertEquals(3, changed.size()); + assertArrayEquals(zero, changed.get(0).getKey()); + assertArrayEquals(zeroZero, changed.get(1).getKey()); + assertArrayEquals(zeroFf, changed.get(2).getKey()); + + List strictUpper = generation.changesInRange( + "account", zero, zeroFf, 0, 1, 10); + assertEquals(2, strictUpper.size()); + assertArrayEquals(zero, strictUpper.get(0).getKey()); + assertArrayEquals(zeroZero, strictUpper.get(1).getKey()); + assertTrue(generation.changesInRange("account", zero, one, 1, 1, 10).isEmpty()); + } + } + } + + @Test + public void rejectsLegacyAndSubstitutedGenerationManifestScope() throws Exception { + Path root = temporaryFolder.newFolder("generation-scope").toPath(); + Path approvedPath = root.resolve("approved-generation"); + try (PersistentServingKeyIndexGeneration approved = + PersistentServingKeyIndexGeneration.build(approvedPath, "approved", 0, hash(0), + Collections.emptyList(), location -> { + throw new AssertionError("empty prefix must not read an index record"); + }, ArchiveParticipantDescriptor.current().getParticipants())) { + assertEquals(ArchiveParticipantDescriptor.FORMAT_ID, approved.getScopeIdentity()); + } + try (Fixture fixture = new Fixture(root.resolve("authoritative"))) { + fixture.append(1, group("account", bytes("key"))); + fixture.sync(); + Path generationPath = root.resolve("generation"); + try (PersistentServingKeyIndexGeneration ignored = + PersistentServingKeyIndexGeneration.build(generationPath, "generation", 0, hash(0), + fixture.markers, fixture.index::read, PARTICIPANTS)) { + // Close before mutating the durable manifest. + } + Path manifest = generationPath.resolve("generation.meta"); + byte[] valid = Files.readAllBytes(manifest); + + byte[] legacy = Arrays.copyOf(valid, valid.length); + ByteBuffer.wrap(legacy).putShort(4, (short) 2); + refreshChecksum(legacy); + Files.write(manifest, legacy); + assertThrows(ArchivePersistenceException.class, + () -> PersistentServingKeyIndexGeneration.open(generationPath)); + + byte[] substituted = Arrays.copyOf(valid, valid.length); + substituted[10] ^= 1; + refreshChecksum(substituted); + Files.write(manifest, substituted); + assertThrows(ArchivePersistenceException.class, + () -> PersistentServingKeyIndexGeneration.open(generationPath)); + } + } + + @Test + public void catalogPinsOldGenerationUntilLastReaderReleasesIt() throws Exception { + Path root = temporaryFolder.newFolder("catalog").toPath(); + Path catalogRoot = root.resolve("catalog"); + try (Fixture fixture = new Fixture(root.resolve("authoritative"))) { + fixture.append(1, group("account", bytes("one"))); + fixture.sync(); + Path firstShadow = catalogRoot.resolve("shadow-1"); + try (PersistentServingKeyIndexGeneration ignored = + PersistentServingKeyIndexGeneration.build(firstShadow, "generation-1", 0, hash(0), + fixture.markers, fixture.index::read, PARTICIPANTS)) { + // Close the shadow engine before its directory is atomically installed. + } + + try (PersistentServingKeyIndexCatalog catalog = + PersistentServingKeyIndexCatalog.create(catalogRoot, firstShadow, + reader(1, "generation-1"))) { + PersistentServingKeyIndexGeneration firstPin = catalog.pin(reader(1, "generation-1")); + assertEquals(1, catalog.getReferenceCount("generation-1")); + + fixture.append(2, group("properties", bytes("two"))); + fixture.sync(); + Path secondShadow = catalogRoot.resolve("shadow-2"); + try (PersistentServingKeyIndexGeneration ignored = + PersistentServingKeyIndexGeneration.build(secondShadow, "generation-2", 0, hash(0), + fixture.markers, fixture.index::read, PARTICIPANTS)) { + // Close before publication. + } + assertThrows(ArchivePersistenceException.class, + () -> catalog.publish("generation-1", secondShadow, + reader(1, "reader-behind"))); + assertThrows(ArchivePersistenceException.class, + () -> catalog.publish("generation-1", secondShadow, + reader(2, hash(99), "wrong-hash"))); + assertTrue(catalog.publish("generation-1", secondShadow, + reader(2, "generation-2"))); + assertEquals("generation-2", catalog.getCurrentGenerationId()); + assertThrows(ArchivePersistenceException.class, + () -> catalog.pin(reader(1, "reader-behind"))); + assertEquals(0, catalog.getReferenceCount("generation-2")); + assertTrue(catalog.generationExists("generation-1")); + assertEquals(1, change(firstPin, "account", bytes("one"), 0, 1)); + + firstPin.close(); + assertEquals(0, catalog.getReferenceCount("generation-1")); + assertFalse(catalog.generationExists("generation-1")); + try (PersistentServingKeyIndexGeneration secondPin = + catalog.pin(reader(2, "generation-2"))) { + assertEquals(2, change(secondPin, "properties", bytes("two"), 0, 2)); + } + } + + try (PersistentServingKeyIndexCatalog reopened = + PersistentServingKeyIndexCatalog.open(catalogRoot); + PersistentServingKeyIndexGeneration pin = + reopened.pin(reader(2, "generation-2"))) { + assertEquals("generation-2", pin.getGenerationId()); + assertEquals(2, pin.getIndexedThrough()); + } + } + } + + @Test + public void readSnapshotOwnsCatalogHistoryAndLatestPinsAsOneUnit() throws Exception { + Path root = temporaryFolder.newFolder("snapshot-pins").toPath(); + Path catalogRoot = root.resolve("catalog"); + try (Fixture fixture = new Fixture(root.resolve("authoritative"))) { + fixture.append(1, group("account", bytes("key"))); + fixture.sync(); + Path shadow = catalogRoot.resolve("shadow"); + try (PersistentServingKeyIndexGeneration ignored = + PersistentServingKeyIndexGeneration.build(shadow, "generation", 0, hash(0), + fixture.markers, fixture.index::read, PARTICIPANTS)) { + // Close before publication. + } + try (PersistentServingKeyIndexCatalog catalog = + PersistentServingKeyIndexCatalog.create(catalogRoot, shadow, + reader(1, "generation"))) { + AtomicBoolean historyClosed = new AtomicBoolean(); + AtomicBoolean latestClosed = new AtomicBoolean(); + try (ArchiveReadSnapshot snapshot = ArchiveReadSnapshot.pin(0, catalog, + reader(1, "generation"), + serving -> history(serving, historyClosed), + serving -> latest(serving, latestClosed))) { + assertEquals(1, catalog.getReferenceCount("generation")); + assertArrayEquals(bytes("old"), snapshot.get("account", bytes("key")).getValue()); + } + assertTrue(historyClosed.get()); + assertTrue(latestClosed.get()); + assertEquals(0, catalog.getReferenceCount("generation")); + } + } + } + + @Test + public void catalogRejectsCorruptCurrentPointer() throws Exception { + Path root = temporaryFolder.newFolder("corrupt-current").toPath(); + Path catalogRoot = root.resolve("catalog"); + try (Fixture fixture = new Fixture(root.resolve("authoritative"))) { + fixture.append(1, group("account", bytes("key"))); + fixture.sync(); + Path shadow = catalogRoot.resolve("shadow"); + try (PersistentServingKeyIndexGeneration ignored = + PersistentServingKeyIndexGeneration.build(shadow, "generation", 0, hash(0), + fixture.markers, fixture.index::read, PARTICIPANTS)) { + // Close before publication. + } + try (PersistentServingKeyIndexCatalog ignored = + PersistentServingKeyIndexCatalog.create(catalogRoot, shadow, + reader(1, "generation"))) { + // Persist a valid initial catalog first. + } + Path current = catalogRoot.resolve("current"); + byte[] corrupt = Files.readAllBytes(current); + corrupt[corrupt.length - 1] ^= 1; + Files.write(current, corrupt); + assertThrows(ArchivePersistenceException.class, + () -> PersistentServingKeyIndexCatalog.open(catalogRoot)); + } + } + + @Test + public void snapshotPinsRealCommitIndexAndSegmentHandles() throws Exception { + Path root = temporaryFolder.newFolder("real-history-pin").toPath(); + Path archive = root.resolve("archive"); + Path catalogRoot = root.resolve("catalog"); + Path shadow = catalogRoot.resolve("shadow"); + try (ArchiveHistoryWriter writer = new ArchiveHistoryWriter(archive, 4096, + new LinkedHashSet<>(PARTICIPANTS))) { + BlockReverseDiff diff = new BlockReverseDiff( + new BlockSnapshotMeta(1, 1, hash(1), hash(0), 1_000), + Collections.singletonList(new BlockReverseDiff.DbGroup("account", + Collections.singletonList(new BlockReverseDiff.Entry(bytes("key"), + OldValue.present(bytes("old"))))))); + writer.accept(diff); + try (PersistentServingKeyIndexGeneration ignored = + writer.buildServingGeneration(shadow, "generation", hash(77))) { + // Close before catalog publication. + } + } + + try (PersistentServingKeyIndexCatalog catalog = + PersistentServingKeyIndexCatalog.create(catalogRoot, shadow, + reader(1, "generation")); + ArchiveReadSnapshot snapshot = ArchiveReadSnapshot.pin(0, catalog, + reader(1, "generation"), archive, 4096, + serving -> latest(serving, new AtomicBoolean()))) { + assertArrayEquals(bytes("old"), snapshot.get("account", bytes("key")).getValue()); + assertEquals(1, catalog.getReferenceCount("generation")); + } + } + + @Test + public void capsuleLoadsDurableReaderHeadAndReleasesPartialAcquireFailures() throws Exception { + Path root = temporaryFolder.newFolder("durable-capsule").toPath(); + Path archive = root.resolve("archive"); + Path catalogRoot = root.resolve("catalog"); + Path shadow = catalogRoot.resolve("shadow"); + Path unboundShadow = root.resolve("unbound-shadow"); + Path readerVisible = root.resolve("progress/reader-visible.progress"); + HistoryCommitMarker marker; + try (ArchiveHistoryWriter writer = new ArchiveHistoryWriter(archive, 4096, + new LinkedHashSet<>(PARTICIPANTS))) { + writer.accept(new BlockReverseDiff(new BlockSnapshotMeta(1, 1, hash(1), hash(0), 1_000), + Collections.singletonList(new BlockReverseDiff.DbGroup("account", + Collections.singletonList(new BlockReverseDiff.Entry(bytes("key"), + OldValue.present(bytes("old")))))))); + marker = writer.committedHead(); + try (PersistentServingKeyIndexGeneration ignored = + writer.buildServingGeneration(shadow, "generation", hash(77))) { + // Close before publication. + } + try (PersistentServingKeyIndexGeneration ignored = + writer.buildServingGeneration(unboundShadow, "unbound-generation")) { + // Legacy/unbound generations remain readable only outside the strict capsule. + } + } + ArchiveProgressEnvelope durableReader = new ArchiveProgressEnvelope( + ArchiveProgressEnvelope.Kind.READER_VISIBLE, null, 1, marker.getMeta().getBlockHash(), + marker.getBatchId(), marker.getHistoryLocation().getBodyDigest(), PARTICIPANTS); + new ArchiveProgressFile(readerVisible, new ArchiveProgressEnvelopeCodec()) + .store(durableReader); + + try (PersistentServingKeyIndexCatalog unboundCatalog = + PersistentServingKeyIndexCatalog.create(root.resolve("unbound-catalog"), unboundShadow, + durableReader)) { + ArchiveGenerationCapsule unbound = new ArchiveGenerationCapsule(unboundCatalog, + readerVisible, archive, 4096, serving -> latest(serving, new AtomicBoolean())); + assertThrows(ArchivePersistenceException.class, () -> unbound.pin(0)); + assertEquals(0, unboundCatalog.getReferenceCount("unbound-generation")); + } + + try (PersistentServingKeyIndexCatalog catalog = + PersistentServingKeyIndexCatalog.create(catalogRoot, shadow, durableReader)) { + ArchiveGenerationCapsule failing = new ArchiveGenerationCapsule(catalog, readerVisible, + archive, 4096, PersistentServingKeyIndexGenerationTest::failLatest); + assertThrows(IOException.class, () -> failing.pin(0)); + assertEquals(0, catalog.getReferenceCount("generation")); + + AtomicBoolean mismatchClosed = new AtomicBoolean(); + ArchiveGenerationCapsule mismatch = new ArchiveGenerationCapsule(catalog, readerVisible, + archive, 4096, serving -> latest(serving, mismatchClosed, hash(88))); + assertThrows(ArchivePersistenceException.class, () -> mismatch.pin(0)); + assertTrue(mismatchClosed.get()); + assertEquals(0, catalog.getReferenceCount("generation")); + + AtomicBoolean latestClosed = new AtomicBoolean(); + ArchiveGenerationCapsule capsule = new ArchiveGenerationCapsule(catalog, readerVisible, + archive, 4096, serving -> latest(serving, latestClosed)); + try (ArchiveReadSnapshot snapshot = capsule.pin(0)) { + assertEquals(1, catalog.getReferenceCount("generation")); + assertArrayEquals(bytes("old"), snapshot.get("account", bytes("key")).getValue()); + } + assertTrue(latestClosed.get()); + assertEquals(0, catalog.getReferenceCount("generation")); + } + } + + @Test + public void publicationCrashReopensAtOldOrNewAtomicGeneration() throws Exception { + for (PersistentServingKeyIndexCatalog.PublicationStage failedStage + : PersistentServingKeyIndexCatalog.PublicationStage.values()) { + Path root = temporaryFolder.newFolder("publish-" + failedStage).toPath(); + Path catalogRoot = root.resolve("catalog"); + try (Fixture fixture = new Fixture(root.resolve("authoritative"))) { + fixture.append(1, group("account", bytes("one"))); + fixture.sync(); + Path firstShadow = catalogRoot.resolve("shadow-1"); + try (PersistentServingKeyIndexGeneration ignored = + PersistentServingKeyIndexGeneration.build(firstShadow, "generation-1", 0, hash(0), + fixture.markers, fixture.index::read, PARTICIPANTS)) { + // Close before publication. + } + try (PersistentServingKeyIndexCatalog ignored = + PersistentServingKeyIndexCatalog.create(catalogRoot, firstShadow, + reader(1, "generation-1"))) { + // Establish old authority. + } + + fixture.append(2, group("properties", bytes("two"))); + fixture.sync(); + Path secondShadow = catalogRoot.resolve("shadow-2"); + try (PersistentServingKeyIndexGeneration ignored = + PersistentServingKeyIndexGeneration.build(secondShadow, "generation-2", 0, hash(0), + fixture.markers, fixture.index::read, PARTICIPANTS)) { + // Close before publication. + } + AtomicReference observed = + new AtomicReference<>(); + try (PersistentServingKeyIndexCatalog failing = + PersistentServingKeyIndexCatalog.open(catalogRoot, stage -> { + observed.set(stage); + if (stage == failedStage) { + throw new IOException("injected after " + stage); + } + })) { + assertThrows(IOException.class, () -> failing.publish("generation-1", secondShadow, + reader(2, "generation-2"))); + assertEquals(failedStage, observed.get()); + } + + try (PersistentServingKeyIndexCatalog reopened = + PersistentServingKeyIndexCatalog.open(catalogRoot)) { + String expected = failedStage + == PersistentServingKeyIndexCatalog.PublicationStage.GENERATION_INSTALLED + ? "generation-1" : "generation-2"; + String retired = expected.equals("generation-1") ? "generation-2" : "generation-1"; + assertEquals(expected, reopened.getCurrentGenerationId()); + assertTrue(reopened.generationExists(expected)); + assertFalse(reopened.generationExists(retired)); + } + } + } + } + + private static PinnedHistory history(PersistentServingKeyIndexGeneration serving, + AtomicBoolean closed) { + return new PinnedHistory() { + @Override + public long getIndexedFrom() { + return serving.getIndexedFrom(); + } + + @Override + public long getIndexedThrough() { + return serving.getIndexedThrough(); + } + + @Override + public byte[] getHeadHash() { + return serving.getHeadHash(); + } + + @Override + public byte[] getAuthoritativePrefixDigest() { + return serving.getAuthoritativePrefixDigest(); + } + + @Override + public OldValue read(String dbName, byte[] rawKey, long firstChangeBlock) { + return OldValue.present(bytes("old")); + } + + @Override + public void close() { + closed.set(true); + } + }; + } + + private static PinnedLatestState failLatest( + PersistentServingKeyIndexGeneration serving) throws IOException { + throw new IOException("injected latest snapshot failure for " + serving.getGenerationId()); + } + + private static ArchiveProgressEnvelope reader(int epoch, String generationId) { + return reader(epoch, hash(epoch), generationId); + } + + private static ArchiveProgressEnvelope reader(int epoch, byte[] blockHash, + String generationId) { + byte[] batch = new byte[16]; + byte[] digest = new byte[32]; + byte[] encoded = bytes(generationId); + System.arraycopy(encoded, 0, batch, 0, Math.min(batch.length, encoded.length)); + return new ArchiveProgressEnvelope(ArchiveProgressEnvelope.Kind.READER_VISIBLE, null, epoch, + blockHash, batch, digest, PARTICIPANTS); + } + + private static PinnedLatestState latest(PersistentServingKeyIndexGeneration serving, + AtomicBoolean closed) { + return latest(serving, closed, serving.getLatestSourceIdentityDigest()); + } + + private static PinnedLatestState latest(PersistentServingKeyIndexGeneration serving, + AtomicBoolean closed, byte[] sourceIdentityDigest) { + return new PinnedLatestState() { + @Override + public long getBlockNumber() { + return serving.getIndexedThrough(); + } + + @Override + public byte[] getBlockHash() { + return serving.getHeadHash(); + } + + @Override + public byte[] getSourceIdentityDigest() { + return Arrays.copyOf(sourceIdentityDigest, sourceIdentityDigest.length); + } + + @Override + public OldValue get(String dbName, byte[] physicalRawKey) { + return OldValue.absent(); + } + + @Override + public List range(String dbName, byte[] lowerInclusive, + byte[] upperExclusive, int maxEntries) { + return Collections.emptyList(); + } + + @Override + public void close() { + closed.set(true); + } + }; + } + + private static void assertCoverage(PersistentServingKeyIndexGeneration generation, + String database, long from, long through, String comparatorId, String generationId) + throws Exception { + PersistentServingKeyIndexGeneration.PersistentStoreCoverage coverage = + generation.getPersistentStoreCoverage(database); + assertEquals(database, coverage.getDbName()); + assertEquals(from, coverage.getIndexedFrom()); + assertEquals(through, coverage.getIndexedThrough()); + assertEquals(comparatorId, coverage.getComparatorId()); + assertEquals(generationId, coverage.getGenerationId()); + assertArrayEquals(generation.getHeadHash(), coverage.getHeadHash()); + assertArrayEquals(generation.getAuthoritativePrefixDigest(), coverage.getSourceDigest()); + } + + private static BlockReverseDiff exactDiff(int epoch, String database, byte[] key) { + return new BlockReverseDiff(new BlockSnapshotMeta(epoch, epoch, hash(epoch), hash(epoch - 1), + epoch * 3_000L), Collections.singletonList(new BlockReverseDiff.DbGroup(database, + Collections.singletonList(new BlockReverseDiff.Entry(key, + OldValue.present(bytes("old-" + epoch))))))); + } + + private static long change(ServingKeyIndex generation, String database, byte[] key, + long target, long upper) throws IOException { + OptionalLong changed = generation.firstChangeAfter(database, key, target, upper); + assertTrue(changed.isPresent()); + return changed.getAsLong(); + } + + private static KeyGroup group(String database, byte[]... keys) { + return new KeyGroup(database, Arrays.asList(keys)); + } + + private static byte[] bytes(String value) { + return value.getBytes(StandardCharsets.UTF_8); + } + + private static byte[] hash(int suffix) { + byte[] hash = new byte[32]; + hash[31] = (byte) suffix; + return hash; + } + + private static void refreshChecksum(byte[] encoded) { + int payloadLength = encoded.length - Integer.BYTES; + int checksum = Hashing.crc32c().hashBytes(encoded, 0, payloadLength).asInt(); + ByteBuffer.wrap(encoded, payloadLength, Integer.BYTES).putInt(checksum); + } + + private static final class Fixture implements AutoCloseable { + private final HistoryIndexStore index; + private final List markers = new ArrayList<>(); + + private Fixture(Path directory) throws IOException { + index = new HistoryIndexStore(directory, new HistoryIndexCodec()); + } + + private void append(int block, KeyGroup... groups) throws IOException { + BlockSnapshotMeta meta = new BlockSnapshotMeta(block, block, hash(block), hash(block - 1), + block * 1_000L); + HistoryLocation body = new HistoryLocation(0, block * 100L, 80, block, hash(block)); + HistoryIndexLocation location = index.append( + new HistoryIndexRecord(meta, body, Arrays.asList(groups))); + markers.add(new HistoryCommitMarker(meta, block - 1L, body, location, new byte[16], + PARTICIPANTS)); + } + + private void sync() throws IOException { + index.sync(); + } + + @Override + public void close() throws IOException { + index.close(); + } + } +} diff --git a/framework/src/test/java/org/tron/core/db2/archive/ServingIndexIncrementalPlanTest.java b/framework/src/test/java/org/tron/core/db2/archive/ServingIndexIncrementalPlanTest.java new file mode 100644 index 00000000000..7592fc09cca --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/archive/ServingIndexIncrementalPlanTest.java @@ -0,0 +1,193 @@ +package org.tron.core.db2.archive; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tron.core.db2.archive.HistoryIndexRecord.KeyGroup; + +public class ServingIndexIncrementalPlanTest { + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void plansOnlyCommittedSuffixAndAdvancesNoChangeStores() throws Exception { + Path archive = temporaryFolder.newFolder("incremental-plan").toPath(); + List suffix = new ArrayList<>(); + AtomicInteger reads = new AtomicInteger(); + byte[] mutableKey = bytes("account-a"); + try (HistoryIndexStore authoritative = new HistoryIndexStore( + archive, new HistoryIndexCodec())) { + suffix.add(append(authoritative, 11, + groups(group("account", mutableKey, bytes("account-b"))))); + suffix.add(append(authoritative, 12, + groups(group("account", bytes("account-a")), group("witness", bytes("witness-a"))))); + authoritative.sync(); + + ServingIndexIncrementalPlan plan = ServingIndexIncrementalPlan.plan(10, hash(10), + participants(), suffix, location -> { + reads.incrementAndGet(); + return authoritative.read(location); + }); + mutableKey[0] ^= 0x7f; + + assertEquals(2, reads.get()); + assertEquals(10, plan.getIndexedFrom()); + assertEquals(12, plan.getIndexedThrough()); + assertArrayEquals(hash(12), plan.getHeadHash()); + assertEquals(27, plan.getParticipatingDatabases().size()); + assertEquals(27, plan.getChangesByDatabase().size()); + assertEquals(3, plan.getChanges("account").size()); + assertEquals(11, plan.getChanges("account").get(0).getEpoch()); + assertArrayEquals(bytes("account-a"), plan.getChanges("account").get(0).getRawKey()); + assertEquals(1, plan.getChanges("witness").size()); + assertTrue(plan.getChanges("abi").isEmpty()); + assertEquals(32, plan.getDeltaSourceDigest().length); + assertEquals(32, plan.getSourceSeedDigest().length); + assertEquals(2, plan.getSourceStepDigests().size()); + assertThrows(UnsupportedOperationException.class, + () -> plan.getChanges("abi").add(plan.getChanges("account").get(0))); + assertThrows(IllegalArgumentException.class, + () -> plan.getChanges("accountTrie")); + } + } + + @Test + public void emptySuffixIsAZeroActionExact27Plan() throws Exception { + ServingIndexIncrementalPlan plan = ServingIndexIncrementalPlan.plan(10, hash(10), + participants(), Collections.emptyList(), ignored -> { + throw new AssertionError("empty suffix must not read authoritative history"); + }); + + assertEquals(10, plan.getIndexedFrom()); + assertEquals(10, plan.getIndexedThrough()); + assertArrayEquals(hash(10), plan.getHeadHash()); + assertTrue(plan.getChangesByDatabase().values().stream().allMatch(List::isEmpty)); + assertTrue(plan.getSourceStepDigests().isEmpty()); + } + + @Test + public void rejectsGapBeforeReadingAuthoritativeHistory() throws Exception { + HistoryCommitMarker gap = marker(12, 10, bodyLocation(12), indexLocation(12)); + AtomicInteger reads = new AtomicInteger(); + + assertThrows(IllegalArgumentException.class, () -> ServingIndexIncrementalPlan.plan( + 10, hash(10), participants(), Collections.singletonList(gap), location -> { + reads.incrementAndGet(); + return null; + })); + assertEquals(0, reads.get()); + } + + @Test + public void rejectsUnknownDuplicateAndMismatchedAuthoritativeChanges() throws Exception { + Path unknownArchive = temporaryFolder.newFolder("unknown-incremental-plan").toPath(); + try (HistoryIndexStore authoritative = new HistoryIndexStore(unknownArchive, + new HistoryIndexCodec())) { + HistoryCommitMarker unknown = append(authoritative, 11, + groups(group("unknown", bytes("key")))); + authoritative.sync(); + assertThrows(IllegalArgumentException.class, () -> ServingIndexIncrementalPlan.plan( + 10, hash(10), participants(), Collections.singletonList(unknown), authoritative::read)); + } + + HistoryLocation duplicateBody = bodyLocation(11); + HistoryCommitMarker duplicate = marker(11, 10, duplicateBody, indexLocation(11)); + HistoryIndexRecord duplicateRecord = new HistoryIndexRecord(meta(11), duplicateBody, + groups(group("account", bytes("same"), bytes("same")))); + assertThrows(IllegalArgumentException.class, () -> ServingIndexIncrementalPlan.plan( + 10, hash(10), participants(), Collections.singletonList(duplicate), + ignored -> duplicateRecord)); + + Path mismatchArchive = temporaryFolder.newFolder("mismatch-incremental-plan").toPath(); + try (HistoryIndexStore authoritative = new HistoryIndexStore(mismatchArchive, + new HistoryIndexCodec())) { + HistoryCommitMarker valid = append(authoritative, 11, + groups(group("account", bytes("valid")))); + authoritative.sync(); + HistoryCommitMarker mismatched = new HistoryCommitMarker(valid.getMeta(), 10, + bodyLocation(99), valid.getIndexLocation(), new byte[16], participants()); + assertThrows(IllegalArgumentException.class, () -> ServingIndexIncrementalPlan.plan( + 10, hash(10), participants(), Collections.singletonList(mismatched), + authoritative::read)); + } + } + + @Test + public void rejectsMissingCaseMismatchedOrDuplicateParticipants() { + List missing = participants(); + missing.remove("abi"); + assertThrows(IllegalArgumentException.class, () -> ServingIndexIncrementalPlan.plan( + 10, hash(10), missing, Collections.emptyList(), ignored -> null)); + + List caseMismatch = participants(); + caseMismatch.set(caseMismatch.indexOf("DelegatedResource"), "delegatedresource"); + assertThrows(IllegalArgumentException.class, () -> ServingIndexIncrementalPlan.plan( + 10, hash(10), caseMismatch, Collections.emptyList(), ignored -> null)); + + List duplicate = participants(); + duplicate.set(duplicate.indexOf("abi"), "account"); + assertThrows(IllegalArgumentException.class, () -> ServingIndexIncrementalPlan.plan( + 10, hash(10), duplicate, Collections.emptyList(), ignored -> null)); + } + + private static HistoryCommitMarker append(HistoryIndexStore authoritative, int block, + List groups) throws Exception { + HistoryLocation body = bodyLocation(block); + HistoryIndexLocation index = authoritative.append( + new HistoryIndexRecord(meta(block), body, groups)); + return marker(block, block - 1L, body, index); + } + + private static HistoryCommitMarker marker(int block, long previousEpoch, + HistoryLocation body, HistoryIndexLocation index) { + return new HistoryCommitMarker(meta(block), previousEpoch, body, index, new byte[16], + participants()); + } + + private static BlockSnapshotMeta meta(int block) { + return new BlockSnapshotMeta(block, block, hash(block), hash(block - 1), block * 3_000L); + } + + private static HistoryLocation bodyLocation(int block) { + return new HistoryLocation(0, block * 100L, 80, block, hash(block)); + } + + private static HistoryIndexLocation indexLocation(int block) { + return new HistoryIndexLocation(block * 120L, 100, hash(block)); + } + + private static List participants() { + return new ArrayList<>(ArchiveStoreScope.getStateDatabases()); + } + + private static List groups(KeyGroup... groups) { + return Arrays.asList(groups); + } + + private static KeyGroup group(String dbName, byte[]... keys) { + return new KeyGroup(dbName, Arrays.asList(keys)); + } + + private static byte[] hash(int suffix) { + byte[] hash = new byte[32]; + hash[31] = (byte) suffix; + return hash; + } + + private static byte[] bytes(String value) { + return value.getBytes(StandardCharsets.UTF_8); + } +} diff --git a/framework/src/test/java/org/tron/core/db2/archive/ServingKeyIndexGenerationTest.java b/framework/src/test/java/org/tron/core/db2/archive/ServingKeyIndexGenerationTest.java new file mode 100644 index 00000000000..bf92a1dc64a --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/archive/ServingKeyIndexGenerationTest.java @@ -0,0 +1,229 @@ +package org.tron.core.db2.archive; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.OptionalLong; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tron.core.db2.archive.HistoryIndexRecord.KeyGroup; +import org.tron.core.db2.archive.ServingKeyIndexGeneration.IndexLayout; +import org.tron.core.db2.archive.ServingKeyIndexGeneration.StoreCoverage; + +public class ServingKeyIndexGenerationTest { + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void rebuildsExactKeysAndSeeksOnlyInsideCompleteCoverage() throws Exception { + Path archive = temporaryFolder.newFolder("serving-index").toPath(); + List markers = new ArrayList<>(); + byte[] accountKey = bytes("same-key"); + byte[] mutableInput = Arrays.copyOf(accountKey, accountKey.length); + byte[] collisionLeft = new byte[]{0, 31}; + byte[] collisionRight = new byte[]{1, 0}; + assertEquals(Arrays.hashCode(collisionLeft), Arrays.hashCode(collisionRight)); + + try (HistoryIndexStore authoritative = new HistoryIndexStore( + archive, new HistoryIndexCodec())) { + markers.add(append(authoritative, 1, + groups(group("account", mutableInput), group("account-asset", accountKey)))); + markers.add(append(authoritative, 2, + groups(group("account", collisionLeft, collisionRight, bytes("other-key"))))); + markers.add(append(authoritative, 3, + groups(group("account", accountKey)))); + append(authoritative, 4, groups(group("account", accountKey))); // not committed + authoritative.sync(); + + ServingKeyIndexGeneration generation = ServingKeyIndexGeneration.rebuild( + "generation-1", 0, hash(0), markers, authoritative::read); + mutableInput[0] ^= 0x7f; + + assertEquals(0, generation.getIndexedFrom()); + assertEquals(3, generation.getIndexedThrough()); + assertArrayEquals(hash(3), generation.getHeadHash()); + assertEquals(5, generation.getKeyMetadataCount()); + assertEquals(5, generation.getInlineKeyCount()); + assertEquals(0, generation.getPagedKeyCount()); + assertEquals(0, generation.getEpochPageCount()); + StoreCoverage accountCoverage = generation.getStoreCoverage("account").get(); + assertEquals(0, accountCoverage.getIndexedFrom()); + assertEquals(3, accountCoverage.getIndexedThrough()); + assertArrayEquals(hash(3), accountCoverage.getHeadHash()); + assertArrayEquals(generation.getAuthoritativePrefixDigest(), + accountCoverage.getAuthoritativePrefixDigest()); + assertTrue(generation.getStoreCoverage("account-asset").isPresent()); + assertEquals(1, change(generation, "account", accountKey, 0, 3)); + assertEquals(3, change(generation, "account", accountKey, 1, 3)); + assertEquals(1, change(generation, "account-asset", accountKey, 0, 3)); + assertEquals(2, change(generation, "account", collisionLeft, 0, 3)); + assertEquals(2, change(generation, "account", collisionRight, 0, 3)); + assertFalse(generation.firstChangeAfter("account", accountKey, 1, 2).isPresent()); + assertFalse(generation.firstChangeAfter("account", bytes("missing"), 0, 3) + .isPresent()); + assertThrows(IllegalArgumentException.class, + () -> generation.firstChangeAfter("account", accountKey, -1, 3)); + assertThrows(IllegalArgumentException.class, + () -> generation.firstChangeAfter("account", accountKey, 3, 4)); + assertThrows(IllegalArgumentException.class, + () -> generation.firstChangeAfter("properties", accountKey, 0, 3)); + } + } + + @Test + public void usesInlineMetadataAndBaseEpochPagesWithoutChangingSeekSemantics() + throws Exception { + Path archive = temporaryFolder.newFolder("hybrid-pages").toPath(); + List markers = new ArrayList<>(); + byte[] hot = bytes("hot"); + byte[] cold = bytes("cold"); + try (HistoryIndexStore authoritative = new HistoryIndexStore( + archive, new HistoryIndexCodec())) { + for (int block = 1; block <= 6; block++) { + markers.add(append(authoritative, block, groups(block == 1 + ? group("account", cold, hot) : group("account", hot)))); + } + authoritative.sync(); + + ServingKeyIndexGeneration generation = ServingKeyIndexGeneration.rebuild( + "generation-hybrid", 0, hash(0), markers, authoritative::read, + new IndexLayout(2, 2)); + + assertEquals(2, generation.getKeyMetadataCount()); + assertEquals(1, generation.getInlineKeyCount()); + assertEquals(1, generation.getPagedKeyCount()); + assertEquals(3, generation.getEpochPageCount()); + assertEquals(1, change(generation, "account", cold, 0, 6)); + assertFalse(generation.firstChangeAfter("account", cold, 1, 6).isPresent()); + assertEquals(1, change(generation, "account", hot, 0, 6)); + assertEquals(3, change(generation, "account", hot, 2, 6)); + assertEquals(5, change(generation, "account", hot, 4, 6)); + assertEquals(6, change(generation, "account", hot, 5, 6)); + assertFalse(generation.firstChangeAfter("account", hot, 6, 6).isPresent()); + } + } + + @Test + public void rejectsParticipantSetChangesInsideOneGeneration() throws Exception { + Path archive = temporaryFolder.newFolder("participant-change").toPath(); + try (HistoryIndexStore authoritative = new HistoryIndexStore( + archive, new HistoryIndexCodec())) { + HistoryCommitMarker first = append(authoritative, 1, + groups(group("account", bytes("key-1")))); + HistoryCommitMarker second = append(authoritative, 2, + groups(group("account", bytes("key-2")))); + second = new HistoryCommitMarker(second.getMeta(), second.getPreviousEpoch(), + second.getHistoryLocation(), second.getIndexLocation(), second.getBatchId(), + Collections.singletonList("account")); + authoritative.sync(); + + HistoryCommitMarker changedParticipants = second; + assertThrows(IllegalArgumentException.class, () -> ServingKeyIndexGeneration.rebuild( + "generation-bad-participants", 0, hash(0), + Arrays.asList(first, changedParticipants), authoritative::read)); + } + } + + @Test + public void rebuildIsDeterministicAndCatalogRejectsStalePublication() throws Exception { + Path archive = temporaryFolder.newFolder("generation-cas").toPath(); + List markers = new ArrayList<>(); + try (HistoryIndexStore authoritative = new HistoryIndexStore( + archive, new HistoryIndexCodec())) { + markers.add(append(authoritative, 1, groups(group("account", bytes("key-1"))))); + markers.add(append(authoritative, 2, groups(group("account", bytes("key-2"))))); + authoritative.sync(); + + ServingKeyIndexGeneration first = ServingKeyIndexGeneration.rebuild( + "generation-1", 0, hash(0), markers, authoritative::read); + ServingKeyIndexGeneration replacement = ServingKeyIndexGeneration.rebuild( + "generation-2", 0, hash(0), markers, authoritative::read); + ServingKeyIndexGeneration stale = ServingKeyIndexGeneration.rebuild( + "generation-stale", 0, hash(0), markers, authoritative::read); + ServingKeyIndexGeneration regressed = ServingKeyIndexGeneration.rebuild( + "generation-regressed", 0, hash(0), Collections.emptyList(), authoritative::read); + assertArrayEquals(first.getAuthoritativePrefixDigest(), + replacement.getAuthoritativePrefixDigest()); + + ServingKeyIndexCatalog catalog = new ServingKeyIndexCatalog(first); + ServingKeyIndexGeneration pinned = catalog.pin(); + assertThrows(IllegalArgumentException.class, () -> catalog.publish(first, regressed)); + assertTrue(catalog.publish(first, replacement)); + assertFalse(catalog.publish(first, stale)); + assertSame(replacement, catalog.pin()); + assertEquals(1, change(pinned, "account", bytes("key-1"), 0, 2)); + } + } + + @Test + public void rejectsMarkerMismatchWithoutReplacingCurrentGeneration() throws Exception { + Path archive = temporaryFolder.newFolder("corrupt-source").toPath(); + try (HistoryIndexStore authoritative = new HistoryIndexStore( + archive, new HistoryIndexCodec())) { + HistoryCommitMarker valid = append(authoritative, 1, + groups(group("account", bytes("key")))); + authoritative.sync(); + ServingKeyIndexGeneration current = ServingKeyIndexGeneration.rebuild( + "generation-1", 0, hash(0), Collections.singletonList(valid), authoritative::read); + ServingKeyIndexCatalog catalog = new ServingKeyIndexCatalog(current); + + HistoryLocation wrongBody = bodyLocation(99); + HistoryCommitMarker mismatched = new HistoryCommitMarker(valid.getMeta(), 0, wrongBody, + valid.getIndexLocation(), new byte[16], Collections.singletonList("account")); + assertThrows(IllegalArgumentException.class, () -> ServingKeyIndexGeneration.rebuild( + "generation-bad", 0, hash(0), Collections.singletonList(mismatched), + authoritative::read)); + assertSame(current, catalog.pin()); + } + } + + private static HistoryCommitMarker append(HistoryIndexStore authoritative, int block, + List groups) throws Exception { + BlockSnapshotMeta meta = new BlockSnapshotMeta(block, block, hash(block), hash(block - 1), + block * 3_000L); + HistoryLocation body = bodyLocation(block); + HistoryIndexLocation index = authoritative.append(new HistoryIndexRecord(meta, body, groups)); + return new HistoryCommitMarker(meta, block - 1L, body, index, new byte[16], + Arrays.asList("account", "account-asset")); + } + + private static List groups(KeyGroup... groups) { + return Arrays.asList(groups); + } + + private static KeyGroup group(String dbName, byte[]... keys) { + return new KeyGroup(dbName, Arrays.asList(keys)); + } + + private static HistoryLocation bodyLocation(int block) { + return new HistoryLocation(0, block * 100L, 80, block, hash(block)); + } + + private static long change(ServingKeyIndexGeneration generation, String dbName, byte[] key, + long target, long upperBound) { + OptionalLong changed = generation.firstChangeAfter(dbName, key, target, upperBound); + assertTrue(changed.isPresent()); + return changed.getAsLong(); + } + + private static byte[] hash(int suffix) { + byte[] hash = new byte[32]; + hash[31] = (byte) suffix; + return hash; + } + + private static byte[] bytes(String value) { + return value.getBytes(java.nio.charset.StandardCharsets.UTF_8); + } +} diff --git a/framework/src/test/java/org/tron/core/db2/archive/SnapshotOldValueCollectorTest.java b/framework/src/test/java/org/tron/core/db2/archive/SnapshotOldValueCollectorTest.java new file mode 100644 index 00000000000..f112bbbc37a --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/archive/SnapshotOldValueCollectorTest.java @@ -0,0 +1,1569 @@ +package org.tron.core.db2.archive; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.withSettings; + +import com.google.common.primitives.Bytes; +import com.google.common.primitives.Longs; +import com.google.protobuf.ByteString; +import java.io.Closeable; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.AbstractMap; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.Test; +import org.tron.common.BaseMethodTest; +import org.tron.core.capsule.BlockCapsule; +import org.tron.core.db.common.DbSourceInter; +import org.tron.core.db2.ISession; +import org.tron.core.db2.archive.BlockReverseDiff.DbGroup; +import org.tron.core.db2.archive.BlockReverseDiff.Entry; +import org.tron.core.db2.common.DB; +import org.tron.core.db2.common.Flusher; +import org.tron.core.db2.common.WrappedByteArray; +import org.tron.core.db2.core.Chainbase; +import org.tron.core.db2.core.SnapshotImpl; +import org.tron.core.db2.core.SnapshotManager; +import org.tron.core.db2.core.SnapshotRoot; +import org.tron.core.db2.stateroot.PathStateBasePublication; +import org.tron.core.db2.stateroot.PathStateBlockTransition; +import org.tron.core.db2.stateroot.PathStateCanonicalizer.P66Phase; +import org.tron.core.db2.stateroot.PathStateLayerLimits; +import org.tron.core.db2.stateroot.PathStateMutation; +import org.tron.core.db2.stateroot.PathStateNodeStoreSet; +import org.tron.core.db2.stateroot.PathStateRoot; +import org.tron.core.db2.stateroot.PathStateRootMetadata; +import org.tron.core.db2.stateroot.PathStateRuntimeAdmission; +import org.tron.core.db2.stateroot.PathStateRuntimeAttachment; +import org.tron.core.db2.stateroot.PathStateSnapshotDelta; +import org.tron.core.db2.stateroot.PathStateSnapshotHead; +import org.tron.core.db2.stateroot.PathStateStoreManifest; +import org.tron.core.db2.stateroot.PathStateStoreManifest.Engine; +import org.tron.core.exception.TronError; +import org.tron.core.store.AccountAssetStore; +import org.tron.core.store.CheckTmpStore; +import org.tron.protos.Protocol.Account; +import org.tron.protos.Protocol.Transaction; + +public class SnapshotOldValueCollectorTest extends BaseMethodTest { + + @Test + public void deferredPathStateCaptureDoesNotWaitForCollectorAndDrainsOnClose() + throws Exception { + MemoryDb codeDb = new MemoryDb("code"); + SnapshotManager manager = new SnapshotManager(""); + Chainbase code = new Chainbase(new SnapshotRoot(codeDb)); + manager.add(code); + manager.enable(); + CountDownLatch collecting = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + AtomicReference published = new AtomicReference<>(); + PathStateRuntimeAttachment attachment = PathStateRuntimeAttachment.deferred(view -> { + collecting.countDown(); + try { + if (!release.await(5, TimeUnit.SECONDS)) { + throw new IOException("timed out waiting to release deferred collector"); + } + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IOException("deferred collector interrupted", interrupted); + } + BlockSnapshotMeta meta = view.getMeta(); + return new PathStateBlockTransition(meta.getBlockNumber(), meta.getBlockHash(), + meta.getParentHash(), meta.getTimestamp(), P66Phase.P66_ON, + Collections.singletonList( + PathStateMutation.put("code", bytes("contract"), bytes("runtime")))); + }, published::set, (blockNumber, blockHash) -> { }, null, (meta, transition) -> null); + attachment.synchronizeReadyHead(PathStateRootMetadata.base(0, hash(0), hash(9), 0, + P66Phase.P66_ON, hash(7), hash(8), hash(6))); + manager.attachPathStateRuntime(attachment); + + try (ISession block = manager.buildSession()) { + code.put(bytes("contract"), bytes("runtime")); + block.commit(BlockSnapshotMeta.forBlock(1, hash(1), hash(0), 3_000L)); + } + assertTrue(collecting.await(5, TimeUnit.SECONDS)); + assertNull(published.get()); + assertFalse(attachment.isReadyForHeaderDiagnostic(1L, hash(1))); + release.countDown(); + attachment.close(); + assertEquals(1, published.get().getBlockNumber()); + assertEquals(PathStateRuntimeAttachment.State.READY, attachment.status().getState()); + assertTrue(attachment.isReadyForHeaderDiagnostic(1L, hash(1))); + assertSame(attachment, manager.detachPathStateRuntime(attachment)); + manager.shutdown(); + } + + @Test + public void deferredPathStateWorkerErrorFailsRuntimeWithoutLeavingAProducerBlocked() + throws Exception { + SnapshotManager manager = new SnapshotManager(""); + Chainbase code = new Chainbase(new SnapshotRoot(new MemoryDb("code"))); + manager.add(code); + manager.enable(); + CountDownLatch attempted = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + AssertionError workerFailure = new AssertionError("injected deferred worker error"); + PathStateRuntimeAttachment attachment = PathStateRuntimeAttachment.deferred(view -> { + attempted.countDown(); + try { + if (!release.await(5, TimeUnit.SECONDS)) { + throw new AssertionError("timed out waiting to fail deferred worker"); + } + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new AssertionError("deferred worker interrupted", interrupted); + } + throw workerFailure; + }, transition -> { }, (blockNumber, blockHash) -> { }, null, + (meta, transition) -> null); + attachment.synchronizeReadyHead(PathStateRootMetadata.base(0, hash(0), hash(9), 0, + P66Phase.P66_ON, hash(7), hash(8), hash(6))); + + BlockChangeView first = captureView(manager, code, + BlockSnapshotMeta.forBlock(1, hash(1), hash(0), 1L)); + attachment.capture(first); + attachment.publish(null); + assertTrue(attempted.await(5, TimeUnit.SECONDS)); + + for (int number = 2; number <= 65; number++) { + BlockChangeView queued = captureView(manager, code, + BlockSnapshotMeta.forBlock(number, hash(number), hash(number - 1), number)); + attachment.capture(queued); + attachment.publish(null); + } + BlockChangeView blocked = captureView(manager, code, + BlockSnapshotMeta.forBlock(66, hash(66), hash(65), 66L)); + attachment.capture(blocked); + ExecutorService publisher = Executors.newSingleThreadExecutor(); + try { + Future blockedPublish = publisher.submit(() -> attachment.publish(null)); + release.countDown(); + blockedPublish.get(5, TimeUnit.SECONDS); + } finally { + release.countDown(); + publisher.shutdownNow(); + } + + assertSame(workerFailure, attachment.getFailure()); + IOException closeFailure = assertThrows(IOException.class, attachment::close); + assertSame(workerFailure, closeFailure.getCause()); + assertEquals(PathStateRuntimeAttachment.State.FAILED, attachment.status().getState()); + assertEquals(PathStateRuntimeAttachment.FailureStage.CAPTURE, + attachment.status().getFailureStage()); + assertEquals(PathStateRuntimeAttachment.FailureKind.RUNTIME, + attachment.status().getFailureKind()); + + BlockChangeView second = captureView(manager, code, + BlockSnapshotMeta.forBlock(67, hash(67), hash(66), 67L)); + attachment.capture(second); + attachment.publish(null); + assertSame(workerFailure, attachment.getFailure()); + manager.shutdown(); + } + + @Test + public void pathStateRuntimeCoexistsWithArchiveAtBlockFinalBoundary() throws Exception { + MemoryDb propertiesDb = new MemoryDb("properties"); + propertiesDb.put(HistoricalAccountAssetBalanceResolver.proposal66PhysicalKey(), + Longs.toByteArray(1L)); + MemoryDb codeDb = new MemoryDb("code"); + SnapshotManager manager = new SnapshotManager(""); + Chainbase properties = new Chainbase(new SnapshotRoot(propertiesDb)); + Chainbase code = new Chainbase(new SnapshotRoot(codeDb)); + manager.add(properties); + manager.add(code); + manager.enable(); + manager.installArchiveCollector(new SnapshotOldValueCollector(), diff -> { }); + AtomicReference published = new AtomicReference<>(); + SnapshotPathStateTransitionCollector collector = new SnapshotPathStateTransitionCollector( + key -> Collections.emptyMap()); + PathStateRuntimeAttachment attachment = new PathStateRuntimeAttachment( + collector::collect, published::set, + (blockNumber, blockHash) -> { }, null, (meta, transition) -> null); + manager.attachPathStateRuntime(attachment); + + byte[] key = bytes("contract"); + try (ISession block = manager.buildSession()) { + code.put(key, bytes("runtime")); + block.commit(BlockSnapshotMeta.forBlock(1, hash(1), hash(0), 3_000L)); + } + + assertFalse(attachment.isFailed()); + assertEquals(1, published.get().getBlockNumber()); + assertEquals(1, published.get().getMutations().size()); + assertEquals("code", published.get().getMutations().get(0).getDbName()); + assertArrayEquals(key, published.get().getMutations().get(0).getCanonicalKey()); + assertSame(attachment, manager.detachPathStateRuntime(attachment)); + manager.shutdown(); + } + + @Test + public void pathStateForwardDeltaIsOwnedByTheSameBlockSnapshotLayer() throws Exception { + MemoryDb codeDb = new MemoryDb("code"); + SnapshotManager manager = new SnapshotManager(""); + Chainbase code = new Chainbase(new SnapshotRoot(codeDb)); + manager.add(code); + manager.enable(); + manager.installArchiveCollector(new SnapshotOldValueCollector(), diff -> { }); + AtomicReference published = new AtomicReference<>(); + AtomicReference prepared = new AtomicReference<>(); + PathStateRuntimeAttachment attachment = new PathStateRuntimeAttachment(view -> { + BlockSnapshotMeta meta = view.getMeta(); + return new PathStateBlockTransition(meta.getBlockNumber(), meta.getBlockHash(), + meta.getParentHash(), meta.getTimestamp(), P66Phase.P66_ON, + Collections.singletonList( + PathStateMutation.put("code", bytes("contract"), bytes("runtime")))); + }, published::set, (blockNumber, blockHash) -> { }, null, (meta, transition) -> { + PathStateSnapshotDelta delta = mock(PathStateSnapshotDelta.class); + when(delta.getMeta()).thenReturn(meta); + when(delta.getTransitionPayloadDigest()).thenReturn(transition.getPayloadDigest()); + prepared.set(delta); + return delta; + }); + manager.attachPathStateRuntime(attachment); + + BlockSnapshotMeta meta = BlockSnapshotMeta.forBlock(1, hash(1), hash(0), 3_000L); + try (ISession block = manager.buildSession()) { + code.put(bytes("contract"), bytes("runtime")); + block.commit(meta); + } + + SnapshotImpl layer = (SnapshotImpl) code.getHead(); + assertEquals(meta, layer.getBlockSnapshotMeta()); + assertSame(prepared.get(), layer.getPreparedPathStateDelta()); + assertEquals(1, published.get().getBlockNumber()); + assertFalse(attachment.isFailed()); + manager.detachPathStateRuntime(attachment); + manager.shutdown(); + } + + @Test + public void disabledAndShadowCapturePreserveCanonicalBlockAndStateOutcome() throws Exception { + byte[] codeKey = bytes("equivalence-code"); + byte[] before = bytes("before"); + byte[] after = bytes("after"); + byte[] parentHash = hash(0); + byte[] accountStateRoot = hash(9); + Transaction transaction = Transaction.newBuilder() + .setRawData(Transaction.raw.newBuilder().setTimestamp(11L)) + .addRet(Transaction.Result.newBuilder().setFee(7L) + .setContractRet(Transaction.Result.contractResult.SUCCESS)) + .build(); + BlockCapsule template = new BlockCapsule(12L, ByteString.copyFrom(parentHash), 1L, + Collections.singletonList(transaction)); + template.setMerkleRoot(); + template.setAccountStateRoot(accountStateRoot); + byte[] canonicalBlock = template.getData(); + byte[] canonicalRaw = template.getInstance().getBlockHeader().getRawData().toByteArray(); + byte[] canonicalBlockId = template.getBlockId().getBytes(); + BlockCapsule controlBlock = new BlockCapsule(canonicalBlock); + BlockCapsule shadowBlock = new BlockCapsule(canonicalBlock); + + SnapshotManager control = new SnapshotManager(""); + SnapshotManager shadow = new SnapshotManager(""); + Chainbase controlCode = equivalenceStore(control, "code", codeKey, before); + Chainbase shadowCode = equivalenceStore(shadow, "code", codeKey, before); + equivalenceProperties(control); + equivalenceProperties(shadow); + control.enable(); + shadow.enable(); + + Path controlDirectory = temporaryFolder.getRoot().toPath().resolve("disabled-path-state"); + assertEquals(PathStateRuntimeAdmission.Status.DISABLED, + PathStateRuntimeAdmission.inspect(false, controlDirectory, Engine.ROCKSDB).getStatus()); + assertFalse(Files.exists(controlDirectory)); + + Path shadowDirectory = temporaryFolder.newFolder("enabled-path-state").toPath(); + PathStateStoreManifest manifest = PathStateStoreManifest.createOrOpen( + shadowDirectory, Engine.ROCKSDB); + PathStateRootMetadata base; + try (PathStateNodeStoreSet stores = PathStateNodeStoreSet.openBase(manifest)) { + PathStateRoot root = stores.createRoot(); + org.tron.core.db2.stateroot.PathStateCanonicalizer canonicalizer = + new org.tron.core.db2.stateroot.PathStateCanonicalizer(); + root.apply(Arrays.asList( + canonicalizer.put(P66Phase.P66_ON, "properties", + HistoricalAccountAssetBalanceResolver.proposal66PhysicalKey(), + Longs.toByteArray(1L)), + canonicalizer.put(P66Phase.P66_ON, "code", codeKey, before))); + base = PathStateRootMetadata.base(0L, parentHash, hash(99), 1L, + P66Phase.P66_ON, manifest.getIdentityDigest(), root.rootHash(), hash(7)); + new PathStateBasePublication(manifest).publish(stores, base); + } + PathStateSnapshotHead owner = PathStateSnapshotHead.open( + manifest, PathStateLayerLimits.defaults()); + PathStateRuntimeAttachment runtime = new PathStateRuntimeAttachment( + new SnapshotPathStateTransitionCollector(ignored -> Collections.emptyMap()), + owner::advance, (blockNumber, blockHash) -> { }, + transition -> owner.prepare(transition).getStateRoot()); + runtime.synchronizeReadyHead(base); + assertTrue(runtime.isReadyForHeaderDiagnostic(base.getBlockNumber(), base.getBlockHash())); + assertFalse(runtime.isReadyForHeaderDiagnostic(base.getBlockNumber() + 1, + base.getBlockHash())); + assertFalse(runtime.isReadyForHeaderDiagnostic(base.getBlockNumber(), hash(100))); + shadow.attachPathStateRuntime(runtime); + + BlockSnapshotMeta meta = BlockSnapshotMeta.forBlock(1L, canonicalBlockId, parentHash, 12L); + try (ISession session = control.buildSession()) { + controlCode.put(codeKey, after); + assertNull(control.previewPathStateRoot(meta)); + session.commit(meta); + } + try (ISession session = shadow.buildSession()) { + shadowCode.put(codeKey, after); + byte[] candidate = shadow.previewPathStateRoot(meta); + assertEquals(32, candidate.length); + assertArrayEquals(base.getStateRoot(), owner.getHead().getStateRoot()); + shadowBlock.setStateRoot(candidate); + session.commit(meta); + } + + assertArrayEquals(controlCode.getUnchecked(codeKey), shadowCode.getUnchecked(codeKey)); + assertArrayEquals(after, shadowCode.getUnchecked(codeKey)); + assertEquals(((SnapshotImpl) controlCode.getHead()).getBlockSnapshotMeta(), + ((SnapshotImpl) shadowCode.getHead()).getBlockSnapshotMeta()); + assertEquals(PathStateRuntimeAttachment.State.READY, runtime.status().getState()); + assertTrue(runtime.isReadyForHeaderDiagnostic(1L, canonicalBlockId)); + assertEquals(1L, owner.getHead().getBlockNumber()); + assertFalse(Arrays.equals(base.getStateRoot(), owner.getHead().getStateRoot())); + + assertArrayEquals(canonicalBlock, controlBlock.getData()); + assertFalse(Arrays.equals(canonicalBlock, shadowBlock.getData())); + assertArrayEquals(canonicalRaw, + shadowBlock.getInstance().getBlockHeader().getRawData().toByteArray()); + assertArrayEquals(canonicalBlockId, controlBlock.getBlockId().getBytes()); + assertArrayEquals(canonicalBlockId, shadowBlock.getBlockId().getBytes()); + assertArrayEquals(accountStateRoot, shadowBlock.getInstance().getBlockHeader().getRawData() + .getAccountStateRoot().toByteArray()); + assertEquals(transaction.getRetList(), shadowBlock.getInstance().getTransactions(0) + .getRetList()); + assertArrayEquals(owner.getHead().getStateRoot(), shadowBlock.getStateRoot()); + + runtime.diagnoseHeader(1L, canonicalBlockId, shadowBlock.getStateRoot(), + owner.getHead().getStateRoot()); + assertEquals(PathStateRuntimeAttachment.HeaderDiagnostic.MATCH, + runtime.status().getHeaderDiagnostic()); + runtime.diagnoseHeader(1L, canonicalBlockId, new byte[31], owner.getHead().getStateRoot()); + assertEquals(PathStateRuntimeAttachment.HeaderDiagnostic.INVALID_LENGTH, + runtime.status().getHeaderDiagnostic()); + runtime.diagnoseHeader(1L, canonicalBlockId, hash(88), owner.getHead().getStateRoot()); + assertEquals(PathStateRuntimeAttachment.HeaderDiagnostic.MISMATCH, + runtime.status().getHeaderDiagnostic()); + runtime.diagnoseHeader(1L, canonicalBlockId, new byte[0], owner.getHead().getStateRoot()); + assertEquals(PathStateRuntimeAttachment.HeaderDiagnostic.ABSENT, + runtime.status().getHeaderDiagnostic()); + assertEquals(PathStateRuntimeAttachment.State.READY, runtime.status().getState()); + + assertSame(runtime, shadow.detachPathStateRuntime(runtime)); + control.shutdown(); + shadow.shutdown(); + } + + @Test + public void pathStateP66ActivationScansPostStateThenResumesIncrementalCapture() + throws Exception { + byte[] firstAddress = archiveAddress(21); + byte[] secondAddress = archiveAddress(22); + Account firstBefore = Account.newBuilder() + .setAddress(ByteString.copyFrom(firstAddress)) + .putAssetV2("1000021", 21L) + .build(); + Account firstPost = firstBefore.toBuilder().putAssetV2("1000021", 210L).build(); + Account second = Account.newBuilder() + .setAddress(ByteString.copyFrom(secondAddress)) + .putAssetV2("1000022", 22L) + .build(); + MemoryDb accountDb = new MemoryDb("account"); + accountDb.put(firstAddress, firstBefore.toByteArray()); + accountDb.put(secondAddress, second.toByteArray()); + MemoryDb propertiesDb = new MemoryDb("properties"); + propertiesDb.put(HistoricalAccountAssetBalanceResolver.proposal66PhysicalKey(), + Longs.toByteArray(0L)); + SnapshotManager manager = new SnapshotManager(""); + Chainbase account = new Chainbase(new SnapshotRoot(accountDb)); + Chainbase properties = new Chainbase(new SnapshotRoot(propertiesDb)); + Chainbase code = new Chainbase(new SnapshotRoot(new MemoryDb("code"))); + manager.add(account); + manager.add(properties); + manager.add(code); + manager.enable(); + List published = new ArrayList<>(); + SnapshotPathStateTransitionCollector collector = new SnapshotPathStateTransitionCollector( + ignored -> Collections.emptyMap(), consumer -> { + Iterator> entries = account.iterator(); + while (entries.hasNext()) { + Map.Entry entry = entries.next(); + consumer.accept(entry.getKey(), entry.getValue()); + } + }); + PathStateRuntimeAttachment attachment = new PathStateRuntimeAttachment(collector, + published::add); + manager.attachPathStateRuntime(attachment); + + try (ISession block = manager.buildSession()) { + properties.put(HistoricalAccountAssetBalanceResolver.proposal66PhysicalKey(), + Longs.toByteArray(1L)); + account.put(firstAddress, firstPost.toByteArray()); + block.commit(BlockSnapshotMeta.forBlock(1, hash(1), hash(0), 1L)); + } + + assertFalse(attachment.isFailed()); + assertEquals(1, published.size()); + PathStateBlockTransition activation = published.get(0); + assertEquals(P66Phase.P66_ACTIVATION, activation.getPhase()); + assertEquals(5, activation.getMutations().size()); + assertEquals(2, mutationCount(activation, "account")); + assertEquals(2, mutationCount(activation, "account-asset")); + PathStateMutation firstAccount = mutation(activation, "account", firstAddress); + Account canonicalFirst = Account.parseFrom(firstAccount.getCanonicalValue()); + assertTrue(canonicalFirst.getAssetOptimized()); + assertTrue(canonicalFirst.getAssetV2Map().isEmpty()); + PathStateMutation firstAsset = mutation(activation, "account-asset", + Bytes.concat(firstAddress, bytes("1000021"))); + assertArrayEquals(Longs.toByteArray(210L), firstAsset.getCanonicalValue()); + assertTrue(firstAccount.isPreviousValueKnown()); + assertFalse(firstAsset.isPreviousValueKnown()); + + byte[] codeKey = bytes("after-activation"); + try (ISession block = manager.buildSession()) { + code.put(codeKey, bytes("incremental")); + block.commit(BlockSnapshotMeta.forBlock(2, hash(2), hash(1), 2L)); + } + + assertFalse(attachment.isFailed()); + assertEquals(2, published.size()); + assertEquals(P66Phase.P66_ON, published.get(1).getPhase()); + assertEquals(1, published.get(1).getMutations().size()); + assertEquals("code", published.get(1).getMutations().get(0).getDbName()); + assertTrue(published.get(1).getMutations().get(0).isPreviousValueKnown()); + assertNull(published.get(1).getMutations().get(0).getPreviousPhysicalValue()); + manager.detachPathStateRuntime(attachment); + manager.shutdown(); + } + + @Test + public void pathStateFailureDoesNotRejectArchiveDisabledBlockCommit() throws Exception { + SnapshotManager manager = new SnapshotManager(""); + Chainbase code = new Chainbase(new SnapshotRoot(new MemoryDb("code"))); + manager.add(code); + manager.enable(); + AtomicInteger published = new AtomicInteger(); + PathStateRuntimeAttachment attachment = new PathStateRuntimeAttachment(view -> { + throw new IOException("capture failed"); + }, transition -> published.incrementAndGet()); + manager.attachPathStateRuntime(attachment); + + BlockSnapshotMeta meta = BlockSnapshotMeta.forBlock(1, hash(1), hash(0), 3_000L); + try (ISession block = manager.buildSession()) { + code.put(bytes("contract"), bytes("runtime")); + block.commit(meta); + } + + assertTrue(attachment.isFailed()); + assertEquals("capture failed", attachment.getFailure().getMessage()); + assertEquals(0, published.get()); + assertEquals(meta, ((SnapshotImpl) code.getHead()).getBlockSnapshotMeta()); + manager.detachPathStateRuntime(attachment); + manager.shutdown(); + } + + @Test + public void pathStateStatusExposesLagStorageFailureAndCaptureGap() throws Exception { + SnapshotManager manager = new SnapshotManager(""); + Chainbase code = new Chainbase(new SnapshotRoot(new MemoryDb("code"))); + manager.add(code); + manager.enable(); + AtomicInteger publications = new AtomicInteger(); + PathStateRuntimeAttachment attachment = new PathStateRuntimeAttachment(view -> { + BlockSnapshotMeta meta = view.getMeta(); + return new PathStateBlockTransition(meta.getBlockNumber(), meta.getBlockHash(), + meta.getParentHash(), meta.getTimestamp(), P66Phase.P66_ON, + Collections.emptyList()); + }, transition -> { + if (publications.incrementAndGet() == 2) { + throw new IOException("No space left on device"); + } + }); + + PathStateBlockTransition first = attachment.capture( + captureView(manager, code, BlockSnapshotMeta.forBlock(1, hash(1), hash(0), 1L))); + assertEquals(PathStateRuntimeAttachment.State.NOT_READY, attachment.status().getState()); + attachment.publish(first); + assertEquals(PathStateRuntimeAttachment.State.READY, attachment.status().getState()); + assertEquals(0L, attachment.status().getRootLag()); + + PathStateBlockTransition second = attachment.capture( + captureView(manager, code, BlockSnapshotMeta.forBlock(2, hash(2), hash(1), 2L))); + assertEquals(PathStateRuntimeAttachment.State.NOT_READY, attachment.status().getState()); + assertEquals(1L, attachment.status().getRootLag()); + attachment.publish(second); + assertEquals(PathStateRuntimeAttachment.State.FAILED, attachment.status().getState()); + assertEquals(PathStateRuntimeAttachment.FailureStage.PUBLISH, + attachment.status().getFailureStage()); + assertEquals(PathStateRuntimeAttachment.FailureKind.STORAGE_FULL, + attachment.status().getFailureKind()); + attachment.capture( + captureView(manager, code, BlockSnapshotMeta.forBlock(3, hash(3), hash(2), 3L))); + assertEquals(2L, attachment.status().getRootLag()); + + PathStateRuntimeAttachment gap = new PathStateRuntimeAttachment(view -> { + BlockSnapshotMeta meta = view.getMeta(); + return new PathStateBlockTransition(meta.getBlockNumber(), meta.getBlockHash(), + meta.getParentHash(), meta.getTimestamp(), P66Phase.P66_ON, + Collections.emptyList()); + }, transition -> { }); + PathStateBlockTransition admitted = gap.capture( + captureView(manager, code, BlockSnapshotMeta.forBlock(4, hash(4), hash(3), 4L))); + gap.publish(admitted); + assertEquals(PathStateRuntimeAttachment.State.READY, gap.status().getState()); + assertEquals(null, gap.capture( + captureView(manager, code, BlockSnapshotMeta.forBlock(6, hash(6), hash(5), 6L)))); + assertEquals(PathStateRuntimeAttachment.FailureStage.CAPTURE_GAP, + gap.status().getFailureStage()); + assertEquals(2L, gap.status().getRootLag()); + + PathStateRuntimeAttachment corrupt = new PathStateRuntimeAttachment(view -> { + throw new AssertionError("capture is not used"); + }, transition -> { }, (blockNumber, blockHash) -> { + throw new IOException("native progress checksum mismatch"); + }); + corrupt.flushBaseThrough(4L, hash(4)); + assertEquals(PathStateRuntimeAttachment.State.FAILED, corrupt.status().getState()); + assertEquals(PathStateRuntimeAttachment.FailureStage.BASE_FLUSH, + corrupt.status().getFailureStage()); + assertEquals(PathStateRuntimeAttachment.FailureKind.CORRUPTION, + corrupt.status().getFailureKind()); + manager.shutdown(); + } + + @Test + public void pathStateCompactsOnlyAfterChainbaseRefreshesThePrefix() throws Exception { + SnapshotManager manager = new SnapshotManager(""); + Chainbase code = new Chainbase(new SnapshotRoot(new MemoryDb("code"))); + manager.add(code); + manager.enable(); + manager.setUnChecked(false); + CheckTmpStore checkpoint = mock(CheckTmpStore.class); + DbSourceInter checkpointDb = mock(DbSourceInter.class); + when(checkpointDb.iterator()).thenReturn(Collections.emptyIterator()); + when(checkpoint.getDbSource()).thenReturn(checkpointDb); + manager.setCheckTmpStore(checkpoint); + AtomicReference flushedNumber = new AtomicReference<>(); + AtomicReference flushedHash = new AtomicReference<>(); + PathStateRuntimeAttachment attachment = new PathStateRuntimeAttachment( + view -> { + BlockSnapshotMeta meta = view.getMeta(); + return new PathStateBlockTransition(meta.getBlockNumber(), meta.getBlockHash(), + meta.getParentHash(), meta.getTimestamp(), P66Phase.P66_ON, + Collections.emptyList()); + }, transition -> { }, + (blockNumber, blockHash) -> { + flushedNumber.set(blockNumber); + flushedHash.set(blockHash); + }); + manager.attachPathStateRuntime(attachment); + + BlockSnapshotMeta first = BlockSnapshotMeta.forBlock(1, hash(1), hash(0), 1L); + BlockSnapshotMeta second = BlockSnapshotMeta.forBlock(2, hash(2), hash(1), 2L); + commitBlock(manager, code, first, "first"); + commitBlock(manager, code, second, "second"); + setFlushCount(manager, 1); + + manager.flush(); + + assertEquals(Long.valueOf(1L), flushedNumber.get()); + assertArrayEquals(first.getBlockHash(), flushedHash.get()); + assertFalse(attachment.isFailed()); + manager.detachPathStateRuntime(attachment); + manager.shutdown(); + } + + @Test + public void borrowedRuntimeAttachmentIsAtomicAndIdentityBound() throws Exception { + SnapshotManager manager = new SnapshotManager(""); + manager.add(new Chainbase(new SnapshotRoot(new MemoryDb("code")))); + OldValueCollector collector = mock(OldValueCollector.class); + DurableBlockReverseDiffSink sink = mock(DurableBlockReverseDiffSink.class, + withSettings().extraInterfaces(Closeable.class)); + ArchiveRuntimeAttachment attachment = + new ArchiveRuntimeAttachment(collector, sink); + ArchiveRuntimeAttachment foreign = + new ArchiveRuntimeAttachment(collector, sink); + + manager.attachArchiveRuntime(attachment); + + assertThrows(IllegalStateException.class, () -> manager.attachArchiveRuntime(foreign)); + assertThrows(IllegalStateException.class, () -> manager.detachArchiveRuntime(foreign)); + assertThrows(IllegalStateException.class, + () -> manager.installArchiveCollector(collector, sink)); + assertSame(attachment, manager.detachArchiveRuntime(attachment)); + assertThrows(IllegalStateException.class, () -> manager.detachArchiveRuntime(attachment)); + verify((Closeable) sink, never()).close(); + + BlockReverseDiffSink legacySink = mock(BlockReverseDiffSink.class, + withSettings().extraInterfaces(Closeable.class)); + manager.installArchiveCollector(collector, legacySink); + manager.shutdown(); + + verify((Closeable) legacySink).close(); + verify((Closeable) sink, never()).close(); + } + + @Test + public void collectsBlockPreStateAfterNestedSessionsFinish() { + MemoryDb memoryDb = new MemoryDb("code"); + byte[] changed = bytes("changed"); + byte[] deleted = bytes("deleted"); + byte[] created = bytes("created"); + byte[] empty = bytes("empty"); + byte[] createThenDelete = bytes("create-then-delete"); + memoryDb.put(changed, bytes("old")); + memoryDb.put(deleted, bytes("gone")); + memoryDb.put(empty, new byte[0]); + + SnapshotManager manager = new SnapshotManager(""); + Chainbase database = new Chainbase(new SnapshotRoot(memoryDb)); + manager.add(database); + manager.enable(); + BlockReverseDiffSink sink = mock(BlockReverseDiffSink.class); + manager.installArchiveCollector(new SnapshotOldValueCollector(), sink); + + byte[] hash = new byte[32]; + hash[31] = 1; + BlockSnapshotMeta meta = BlockSnapshotMeta.forBlock(1, hash, new byte[32], 3_000L); + try (ISession block = manager.buildSession()) { + database.put(changed, bytes("intermediate")); + try (ISession transaction = manager.buildSession()) { + database.put(changed, bytes("new")); + database.put(created, bytes("created-value")); + transaction.merge(); + } + try (ISession revertedTransaction = manager.buildSession()) { + database.put(bytes("reverted"), bytes("not-visible")); + } + database.delete(deleted); + database.put(empty, new byte[0]); + database.put(createThenDelete, bytes("temporary")); + database.delete(createThenDelete); + block.commit(meta); + } + + BlockReverseDiff diff = prepared(database); + verify(sink, never()).accept(any(BlockReverseDiff.class)); + assertEquals(meta, diff.getMeta()); + assertEquals(meta, ((SnapshotImpl) database.getHead()).getBlockSnapshotMeta()); + assertEquals(1, diff.getGroups().size()); + DbGroup group = diff.getGroups().get(0); + assertEquals("code", group.getDbName()); + assertEquals(3, group.getEntries().size()); + + assertArrayEquals(bytes("old"), find(group, changed).getOldValue().getValue()); + assertArrayEquals(bytes("gone"), find(group, deleted).getOldValue().getValue()); + assertFalse(find(group, created).getOldValue().isPresent()); + assertFalse(contains(group, empty)); + assertFalse(contains(group, createThenDelete)); + assertFalse(contains(group, bytes("reverted"))); + manager.shutdown(); + } + + @Test + public void preservesStorageRowPhysicalKeyWithoutLogicalProjection() { + MemoryDb memoryDb = new MemoryDb("storage-row"); + byte[] addressHash = new byte[32]; + byte[] slotPart = new byte[32]; + for (int i = 0; i < 32; i++) { + addressHash[i] = (byte) i; + slotPart[i] = (byte) (0x80 + i); + } + byte[] physicalKey = new byte[32]; + System.arraycopy(addressHash, 0, physicalKey, 0, 16); + System.arraycopy(slotPart, 16, physicalKey, 16, 16); + memoryDb.put(physicalKey, bytes("old-word")); + + SnapshotManager manager = new SnapshotManager(""); + Chainbase database = new Chainbase(new SnapshotRoot(memoryDb)); + manager.add(database); + manager.enable(); + manager.installArchiveCollector(new SnapshotOldValueCollector(), diff -> { }); + + try (ISession block = manager.buildSession()) { + database.put(physicalKey, bytes("new-word")); + block.commit(BlockSnapshotMeta.forBlock(1, hash(1), hash(0), 1L)); + } + + BlockReverseDiff diff = prepared(database); + assertEquals(1, diff.getGroups().size()); + DbGroup group = diff.getGroups().get(0); + assertEquals("storage-row", group.getDbName()); + assertEquals(1, group.getEntries().size()); + assertArrayEquals(physicalKey, group.getEntries().get(0).getKey()); + assertArrayEquals(bytes("old-word"), group.getEntries().get(0).getOldValue().getValue()); + manager.shutdown(); + } + + @Test + public void retainsAbiChangesInCanonicalStateHistory() { + SnapshotManager manager = new SnapshotManager(""); + Chainbase abi = new Chainbase(new SnapshotRoot(new MemoryDb("abi"))); + Chainbase code = new Chainbase(new SnapshotRoot(new MemoryDb("code"))); + manager.add(abi); + manager.add(code); + manager.enable(); + manager.installArchiveCollector(new SnapshotOldValueCollector(), diff -> { }); + + try (ISession block = manager.buildSession()) { + abi.put(bytes("contract"), bytes("abi-metadata")); + code.put(bytes("contract"), bytes("runtime-code")); + block.commit(BlockSnapshotMeta.forBlock(1, hash(1), hash(0), 1L)); + } + + BlockReverseDiff diff = prepared(code); + assertEquals(2, diff.getGroups().size()); + assertEquals("abi", diff.getGroups().get(0).getDbName()); + assertEquals("code", diff.getGroups().get(1).getDbName()); + assertFalse(find(diff.getGroups().get(0), bytes("contract")).getOldValue().isPresent()); + assertEquals(diff, prepared(abi)); + manager.shutdown(); + } + + @Test + public void preservesPresentEmptyAndEmitsNoopBlockMetadata() { + MemoryDb memoryDb = new MemoryDb("code"); + byte[] key = bytes("key"); + memoryDb.put(key, new byte[0]); + SnapshotManager manager = new SnapshotManager(""); + Chainbase database = new Chainbase(new SnapshotRoot(memoryDb)); + manager.add(database); + manager.enable(); + manager.installArchiveCollector(new SnapshotOldValueCollector(), diff -> { }); + + try (ISession block = manager.buildSession()) { + database.put(key, bytes("value")); + block.commit(BlockSnapshotMeta.forBlock(1, hash(1), hash(0), 1L)); + } + BlockReverseDiff first = prepared(database); + assertTrue(find(first.getGroups().get(0), key).getOldValue().isPresent()); + assertEquals(0, + find(first.getGroups().get(0), key).getOldValue().getValue().length); + + try (ISession block = manager.buildSession()) { + block.commit(BlockSnapshotMeta.forBlock(2, hash(2), hash(1), 2L)); + } + assertTrue(prepared(database).getGroups().isEmpty()); + manager.shutdown(); + } + + @Test + public void rejectsUnknownOrDuplicateRegisteredDatabaseNames() { + SnapshotManager unknown = new SnapshotManager(""); + unknown.add(new Chainbase(new SnapshotRoot(new MemoryDb("new-store")))); + IllegalStateException unknownError = assertThrows(IllegalStateException.class, + () -> unknown.installArchiveCollector(new SnapshotOldValueCollector(), diff -> { })); + assertTrue(unknownError.getMessage().contains("new-store")); + unknown.shutdown(); + + SnapshotManager duplicate = new SnapshotManager(""); + duplicate.add(new Chainbase(new SnapshotRoot(new MemoryDb("code")))); + duplicate.add(new Chainbase(new SnapshotRoot(new MemoryDb("code")))); + IllegalStateException duplicateError = assertThrows(IllegalStateException.class, + () -> duplicate.installArchiveCollector(new SnapshotOldValueCollector(), diff -> { })); + assertTrue(duplicateError.getMessage().contains("Duplicate")); + duplicate.shutdown(); + } + + @Test + public void classifiesEveryChainbaseRegisteredByTheApplication() { + SnapshotManager applicationManager = context.getBean(SnapshotManager.class); + ArchiveStoreScope.validate(applicationManager.getDbs()); + assertEquals(27, ArchiveStoreScope.getStateDatabases().size()); + assertTrue(ArchiveStoreScope.isStateDatabase("abi")); + assertFalse(ArchiveStoreScope.isExcludedDatabase("abi")); + assertTrue(ArchiveStoreScope.isClassified("abi")); + } + + @Test + public void matchesReferenceStateForRandomBlockOperations() { + MemoryDb memoryDb = new MemoryDb("code"); + SnapshotManager manager = new SnapshotManager(""); + Chainbase database = new Chainbase(new SnapshotRoot(memoryDb)); + manager.add(database); + manager.enable(); + manager.installArchiveCollector(new SnapshotOldValueCollector(), diff -> { }); + + Random random = new Random(0x5a17L); + Map reference = new HashMap<>(); + for (int blockNumber = 1; blockNumber <= 40; blockNumber++) { + Map before = copy(reference); + try (ISession block = manager.buildSession()) { + for (int operation = 0; operation < 25; operation++) { + String key = "key-" + random.nextInt(12); + int action = random.nextInt(4); + boolean nested = random.nextBoolean(); + boolean keepNested = random.nextBoolean(); + if (nested) { + try (ISession transaction = manager.buildSession()) { + byte[] post = applyRandomOperation(database, key, action, blockNumber, operation); + if (keepNested) { + updateReference(reference, key, post); + transaction.merge(); + } + } + } else { + byte[] post = applyRandomOperation(database, key, action, blockNumber, operation); + updateReference(reference, key, post); + } + } + block.commit(BlockSnapshotMeta.forBlock(blockNumber, hash(blockNumber), + hash(blockNumber - 1), blockNumber)); + } + + BlockReverseDiff diff = prepared(database); + Map actual = new HashMap<>(); + if (!diff.getGroups().isEmpty()) { + diff.getGroups().get(0).getEntries().forEach(entry -> actual.put( + new String(entry.getKey(), java.nio.charset.StandardCharsets.UTF_8), + entry.getOldValue())); + } + Set keys = new HashSet<>(before.keySet()); + keys.addAll(reference.keySet()); + for (String key : keys) { + byte[] oldValue = before.get(key); + byte[] postValue = reference.get(key); + if (Arrays.equals(oldValue, postValue)) { + assertFalse("no-op key was emitted: " + key, actual.containsKey(key)); + } else { + assertTrue("changed key was not emitted: " + key, actual.containsKey(key)); + OldValue archived = actual.get(key); + assertEquals(oldValue != null, archived.isPresent()); + if (oldValue != null) { + assertArrayEquals(oldValue, archived.getValue()); + } + } + } + assertEquals(keys.stream().filter(key -> !Arrays.equals(before.get(key), reference.get(key))) + .count(), actual.size()); + } + manager.shutdown(); + } + + @Test + public void projectsAccountAssetTransitionBeforeRootMerge() { + byte[] address = archiveAddress(1); + byte[] token = bytes("1000001"); + Account oldAccount = Account.newBuilder() + .setAddress(ByteString.copyFrom(address)) + .putAssetV2("1000001", 100L) + .build(); + Account postAccount = oldAccount.toBuilder().putAssetV2("1000001", 80L).build(); + + MemoryDb memoryDb = new MemoryDb("account"); + memoryDb.put(address, oldAccount.toByteArray()); + AccountAssetStore assetStore = mock(AccountAssetStore.class); + when(assetStore.prefixQuery(any(byte[].class))).thenReturn(new HashMap<>()); + + SnapshotManager manager = new SnapshotManager(""); + Chainbase database = new Chainbase(new SnapshotRoot(memoryDb)); + manager.add(database); + manager.enable(); + AccountAssetArchiveProjector projector = new AccountAssetArchiveProjector(); + manager.installArchiveCollector(new SnapshotOldValueCollector(projector, + accountKey -> assetStore.prefixQuery(accountKey), () -> true), diff -> { }); + + try (ISession block = manager.buildSession()) { + database.put(address, postAccount.toByteArray()); + block.commit(BlockSnapshotMeta.forBlock(1, hash(1), hash(0), 1L)); + } + + BlockReverseDiff diff = prepared(database); + DbGroup accountGroup = diff.getGroups().stream() + .filter(group -> "account".equals(group.getDbName())) + .findFirst().orElseThrow(AssertionError::new); + Account archivedAccount; + try { + archivedAccount = Account.parseFrom(find(accountGroup, address).getOldValue().getValue()); + } catch (com.google.protobuf.InvalidProtocolBufferException e) { + throw new AssertionError(e); + } + assertFalse(archivedAccount.getAssetOptimized()); + assertEquals(100L, archivedAccount.getAssetV2Map().get("1000001").longValue()); + + DbGroup assetGroup = diff.getGroups().stream() + .filter(group -> "account-asset".equals(group.getDbName())) + .findFirst().orElseThrow(AssertionError::new); + Entry assetEntry = find(assetGroup, Bytes.concat(address, token)); + assertFalse(assetEntry.getOldValue().isPresent()); + manager.shutdown(); + } + + @Test + public void projectsOldPhysicalAssetValueForOptimizedAccount() { + byte[] address = archiveAddress(2); + byte[] token = bytes("1000002"); + byte[] assetKey = Bytes.concat(address, token); + Account oldAccount = Account.newBuilder() + .setAddress(ByteString.copyFrom(address)) + .setAssetOptimized(true) + .build(); + Account postAccount = oldAccount.toBuilder().putAssetV2("1000002", 80L).build(); + + MemoryDb memoryDb = new MemoryDb("account"); + memoryDb.put(address, oldAccount.toByteArray()); + AccountAssetStore assetStore = mock(AccountAssetStore.class); + Map persisted = new HashMap<>(); + persisted.put(WrappedByteArray.copyOf(assetKey), Longs.toByteArray(100L)); + when(assetStore.prefixQuery(any(byte[].class))).thenReturn(persisted); + + SnapshotManager manager = new SnapshotManager(""); + Chainbase database = new Chainbase(new SnapshotRoot(memoryDb)); + manager.add(database); + manager.enable(); + manager.installArchiveCollector(new SnapshotOldValueCollector( + new AccountAssetArchiveProjector(), + accountKey -> assetStore.prefixQuery(accountKey), () -> true), diff -> { }); + + try (ISession block = manager.buildSession()) { + database.put(address, postAccount.toByteArray()); + block.commit(BlockSnapshotMeta.forBlock(1, hash(1), hash(0), 1L)); + } + + BlockReverseDiff diff = prepared(database); + assertFalse(diff.getGroups().stream() + .anyMatch(group -> "account".equals(group.getDbName()))); + DbGroup assetGroup = diff.getGroups().stream() + .filter(group -> "account-asset".equals(group.getDbName())) + .findFirst().orElseThrow(AssertionError::new); + assertArrayEquals(Longs.toByteArray(100L), + find(assetGroup, assetKey).getOldValue().getValue()); + manager.shutdown(); + } + + @Test + public void pureProjectionRequiresAndCopiesExplicitOldPhysicalAssets() { + byte[] address = archiveAddress(4); + byte[] assetKey = Bytes.concat(address, bytes("1000009")); + Account optimized = Account.newBuilder() + .setAddress(ByteString.copyFrom(address)) + .setAssetOptimized(true) + .build(); + Map oldPhysicalAssets = new HashMap<>(); + oldPhysicalAssets.put(WrappedByteArray.copyOf(assetKey), Longs.toByteArray(900L)); + AccountAssetArchiveProjector projector = new AccountAssetArchiveProjector(); + + assertThrows(ArchivePersistenceException.class, + () -> projector.project(address, optimized.toByteArray(), + BlockChangeView.PostValue.absent(), true, null)); + Map wrongAccountAssets = new HashMap<>(); + wrongAccountAssets.put(WrappedByteArray.copyOf(bytes("another-account-token")), + Longs.toByteArray(1L)); + assertThrows(ArchivePersistenceException.class, + () -> projector.project(address, optimized.toByteArray(), + BlockChangeView.PostValue.absent(), true, wrongAccountAssets)); + + AccountAssetArchiveProjector.Projection projection = projector.project(address, + optimized.toByteArray(), BlockChangeView.PostValue.absent(), true, + oldPhysicalAssets); + oldPhysicalAssets.clear(); + + assertEquals(1, projection.reverseAssets.size()); + assertArrayEquals(assetKey, projection.reverseAssets.get(0).getKey()); + assertArrayEquals(Longs.toByteArray(900L), + projection.reverseAssets.get(0).getOldValue().getValue()); + } + + @Test + public void targetAssetOptimizationOverridesLegacySupplierAndCoversDelete() { + byte[] address = archiveAddress(5); + byte[] assetKey = Bytes.concat(address, bytes("1000003")); + Account rawPost = Account.newBuilder() + .setAddress(ByteString.copyFrom(address)) + .putAssetV2("1000003", 300L) + .build(); + AccountAssetArchiveProjector projector = new AccountAssetArchiveProjector(); + + AccountAssetArchiveProjector.Projection enabled = projector.project(address, null, + BlockChangeView.PostValue.present(rawPost.toByteArray()), true, Collections.emptyMap()); + assertTrue(parseAccount(enabled.postAccount.getValue()).getAssetOptimized()); + + AccountAssetArchiveProjector.Projection disabled = + new AccountAssetArchiveProjector().project(address, null, + BlockChangeView.PostValue.present(rawPost.toByteArray()), false, + Collections.emptyMap()); + assertArrayEquals(rawPost.toByteArray(), disabled.postAccount.getValue()); + Map mixedPhysical = new HashMap<>(); + mixedPhysical.put(WrappedByteArray.copyOf(assetKey), Longs.toByteArray(300L)); + assertThrows(ArchivePersistenceException.class, + () -> projector.project(address, null, + BlockChangeView.PostValue.present(rawPost.toByteArray()), true, mixedPhysical)); + + Account optimizedOld = rawPost.toBuilder() + .setAssetOptimized(true) + .clearAssetV2() + .build(); + Map persisted = new HashMap<>(); + persisted.put(WrappedByteArray.copyOf(assetKey), Longs.toByteArray(300L)); + AccountAssetArchiveProjector.Projection deleted = projector.project(address, + optimizedOld.toByteArray(), BlockChangeView.PostValue.absent(), true, persisted); + assertFalse(deleted.postAccount.isPresent()); + assertEquals(1, deleted.reverseAssets.size()); + } + + @Test + public void targetAssetOptimizationComesFromTheCapturedPropertiesPostState() { + List invalidValues = Arrays.asList(null, new byte[]{1}, Longs.toByteArray(2)); + for (byte[] invalidValue : invalidValues) { + SnapshotManager manager = new SnapshotManager(""); + Chainbase account = new Chainbase(new SnapshotRoot(new MemoryDb("account"))); + MemoryDb propertiesRoot = new MemoryDb("properties"); + if (invalidValue != null) { + propertiesRoot.put(HistoricalAccountAssetBalanceResolver.proposal66PhysicalKey(), + invalidValue); + } + Chainbase properties = new Chainbase(new SnapshotRoot(propertiesRoot)); + manager.add(account); + manager.add(properties); + manager.enable(); + manager.installArchiveCollector(new SnapshotOldValueCollector( + new AccountAssetArchiveProjector(), ignored -> Collections.emptyMap(), + SnapshotOldValueCollector::resolveTargetAssetOptimization), diff -> { }); + byte[] address = archiveAddress(12); + Account post = Account.newBuilder().setAddress(ByteString.copyFrom(address)) + .putAssetV2("1000012", 12L).build(); + + assertThrows(ArchivePersistenceException.class, () -> { + try (ISession block = manager.buildSession()) { + account.put(address, post.toByteArray()); + block.commit(BlockSnapshotMeta.forBlock(1, hash(1), hash(0), 1L)); + } + }); + assertEquals(0, manager.getActiveSession()); + assertEquals(0, manager.size()); + manager.shutdown(); + } + + SnapshotManager manager = new SnapshotManager(""); + Chainbase account = new Chainbase(new SnapshotRoot(new MemoryDb("account"))); + MemoryDb propertiesRoot = new MemoryDb("properties"); + propertiesRoot.put(HistoricalAccountAssetBalanceResolver.proposal66PhysicalKey(), + Longs.toByteArray(0)); + Chainbase properties = new Chainbase(new SnapshotRoot(propertiesRoot)); + manager.add(account); + manager.add(properties); + manager.enable(); + manager.installArchiveCollector(new SnapshotOldValueCollector( + new AccountAssetArchiveProjector(), ignored -> Collections.emptyMap(), + SnapshotOldValueCollector::resolveTargetAssetOptimization), diff -> { }); + byte[] address = archiveAddress(13); + Account post = Account.newBuilder().setAddress(ByteString.copyFrom(address)) + .putAssetV2("1000013", 13L).build(); + try (ISession block = manager.buildSession()) { + properties.put(HistoricalAccountAssetBalanceResolver.proposal66PhysicalKey(), + Longs.toByteArray(1)); + account.put(address, post.toByteArray()); + block.commit(BlockSnapshotMeta.forBlock(1, hash(1), hash(0), 1L)); + } + DbGroup assetGroup = prepared(account).getGroups().stream() + .filter(group -> "account-asset".equals(group.getDbName())) + .findFirst().orElseThrow(AssertionError::new); + assertFalse(find(assetGroup, Bytes.concat(address, bytes("1000013"))) + .getOldValue().isPresent()); + manager.shutdown(); + } + + @Test + public void sharedProjectionMatchesSnapshotRootBytesWithProposalSixtySix() { + byte[] address = new byte[21]; + address[0] = 65; + address[20] = 79; + String token = "1000005"; + byte[] assetKey = Bytes.concat(address, bytes(token)); + Account rawPost = Account.newBuilder() + .setAddress(ByteString.copyFrom(address)) + .putAssetV2(token, 500L) + .build(); + AccountAssetArchiveProjector.Projection projection = + new AccountAssetArchiveProjector().project(address, null, + BlockChangeView.PostValue.present(rawPost.toByteArray()), true, + Collections.emptyMap()); + + chainBaseManager.getDynamicPropertiesStore().setAllowAccountAssetOptimization(0); + chainBaseManager.getDynamicPropertiesStore().setAllowAssetOptimization(1); + MemoryDb accountRootDb = new MemoryDb("account"); + SnapshotRoot accountRoot = new SnapshotRoot(accountRootDb); + accountRoot.put(address, rawPost.toByteArray()); + + assertArrayEquals(projection.postAccount.getValue(), accountRootDb.get(address)); + assertArrayEquals(Longs.toByteArray(500L), + chainBaseManager.getAccountAssetStore().get(assetKey)); + } + + @Test + public void archiveDurabilityFailurePreventsCheckpointAndRefresh() throws Exception { + MemoryDb memoryDb = new MemoryDb("code"); + SnapshotManager manager = new SnapshotManager(""); + Chainbase database = new Chainbase(new SnapshotRoot(memoryDb)); + manager.add(database); + manager.enable(); + manager.setUnChecked(false); + CheckTmpStore checkpoint = mock(CheckTmpStore.class); + manager.setCheckTmpStore(checkpoint); + DurableBlockReverseDiffSink sink = mock(DurableBlockReverseDiffSink.class); + manager.installArchiveCollector(new SnapshotOldValueCollector(), sink); + try (ISession block = manager.buildSession()) { + database.put(bytes("key"), bytes("value")); + block.commit(BlockSnapshotMeta.forBlock(1, hash(1), hash(0), 1L)); + } + setFlushCount(manager, 1); + doThrow(new ArchivePersistenceException("injected")) + .when(sink).awaitCommitted(1L); + + assertThrows(TronError.class, manager::flush); + verify(sink).acceptAll(Collections.singletonList(prepared(database))); + verify(sink).awaitCommitted(1L); + verify(checkpoint, never()).updateByBatch(any(Map.class)); + manager.shutdown(); + } + + @Test + public void flushRetriesDurabilityAndEvidenceWithoutResubmittingHistory() throws Exception { + MemoryDb memoryDb = new MemoryDb("code"); + SnapshotManager manager = new SnapshotManager(""); + Chainbase database = new Chainbase(new SnapshotRoot(memoryDb)); + manager.add(database); + manager.enable(); + manager.setUnChecked(false); + CheckTmpStore checkpoint = mock(CheckTmpStore.class); + DbSourceInter checkpointDb = mock(DbSourceInter.class); + when(checkpointDb.iterator()).thenReturn(Collections.emptyIterator()); + when(checkpoint.getDbSource()).thenReturn(checkpointDb); + manager.setCheckTmpStore(checkpoint); + ArchiveHistoryWriter writer = new ArchiveHistoryWriter( + temporaryFolder.newFolder("flush-evidence-retry").toPath(), 4096, + ArchiveStoreScope.getStateDatabases()); + FailOnceEvidenceSink sink = new FailOnceEvidenceSink(writer); + manager.installArchiveCollector(new SnapshotOldValueCollector(), sink); + + BlockSnapshotMeta meta = BlockSnapshotMeta.forBlock(1, hash(1), hash(0), 1L); + commitBlock(manager, database, meta, "key-1"); + setFlushCount(manager, 1); + + assertThrows(TronError.class, manager::flush); + verify(checkpoint, never()).updateByBatch(any(Map.class)); + + assertThrows(TronError.class, manager::flush); + verify(checkpoint, never()).updateByBatch(any(Map.class)); + + manager.flush(); + + assertEquals(1, sink.acceptAllCalls); + assertEquals(3, sink.awaitCalls); + assertEquals(2, sink.evidenceCalls); + manager.shutdown(); + writer.close(); + } + + @Test + public void fastPopDiscardsPreparedPayloadWithoutRevertingDurableHistory() { + MemoryDb memoryDb = new MemoryDb("code"); + SnapshotManager manager = new SnapshotManager(""); + Chainbase database = new Chainbase(new SnapshotRoot(memoryDb)); + manager.add(database); + manager.enable(); + BlockReverseDiffSink sink = mock(BlockReverseDiffSink.class); + manager.installArchiveCollector(new SnapshotOldValueCollector(), sink); + + BlockSnapshotMeta meta = BlockSnapshotMeta.forBlock(1, hash(1), hash(0), 1L); + try (ISession block = manager.buildSession()) { + database.put(bytes("key"), bytes("value")); + block.commit(meta); + } + + assertEquals(meta, prepared(database).getMeta()); + manager.fastPop(); + + assertEquals(0, manager.size()); + assertTrue(database.getHead() instanceof SnapshotRoot); + verify(sink, never()).accept(any(BlockReverseDiff.class)); + verify(sink, never()).revert(any(BlockSnapshotMeta.class)); + manager.shutdown(); + } + + @Test + public void collectorFailureLeavesSessionOwnedSoCloseRevokesLayer() { + MemoryDb memoryDb = new MemoryDb("code"); + SnapshotManager manager = new SnapshotManager(""); + Chainbase database = new Chainbase(new SnapshotRoot(memoryDb)); + manager.add(database); + manager.enable(); + OldValueCollector collector = mock(OldValueCollector.class); + BlockReverseDiffSink sink = mock(BlockReverseDiffSink.class); + when(collector.collect(any(BlockChangeView.class))) + .thenThrow(new IllegalStateException("injected collector failure")); + manager.installArchiveCollector(collector, sink); + + assertThrows(IllegalStateException.class, () -> { + try (ISession block = manager.buildSession()) { + database.put(bytes("key"), bytes("value")); + block.commit(BlockSnapshotMeta.forBlock(1, hash(1), hash(0), 1L)); + } + }); + + assertEquals(0, manager.getActiveSession()); + assertEquals(0, manager.size()); + assertTrue(database.getHead() instanceof SnapshotRoot); + verify(sink, never()).accept(any(BlockReverseDiff.class)); + manager.shutdown(); + } + + @Test + public void flushPublishesOnlyTheNonRevertibleRange() throws Exception { + MemoryDb memoryDb = new MemoryDb("code"); + SnapshotManager manager = new SnapshotManager(""); + Chainbase database = new Chainbase(new SnapshotRoot(memoryDb)); + manager.add(database); + manager.enable(); + manager.setUnChecked(false); + CheckTmpStore checkpoint = mock(CheckTmpStore.class); + DbSourceInter checkpointDb = mock(DbSourceInter.class); + when(checkpointDb.iterator()).thenReturn(Collections.emptyIterator()); + when(checkpoint.getDbSource()).thenReturn(checkpointDb); + manager.setCheckTmpStore(checkpoint); + DurableBlockReverseDiffSink sink = mock(DurableBlockReverseDiffSink.class); + manager.installArchiveCollector(new SnapshotOldValueCollector(), sink); + + try (ISession block = manager.buildSession()) { + database.put(bytes("key-1"), bytes("value-1")); + block.commit(BlockSnapshotMeta.forBlock(1, hash(1), hash(0), 1L)); + } + BlockReverseDiff first = prepared(database); + try (ISession block = manager.buildSession()) { + database.put(bytes("key-2"), bytes("value-2")); + block.commit(BlockSnapshotMeta.forBlock(2, hash(2), hash(1), 2L)); + } + BlockReverseDiff second = prepared(database); + setFlushCount(manager, 1); + HistoryCommitMarker committed = marker(first.getMeta()); + when(sink.createMarkerRangeEvidence(1)).thenReturn( + new DurableHistoryMarkerRangeEvidence(new DurableHistoryMarkerRangeEvidence.Source() { + @Override + public HistoryCommitMarker marker(long epoch) { + return committed; + } + + @Override + public BlockReverseDiff readCommitted(long epoch) { + return first; + } + }, 1)); + + manager.flush(); + + verify(sink).acceptAll(Collections.singletonList(first)); + verify(sink, never()).acceptAll(Collections.singletonList(second)); + verify(sink).awaitCommitted(1L); + verify(sink).releaseThrough(1L); + manager.shutdown(); + } + + private static Entry find(DbGroup group, byte[] key) { + return group.getEntries().stream() + .filter(entry -> Arrays.equals(entry.getKey(), key)) + .findFirst() + .orElseThrow(AssertionError::new); + } + + private static Account parseAccount(byte[] value) { + try { + return Account.parseFrom(value); + } catch (com.google.protobuf.InvalidProtocolBufferException e) { + throw new AssertionError(e); + } + } + + private static BlockReverseDiff prepared(Chainbase database) { + return ((SnapshotImpl) database.getHead()).getPreparedArchiveBlock(); + } + + private static void commitBlock(SnapshotManager manager, Chainbase database, + BlockSnapshotMeta meta, String key) { + try (ISession block = manager.buildSession()) { + database.put(bytes(key), bytes("value-" + key)); + block.commit(meta); + } + } + + private static HistoryCommitMarker marker(BlockSnapshotMeta meta) { + int epoch = (int) meta.getEpoch(); + List participants = new ArrayList<>(ArchiveStoreScope.getStateDatabases()); + Collections.sort(participants); + return new HistoryCommitMarker(meta, epoch - 1, + new HistoryLocation(0, epoch * 100L, 100, epoch, hash(epoch + 20)), + new HistoryIndexLocation(epoch * 50L, 50, hash(epoch + 30)), + Arrays.copyOf(hash(epoch + 40), 16), participants); + } + + private static void setFlushCount(SnapshotManager manager, int count) throws Exception { + java.lang.reflect.Field flushCount = SnapshotManager.class.getDeclaredField("flushCount"); + flushCount.setAccessible(true); + flushCount.setInt(manager, count); + } + + private static boolean contains(DbGroup group, byte[] key) { + return group.getEntries().stream().anyMatch(entry -> Arrays.equals(entry.getKey(), key)); + } + + private static int mutationCount(PathStateBlockTransition transition, String dbName) { + return (int) transition.getMutations().stream() + .filter(mutation -> dbName.equals(mutation.getDbName())) + .count(); + } + + private static PathStateMutation mutation(PathStateBlockTransition transition, + String dbName, byte[] key) { + return transition.getMutations().stream() + .filter(candidate -> dbName.equals(candidate.getDbName()) + && Arrays.equals(key, candidate.getCanonicalKey())) + .findFirst() + .orElseThrow(AssertionError::new); + } + + private static BlockChangeView captureView(SnapshotManager manager, Chainbase database, + BlockSnapshotMeta meta) { + try (ISession session = manager.buildSession()) { + database.put(bytes("status-" + meta.getBlockNumber()), + bytes("value-" + meta.getBlockNumber())); + BlockChangeView view = BlockChangeView.capture(meta, manager.getDbs()); + session.commit(); + return view; + } + } + + private static Chainbase equivalenceStore(SnapshotManager manager, String dbName, + byte[] key, byte[] value) { + MemoryDb database = new MemoryDb(dbName); + database.put(key, value); + Chainbase chainbase = new Chainbase(new SnapshotRoot(database)); + manager.add(chainbase); + return chainbase; + } + + private static void equivalenceProperties(SnapshotManager manager) { + equivalenceStore(manager, HistoricalAccountAssetBalanceResolver.PROPERTIES_DATABASE, + HistoricalAccountAssetBalanceResolver.proposal66PhysicalKey(), + Longs.toByteArray(1L)); + } + + private static byte[] bytes(String value) { + return value.getBytes(java.nio.charset.StandardCharsets.UTF_8); + } + + private static byte[] archiveAddress(int suffix) { + byte[] address = new byte[21]; + address[0] = 0x41; + address[20] = (byte) suffix; + return address; + } + + private static byte[] hash(int suffix) { + byte[] hash = new byte[32]; + hash[31] = (byte) suffix; + return hash; + } + + private static byte[] applyRandomOperation(Chainbase database, String key, int action, + int blockNumber, int operation) { + byte[] encodedKey = bytes(key); + if (action == 0) { + database.delete(encodedKey); + return null; + } + byte[] value = action == 1 ? new byte[0] + : bytes("value-" + blockNumber + '-' + operation + '-' + action); + database.put(encodedKey, value); + return value; + } + + private static void updateReference(Map reference, String key, byte[] value) { + if (value == null) { + reference.remove(key); + } else { + reference.put(key, Arrays.copyOf(value, value.length)); + } + } + + private static Map copy(Map source) { + Map copy = new HashMap<>(); + source.forEach((key, value) -> copy.put(key, Arrays.copyOf(value, value.length))); + return copy; + } + + private static final class FailOnceEvidenceSink implements DurableBlockReverseDiffSink { + private final ArchiveHistoryWriter writer; + private int acceptAllCalls; + private int awaitCalls; + private int evidenceCalls; + + private FailOnceEvidenceSink(ArchiveHistoryWriter writer) { + this.writer = writer; + } + + @Override + public void accept(BlockReverseDiff diff) { + writer.accept(diff); + } + + @Override + public void acceptAll(List diffs) { + acceptAllCalls++; + writer.acceptAll(diffs); + } + + @Override + public void revert(BlockSnapshotMeta meta) { + writer.revert(meta); + } + + @Override + public void awaitCommitted(long epoch) { + awaitCalls++; + if (awaitCalls == 1) { + throw new ArchivePersistenceException("injected durable wait failure"); + } + writer.awaitCommitted(epoch); + } + + @Override + public DurableHistoryMarkerRangeEvidence createMarkerRangeEvidence(int maxMarkers) { + evidenceCalls++; + if (evidenceCalls == 1) { + return new DurableHistoryMarkerRangeEvidence( + new DurableHistoryMarkerRangeEvidence.Source() { + @Override + public HistoryCommitMarker marker(long epoch) { + return null; + } + + @Override + public BlockReverseDiff readCommitted(long epoch) { + return writer.readCommitted(epoch); + } + }, maxMarkers); + } + return writer.createMarkerRangeEvidence(maxMarkers); + } + + @Override + public void releaseThrough(long epoch) { + writer.releaseThrough(epoch); + } + } + + private static final class MemoryDb implements DB, Flusher { + private final String name; + private final Map values = new LinkedHashMap<>(); + + private MemoryDb(String name) { + this.name = name; + } + + @Override + public byte[] get(byte[] key) { + byte[] value = values.get(WrappedByteArray.of(key)); + return value == null ? null : Arrays.copyOf(value, value.length); + } + + @Override + public void put(byte[] key, byte[] value) { + values.put(WrappedByteArray.copyOf(key), Arrays.copyOf(value, value.length)); + } + + @Override + public long size() { + return values.size(); + } + + @Override + public boolean isEmpty() { + return values.isEmpty(); + } + + @Override + public void remove(byte[] key) { + values.remove(WrappedByteArray.of(key)); + } + + @Override + public Iterator> iterator() { + List> entries = new ArrayList<>(); + values.forEach((key, value) -> entries.add(new AbstractMap.SimpleImmutableEntry<>( + key.getBytes(), Arrays.copyOf(value, value.length)))); + return entries.iterator(); + } + + @Override + public void close() { + values.clear(); + } + + @Override + public void flush(Map batch) { + batch.forEach((key, value) -> { + if (value == null || value.getBytes() == null) { + values.remove(key); + } else { + values.put(WrappedByteArray.copyOf(key.getBytes()), value.getBytes()); + } + }); + } + + @Override + public void reset() { + values.clear(); + } + + @Override + public String getDbName() { + return name; + } + + @Override + public void stat() { + } + + @Override + public DB newInstance() { + return new MemoryDb(name); + } + } +} diff --git a/framework/src/test/java/org/tron/core/db2/archive/StateArchiveAppendCheckpointMaterializerV3Test.java b/framework/src/test/java/org/tron/core/db2/archive/StateArchiveAppendCheckpointMaterializerV3Test.java new file mode 100644 index 00000000000..277ed875164 --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/archive/StateArchiveAppendCheckpointMaterializerV3Test.java @@ -0,0 +1,378 @@ +package org.tron.core.db2.archive; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tron.core.db2.archive.BlockReverseDiff.DbGroup; +import org.tron.core.db2.archive.BlockReverseDiff.Entry; +import org.tron.core.db2.archive.StateArchiveFiveLaneSegmentWriterV3.SyncStage; +import org.tron.core.db2.core.CommonCheckpointCapture; +import org.tron.core.db2.core.CommonCheckpointFile; +import org.tron.core.db2.core.CommonCheckpointMaterializer; +import org.tron.core.db2.core.CommonCheckpointMaterializer.Authority; +import org.tron.core.db2.core.CommonCheckpointMaterializer.Status; +import org.tron.core.db2.core.CommonCheckpointPayload; +import org.tron.core.db2.core.CommonCheckpointRedoCoordinator; +import org.tron.core.db2.core.CommonCheckpointTarget; +import org.tron.core.db2.stateroot.PathStateFlushTarget; +import org.tron.core.db2.stateroot.PathStateStoreManifest.Engine; + +public class StateArchiveAppendCheckpointMaterializerV3Test { + + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void preparesBeforeWalPublishesAndReopensExactTarget() throws Exception { + Path root = temporaryFolder.newFolder("append-materializer").toPath(); + byte[] format = hash(70); + byte[] baseline = hash(80); + BlockReverseDiff diff = diff(1, 64); + CommonCheckpointPayload payload; + CommonCheckpointTarget target; + try (StateArchiveAppendCheckpointMaterializerV3 archive = materializer( + root, format, baseline, 10_000)) { + StateArchiveHotBatchDescriptor descriptor = archive.planCheckpoint( + Collections.singletonList(diff)); + payload = payload(format, Collections.singletonList(diff), descriptor); + target = CommonCheckpointTarget.from(payload); + CommonCheckpointCapture capture = CommonCheckpointCapture.create(payload, + Collections.singletonList(diff), descriptor); + + assertEquals(Status.NEEDS_MATERIALIZATION, archive.inspect(target)); + assertThrows(java.io.IOException.class, () -> archive.materialize(payload, target)); + assertEquals(target, archive.prepare(capture)); + assertEquals(target, archive.prepare(capture)); + assertEquals(Status.MATERIALIZED, archive.inspect(target)); + assertFalse(Files.exists(root.resolve(StateArchiveCheckpointMaterializer.READABLE_FILE))); + archive.materialize(payload, target); + archive.publish(target); + assertEquals(Status.PUBLISHED, archive.inspect(target)); + } + + try (StateArchiveAppendCheckpointMaterializerV3 reopened = materializer( + root, format, baseline, 10_000)) { + assertEquals(Status.PUBLISHED, reopened.inspect(target)); + reopened.afterCommit(target); + reopened.completeServingInitialSync(target); + assertEquals(1, reopened.servingIndexStatus().getIndexedThrough()); + assertEquals(StateArchiveServingIndexBuildCoordinatorV3.Mode.LIVE_IMMEDIATE, + reopened.servingIndexStatus().getMode()); + reopened.materialize(payload, target); + reopened.publish(target); + } + } + + @Test + public void crossesExistingCoordinatorWithoutArchiveBodiesInWal() throws Exception { + Path root = temporaryFolder.newFolder("append-coordinator").toPath(); + byte[] format = hash(71); + BlockReverseDiff diff = diff(1, 32); + StateArchiveAppendCheckpointMaterializerV3 archive = materializer( + root.resolve("history"), format, hash(81), 10_000); + StateArchiveHotBatchDescriptor descriptor = archive.planCheckpoint( + Collections.singletonList(diff)); + CommonCheckpointPayload payload = payload(format, Collections.singletonList(diff), + descriptor); + CommonCheckpointTarget target = archive.prepare(CommonCheckpointCapture.create(payload, + Collections.singletonList(diff), descriptor)); + + try (CommonCheckpointRedoCoordinator coordinator = new CommonCheckpointRedoCoordinator( + new CommonCheckpointFile(root.resolve("wal")), new FakeMaterializer(Authority.CHAINBASE), + new FakeMaterializer(Authority.PATH_STATE), archive)) { + assertEquals(CommonCheckpointRedoCoordinator.RecoveryAction.COMPLETED_REDO, + coordinator.apply(payload)); + assertEquals(Status.PUBLISHED, archive.inspect(target)); + assertEquals(CommonCheckpointRedoCoordinator.RecoveryAction.NO_CHECKPOINT, + coordinator.recover()); + } + } + + @Test + public void persistsRotationSpanningSap3DuringPrepare() throws Exception { + Path root = temporaryFolder.newFolder("append-materializer-rotation").toPath(); + byte[] format = hash(72); + byte[] baseline = hash(82); + List diffs = Arrays.asList(diff(1, 1_400), diff(2, 0)); + try (StateArchiveAppendCheckpointMaterializerV3 archive = materializer( + root, format, baseline, 1_500)) { + StateArchiveHotBatchDescriptor descriptor = archive.planCheckpoint(diffs); + CommonCheckpointPayload payload = payload(format, diffs, descriptor); + CommonCheckpointTarget target = CommonCheckpointTarget.from(payload); + archive.prepare(CommonCheckpointCapture.create(payload, diffs, descriptor)); + StateArchiveFiveLaneSegmentWriterV3.ArchiveDurabilityProof proof = + StateArchiveFiveLaneDurabilityProofV3.decode(Files.readAllBytes( + root.resolve(StateArchiveFiveLaneDurabilityProofV3.FILE_NAME))); + assertEquals(6, proof.getFileTails().size()); + assertEquals(Status.MATERIALIZED, archive.inspect(target)); + } + } + + @Test(timeout = 15000) + public void commonRetiresWhileDerivedBuilderIsBlocked() throws Exception { + Path root = temporaryFolder.newFolder("isolated-serving").toPath(); + java.util.concurrent.CountDownLatch entered = new java.util.concurrent.CountDownLatch(1); + java.util.concurrent.CountDownLatch release = new java.util.concurrent.CountDownLatch(1); + StateArchiveAppendCheckpointMaterializerV3 archive = + new StateArchiveAppendCheckpointMaterializerV3(root.resolve("history"), hash(71), + Engine.LEVELDB, hash(81), StateArchiveFileFormatV3.COMPRESSION_NONE, 10000, () -> { + entered.countDown(); + try { + if (!release.await(5, java.util.concurrent.TimeUnit.SECONDS)) { + throw new IllegalStateException("test release timed out"); + } + } catch (InterruptedException failure) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(failure); + } + }); + CommonCheckpointFile wal = new CommonCheckpointFile(root.resolve("wal")); + CommonCheckpointRedoCoordinator coordinator = new CommonCheckpointRedoCoordinator(wal, + new FakeMaterializer(Authority.CHAINBASE), new FakeMaterializer(Authority.PATH_STATE), + archive); + try { + List diffs = Collections.singletonList(diff(1, 32)); + StateArchiveHotBatchDescriptor descriptor = archive.planCheckpoint(diffs); + CommonCheckpointPayload payload = payload(hash(71), diffs, descriptor); + CommonCheckpointTarget target = archive.prepare( + CommonCheckpointCapture.create(payload, diffs, descriptor)); + coordinator.apply(payload); + assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS)); + assertEquals(CommonCheckpointRedoCoordinator.RecoveryAction.NO_CHECKPOINT, + coordinator.recover()); + assertEquals(Status.PUBLISHED, archive.inspect(target)); + assertEquals(-1, archive.servingIndexStatus().getIndexedThrough()); + release.countDown(); + archive.completeServingInitialSync(target); + assertEquals(1, archive.servingIndexStatus().getIndexedThrough()); + } finally { + release.countDown(); + coordinator.close(); + } + } + + @Test + public void advancesTwoPublishedTargetsAndPreservesFreshHotBindingBytes() throws Exception { + Path root = temporaryFolder.newFolder("append-materializer-sequential").toPath(); + byte[] format = hash(73); + byte[] baseline = hash(83); + BlockReverseDiff first = diff(1, 48); + try (StateArchiveAppendCheckpointMaterializerV3 archive = materializer( + root.resolve("history"), format, baseline, 10_000)) { + StateArchiveHotBatchDescriptor firstDescriptor = archive.planCheckpoint( + Collections.singletonList(first)); + assertEquals(StateArchiveHotStore.planCheckpointDescriptor(Engine.LEVELDB, + 0, hash(0), new byte[StateArchiveFileFormatV3.HASH_LENGTH], + Collections.singletonList(first)), firstDescriptor); + CommonCheckpointPayload firstPayload = payload(format, + Collections.singletonList(first), firstDescriptor); + CommonCheckpointTarget firstTarget = archive.prepare(CommonCheckpointCapture.create( + firstPayload, Collections.singletonList(first), firstDescriptor)); + archive.publish(firstTarget); + assertEquals(Status.PUBLISHED, archive.inspect(firstTarget)); + assertEquals(StateArchiveServingIndexBuildCoordinatorV3.Mode.BULK_CATCH_UP, + archive.servingIndexStatus().getMode()); + archive.afterCommit(firstTarget); + archive.completeServingInitialSync(firstTarget); + assertEquals(StateArchiveServingIndexBuildCoordinatorV3.Mode.LIVE_IMMEDIATE, + archive.servingIndexStatus().getMode()); + assertEquals(1, archive.servingIndexStatus().getIndexedThrough()); + + BlockReverseDiff second = diff(2, 16); + StateArchiveHotBatchDescriptor secondDescriptor = archive.planCheckpoint( + Collections.singletonList(second)); + CommonCheckpointPayload secondPayload = payload(format, + Collections.singletonList(second), secondDescriptor); + CommonCheckpointTarget secondTarget = archive.prepare(CommonCheckpointCapture.create( + secondPayload, Collections.singletonList(second), secondDescriptor)); + assertEquals(Status.MATERIALIZED, archive.inspect(secondTarget)); + archive.publish(secondTarget); + archive.afterCommit(secondTarget); + assertEquals(Status.PUBLISHED, archive.inspect(secondTarget)); + assertEquals(2, archive.servingIndexStatus().getIndexedThrough()); + assertEquals(0, archive.servingIndexStatus().getPendingBlocks()); + assertThrows(java.io.IOException.class, () -> archive.inspect(firstTarget)); + } + } + + @Test + public void resumesEveryRotationMarkerPhaseBeforeSap3Publication() throws Exception { + for (SyncStage stage : new SyncStage[]{SyncStage.MARKER_WRITTEN, + SyncStage.DATA_FORCED, SyncStage.MARKER_VERIFIED}) { + Path root = temporaryFolder.newFolder("append-rotation-resume-" + stage).toPath(); + byte[] format = hash(74); + byte[] baseline = hash(84); + List diffs = Arrays.asList(diff(1, 1_400), diff(2, 0)); + CommonCheckpointCapture capture = capture(root, format, baseline, diffs, 1_500); + CommonCheckpointTarget target = CommonCheckpointTarget.from(capture.getPayload()); + StateArchiveFiveLaneBlockCodecV3 codec = new StateArchiveFiveLaneBlockCodecV3(); + StateArchiveFiveLaneBlockCodecV3.EncodedBundle first = codec.encode( + diffs.get(0), baseline, StateArchiveFileFormatV3.COMPRESSION_NONE); + StateArchiveFiveLaneBlockCodecV3.EncodedBundle second = codec.encode( + diffs.get(1), first.getResultHistoryDigest(), + StateArchiveFileFormatV3.COMPRESSION_NONE); + try (StateArchiveFiveLaneSegmentWriterV3 writer = + new StateArchiveFiveLaneSegmentWriterV3(root, baseline, + StateArchiveFileFormatV3.COMPRESSION_NONE, 1_500)) { + writer.appendForCheckpoint(first, 2, target.getPayloadDigest()); + assertThrows(java.io.IOException.class, + () -> writer.appendForCheckpoint(second, 2, target.getPayloadDigest(), + (actual, laneId) -> { + if (actual == stage && laneId == 0) { + throw new java.io.IOException("rotation crash at " + stage); + } + })); + } + try (StateArchiveAppendCheckpointMaterializerV3 recovered = materializer( + root, format, baseline, 1_500)) { + recovered.prepare(capture); + assertEquals(Status.MATERIALIZED, recovered.inspect(target)); + assertEquals(6, StateArchiveFiveLaneDurabilityProofV3.decode(Files.readAllBytes( + root.resolve(StateArchiveFiveLaneDurabilityProofV3.FILE_NAME))) + .getFileTails().size()); + } + } + } + + @Test + public void resumesEveryFinalBarrierPhaseBeforeSap3Publication() throws Exception { + for (SyncStage stage : SyncStage.values()) { + Path root = temporaryFolder.newFolder("append-sync-resume-" + stage).toPath(); + byte[] format = hash(75); + byte[] baseline = hash(85); + List diffs = Collections.singletonList(diff(1, 24)); + CommonCheckpointCapture capture = capture(root, format, baseline, diffs, 10_000); + CommonCheckpointTarget target = CommonCheckpointTarget.from(capture.getPayload()); + StateArchiveFiveLaneBlockCodecV3.EncodedBundle bundle = + new StateArchiveFiveLaneBlockCodecV3().encode(diffs.get(0), baseline, + StateArchiveFileFormatV3.COMPRESSION_NONE); + try (StateArchiveFiveLaneSegmentWriterV3 writer = + new StateArchiveFiveLaneSegmentWriterV3(root, baseline, + StateArchiveFileFormatV3.COMPRESSION_NONE, 10_000)) { + writer.appendForCheckpoint(bundle, 1, target.getPayloadDigest()); + assertThrows(java.io.IOException.class, + () -> writer.sync(1, point(bundle), target.getPayloadDigest(), + (actual, laneId) -> { + if (actual == stage) { + throw new java.io.IOException("sync crash at " + stage); + } + })); + } + try (StateArchiveAppendCheckpointMaterializerV3 recovered = materializer( + root, format, baseline, 10_000)) { + recovered.prepare(capture); + assertEquals(Status.MATERIALIZED, recovered.inspect(target)); + } + } + } + + private static StateArchiveAppendCheckpointMaterializerV3 materializer(Path root, + byte[] format, byte[] baseline, long rotationTarget) throws Exception { + return new StateArchiveAppendCheckpointMaterializerV3(root, format, Engine.LEVELDB, + baseline, StateArchiveFileFormatV3.COMPRESSION_NONE, rotationTarget); + } + + private static CommonCheckpointCapture capture(Path root, byte[] format, byte[] baseline, + List diffs, long rotationTarget) throws Exception { + StateArchiveHotBatchDescriptor descriptor; + try (StateArchiveAppendCheckpointMaterializerV3 planner = materializer( + root, format, baseline, rotationTarget)) { + descriptor = planner.planCheckpoint(diffs); + } + CommonCheckpointPayload payload = payload(format, diffs, descriptor); + return CommonCheckpointCapture.create(payload, diffs, descriptor); + } + + private static StateArchiveFiveLaneRecoveryIntentV3.RecoveryPoint point( + StateArchiveFiveLaneBlockCodecV3.EncodedBundle bundle) { + BlockSnapshotMeta meta = bundle.getDiff().getMeta(); + return new StateArchiveFiveLaneRecoveryIntentV3.RecoveryPoint(meta.getEpoch(), + meta.getBlockNumber(), meta.getTimestamp(), meta.getBlockHash(), meta.getParentHash(), + bundle.getResultHistoryDigest()); + } + + private static CommonCheckpointPayload payload(byte[] format, List diffs, + StateArchiveHotBatchDescriptor descriptor) { + List bindings = new ArrayList<>(); + for (BlockReverseDiff diff : diffs) { + BlockSnapshotMeta meta = diff.getMeta(); + PathStateFlushTarget.BlockBinding binding = mock(PathStateFlushTarget.BlockBinding.class); + when(binding.getMeta()).thenReturn(meta); + when(binding.getParentStateRoot()).thenReturn(hash(30 + (int) meta.getBlockNumber())); + when(binding.getStateRoot()).thenReturn(hash(31 + (int) meta.getBlockNumber())); + when(binding.getTransitionPayloadDigest()).thenReturn(hash(90)); + bindings.add(binding); + } + PathStateFlushTarget path = mock(PathStateFlushTarget.class); + byte[] parentStateRoot = bindings.get(0).getParentStateRoot(); + byte[] stateRoot = bindings.get(bindings.size() - 1).getStateRoot(); + when(path.getBlocks()).thenReturn(bindings); + when(path.getParentStateRoot()).thenReturn(parentStateRoot); + when(path.getStateRoot()).thenReturn(stateRoot); + when(path.getStores()).thenReturn(Collections.emptyList()); + when(path.getSuperNodeMutations()).thenReturn(Collections.emptyList()); + return CommonCheckpointPayload.createV2(format, path, descriptor, Collections.emptyList()); + } + + private static BlockReverseDiff diff(int blockNumber, int valueLength) { + List groups; + if (valueLength == 0) { + groups = Collections.emptyList(); + } else { + byte[] value = new byte[valueLength]; + Arrays.fill(value, (byte) blockNumber); + groups = Collections.singletonList(new DbGroup("code", Collections.singletonList( + new Entry(new byte[]{1}, OldValue.present(value))))); + } + return new BlockReverseDiff(BlockSnapshotMeta.forBlock(blockNumber, hash(blockNumber), + hash(blockNumber - 1), blockNumber * 3_000L), groups); + } + + private static byte[] hash(int marker) { + byte[] hash = new byte[32]; + hash[31] = (byte) marker; + return hash; + } + + private static final class FakeMaterializer implements CommonCheckpointMaterializer { + private final Authority authority; + private Status status = Status.NEEDS_MATERIALIZATION; + + private FakeMaterializer(Authority authority) { + this.authority = authority; + } + + @Override + public Authority authority() { + return authority; + } + + @Override + public Status inspect(CommonCheckpointTarget target) { + return status; + } + + @Override + public void materialize(CommonCheckpointPayload payload, CommonCheckpointTarget target) { + status = Status.MATERIALIZED; + } + + @Override + public void publish(CommonCheckpointTarget target) { + status = Status.PUBLISHED; + } + } +} diff --git a/framework/src/test/java/org/tron/core/db2/archive/StateArchiveBlockFrameCodecV3Test.java b/framework/src/test/java/org/tron/core/db2/archive/StateArchiveBlockFrameCodecV3Test.java new file mode 100644 index 00000000000..fb15ed1405d --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/archive/StateArchiveBlockFrameCodecV3Test.java @@ -0,0 +1,216 @@ +package org.tron.core.db2.archive; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import org.junit.Test; +import org.tron.core.db2.archive.BlockReverseDiff.DbGroup; +import org.tron.core.db2.archive.BlockReverseDiff.Entry; + +public class StateArchiveBlockFrameCodecV3Test { + + private static final String FORMAT_DESCRIPTOR_HEX = + "464d543300030000000000600150012001400030020000800020010000a000300020" + + "0050001000010000000100030001000100010001000040000000000077359400" + + "00000000040000000000000007ffffff0000000000000000000000000000"; + + private final StateArchiveBlockFrameCodecV3 codec = + new StateArchiveBlockFrameCodecV3(); + + @Test + public void freezesFormatDescriptorAndEmptyBlockGoldenFrame() { + assertEquals(96, StateArchiveFileFormatV3.formatDescriptor().length); + assertEquals(FORMAT_DESCRIPTOR_HEX, + hex(StateArchiveFileFormatV3.formatDescriptor())); + assertEquals("380d370ba9adf724ae313c05ede5625749c82ab1240ef5f8b510e3aa5c4f649a", + hex(StateArchiveFileFormatV3.formatDigest())); + assertEquals("19c7827c66ad7811d60a964d3a24b85a0b2ed41119bc5eedf444835f738159c8", + hex(StateArchiveFileFormatV3.storeDescriptorDigest())); + + StateArchiveBlockFrameCodecV3.EncodedBlock encoded = codec.encode( + diff(12, Collections.emptyList()), hash(10), + StateArchiveFileFormatV3.COMPRESSION_NONE); + assertEquals(416, encoded.getFrame().length); + assertEquals(32, encoded.getCanonicalPayload().length); + assertEquals("3123bbd47cd083892c1c5cd9e38eb016081d47dc238ddc5757ccb7e4112f63fb", + hex(StateArchiveFileFormatV3.sha256(encoded.getFrame()))); + + StateArchiveBlockFrameCodecV3.DecodedBlock decoded = codec.decode(encoded.getFrame()); + assertEquals(diff(12, Collections.emptyList()).getMeta(), decoded.getDiff().getMeta()); + assertTrue(decoded.getDiff().getGroups().isEmpty()); + } + + @Test + public void canonicalizesStoreIdsAndUnsignedKeysAndPreservesValueStates() { + List storeOneEntries = Arrays.asList( + new Entry(bytes(0xff), OldValue.present(bytes(4, 5))), + new Entry(bytes(0x00, 0x01), OldValue.absent()), + new Entry(bytes(0x80), OldValue.present(new byte[0])), + new Entry(bytes(0x00), OldValue.present(bytes(7)))); + BlockReverseDiff first = diff(12, Arrays.asList( + new DbGroup("IncrementalMerkleTree", Collections.singletonList( + new Entry(bytes(0x7f), OldValue.absent()))), + new DbGroup("abi", storeOneEntries))); + List reversedEntries = new ArrayList<>(storeOneEntries); + Collections.reverse(reversedEntries); + BlockReverseDiff reordered = diff(12, Arrays.asList( + new DbGroup("abi", reversedEntries), + new DbGroup("IncrementalMerkleTree", Collections.singletonList( + new Entry(bytes(0x7f), OldValue.absent()))))); + + StateArchiveBlockFrameCodecV3.EncodedBlock firstFrame = codec.encode(first, hash(10), + StateArchiveFileFormatV3.COMPRESSION_NONE); + StateArchiveBlockFrameCodecV3.EncodedBlock reorderedFrame = codec.encode(reordered, + hash(10), StateArchiveFileFormatV3.COMPRESSION_NONE); + assertArrayEquals(firstFrame.getFrame(), reorderedFrame.getFrame()); + + ByteBuffer payload = ByteBuffer.wrap(firstFrame.getCanonicalPayload()); + payload.position(StateArchiveFileFormatV3.PAYLOAD_HEADER_LENGTH); + assertEquals(1, Short.toUnsignedInt(payload.getShort())); + StateArchiveBlockFrameCodecV3.DecodedBlock decoded = codec.decode(firstFrame.getFrame()); + assertEquivalent(first, decoded.getDiff()); + List decodedStoreOne = group(decoded.getDiff(), "abi").getEntries(); + assertArrayEquals(bytes(0x00), decodedStoreOne.get(0).getKey()); + assertArrayEquals(bytes(0x00, 0x01), decodedStoreOne.get(1).getKey()); + assertArrayEquals(bytes(0x80), decodedStoreOne.get(2).getKey()); + assertArrayEquals(bytes(0xff), decodedStoreOne.get(3).getKey()); + assertTrue(decodedStoreOne.get(0).getOldValue().isPresent()); + assertFalse(decodedStoreOne.get(1).getOldValue().isPresent()); + assertEquals(0, decodedStoreOne.get(2).getOldValue().getValue().length); + } + + @Test + public void compressionChangesPhysicalFrameButNotLogicalIdentity() { + BlockReverseDiff diff = diff(42, Collections.singletonList( + new DbGroup("account", Collections.singletonList( + new Entry(bytes(1, 2, 3), OldValue.present(new byte[4096])))))); + StateArchiveBlockFrameCodecV3.EncodedBlock none = codec.encode(diff, hash(41), + StateArchiveFileFormatV3.COMPRESSION_NONE); + StateArchiveBlockFrameCodecV3.EncodedBlock compressed = codec.encode(diff, hash(41), + StateArchiveFileFormatV3.COMPRESSION_RAW_DEFLATE_LEVEL_1); + + assertFalse(Arrays.equals(none.getFrame(), compressed.getFrame())); + assertTrue(compressed.getFrame().length < none.getFrame().length); + assertArrayEquals(none.getCanonicalPayload(), compressed.getCanonicalPayload()); + assertArrayEquals(none.getPayloadDigest(), compressed.getPayloadDigest()); + assertArrayEquals(none.getBlockHistoryDigest(), compressed.getBlockHistoryDigest()); + assertArrayEquals(none.getResultHistoryDigest(), compressed.getResultHistoryDigest()); + assertFalse(Arrays.equals(none.getEncodedFrameDigest(), + compressed.getEncodedFrameDigest())); + assertEquivalent(diff, codec.decode(compressed.getFrame()).getDiff()); + } + + @Test + public void matchesLegacyHotBodyAtStoreKeyAndOldValueBoundary() { + BlockReverseDiff expected = diff(77, Arrays.asList( + new DbGroup("votes", Arrays.asList( + new Entry(bytes(0x80), OldValue.present(bytes(9))), + new Entry(bytes(0xff), OldValue.absent()))), + new DbGroup("account", Arrays.asList( + new Entry(bytes(0), OldValue.present(new byte[0])), + new Entry(bytes(0, 1), OldValue.present(bytes(3, 4))))))); + BlockReverseDiff legacy = new BlockHistoryCodec().decode( + new BlockHistoryCodec().encode(expected)); + BlockReverseDiff v3 = codec.decode(codec.encode(expected, hash(76), + StateArchiveFileFormatV3.COMPRESSION_NONE).getFrame()).getDiff(); + + assertEquivalent(legacy, v3); + } + + @Test + public void rejectsDuplicateStoreKeyCorruptionAndIdentityDrift() { + assertThrows(ArchivePersistenceException.class, + () -> StateArchiveFileFormatV3.requireExactCapture( + Collections.singletonList("account"))); + + BlockReverseDiff duplicateStore = diff(12, Arrays.asList( + new DbGroup("account", Collections.singletonList( + new Entry(bytes(1), OldValue.absent()))), + new DbGroup("account", Collections.singletonList( + new Entry(bytes(2), OldValue.absent()))))); + assertThrows(IllegalArgumentException.class, () -> codec.encode(duplicateStore, hash(11), + StateArchiveFileFormatV3.COMPRESSION_NONE)); + + BlockReverseDiff duplicateKey = diff(12, Collections.singletonList( + new DbGroup("account", Arrays.asList( + new Entry(bytes(1), OldValue.absent()), + new Entry(bytes(1), OldValue.present(bytes(2))))))); + assertThrows(IllegalArgumentException.class, () -> codec.encode(duplicateKey, hash(11), + StateArchiveFileFormatV3.COMPRESSION_NONE)); + + BlockReverseDiff epochDrift = new BlockReverseDiff(new BlockSnapshotMeta( + 11, 12, hash(12), hash(11), 36_000L), Collections.emptyList()); + assertThrows(IllegalArgumentException.class, () -> codec.encode(epochDrift, hash(10), + StateArchiveFileFormatV3.COMPRESSION_NONE)); + + byte[] frame = codec.encode(diff(12, Collections.emptyList()), hash(10), + StateArchiveFileFormatV3.COMPRESSION_NONE).getFrame(); + byte[] corrupted = Arrays.copyOf(frame, frame.length); + corrupted[StateArchiveFileFormatV3.BLOCK_HEADER_LENGTH] ^= 1; + assertThrows(IllegalArgumentException.class, () -> codec.decode(corrupted)); + assertThrows(IllegalArgumentException.class, + () -> codec.decode(Arrays.copyOf(frame, frame.length - 1))); + } + + private static BlockReverseDiff diff(long blockNumber, List groups) { + return new BlockReverseDiff(BlockSnapshotMeta.forBlock( + blockNumber, hash((int) blockNumber), hash((int) blockNumber - 1), + blockNumber * 3_000L), groups); + } + + private static DbGroup group(BlockReverseDiff diff, String dbName) { + for (DbGroup group : diff.getGroups()) { + if (group.getDbName().equals(dbName)) { + return group; + } + } + throw new AssertionError("Missing group " + dbName); + } + + private static void assertEquivalent(BlockReverseDiff expected, BlockReverseDiff actual) { + assertEquals(expected.getMeta(), actual.getMeta()); + assertEquals(expected.getGroups().size(), actual.getGroups().size()); + for (int groupIndex = 0; groupIndex < expected.getGroups().size(); groupIndex++) { + DbGroup expectedGroup = expected.getGroups().get(groupIndex); + DbGroup actualGroup = actual.getGroups().get(groupIndex); + assertEquals(expectedGroup.getDbName(), actualGroup.getDbName()); + assertEquals(expectedGroup.getEntries().size(), actualGroup.getEntries().size()); + for (int entryIndex = 0; entryIndex < expectedGroup.getEntries().size(); entryIndex++) { + Entry expectedEntry = expectedGroup.getEntries().get(entryIndex); + Entry actualEntry = actualGroup.getEntries().get(entryIndex); + assertArrayEquals(expectedEntry.getKey(), actualEntry.getKey()); + assertEquals(expectedEntry.getOldValue(), actualEntry.getOldValue()); + } + } + } + + private static byte[] hash(int suffix) { + byte[] hash = new byte[32]; + hash[31] = (byte) suffix; + return hash; + } + + private static byte[] bytes(int... values) { + byte[] bytes = new byte[values.length]; + for (int index = 0; index < values.length; index++) { + bytes[index] = (byte) values[index]; + } + return bytes; + } + + private static String hex(byte[] bytes) { + StringBuilder result = new StringBuilder(bytes.length * 2); + for (byte value : bytes) { + result.append(String.format("%02x", value & 0xff)); + } + return result.toString(); + } +} diff --git a/framework/src/test/java/org/tron/core/db2/archive/StateArchiveCatalogAndServingCornerCaseTest.java b/framework/src/test/java/org/tron/core/db2/archive/StateArchiveCatalogAndServingCornerCaseTest.java new file mode 100644 index 00000000000..a6385236d0f --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/archive/StateArchiveCatalogAndServingCornerCaseTest.java @@ -0,0 +1,205 @@ +package org.tron.core.db2.archive; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.OptionalLong; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tron.core.db2.archive.BlockReverseDiff.DbGroup; +import org.tron.core.db2.archive.BlockReverseDiff.Entry; +import org.tron.core.db2.archive.StateArchiveServingIndexBuildCoordinatorV3.LiveServingIndexer; +import org.tron.core.db2.core.CommonCheckpointTarget; +import org.tron.core.db2.stateroot.PathStateStoreManifest.Engine; + +public class StateArchiveCatalogAndServingCornerCaseTest { + + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void bulkBatchCutsProduceTheSameExactIndexIdentity() throws Exception { + List all = diffs(1, 6, 0); + Path oneBatch = temporaryFolder.newFolder("one-batch").toPath(); + Path splitBatch = temporaryFolder.newFolder("split-batch").toPath(); + try (StateArchiveServingIndexBuildCoordinatorV3 one = + new StateArchiveServingIndexBuildCoordinatorV3(oneBatch, Engine.LEVELDB, 6); + StateArchiveServingIndexBuildCoordinatorV3 split = + new StateArchiveServingIndexBuildCoordinatorV3(splitBatch, Engine.LEVELDB, 3)) { + one.offerCommittedRange(all, target(all, 1)); + split.offerCommittedRange(all.subList(0, 2), target(all.subList(0, 2), 2)); + split.offerCommittedRange(all.subList(2, 3), target(all.subList(2, 3), 3)); + split.offerCommittedRange(all.subList(3, 6), target(all.subList(3, 6), 4)); + assertEquals(6, one.status().getIndexedThrough()); + assertEquals(6, split.status().getIndexedThrough()); + } + try (PersistentServingKeyIndexCatalog one = PersistentServingKeyIndexCatalog.open( + oneBatch.resolve(StateArchiveServingIndexBuildCoordinatorV3.DIRECTORY), + Engine.LEVELDB, stage -> { }); + PersistentServingKeyIndexCatalog split = PersistentServingKeyIndexCatalog.open( + splitBatch.resolve(StateArchiveServingIndexBuildCoordinatorV3.DIRECTORY), + Engine.LEVELDB, stage -> { }); + PersistentServingKeyIndexGeneration oneGeneration = one.pin(); + PersistentServingKeyIndexGeneration splitGeneration = split.pin()) { + assertArrayEquals(oneGeneration.getAuthoritativePrefixDigest(), + splitGeneration.getAuthoritativePrefixDigest()); + assertEquals(oneGeneration.getKeyChangeCount(), splitGeneration.getKeyChangeCount()); + OptionalLong oneChange = oneGeneration.firstChangeAfter("code", new byte[]{3}, 0, 6); + OptionalLong splitChange = splitGeneration.firstChangeAfter("code", new byte[]{3}, 0, 6); + assertEquals(oneChange, splitChange); + } + } + + @Test + public void liveRangePublishesEveryBlockWithTheBulkLogicalIdentity() throws Exception { + List all = diffs(1, 17, 0); + Path bulkRoot = temporaryFolder.newFolder("bulk-reference").toPath(); + Path liveRoot = temporaryFolder.newFolder("live-range").toPath(); + try (StateArchiveServingIndexBuildCoordinatorV3 bulk = + new StateArchiveServingIndexBuildCoordinatorV3(bulkRoot, Engine.LEVELDB, 17); + StateArchiveServingIndexBuildCoordinatorV3 liveOwner = + new StateArchiveServingIndexBuildCoordinatorV3(liveRoot, Engine.LEVELDB, 1)) { + bulk.offerCommittedRange(all, target(all, 1)); + List first = all.subList(0, 1); + liveOwner.offerCommittedRange(first, target(first, 2)); + LiveServingIndexer live = liveOwner.completeInitialSync(target(first, 2)); + List suffix = all.subList(1, all.size()); + live.indexNow(suffix, target(suffix, 3)); + assertEquals(17, liveOwner.status().getIndexedThrough()); + assertEquals(17, liveOwner.status().getBuildSequence()); + assertEquals(0, liveOwner.status().getPendingBlocks()); + } + try (PersistentServingKeyIndexCatalog bulk = PersistentServingKeyIndexCatalog.open( + bulkRoot.resolve(StateArchiveServingIndexBuildCoordinatorV3.DIRECTORY), + Engine.LEVELDB, stage -> { }); + PersistentServingKeyIndexCatalog live = PersistentServingKeyIndexCatalog.open( + liveRoot.resolve(StateArchiveServingIndexBuildCoordinatorV3.DIRECTORY), + Engine.LEVELDB, stage -> { }); + PersistentServingKeyIndexGeneration expected = bulk.pin(); + PersistentServingKeyIndexGeneration actual = live.pin()) { + assertArrayEquals(expected.getAuthoritativePrefixDigest(), + actual.getAuthoritativePrefixDigest()); + assertEquals(expected.getKeyChangeCount(), actual.getKeyChangeCount()); + for (int key = 0; key < 8; key++) { + assertEquals(expected.firstChangeAfter("code", new byte[]{(byte) key}, 0, 17), + actual.firstChangeAfter("code", new byte[]{(byte) key}, 0, 17)); + } + } + } + + @Test + public void gapInvalidatesLiveHandleWithoutAdvancingI() throws Exception { + Path root = temporaryFolder.newFolder("live-gap").toPath(); + List first = diffs(1, 1, 0); + try (StateArchiveServingIndexBuildCoordinatorV3 coordinator = + new StateArchiveServingIndexBuildCoordinatorV3(root, Engine.LEVELDB, 1)) { + CommonCheckpointTarget firstTarget = target(first, 1); + coordinator.offerCommittedRange(first, firstTarget); + LiveServingIndexer live = coordinator.completeInitialSync(firstTarget); + List gap = diffs(3, 1, 2); + assertThrows(IllegalArgumentException.class, + () -> live.indexNow(gap, target(gap, 3))); + assertEquals(1, coordinator.status().getIndexedThrough()); + assertEquals(StateArchiveServingIndexBuildCoordinatorV3.Mode.CATCH_UP_REQUIRED, + coordinator.status().getMode()); + List successor = diffs(2, 1, 1); + assertThrows(IllegalStateException.class, + () -> live.indexNow(successor, target(successor, 2))); + } + } + + @Test + public void catalogIgnoresOrphanButRejectsCorruptCurrentAndMissingManifest() throws Exception { + Path root = temporaryFolder.newFolder("catalog-corners").toPath(); + byte[] baseline = hash(0); + StateArchiveFiveLaneBlockCodecV3 codec = new StateArchiveFiveLaneBlockCodecV3(); + BlockReverseDiff first = diff(1, 0, 1_400); + StateArchiveFiveLaneBlockCodecV3.EncodedBundle firstBundle = codec.encode(first, baseline, + StateArchiveFileFormatV3.COMPRESSION_NONE); + StateArchiveFiveLaneBlockCodecV3.EncodedBundle secondBundle = codec.encode( + diff(2, 1, 0), firstBundle.getResultHistoryDigest(), + StateArchiveFileFormatV3.COMPRESSION_NONE); + Path current = root.resolve(StateArchiveHistoryCatalogV3.DIRECTORY) + .resolve(StateArchiveHistoryCatalogV3.CURRENT); + byte[] preRotationCurrent; + try (StateArchiveFiveLaneSegmentWriterV3 writer = + new StateArchiveFiveLaneSegmentWriterV3(root, baseline, + StateArchiveFileFormatV3.COMPRESSION_NONE, 1_500)) { + writer.append(firstBundle); + preRotationCurrent = Files.readAllBytes(current); + writer.append(secondBundle); + } + Path generations = root.resolve(StateArchiveHistoryCatalogV3.DIRECTORY) + .resolve("generations"); + Files.write(generations.resolve("catalog-99999999999999999999.bin"), new byte[]{1}); + try (StateArchiveFiveLaneSegmentWriterV3 reopened = + new StateArchiveFiveLaneSegmentWriterV3(root, baseline, + StateArchiveFileFormatV3.COMPRESSION_NONE, 1_500)) { + assertEquals(2, reopened.getAppendHead().getBlockNumber()); + } + Path manifest; + try (java.util.stream.Stream paths = Files.walk(root.resolve("segments"))) { + manifest = paths.filter(path -> path.getFileName().toString().endsWith(".manifest")) + .findFirst().orElseThrow(AssertionError::new); + } + Files.delete(manifest); + Files.write(current, preRotationCurrent); + try (StateArchiveFiveLaneSegmentWriterV3 recoveredPublication = + new StateArchiveFiveLaneSegmentWriterV3(root, baseline, + StateArchiveFileFormatV3.COMPRESSION_NONE, 1_500)) { + assertEquals(2, recoveredPublication.getAppendHead().getBlockNumber()); + } + + byte[] validCurrent = Files.readAllBytes(current); + byte[] corruptCurrent = validCurrent.clone(); + corruptCurrent[20] ^= 1; + Files.write(current, corruptCurrent); + assertThrows(IOException.class, () -> new StateArchiveFiveLaneSegmentWriterV3(root, + baseline, StateArchiveFileFormatV3.COMPRESSION_NONE, 1_500)); + Files.write(current, validCurrent); + + Files.delete(manifest); + assertThrows(IllegalArgumentException.class, + () -> new StateArchiveFiveLaneSegmentWriterV3(root, baseline, + StateArchiveFileFormatV3.COMPRESSION_NONE, 1_500)); + } + + private static List diffs(int first, int count, int parent) { + List result = new ArrayList<>(); + for (int block = first; block < first + count; block++) { + result.add(diff(block, block == first ? parent : block - 1, 8)); + } + return result; + } + + private static BlockReverseDiff diff(int block, int parent, int valueLength) { + byte[] value = new byte[valueLength]; + Arrays.fill(value, (byte) block); + List groups = valueLength == 0 ? Collections.emptyList() + : Collections.singletonList(new DbGroup("code", Collections.singletonList( + new Entry(new byte[]{(byte) (block & 7)}, OldValue.present(value))))); + return new BlockReverseDiff(new BlockSnapshotMeta(block, block, hash(block), + hash(parent), block * 3_000L), groups); + } + + private static CommonCheckpointTarget target(List diffs, int salt) { + return CommonCheckpointTarget.restore(hash(70), hash(80 + salt), + diffs.get(0).getMeta(), diffs.get(diffs.size() - 1).getMeta(), hash(90), hash(91)); + } + + private static byte[] hash(int value) { + byte[] result = new byte[32]; + result[27] = (byte) value; + result[31] = (byte) value; + return result; + } +} diff --git a/framework/src/test/java/org/tron/core/db2/archive/StateArchiveCheckpointMaterializerTest.java b/framework/src/test/java/org/tron/core/db2/archive/StateArchiveCheckpointMaterializerTest.java new file mode 100644 index 00000000000..5a7bed45882 --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/archive/StateArchiveCheckpointMaterializerTest.java @@ -0,0 +1,321 @@ +package org.tron.core.db2.archive; + +import static org.fusesource.leveldbjni.JniDBFactory.factory; +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import org.iq80.leveldb.DB; +import org.iq80.leveldb.Options; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tron.core.db2.archive.BlockReverseDiff.DbGroup; +import org.tron.core.db2.archive.BlockReverseDiff.Entry; +import org.tron.core.db2.core.CommonCheckpointMaterializer.Status; +import org.tron.core.db2.core.CommonCheckpointPayload; +import org.tron.core.db2.core.CommonCheckpointTarget; +import org.tron.core.db2.stateroot.PathStateFlushTarget; +import org.tron.core.db2.stateroot.PathStateStoreManifest.Engine; + +public class StateArchiveCheckpointMaterializerTest { + + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void preservesEveryBlockBoundaryBeforePublishingReadableAcrossReopen() + throws Exception { + Path root = temporaryFolder.newFolder("normal").toPath(); + byte[] format = hash(90); + CommonCheckpointPayload payload = payload(format, 1, 3, hash(0), hash(10), hash(13)); + CommonCheckpointTarget target = CommonCheckpointTarget.from(payload); + StateArchiveCheckpointMaterializer materializer = + new StateArchiveCheckpointMaterializer(root, format, null, Engine.LEVELDB); + + assertEquals(Status.NEEDS_MATERIALIZATION, materializer.inspect(target)); + materializer.materialize(payload, target); + assertEquals(Status.MATERIALIZED, materializer.inspect(target)); + assertFalse(Files.exists(root.resolve(StateArchiveCheckpointMaterializer.READABLE_FILE))); + assertEquals(3, blockFileCount(root)); + assertThrows(IOException.class, + () -> StateArchiveCheckpointReadAdapter.open(root, target, Engine.LEVELDB)); + for (int index = 0; index < payload.getBlocks().size(); index++) { + BlockReverseDiff actual = materializer.loadBlock(target, index); + assertEquals(payload.getBlocks().get(index).getMeta(), actual.getMeta()); + assertEquals("code", actual.getGroups().get(0).getDbName()); + } + + materializer.materialize(payload, target); + assertEquals(3, blockFileCount(root)); + materializer.publish(target); + assertEquals(Status.PUBLISHED, materializer.inspect(target)); + try (StateArchiveCheckpointReadAdapter reader = + StateArchiveCheckpointReadAdapter.open(root, target, Engine.LEVELDB); + StateArchiveCheckpointReadAdapter concurrent = + StateArchiveCheckpointReadAdapter.open(root, target, Engine.LEVELDB)) { + assertEquals(0, reader.getIndexedFrom()); + assertEquals(3, reader.getIndexedThrough()); + assertArrayEquals(new byte[]{0}, reader.findOldValueAfter("code", new byte[]{1}, 0) + .get().getValue()); + assertFalse(reader.findOldValueAfter("code", new byte[]{2}, 2).isPresent()); + assertArrayEquals(new byte[]{0}, concurrent.findOldValueAfter("code", new byte[]{1}, 0) + .get().getValue()); + } + assertTrue(Files.isRegularFile(root.resolve(StateArchiveCheckpointServingIndex.DIRECTORY) + .resolve(StateArchiveIndexEngineManifest.FILE))); + StateArchiveCheckpointMaterializer wrongEngine = new StateArchiveCheckpointMaterializer( + root, format, null, Engine.ROCKSDB); + assertThrows(IOException.class, () -> wrongEngine.inspect(target)); + + StateArchiveCheckpointMaterializer reopened = + new StateArchiveCheckpointMaterializer(root, format, null, Engine.LEVELDB); + assertEquals(Status.PUBLISHED, reopened.inspect(target)); + reopened.publish(target); + + CommonCheckpointPayload child = payload(format, 4, 2, hash(3), hash(13), hash(15)); + CommonCheckpointTarget childTarget = CommonCheckpointTarget.from(child); + reopened.materialize(child, childTarget); + reopened.publish(childTarget); + assertEquals(Status.PUBLISHED, reopened.inspect(childTarget)); + try (StateArchiveCheckpointReadAdapter reader = + StateArchiveCheckpointReadAdapter.open(root, childTarget, Engine.LEVELDB)) { + assertEquals(0, reader.getIndexedFrom()); + assertEquals(5, reader.getIndexedThrough()); + assertArrayEquals(hash(5), reader.getHeadHash()); + assertArrayEquals(new byte[]{1}, reader.findOldValueAfter("code", new byte[]{2}, 0) + .get().getValue()); + assertArrayEquals(new byte[]{3}, reader.findOldValueAfter("code", new byte[]{4}, 3) + .get().getValue()); + } + CommonCheckpointTarget restored = + StateArchiveCheckpointMaterializer.loadPublishedTarget(root, format, Engine.LEVELDB); + assertEquals(childTarget, restored); + try (StateArchiveCheckpointReadAdapter reader = + StateArchiveCheckpointReadAdapter.open(root, format, Engine.LEVELDB)) { + assertEquals(5, reader.getIndexedThrough()); + assertArrayEquals(new byte[]{1}, reader.findOldValueAfter("code", new byte[]{2}, 0) + .get().getValue()); + } + } + + @Test + public void reusesServingWriterAcrossTargetsAndClosesItWithMaterializer() throws Exception { + for (Engine engine : Engine.values()) { + Path root = temporaryFolder.newFolder("writer-lifecycle-" + engine).toPath(); + byte[] format = hash(89); + StateArchiveCheckpointMaterializer materializer = + new StateArchiveCheckpointMaterializer(root, format, null, engine); + + CommonCheckpointPayload first = payload(format, 1, 2, hash(0), hash(10), hash(12)); + CommonCheckpointTarget firstTarget = CommonCheckpointTarget.from(first); + materializer.beginCheckpoint(firstTarget); + materializer.materialize(first, firstTarget); + materializer.publish(firstTarget); + materializer.endCheckpoint(firstTarget); + assertEquals(1, StateArchiveCheckpointServingIndex.openReferenceCount(root, engine)); + + CommonCheckpointPayload second = payload(format, 3, 2, hash(2), hash(12), hash(14)); + CommonCheckpointTarget secondTarget = CommonCheckpointTarget.from(second); + materializer.beginCheckpoint(secondTarget); + materializer.materialize(second, secondTarget); + materializer.publish(secondTarget); + materializer.endCheckpoint(secondTarget); + assertEquals(1, StateArchiveCheckpointServingIndex.openReferenceCount(root, engine)); + + materializer.close(); + materializer.close(); + assertEquals(0, StateArchiveCheckpointServingIndex.openReferenceCount(root, engine)); + assertThrows(IOException.class, () -> materializer.inspect(secondTarget)); + } + } + + @Test + public void releasesRetainedWriterAfterCheckpointFailure() throws Exception { + for (Engine engine : Engine.values()) { + Path root = temporaryFolder.newFolder("writer-failure-" + engine).toPath(); + byte[] format = hash(88); + CommonCheckpointPayload payload = payload(format, 1, 2, hash(0), hash(10), hash(12)); + CommonCheckpointTarget target = CommonCheckpointTarget.from(payload); + StateArchiveCheckpointMaterializer materializer = new StateArchiveCheckpointMaterializer( + root, format, engine, + failAt(StateArchiveCheckpointMaterializer.Stage.AFTER_SERVING_INDEX_BATCH)); + + materializer.beginCheckpoint(target); + assertThrows(IOException.class, () -> materializer.materialize(payload, target)); + assertEquals(1, StateArchiveCheckpointServingIndex.openReferenceCount(root, engine)); + materializer.endCheckpoint(target); + materializer.close(); + assertEquals(0, StateArchiveCheckpointServingIndex.openReferenceCount(root, engine)); + } + } + + @Test + public void resumesEveryDurabilityBoundaryUsingOnlyCheckpointRedo() throws Exception { + for (StateArchiveCheckpointMaterializer.Stage stage + : StateArchiveCheckpointMaterializer.Stage.values()) { + Path root = temporaryFolder.newFolder("fault-" + stage).toPath(); + byte[] format = hash(91); + CommonCheckpointPayload payload = payload(format, 8, 3, hash(7), hash(20), hash(23)); + CommonCheckpointTarget target = CommonCheckpointTarget.from(payload); + StateArchiveCheckpointMaterializer failed = new StateArchiveCheckpointMaterializer(root, + format, failAt(stage)); + if (stage == StateArchiveCheckpointMaterializer.Stage.AFTER_READABLE) { + failed.materialize(payload, target); + assertThrows(IOException.class, () -> failed.publish(target)); + } else { + assertThrows(IOException.class, () -> failed.materialize(payload, target)); + } + + StateArchiveCheckpointMaterializer recovered = + new StateArchiveCheckpointMaterializer(root, format); + if (recovered.inspect(target) == Status.NEEDS_MATERIALIZATION) { + recovered.materialize(payload, target); + } + recovered.publish(target); + assertEquals(Status.PUBLISHED, recovered.inspect(target)); + assertEquals(3, blockFileCount(root)); + } + } + + @Test + public void rejectsForeignFormatCorruptImmutableBlockAndNonParentReadable() + throws Exception { + Path root = temporaryFolder.newFolder("reject").toPath(); + byte[] format = hash(92); + CommonCheckpointPayload payload = payload(format, 1, 2, hash(0), hash(30), hash(32)); + CommonCheckpointTarget target = CommonCheckpointTarget.from(payload); + StateArchiveCheckpointMaterializer materializer = + new StateArchiveCheckpointMaterializer(root, format, null, Engine.LEVELDB); + + CommonCheckpointPayload foreign = payload(hash(99), 1, 1, hash(0), hash(30), hash(31)); + assertThrows(IOException.class, () -> materializer.materialize(foreign, + CommonCheckpointTarget.from(foreign))); + + StateArchiveCheckpointMaterializer interrupted = new StateArchiveCheckpointMaterializer(root, + format, Engine.LEVELDB, + failAt(StateArchiveCheckpointMaterializer.Stage.AFTER_BLOCK_FILE)); + assertThrows(IOException.class, () -> interrupted.materialize(payload, target)); + Path block = firstBlockFile(root); + byte[] corrupt = Files.readAllBytes(block); + corrupt[corrupt.length - 1] ^= 1; + Files.write(block, corrupt); + assertThrows(IOException.class, () -> materializer.materialize(payload, target)); + + Path cleanRoot = temporaryFolder.newFolder("non-parent").toPath(); + StateArchiveCheckpointMaterializer clean = + new StateArchiveCheckpointMaterializer(cleanRoot, format, null, Engine.LEVELDB); + clean.materialize(payload, target); + clean.publish(target); + CommonCheckpointPayload nonChild = payload(format, 5, 1, hash(9), hash(40), hash(41)); + assertThrows(IOException.class, + () -> clean.inspect(CommonCheckpointTarget.from(nonChild))); + assertTrue(Files.isRegularFile(cleanRoot.resolve( + StateArchiveCheckpointMaterializer.READABLE_FILE))); + Options options = new Options().createIfMissing(false); + try (DB database = factory.open(cleanRoot.resolve( + StateArchiveCheckpointServingIndex.DIRECTORY).resolve("keys").toFile(), options)) { + database.put(new byte[]{0}, new byte[]{1}); + } + assertThrows(IOException.class, () -> clean.inspect(target)); + } + + private static StateArchiveCheckpointMaterializer.FaultHook failAt( + StateArchiveCheckpointMaterializer.Stage failedStage) { + return (stage, blockIndex) -> { + if (stage == failedStage) { + throw new IOException("injected " + stage + " at " + blockIndex); + } + }; + } + + private static CommonCheckpointPayload payload(byte[] format, long firstBlock, int count, + byte[] parentHash, byte[] parentRoot, byte[] stateRoot) { + List bindings = new ArrayList<>(); + List archives = new ArrayList<>(); + byte[] priorHash = parentHash; + byte[] priorRoot = parentRoot; + for (int index = 0; index < count; index++) { + long number = firstBlock + index; + byte[] blockHash = hash((int) number); + byte[] nextRoot = index == count - 1 ? stateRoot : hash(30 + (int) number); + BlockSnapshotMeta meta = BlockSnapshotMeta.forBlock(number, blockHash, priorHash, + number * 3_000L); + PathStateFlushTarget.BlockBinding binding = mock(PathStateFlushTarget.BlockBinding.class); + when(binding.getMeta()).thenReturn(meta); + when(binding.getParentStateRoot()).thenReturn(priorRoot); + when(binding.getStateRoot()).thenReturn(nextRoot); + when(binding.getTransitionPayloadDigest()).thenReturn(hash(70 + (int) number)); + bindings.add(binding); + DbGroup group = new DbGroup("code", Collections.singletonList( + new Entry(new byte[]{(byte) number}, + OldValue.present(new byte[]{(byte) (number - 1)})))); + archives.add(new BlockReverseDiff(meta, Collections.singletonList(group))); + priorHash = blockHash; + priorRoot = nextRoot; + } + PathStateFlushTarget pathState = mock(PathStateFlushTarget.class); + when(pathState.getBlocks()).thenReturn(bindings); + when(pathState.getParentStateRoot()).thenReturn(parentRoot); + when(pathState.getStateRoot()).thenReturn(stateRoot); + when(pathState.getStores()).thenReturn(Collections.emptyList()); + when(pathState.getSuperNodeMutations()).thenReturn(Collections.emptyList()); + return CommonCheckpointPayload.create(format, pathState, archives, + Collections.emptyList()); + } + + private static int blockFileCount(Path root) throws IOException { + Path targets = root.resolve(StateArchiveCheckpointMaterializer.TARGET_DIRECTORY); + try (DirectoryStream targetPaths = Files.newDirectoryStream(targets)) { + for (Path target : targetPaths) { + Path blocks = target.resolve(StateArchiveCheckpointMaterializer.BLOCK_DIRECTORY); + if (Files.isDirectory(blocks)) { + int count = 0; + try (DirectoryStream paths = Files.newDirectoryStream(blocks, "*.diff")) { + for (Path ignored : paths) { + count++; + } + } + return count; + } + } + } + return 0; + } + + private static Path firstBlockFile(Path root) throws IOException { + Path targets = root.resolve(StateArchiveCheckpointMaterializer.TARGET_DIRECTORY); + try (DirectoryStream targetPaths = Files.newDirectoryStream(targets)) { + for (Path target : targetPaths) { + Path blocks = target.resolve(StateArchiveCheckpointMaterializer.BLOCK_DIRECTORY); + if (Files.isDirectory(blocks)) { + try (DirectoryStream paths = Files.newDirectoryStream(blocks, "*.diff")) { + for (Path path : paths) { + return path; + } + } + } + } + } + throw new IOException("checkpoint block not found"); + } + + private static byte[] hash(int seed) { + byte[] hash = new byte[32]; + for (int index = 0; index < hash.length; index++) { + hash[index] = (byte) (seed + index); + } + return hash; + } +} diff --git a/framework/src/test/java/org/tron/core/db2/archive/StateArchiveCheckpointReadSnapshotTest.java b/framework/src/test/java/org/tron/core/db2/archive/StateArchiveCheckpointReadSnapshotTest.java new file mode 100644 index 00000000000..b90c337daa8 --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/archive/StateArchiveCheckpointReadSnapshotTest.java @@ -0,0 +1,188 @@ +package org.tron.core.db2.archive; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tron.core.db2.archive.ArchiveReadSnapshot.PinnedLatestState; +import org.tron.core.db2.archive.BlockReverseDiff.DbGroup; +import org.tron.core.db2.archive.BlockReverseDiff.Entry; +import org.tron.core.db2.core.CommonCheckpointFile; +import org.tron.core.db2.core.CommonCheckpointMaterializer; +import org.tron.core.db2.core.CommonCheckpointMaterializer.Authority; +import org.tron.core.db2.core.CommonCheckpointPayload; +import org.tron.core.db2.core.CommonCheckpointRedoCoordinator; +import org.tron.core.db2.core.CommonCheckpointRuntimeOwner; +import org.tron.core.db2.core.CommonCheckpointTarget; +import org.tron.core.db2.stateroot.PathStateFlushTarget; + +public class StateArchiveCheckpointReadSnapshotTest { + + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void combinesHistoryAndPinnedLatestUnderOneRuntimeLease() throws Exception { + Path root = temporaryFolder.newFolder("snapshot").toPath(); + byte[] format = hash(90); + CommonCheckpointPayload payload = payload(format, 1, 3); + CommonCheckpointTarget target = CommonCheckpointTarget.from(payload); + StateArchiveCheckpointMaterializer materializer = + new StateArchiveCheckpointMaterializer(root, format); + materializer.materialize(payload, target); + materializer.publish(target); + CommonCheckpointRuntimeOwner owner = readyOwner(root.resolve("runtime")); + FakeLatest latest = new FakeLatest(3, hash(3), OldValue.present(new byte[]{99})); + + try (StateArchiveCheckpointReadSnapshot snapshot = + StateArchiveCheckpointReadSnapshot.pin(2, owner, root, format, + (blockNumber, blockHash) -> { + assertEquals(3, blockNumber); + assertArrayEquals(hash(3), blockHash); + return latest; + })) { + assertEquals(2, snapshot.getTargetBlock()); + assertEquals(3, snapshot.getPinnedBlock()); + assertArrayEquals(hash(3), snapshot.getPinnedHash()); + assertArrayEquals(new byte[]{2}, snapshot.get("code", new byte[]{3}).getValue()); + assertArrayEquals(new byte[]{99}, snapshot.get("code", new byte[]{1}).getValue()); + snapshot.requirePinnedIdentity(); + assertFalse(latest.closed); + } + assertTrue(latest.closed); + owner.close(); + assertEquals(CommonCheckpointRuntimeOwner.State.CLOSED, owner.getState()); + } + + @Test + public void rejectsLatestHeadMismatchAndClosesFailedPin() throws Exception { + Path root = temporaryFolder.newFolder("mismatch").toPath(); + byte[] format = hash(91); + CommonCheckpointPayload payload = payload(format, 1, 2); + CommonCheckpointTarget target = CommonCheckpointTarget.from(payload); + StateArchiveCheckpointMaterializer materializer = + new StateArchiveCheckpointMaterializer(root, format); + materializer.materialize(payload, target); + materializer.publish(target); + CommonCheckpointRuntimeOwner owner = readyOwner(root.resolve("runtime")); + FakeLatest latest = new FakeLatest(1, hash(1), OldValue.absent()); + + assertThrows(IllegalArgumentException.class, + () -> StateArchiveCheckpointReadSnapshot.pin(1, owner, root, format, + (blockNumber, blockHash) -> latest)); + assertTrue(latest.closed); + owner.close(); + assertEquals(CommonCheckpointRuntimeOwner.State.CLOSED, owner.getState()); + } + + private static CommonCheckpointRuntimeOwner readyOwner(Path directory) throws IOException { + CommonCheckpointMaterializer chainbase = materializer(Authority.CHAINBASE); + CommonCheckpointMaterializer pathState = materializer(Authority.PATH_STATE); + CommonCheckpointMaterializer archive = materializer(Authority.STATE_ARCHIVE); + CommonCheckpointRuntimeOwner owner = new CommonCheckpointRuntimeOwner( + new CommonCheckpointRedoCoordinator(new CommonCheckpointFile(directory), chainbase, + pathState, archive)); + owner.recoverBeforeServing(); + return owner; + } + + private static CommonCheckpointMaterializer materializer(Authority authority) { + CommonCheckpointMaterializer materializer = mock(CommonCheckpointMaterializer.class); + when(materializer.authority()).thenReturn(authority); + return materializer; + } + + private static CommonCheckpointPayload payload(byte[] format, long firstBlock, int count) { + List bindings = new ArrayList<>(); + List archives = new ArrayList<>(); + byte[] priorHash = hash((int) firstBlock - 1); + byte[] priorRoot = hash(30); + for (int index = 0; index < count; index++) { + long number = firstBlock + index; + byte[] blockHash = hash((int) number); + byte[] nextRoot = hash(31 + index); + BlockSnapshotMeta meta = BlockSnapshotMeta.forBlock(number, blockHash, priorHash, + number * 3_000L); + PathStateFlushTarget.BlockBinding binding = mock(PathStateFlushTarget.BlockBinding.class); + when(binding.getMeta()).thenReturn(meta); + when(binding.getParentStateRoot()).thenReturn(priorRoot); + when(binding.getStateRoot()).thenReturn(nextRoot); + when(binding.getTransitionPayloadDigest()).thenReturn(hash(70 + index)); + bindings.add(binding); + DbGroup group = new DbGroup("code", Collections.singletonList( + new Entry(new byte[]{(byte) number}, + OldValue.present(new byte[]{(byte) (number - 1)})))); + archives.add(new BlockReverseDiff(meta, Collections.singletonList(group))); + priorHash = blockHash; + priorRoot = nextRoot; + } + PathStateFlushTarget pathState = mock(PathStateFlushTarget.class); + when(pathState.getBlocks()).thenReturn(bindings); + when(pathState.getParentStateRoot()).thenReturn(hash(30)); + when(pathState.getStateRoot()).thenReturn(priorRoot); + when(pathState.getStores()).thenReturn(Collections.emptyList()); + when(pathState.getSuperNodeMutations()).thenReturn(Collections.emptyList()); + return CommonCheckpointPayload.create(format, pathState, archives, + Collections.emptyList()); + } + + private static byte[] hash(int seed) { + byte[] value = new byte[32]; + for (int index = 0; index < value.length; index++) { + value[index] = (byte) (seed + index); + } + return value; + } + + private static final class FakeLatest implements PinnedLatestState { + + private final long blockNumber; + private final byte[] blockHash; + private final OldValue value; + private boolean closed; + + private FakeLatest(long blockNumber, byte[] blockHash, OldValue value) { + this.blockNumber = blockNumber; + this.blockHash = blockHash; + this.value = value; + } + + @Override + public long getBlockNumber() { + return blockNumber; + } + + @Override + public byte[] getBlockHash() { + return blockHash; + } + + @Override + public OldValue get(String dbName, byte[] physicalRawKey) { + return value; + } + + @Override + public List range(String dbName, byte[] lowerInclusive, + byte[] upperExclusive, int maxEntries) { + throw new UnsupportedOperationException("point-only checkpoint snapshot"); + } + + @Override + public void close() { + closed = true; + } + } +} diff --git a/framework/src/test/java/org/tron/core/db2/archive/StateArchiveFiveLaneBlockCodecV3Test.java b/framework/src/test/java/org/tron/core/db2/archive/StateArchiveFiveLaneBlockCodecV3Test.java new file mode 100644 index 00000000000..f6893dc2e95 --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/archive/StateArchiveFiveLaneBlockCodecV3Test.java @@ -0,0 +1,332 @@ +package org.tron.core.db2.archive; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import org.junit.Test; +import org.tron.core.db2.archive.BlockReverseDiff.DbGroup; +import org.tron.core.db2.archive.BlockReverseDiff.Entry; +import org.tron.core.db2.archive.StateArchiveFiveLaneBlockCodecV3.DecodedBundle; +import org.tron.core.db2.archive.StateArchiveFiveLaneBlockCodecV3.EncodedBundle; +import org.tron.core.db2.archive.StateArchiveFiveLaneBlockCodecV3.EncodedLane; + +public class StateArchiveFiveLaneBlockCodecV3Test { + + private static final String EMPTY_BUNDLE_GOLDEN = + "9a9e46c5b7fea88a262bc7e4e52582b0c0d42bd6e5e454001c1d571b77076c4a" + + ":2c6ef2d937c331ce2d8dbdf9f0cfbb5656addc43bb1720259b3cac873d6166f5" + + ":da6987a93e4fdcd3550e17d5abe60152bce13ca674d18e92e108b83f55550485" + + ":ef3b67f170ae5154c8fbe8b7021695a46492146686e3557bedf0c1763b5fd2b1" + + ":2f50e5993055074c70ee5d32b5f7208782ef9ee2fa5cf1f94b4319ae0fa401dd" + + ":937abd4c413f25d5e9ed4147339b0a9b9b17a22e86171df82d347bf861aa9a7b" + + ":d5c21252a7b74ee1ea037dcf891a81918673d14c953ada93a26a02b2f44bc9c5" + + ":72c3cadb4dad71dc9f230afd2bd306d442325a2705e325e95083fc28c9470dcc"; + + private final StateArchiveFiveLaneBlockCodecV3 codec = + new StateArchiveFiveLaneBlockCodecV3(); + + @Test + public void emitsFiveEmptyFramesWithExactDisjointCoverage() { + EncodedBundle bundle = codec.encode(diff(12, Collections.emptyList()), hash(11), + StateArchiveFileFormatV3.COMPRESSION_NONE); + + assertEquals(5, bundle.getLanes().size()); + long coverage = 0; + int[] laneIds = StateArchiveFileFormatV3.fiveLaneIds(); + for (int index = 0; index < laneIds.length; index++) { + EncodedLane lane = bundle.getLanes().get(index); + int laneId = laneIds[index]; + assertEquals(laneId, lane.getLaneId()); + assertEquals(StateArchiveFileFormatV3.laneBodyCodec(laneId), lane.getBodyCodec()); + assertEquals(32, lane.getCanonicalPayload().length); + assertEquals(0, coverage & lane.getCoverageBitmap()); + coverage |= lane.getCoverageBitmap(); + } + assertEquals(StateArchiveFileFormatV3.EXACT_COVERAGE_BITMAP, coverage); + assertEquals(StateArchiveFileFormatV3.MIXED_LANE_COVERAGE_BITMAP, + bundle.getLanes().get(0).getCoverageBitmap()); + assertFalse(Arrays.equals(StateArchiveFileFormatV3.storeDescriptorDigest(), + StateArchiveFileFormatV3.fiveLaneDescriptorDigest())); + StringBuilder golden = new StringBuilder() + .append(hex(StateArchiveFileFormatV3.fiveLaneDescriptorDigest())); + for (EncodedLane lane : bundle.getLanes()) { + golden.append(':').append(hex(StateArchiveFileFormatV3.sha256(lane.getFrame()))); + } + golden.append(':').append(hex(bundle.getBlockHistoryDigest())) + .append(':').append(hex(bundle.getResultHistoryDigest())); + assertEquals(EMPTY_BUNDLE_GOLDEN, golden.toString()); + + DecodedBundle decoded = codec.decode(frames(bundle)); + assertEquivalent(diff(12, Collections.emptyList()), decoded.getDiff()); + assertArrayEquals(bundle.getBlockHistoryDigest(), decoded.getBlockHistoryDigest()); + assertArrayEquals(bundle.getResultHistoryDigest(), decoded.getResultHistoryDigest()); + } + + @Test + public void roundTripsVariableAndFixedWidthLanes() { + BlockReverseDiff input = diff(42, Arrays.asList( + new DbGroup("abi", Arrays.asList( + new Entry(bytes(0), OldValue.absent()), + new Entry(bytes(0xff), OldValue.present(bytes(1))))), + new DbGroup("account", Arrays.asList( + new Entry(fixedKey(21, 1), OldValue.absent()), + new Entry(fixedKey(21, 2), OldValue.present(new byte[0])), + new Entry(fixedKey(21, 3), OldValue.present(bytes(7, 8))))), + new DbGroup("account-asset", Arrays.asList( + new Entry(fixedKey(22, 1), OldValue.present(bytes(9))), + new Entry(fixedKey(29, 2), OldValue.absent()))), + new DbGroup("delegation", Arrays.asList( + new Entry(bytes(0, 0x80), OldValue.present(bytes(3))), + new Entry(bytes(0xff), OldValue.absent()))), + new DbGroup("storage-row", Arrays.asList( + new Entry(fixedKey(32, 4), OldValue.present(bytes(5))), + new Entry(fixedKey(32, 5), OldValue.absent()))))); + + EncodedBundle bundle = codec.encode(input, hash(41), + StateArchiveFileFormatV3.COMPRESSION_NONE); + EncodedLane account = lane(bundle, 4); + EncodedLane accountAsset = lane(bundle, 5); + EncodedLane storage = lane(bundle, 22); + assertEquals(StateArchiveFileFormatV3.DEDICATED_FIXED_WIDTH_BODY_CODEC_ID, + account.getBodyCodec()); + assertEquals(StateArchiveFileFormatV3.LANE_VARIABLE_BODY_CODEC_ID, + accountAsset.getBodyCodec()); + assertEquals(StateArchiveFileFormatV3.DEDICATED_FIXED_WIDTH_BODY_CODEC_ID, + storage.getBodyCodec()); + assertFixedSection(account.getCanonicalPayload(), 4, 21, 3); + assertVariableSection(accountAsset.getCanonicalPayload(), 5, 2); + assertFixedSection(storage.getCanonicalPayload(), 22, 32, 2); + + byte[] u0AccountPayload = new StateArchiveBlockFrameCodecV3().encode( + diff(42, Collections.singletonList(group(input, "account"))), hash(41), + StateArchiveFileFormatV3.COMPRESSION_NONE).getCanonicalPayload(); + assertTrue(account.getCanonicalPayload().length < u0AccountPayload.length); + assertEquivalent(input, codec.decode(frames(bundle)).getDiff()); + } + + @Test + public void roundTripsEveryExact27StoreThroughItsAssignedLane() { + ArchiveParticipantDescriptor descriptor = ArchiveParticipantDescriptor.current(); + List groups = new ArrayList<>(); + for (String dbName : descriptor.getActiveDatabases()) { + int storeId = descriptor.getStoreId(dbName); + int laneId = StateArchiveFileFormatV3.laneId(storeId); + int keyWidth = StateArchiveFileFormatV3.fixedKeyWidth(laneId); + byte[] key = keyWidth == 0 + ? bytes(storeId, 0xff - storeId) + : fixedKey(keyWidth, storeId); + OldValue oldValue = (storeId & 1) == 0 + ? OldValue.absent() + : OldValue.present(bytes(storeId)); + groups.add(new DbGroup(dbName, + Collections.singletonList(new Entry(key, oldValue)))); + } + BlockReverseDiff input = diff(64, groups); + + EncodedBundle bundle = codec.encode(input, hash(63), + StateArchiveFileFormatV3.COMPRESSION_RAW_DEFLATE_LEVEL_1); + + assertEquals(5, bundle.getLanes().size()); + assertEquivalent(input, codec.decode(frames(bundle)).getDiff()); + } + + @Test + public void compressionKeepsBundleIdentityAndCanonicalLanePayloads() { + BlockReverseDiff input = diff(77, Arrays.asList( + new DbGroup("account", Collections.singletonList( + new Entry(fixedKey(21, 1), OldValue.present(new byte[4096])))), + new DbGroup("votes", Collections.singletonList( + new Entry(bytes(0x80), OldValue.present(new byte[2048])))))); + EncodedBundle none = codec.encode(input, hash(76), + StateArchiveFileFormatV3.COMPRESSION_NONE); + EncodedBundle compressed = codec.encode(input, hash(76), + StateArchiveFileFormatV3.COMPRESSION_RAW_DEFLATE_LEVEL_1); + + assertArrayEquals(none.getBlockHistoryDigest(), compressed.getBlockHistoryDigest()); + assertArrayEquals(none.getResultHistoryDigest(), compressed.getResultHistoryDigest()); + boolean physicalDifference = false; + for (int index = 0; index < none.getLanes().size(); index++) { + assertArrayEquals(none.getLanes().get(index).getCanonicalPayload(), + compressed.getLanes().get(index).getCanonicalPayload()); + physicalDifference |= !Arrays.equals(none.getLanes().get(index).getFrame(), + compressed.getLanes().get(index).getFrame()); + } + assertTrue(physicalDifference); + assertEquivalent(input, codec.decode(frames(compressed)).getDiff()); + } + + @Test + public void rejectsInvalidFixedKeysIncompleteAndMixedBundles() { + BlockReverseDiff invalidAccount = diff(12, Collections.singletonList( + new DbGroup("account", Collections.singletonList( + new Entry(fixedKey(20, 1), OldValue.absent()))))); + assertThrows(IllegalArgumentException.class, () -> codec.encode( + invalidAccount, hash(11), StateArchiveFileFormatV3.COMPRESSION_NONE)); + BlockReverseDiff invalidStorage = diff(12, Collections.singletonList( + new DbGroup("storage-row", Collections.singletonList( + new Entry(fixedKey(33, 1), OldValue.absent()))))); + assertThrows(IllegalArgumentException.class, () -> codec.encode( + invalidStorage, hash(11), StateArchiveFileFormatV3.COMPRESSION_NONE)); + + EncodedBundle first = codec.encode(diff(12, Collections.emptyList()), hash(11), + StateArchiveFileFormatV3.COMPRESSION_NONE); + List missing = frames(first); + missing.remove(0); + assertThrows(IllegalArgumentException.class, () -> codec.decode(missing)); + + List duplicate = frames(first); + duplicate.set(4, duplicate.get(0)); + assertThrows(IllegalArgumentException.class, () -> codec.decode(duplicate)); + + EncodedBundle second = codec.encode(diff(13, Collections.emptyList()), hash(12), + StateArchiveFileFormatV3.COMPRESSION_NONE); + List mixed = frames(first); + mixed.set(2, second.getLanes().get(2).getFrame()); + assertThrows(IllegalArgumentException.class, () -> codec.decode(mixed)); + + List corrupted = frames(first); + byte[] bad = corrupted.get(1); + bad[StateArchiveFileFormatV3.BLOCK_HEADER_LENGTH] ^= 1; + assertThrows(IllegalArgumentException.class, () -> codec.decode(corrupted)); + } + + @Test + public void bundleEncodingIsDeterministicAndMatchesLegacySemanticOracle() { + List entries = Arrays.asList( + new Entry(bytes(0xff), OldValue.absent()), + new Entry(bytes(0), OldValue.present(new byte[0])), + new Entry(bytes(0x80), OldValue.present(bytes(3, 4)))); + BlockReverseDiff input = diff(88, Arrays.asList( + new DbGroup("votes", entries), + new DbGroup("abi", Collections.singletonList( + new Entry(bytes(7), OldValue.present(bytes(1))))))); + List reversedEntries = new ArrayList<>(entries); + Collections.reverse(reversedEntries); + BlockReverseDiff reordered = diff(88, Arrays.asList( + new DbGroup("abi", Collections.singletonList( + new Entry(bytes(7), OldValue.present(bytes(1))))), + new DbGroup("votes", reversedEntries))); + + EncodedBundle first = codec.encode(input, hash(87), + StateArchiveFileFormatV3.COMPRESSION_NONE); + EncodedBundle second = codec.encode(reordered, hash(87), + StateArchiveFileFormatV3.COMPRESSION_NONE); + for (int index = 0; index < first.getLanes().size(); index++) { + assertArrayEquals(first.getLanes().get(index).getFrame(), + second.getLanes().get(index).getFrame()); + } + BlockReverseDiff legacy = new BlockHistoryCodec().decode( + new BlockHistoryCodec().encode(input)); + assertEquivalent(legacy, codec.decode(frames(first)).getDiff()); + assertNotEquals(hex(StateArchiveFileFormatV3.formatDigest()), + hex(StateArchiveFileFormatV3.fiveLaneDescriptorDigest())); + } + + private static void assertFixedSection(byte[] payloadBytes, int storeId, + int keyWidth, int entryCount) { + ByteBuffer payload = ByteBuffer.wrap(payloadBytes); + payload.position(StateArchiveFileFormatV3.PAYLOAD_HEADER_LENGTH); + assertEquals(storeId, Short.toUnsignedInt(payload.getShort())); + assertEquals(2, Short.toUnsignedInt(payload.getShort())); + assertEquals(96, payload.getInt()); + assertEquals(entryCount, payload.getInt()); + assertEquals(0, payload.getInt()); + assertEquals(keyWidth, payload.getInt()); + } + + private static void assertVariableSection(byte[] payloadBytes, int storeId, + int entryCount) { + ByteBuffer payload = ByteBuffer.wrap(payloadBytes); + payload.position(StateArchiveFileFormatV3.PAYLOAD_HEADER_LENGTH); + assertEquals(storeId, Short.toUnsignedInt(payload.getShort())); + assertEquals(1, Short.toUnsignedInt(payload.getShort())); + assertEquals(StateArchiveFileFormatV3.SECTION_HEADER_LENGTH, payload.getInt()); + assertEquals(entryCount, payload.getInt()); + } + + private static EncodedLane lane(EncodedBundle bundle, int laneId) { + for (EncodedLane lane : bundle.getLanes()) { + if (lane.getLaneId() == laneId) { + return lane; + } + } + throw new AssertionError("Missing lane " + laneId); + } + + private static List frames(EncodedBundle bundle) { + List frames = new ArrayList<>(); + for (EncodedLane lane : bundle.getLanes()) { + frames.add(lane.getFrame()); + } + return frames; + } + + private static BlockReverseDiff diff(long blockNumber, List groups) { + return new BlockReverseDiff(BlockSnapshotMeta.forBlock( + blockNumber, hash((int) blockNumber), hash((int) blockNumber - 1), + blockNumber * 3_000L), groups); + } + + private static DbGroup group(BlockReverseDiff diff, String dbName) { + for (DbGroup group : diff.getGroups()) { + if (group.getDbName().equals(dbName)) { + return group; + } + } + throw new AssertionError("Missing group " + dbName); + } + + private static void assertEquivalent(BlockReverseDiff expected, BlockReverseDiff actual) { + assertEquals(expected.getMeta(), actual.getMeta()); + assertEquals(expected.getGroups().size(), actual.getGroups().size()); + for (int groupIndex = 0; groupIndex < expected.getGroups().size(); groupIndex++) { + DbGroup expectedGroup = expected.getGroups().get(groupIndex); + DbGroup actualGroup = actual.getGroups().get(groupIndex); + assertEquals(expectedGroup.getDbName(), actualGroup.getDbName()); + assertEquals(expectedGroup.getEntries().size(), actualGroup.getEntries().size()); + for (int entryIndex = 0; entryIndex < expectedGroup.getEntries().size(); entryIndex++) { + Entry expectedEntry = expectedGroup.getEntries().get(entryIndex); + Entry actualEntry = actualGroup.getEntries().get(entryIndex); + assertArrayEquals(expectedEntry.getKey(), actualEntry.getKey()); + assertEquals(expectedEntry.getOldValue(), actualEntry.getOldValue()); + } + } + } + + private static byte[] hash(int suffix) { + byte[] hash = new byte[32]; + hash[31] = (byte) suffix; + return hash; + } + + private static byte[] fixedKey(int length, int suffix) { + byte[] key = new byte[length]; + key[length - 1] = (byte) suffix; + return key; + } + + private static byte[] bytes(int... values) { + byte[] bytes = new byte[values.length]; + for (int index = 0; index < values.length; index++) { + bytes[index] = (byte) values[index]; + } + return bytes; + } + + private static String hex(byte[] bytes) { + StringBuilder result = new StringBuilder(bytes.length * 2); + for (byte value : bytes) { + result.append(String.format("%02x", value & 0xff)); + } + return result.toString(); + } +} diff --git a/framework/src/test/java/org/tron/core/db2/archive/StateArchiveFiveLaneDurabilityProcessTest.java b/framework/src/test/java/org/tron/core/db2/archive/StateArchiveFiveLaneDurabilityProcessTest.java new file mode 100644 index 00000000000..ef031e217e0 --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/archive/StateArchiveFiveLaneDurabilityProcessTest.java @@ -0,0 +1,123 @@ +package org.tron.core.db2.archive; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import java.io.File; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tron.core.db2.archive.BlockReverseDiff.DbGroup; +import org.tron.core.db2.archive.BlockReverseDiff.Entry; +import org.tron.core.db2.archive.StateArchiveFiveLaneBlockCodecV3.EncodedBundle; +import org.tron.core.db2.archive.StateArchiveFiveLaneRecoveryIntentV3.RecoveryPoint; +import org.tron.core.db2.archive.StateArchiveFiveLaneSegmentWriterV3.ArchiveDurabilityProof; + +/** Process boundary for marker force: the child never closes the writer normally. */ +public class StateArchiveFiveLaneDurabilityProcessTest { + + private static final int HALT_CODE = 92; + private static final byte[] BASELINE = hash(20); + private static final byte[] COMMON_TARGET = hash(90); + + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void jvmHaltAfterFiveLaneForceLeavesRestartVerifiableProof() throws Exception { + Path root = temporaryFolder.newFolder("five-lane-durability-process").toPath(); + Process child = new ProcessBuilder(javaExecutable(), "-cp", runtimeClasspath(), + StateArchiveFiveLaneDurabilityProcessTest.class.getName(), "halt-after-sync", + root.toString()).redirectErrorStream(true) + .redirectOutput(root.resolve("halt-after-sync.log").toFile()).start(); + assertTrue("child process timed out", child.waitFor(30, TimeUnit.SECONDS)); + assertEquals(HALT_CODE, child.exitValue()); + + try (StateArchiveFiveLaneSegmentWriterV3 reopened = + new StateArchiveFiveLaneSegmentWriterV3(root, BASELINE, + StateArchiveFileFormatV3.COMPRESSION_NONE, 10_000)) { + ArchiveDurabilityProof proof = reopened.getLastDurabilityProof(); + assertNotNull(proof); + assertEquals(1, proof.getCheckpointSequence()); + assertEquals(2, proof.getTarget().getBlockNumber()); + assertEquals(5, proof.getFileTails().size()); + reopened.verifyDurabilityProof(proof); + } + } + + public static void main(String[] args) throws Exception { + if (args.length != 2 || !"halt-after-sync".equals(args[0])) { + throw new IllegalArgumentException("unknown five-lane durability process mode"); + } + Path root = Paths.get(args[1]); + StateArchiveFiveLaneBlockCodecV3 codec = new StateArchiveFiveLaneBlockCodecV3(); + EncodedBundle first = codec.encode(diff(1), BASELINE, + StateArchiveFileFormatV3.COMPRESSION_NONE); + EncodedBundle second = codec.encode(diff(2), first.getResultHistoryDigest(), + StateArchiveFileFormatV3.COMPRESSION_NONE); + StateArchiveFiveLaneSegmentWriterV3 writer = + new StateArchiveFiveLaneSegmentWriterV3(root, BASELINE, + StateArchiveFileFormatV3.COMPRESSION_NONE, 10_000); + writer.append(first); + writer.append(second); + writer.sync(1, point(second), COMMON_TARGET); + Runtime.getRuntime().halt(HALT_CODE); + } + + private static BlockReverseDiff diff(int blockNumber) { + byte[] value = new byte[10]; + java.util.Arrays.fill(value, (byte) blockNumber); + DbGroup group = new DbGroup(StateArchiveFileFormatV3.dbName(1), + Collections.singletonList(new Entry(new byte[]{1}, OldValue.present(value)))); + return new BlockReverseDiff(new BlockSnapshotMeta(blockNumber, blockNumber, + hash(blockNumber), hash(blockNumber - 1), blockNumber * 3_000L), + Collections.singletonList(group)); + } + + private static RecoveryPoint point(EncodedBundle bundle) { + BlockSnapshotMeta meta = bundle.getDiff().getMeta(); + return new RecoveryPoint(meta.getEpoch(), meta.getBlockNumber(), meta.getTimestamp(), + meta.getBlockHash(), meta.getParentHash(), bundle.getResultHistoryDigest()); + } + + private static String javaExecutable() { + return Paths.get(System.getProperty("java.home"), "bin", "java").toString(); + } + + private static String runtimeClasspath() { + Set entries = new LinkedHashSet<>(); + String configured = System.getProperty("java.class.path", ""); + Collections.addAll(entries, configured.split(java.util.regex.Pattern.quote( + File.pathSeparator))); + for (ClassLoader loader = StateArchiveFiveLaneDurabilityProcessTest.class.getClassLoader(); + loader != null; loader = loader.getParent()) { + if (loader instanceof URLClassLoader) { + for (URL url : ((URLClassLoader) loader).getURLs()) { + if ("file".equals(url.getProtocol())) { + try { + entries.add(Paths.get(url.toURI()).toString()); + } catch (java.net.URISyntaxException invalid) { + throw new IllegalStateException("invalid test runtime classpath", invalid); + } + } + } + } + } + return String.join(File.pathSeparator, entries); + } + + private static byte[] hash(int suffix) { + byte[] result = new byte[32]; + result[31] = (byte) suffix; + return result; + } +} diff --git a/framework/src/test/java/org/tron/core/db2/archive/StateArchiveFiveLaneDurabilityProofV3Test.java b/framework/src/test/java/org/tron/core/db2/archive/StateArchiveFiveLaneDurabilityProofV3Test.java new file mode 100644 index 00000000000..53faa67788c --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/archive/StateArchiveFiveLaneDurabilityProofV3Test.java @@ -0,0 +1,173 @@ +package org.tron.core.db2.archive; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.Collections; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tron.core.db2.archive.BlockReverseDiff.DbGroup; +import org.tron.core.db2.archive.BlockReverseDiff.Entry; +import org.tron.core.db2.archive.StateArchiveFiveLaneBlockCodecV3.EncodedBundle; +import org.tron.core.db2.archive.StateArchiveFiveLaneRecoveryIntentV3.RecoveryPoint; +import org.tron.core.db2.archive.StateArchiveFiveLaneSegmentWriterV3.ArchiveDurabilityProof; +import org.tron.core.db2.archive.StateArchiveFiveLaneSegmentWriterV3.FileTailProof; + +public class StateArchiveFiveLaneDurabilityProofV3Test { + + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void freezesRoundTripAndReloadsExactMarkerProof() throws Exception { + Path root = temporaryFolder.newFolder("proof").toPath(); + byte[] baseline = hash(40); + byte[] commonDigest = hash(99); + ArchiveDurabilityProof proof; + try (StateArchiveFiveLaneSegmentWriterV3 writer = writer(root, baseline)) { + EncodedBundle bundle = bundle(baseline); + writer.append(bundle); + proof = writer.sync(7, point(bundle), commonDigest); + StateArchiveFiveLaneDurabilityProofV3.publish(root, proof); + } + + byte[] encoded = Files.readAllBytes( + root.resolve(StateArchiveFiveLaneDurabilityProofV3.FILE_NAME)); + assertEquals(760, encoded.length); + ByteBuffer bytes = ByteBuffer.wrap(encoded); + assertEquals(StateArchiveFiveLaneDurabilityProofV3.MAGIC, bytes.getInt(0)); + assertEquals(352, bytes.getInt(8)); + assertEquals(72, Short.toUnsignedInt(bytes.getShort(12))); + assertEquals(5, Short.toUnsignedInt(bytes.getShort(14))); + assertEquals(760, bytes.getLong(16)); + assertArrayEquals(StateArchiveFileFormatV3.compositeFormatDigest(), + slice(encoded, 32, 32)); + assertArrayEquals(StateArchiveFileFormatV3.fiveLaneDescriptorDigest(), + slice(encoded, 64, 32)); + assertEquals(7, bytes.getLong(96)); + assertEquals(1, bytes.getLong(112)); + assertEquals(0, Short.toUnsignedInt(bytes.getShort(352))); + assertEquals(22, Short.toUnsignedInt(bytes.getShort(352 + 4 * 72))); + + ArchiveDurabilityProof decoded = StateArchiveFiveLaneDurabilityProofV3.decode(encoded); + assertEquals(7, decoded.getCheckpointSequence()); + assertEquals(1, decoded.getTarget().getBlockNumber()); + assertArrayEquals(commonDigest, decoded.getCommonTargetDigest()); + assertEquals(5, decoded.getFileTails().size()); + + try (StateArchiveFiveLaneSegmentWriterV3 reopened = writer(root, baseline)) { + ArchiveDurabilityProof verified = StateArchiveFiveLaneDurabilityProofV3.loadAndVerify( + root, reopened, proof.getTarget(), commonDigest); + assertEquals(1, verified.getTarget().getBlockNumber()); + assertArrayEquals(proof.getFileTails().get(4).getMarkerDigest(), + verified.getFileTails().get(4).getMarkerDigest()); + assertThrows(IllegalArgumentException.class, + () -> StateArchiveFiveLaneDurabilityProofV3.loadAndVerify(root, reopened, + proof.getTarget(), hash(98))); + } + } + + @Test + public void rejectsHeaderTailTrailerAndMarkerDrift() throws Exception { + Path root = temporaryFolder.newFolder("proof-fault").toPath(); + byte[] baseline = hash(50); + ArchiveDurabilityProof proof; + try (StateArchiveFiveLaneSegmentWriterV3 writer = writer(root, baseline)) { + EncodedBundle bundle = bundle(baseline); + writer.append(bundle); + proof = writer.sync(8, point(bundle), hash(100)); + StateArchiveFiveLaneDurabilityProofV3.publish(root, proof); + } + byte[] encoded = Files.readAllBytes( + root.resolve(StateArchiveFiveLaneDurabilityProofV3.FILE_NAME)); + for (int offset : new int[]{50, 400, 759}) { + byte[] corrupt = encoded.clone(); + corrupt[offset] ^= 1; + assertThrows(IllegalArgumentException.class, + () -> StateArchiveFiveLaneDurabilityProofV3.decode(corrupt)); + } + + FileTailProof last = proof.getFileTails().get(4); + try (StateArchiveFiveLaneSegmentWriterV3 reopened = writer(root, baseline)) { + Path marker = root.resolve("segments").resolve("shard-000000") + .resolve(String.format("lane-%04d-seg-%020d.dat", last.getLaneId(), + last.getSegmentSeq())); + try (FileChannel channel = FileChannel.open(marker, StandardOpenOption.READ, + StandardOpenOption.WRITE)) { + ByteBuffer one = ByteBuffer.allocate(1); + channel.position(last.getMarkerOffset() + 20); + channel.read(one); + one.flip(); + one.put(0, (byte) (one.get(0) ^ 1)); + channel.position(last.getMarkerOffset() + 20); + channel.write(one); + channel.force(false); + } + assertThrows(IllegalArgumentException.class, + () -> StateArchiveFiveLaneDurabilityProofV3.loadAndVerify(root, reopened)); + } + } + + @Test + public void rejectsMissingDuplicateAndOutOfOrderLaneTails() throws Exception { + Path root = temporaryFolder.newFolder("proof-order").toPath(); + byte[] baseline = hash(60); + ArchiveDurabilityProof proof; + try (StateArchiveFiveLaneSegmentWriterV3 writer = writer(root, baseline)) { + EncodedBundle bundle = bundle(baseline); + writer.append(bundle); + proof = writer.sync(9, point(bundle), hash(101)); + } + assertThrows(IllegalArgumentException.class, () -> new ArchiveDurabilityProof(9, + proof.getTarget(), proof.getCommonTargetDigest(), + proof.getFileTails().subList(0, 4))); + java.util.List duplicate = new java.util.ArrayList<>(proof.getFileTails()); + duplicate.add(1, duplicate.get(0)); + assertThrows(IllegalArgumentException.class, () -> new ArchiveDurabilityProof(9, + proof.getTarget(), proof.getCommonTargetDigest(), duplicate)); + java.util.List reversed = new java.util.ArrayList<>(proof.getFileTails()); + Collections.swap(reversed, 0, 1); + assertThrows(IllegalArgumentException.class, () -> new ArchiveDurabilityProof(9, + proof.getTarget(), proof.getCommonTargetDigest(), reversed)); + assertTrue(proof.getFileTails().stream().allMatch(tail -> tail.getMarkerLength() == 336)); + } + + private static StateArchiveFiveLaneSegmentWriterV3 writer(Path root, byte[] baseline) + throws Exception { + return new StateArchiveFiveLaneSegmentWriterV3(root, baseline, + StateArchiveFileFormatV3.COMPRESSION_NONE, 10_000); + } + + private static EncodedBundle bundle(byte[] baseline) { + BlockSnapshotMeta meta = new BlockSnapshotMeta(1, 1, hash(1), hash(0), 3_000); + DbGroup group = new DbGroup(StateArchiveFileFormatV3.dbName(1), + Collections.singletonList(new Entry(new byte[]{1}, OldValue.present(new byte[]{7})))); + return new StateArchiveFiveLaneBlockCodecV3().encode( + new BlockReverseDiff(meta, Collections.singletonList(group)), baseline, + StateArchiveFileFormatV3.COMPRESSION_NONE); + } + + private static RecoveryPoint point(EncodedBundle bundle) { + BlockSnapshotMeta meta = bundle.getDiff().getMeta(); + return new RecoveryPoint(meta.getEpoch(), meta.getBlockNumber(), meta.getTimestamp(), + meta.getBlockHash(), meta.getParentHash(), bundle.getResultHistoryDigest()); + } + + private static byte[] hash(int suffix) { + byte[] result = new byte[32]; + result[31] = (byte) suffix; + return result; + } + + private static byte[] slice(byte[] bytes, int offset, int length) { + return java.util.Arrays.copyOfRange(bytes, offset, offset + length); + } +} diff --git a/framework/src/test/java/org/tron/core/db2/archive/StateArchiveFiveLaneRecoveryIntentV3Test.java b/framework/src/test/java/org/tron/core/db2/archive/StateArchiveFiveLaneRecoveryIntentV3Test.java new file mode 100644 index 00000000000..678f1fcca03 --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/archive/StateArchiveFiveLaneRecoveryIntentV3Test.java @@ -0,0 +1,151 @@ +package org.tron.core.db2.archive; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; + +import java.nio.ByteBuffer; +import java.util.Arrays; +import java.util.List; +import org.bouncycastle.util.encoders.Hex; +import org.junit.Test; +import org.tron.core.db2.archive.StateArchiveFiveLaneRecoveryIntentV3.Intent; +import org.tron.core.db2.archive.StateArchiveFiveLaneRecoveryIntentV3.LaneTarget; +import org.tron.core.db2.archive.StateArchiveFiveLaneRecoveryIntentV3.RecoveryPoint; + +public class StateArchiveFiveLaneRecoveryIntentV3Test { + + @Test + public void freezesLayoutAndRoundTripsCompleteFiveLanePlan() { + Intent intent = intent(); + byte[] encoded = StateArchiveFiveLaneRecoveryIntentV3.encode(intent); + assertEquals(1_616, encoded.length); + ByteBuffer bytes = ByteBuffer.wrap(encoded); + assertEquals(StateArchiveFileFormatV3.RECOVERY_INTENT_MAGIC, bytes.getInt(0)); + assertEquals(768, bytes.getInt(8)); + assertEquals(160, Short.toUnsignedInt(bytes.getShort(12))); + assertEquals(5, Short.toUnsignedInt(bytes.getShort(14))); + assertEquals(1_616, bytes.getLong(16)); + assertEquals(3, Short.toUnsignedInt(bytes.getShort(26))); + assertArrayEquals(StateArchiveFileFormatV3.compositeFormatDigest(), + slice(encoded, 32, 32)); + assertArrayEquals(StateArchiveFileFormatV3.fiveLaneDescriptorDigest(), + slice(encoded, 64, 32)); + assertEquals(12, bytes.getLong(136)); + assertEquals(10, bytes.getLong(256)); + assertEquals(11, bytes.getLong(376)); + assertEquals(0, Short.toUnsignedInt(bytes.getShort(768))); + assertEquals(22, Short.toUnsignedInt(bytes.getShort(768 + 4 * 160))); + + Intent decoded = StateArchiveFiveLaneRecoveryIntentV3.decode(encoded); + assertEquals(12, decoded.getAuthorizedCeiling().getBlockNumber()); + assertEquals(10, decoded.getCommonCommitted().getBlockNumber()); + assertEquals(11, decoded.getTarget().getBlockNumber()); + assertEquals(5, decoded.getLanes().size()); + assertEquals(StateArchiveFiveLaneRecoveryIntentV3.NO_TARGET_SEGMENT, + decoded.getLanes().get(4).getTargetSegmentSeq()); + assertArrayEquals(slice(encoded, 732, 32), decoded.getHeaderDigest()); + assertArrayEquals(slice(encoded, 488, 32), decoded.getRecordsDigest()); + assertArrayEquals(slice(encoded, 1_568, 32), decoded.getIntentDigest()); + + assertEquals( + "524944330003000000000020030000a000050030000006500001000100010001" + + ":e19713bb16c631eafb76b4fdd82101440de0e842e6c70e369f289934402e364b" + + ":c5464d3c1c3b2ee2d5622dabd83169db2140ac05d7fe995866bd294524b8f6a4" + + ":6399bb2ce2bdf435c2fd5b080c2eaade83e4fdd1ba00970495da96b41e186463", + Hex.toHexString(StateArchiveFileFormatV3.recoveryIntentLayoutDescriptor()) + ":" + + Hex.toHexString(StateArchiveFileFormatV3.recoveryIntentLayoutDigest()) + ":" + + Hex.toHexString(StateArchiveFileFormatV3.compositeFormatDigest()) + ":" + + Hex.toHexString(StateArchiveFileFormatV3.sha256(encoded))); + } + + @Test + public void rejectsHeaderRecordTrailerAndPointDrift() { + byte[] encoded = StateArchiveFiveLaneRecoveryIntentV3.encode(intent()); + for (int offset : new int[]{600, 800, 1_615}) { + byte[] corrupt = encoded.clone(); + corrupt[offset] ^= 1; + assertThrows(IllegalArgumentException.class, + () -> StateArchiveFiveLaneRecoveryIntentV3.decode(corrupt)); + } + + List wrongOrder = lanes(); + java.util.Collections.swap(wrongOrder, 0, 1); + assertThrows(IllegalArgumentException.class, () -> new Intent(hash(90), point(12), + point(10), point(11), wrongOrder)); + assertThrows(IllegalArgumentException.class, () -> new Intent(hash(90), point(12), + point(11), point(10), lanes())); + assertThrows(IllegalArgumentException.class, () -> new Intent(hash(90), point(11), + point(11), new RecoveryPoint(11, 11, 33_000, hash(99), hash(10), hash(111)), + lanes())); + assertThrows(IllegalArgumentException.class, () -> new Intent(hash(90), point(12), + point(10), null, lanes())); + } + + @Test + public void representsBaselineTargetAndLanesNotYetCreated() { + byte[] zero = new byte[32]; + List baselineLanes = Arrays.asList( + new LaneTarget(0, StateArchiveFiveLaneRecoveryIntentV3.DELETE_PAIR, + 0, 700, 128, StateArchiveFiveLaneRecoveryIntentV3.NO_TARGET_SEGMENT, + 0, 0, hash(1), zero, zero), + missingLane(4), missingLane(5), missingLane(13), missingLane(22)); + byte[] encoded = StateArchiveFiveLaneRecoveryIntentV3.encode( + new Intent(hash(90), point(12), null, null, baselineLanes)); + + assertEquals(0, Short.toUnsignedInt(ByteBuffer.wrap(encoded).getShort(26))); + assertArrayEquals(new byte[240], slice(encoded, 248, 240)); + Intent decoded = StateArchiveFiveLaneRecoveryIntentV3.decode(encoded); + assertNull(decoded.getCommonCommitted()); + assertNull(decoded.getTarget()); + assertEquals(StateArchiveFiveLaneRecoveryIntentV3.SOURCE_PAIR_MISSING, + decoded.getLanes().get(1).getActionFlags()); + } + + private static Intent intent() { + return new Intent(hash(90), point(12), point(10), point(11), lanes()); + } + + private static RecoveryPoint point(int block) { + return new RecoveryPoint(block, block, block * 3_000L, + hash(block), hash(block - 1), hash(100 + block)); + } + + private static List lanes() { + byte[] zero = new byte[32]; + return new java.util.ArrayList<>(Arrays.asList( + new LaneTarget(0, + StateArchiveFiveLaneRecoveryIntentV3.DATA_TRUNCATE + | StateArchiveFiveLaneRecoveryIntentV3.INDEX_REPLACE, + 2, 1_000, 192, 2, 900, 160, hash(1), hash(2), hash(3)), + new LaneTarget(4, StateArchiveFiveLaneRecoveryIntentV3.INDEX_REPLACE, + 1, 800, 160, 1, 800, 160, hash(4), hash(5), hash(6)), + new LaneTarget(5, 0, + 1, 800, 160, 1, 800, 160, hash(7), hash(8), hash(9)), + new LaneTarget(13, + StateArchiveFiveLaneRecoveryIntentV3.INDEX_REPLACE + | StateArchiveFiveLaneRecoveryIntentV3.ORIGINAL_INDEX_MISSING, + 1, 800, 0, 1, 800, 160, hash(10), hash(11), hash(12)), + new LaneTarget(22, StateArchiveFiveLaneRecoveryIntentV3.DELETE_PAIR, + 3, 700, 128, StateArchiveFiveLaneRecoveryIntentV3.NO_TARGET_SEGMENT, + 0, 0, hash(13), zero, zero))); + } + + private static LaneTarget missingLane(int laneId) { + byte[] zero = new byte[32]; + return new LaneTarget(laneId, StateArchiveFiveLaneRecoveryIntentV3.SOURCE_PAIR_MISSING, + StateArchiveFiveLaneRecoveryIntentV3.NO_TARGET_SEGMENT, 0, 0, + StateArchiveFiveLaneRecoveryIntentV3.NO_TARGET_SEGMENT, 0, 0, zero, zero, zero); + } + + private static byte[] hash(int suffix) { + byte[] result = new byte[32]; + result[31] = (byte) suffix; + return result; + } + + private static byte[] slice(byte[] bytes, int offset, int length) { + return Arrays.copyOfRange(bytes, offset, offset + length); + } +} diff --git a/framework/src/test/java/org/tron/core/db2/archive/StateArchiveFiveLaneSegmentWriterV3Test.java b/framework/src/test/java/org/tron/core/db2/archive/StateArchiveFiveLaneSegmentWriterV3Test.java new file mode 100644 index 00000000000..ad4424023b2 --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/archive/StateArchiveFiveLaneSegmentWriterV3Test.java @@ -0,0 +1,606 @@ +package org.tron.core.db2.archive; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tron.core.db2.archive.BlockReverseDiff.DbGroup; +import org.tron.core.db2.archive.BlockReverseDiff.Entry; +import org.tron.core.db2.archive.StateArchiveFiveLaneBlockCodecV3.EncodedBundle; +import org.tron.core.db2.archive.StateArchiveFiveLaneRecoveryIntentV3.RecoveryPoint; +import org.tron.core.db2.archive.StateArchiveFiveLaneSegmentWriterV3.ArchiveDurabilityProof; +import org.tron.core.db2.archive.StateArchiveFiveLaneSegmentWriterV3.RecoveryStage; +import org.tron.core.db2.archive.StateArchiveFiveLaneSegmentWriterV3.SyncStage; +import org.tron.core.db2.archive.StateArchiveSegmentFormatV3.CurrentSegment; +import org.tron.core.db2.archive.StateArchiveSegmentFormatV3.SealedSegment; + +public class StateArchiveFiveLaneSegmentWriterV3Test { + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void independentlyRotatesOvershotLaneAndReopensCompleteBundle() throws Exception { + Path root = temporaryFolder.newFolder("five-lane-segments").toPath(); + byte[] baseline = hash(90); + StateArchiveFiveLaneBlockCodecV3 codec = new StateArchiveFiveLaneBlockCodecV3(); + EncodedBundle first = codec.encode(diff(1, 1_400), baseline, + StateArchiveFileFormatV3.COMPRESSION_NONE); + EncodedBundle second = codec.encode(diff(2, 0), first.getResultHistoryDigest(), + StateArchiveFileFormatV3.COMPRESSION_NONE); + + try (StateArchiveFiveLaneSegmentWriterV3 writer = + new StateArchiveFiveLaneSegmentWriterV3(root, baseline, + StateArchiveFileFormatV3.COMPRESSION_NONE, 1_500)) { + writer.append(first); + writer.append(second); + assertEquals(2, writer.getAppendHead().getBlockNumber()); + assertEquals(1, writer.getSealedSegments().size()); + SealedSegment sealed = writer.getSealedSegments().get(0); + assertEquals(0, sealed.getLaneId()); + assertEquals(0, sealed.getSegmentSeq()); + assertEquals(1, sealed.getFirstBlock()); + assertEquals(1, sealed.getLastBlock()); + + List current = writer.getCurrentSegments(); + assertEquals(5, current.size()); + assertEquals(1, current.stream().filter(segment -> segment.getSegmentSeq() == 1) + .count()); + CurrentSegment mixed = current.stream().filter(segment -> segment.getLaneId() == 0) + .findFirst().get(); + assertEquals(2, mixed.getFirstBlock()); + assertEquals(2, mixed.getCurrentLastBlock()); + assertTrue(current.stream().filter(segment -> segment.getLaneId() != 0) + .allMatch(segment -> segment.getFirstBlock() == 1 + && segment.getCurrentLastBlock() == 2 + && segment.getSegmentSeq() == 0)); + } + + assertEquals(6, filesWithSuffix(root, ".dat").size()); + assertEquals(6, filesWithSuffix(root, ".bidx").size()); + + try (StateArchiveFiveLaneSegmentWriterV3 reopened = + new StateArchiveFiveLaneSegmentWriterV3(root, baseline, + StateArchiveFileFormatV3.COMPRESSION_NONE, 1_500)) { + assertEquals(2, reopened.getAppendHead().getBlockNumber()); + assertEquals(1, reopened.getSealedSegments().size()); + EncodedBundle third = codec.encode(diff(3, 0), reopened.getResultHistoryDigest(), + StateArchiveFileFormatV3.COMPRESSION_NONE); + reopened.append(third); + assertEquals(3, reopened.getAppendHead().getBlockNumber()); + } + } + + @Test + public void indexedRangesReadOnlyRequestedFramesAcrossGrowingHistory() throws Exception { + Path root = temporaryFolder.newFolder("bounded-serving-read").toPath(); + byte[] baseline = hash(89); + StateArchiveFiveLaneBlockCodecV3 codec = new StateArchiveFiveLaneBlockCodecV3(); + try (StateArchiveFiveLaneSegmentWriterV3 writer = + new StateArchiveFiveLaneSegmentWriterV3(root, baseline, + StateArchiveFileFormatV3.COMPRESSION_NONE, 800_000)) { + byte[] previous = baseline; + long shortHistoryBytes = 0; + for (int block = 1; block <= 2_048; block++) { + EncodedBundle bundle = codec.encode(diff(block, 1_400), previous, + StateArchiveFileFormatV3.COMPRESSION_NONE); + writer.appendForCheckpoint(bundle, 1, hash(109)); + previous = bundle.getResultHistoryDigest(); + if (block == 64) { + assertEquals(8, writer.readCommittedDiffs(56, 64).size()); + assertEquals(40, writer.getServingReadFrames()); + shortHistoryBytes = writer.getServingReadBytes(); + } + } + long start = System.nanoTime(); + long totalFrames = 0; + for (int first = 0; first < 2_048; first += 64) { + List read = writer.readCommittedDiffs(first, first + 64); + assertEquals(64, read.size()); + assertEquals(320, writer.getServingReadFrames()); + totalFrames += writer.getServingReadFrames(); + for (int offset = 0; offset < read.size(); offset++) { + assertArrayEquals(new BlockHistoryCodec().encode(diff(first + offset + 1, 1_400)), + new BlockHistoryCodec().encode(read.get(offset))); + } + } + assertEquals(10_240, totalFrames); + assertEquals(1_000, writer.readCommittedDiffs(1_000, 2_000).size()); + assertEquals(5_000, writer.getServingReadFrames()); + assertEquals(8, writer.readCommittedDiffs(2_040, 2_048).size()); + assertEquals(40, writer.getServingReadFrames()); + assertEquals(shortHistoryBytes, writer.getServingReadBytes()); + // Out-of-range data is neither opened nor read, even when that segment is unavailable. + Files.move(segment(root, 0, ".dat"), root.resolve("unrelated-segment.dat")); + assertEquals(8, writer.readCommittedDiffs(2_040, 2_048).size()); + System.out.printf("SERVING_RANGE_READ blocks=2048 batches=32 frames=%d seconds=%.6f%n", + totalFrames, (System.nanoTime() - start) / 1_000_000_000.0); + } + } + + @Test + public void indexedRangesRejectCorruptSelectedIndexWithoutScanningData() throws Exception { + Path root = temporaryFolder.newFolder("corrupt-serving-index").toPath(); + byte[] baseline = hash(89); + StateArchiveFiveLaneBlockCodecV3 codec = new StateArchiveFiveLaneBlockCodecV3(); + try (StateArchiveFiveLaneSegmentWriterV3 writer = + new StateArchiveFiveLaneSegmentWriterV3(root, baseline, + StateArchiveFileFormatV3.COMPRESSION_NONE, 80_000)) { + writer.append(codec.encode(diff(1, 1_400), baseline, + StateArchiveFileFormatV3.COMPRESSION_NONE)); + assertEquals(1, writer.readCommittedDiffs(0, 1).size()); + try (FileChannel index = FileChannel.open(segment(root, 0, ".bidx"), + StandardOpenOption.WRITE)) { + ByteBuffer wrongBlock = ByteBuffer.allocate(8); + wrongBlock.putLong(2).flip(); + index.write(wrongBlock, StateArchiveFileFormatV3.BLOCK_INDEX_HEADER_LENGTH); + } + assertThrows(java.io.IOException.class, () -> writer.readCommittedDiffs(0, 1)); + assertEquals(0, writer.getServingReadFrames()); + assertTrue(writer.readCommittedDiffs(1, 1).isEmpty()); + } + } + + @Test + public void rotatesPreviouslyPublishedTailAtNextCheckpointBoundary() throws Exception { + Path root = temporaryFolder.newFolder("published-tail-rotation").toPath(); + byte[] baseline = hash(89); + StateArchiveFiveLaneBlockCodecV3 codec = new StateArchiveFiveLaneBlockCodecV3(); + EncodedBundle first = codec.encode(diff(1, 1_400), baseline, + StateArchiveFileFormatV3.COMPRESSION_NONE); + EncodedBundle second = codec.encode(diff(2, 0), first.getResultHistoryDigest(), + StateArchiveFileFormatV3.COMPRESSION_NONE); + try (StateArchiveFiveLaneSegmentWriterV3 writer = + new StateArchiveFiveLaneSegmentWriterV3(root, baseline, + StateArchiveFileFormatV3.COMPRESSION_NONE, 1_500)) { + writer.appendForCheckpoint(first, 11, hash(109)); + writer.sync(11, point(first), hash(109)); + writer.appendForCheckpoint(second, 12, hash(110)); + ArchiveDurabilityProof proof = writer.sync(12, point(second), hash(110)); + assertEquals(5, proof.getFileTails().size()); + assertFalse(writer.getSealedSegments().isEmpty()); + } + try (StateArchiveFiveLaneSegmentWriterV3 reopened = + new StateArchiveFiveLaneSegmentWriterV3(root, baseline, + StateArchiveFileFormatV3.COMPRESSION_NONE, 1_500)) { + assertEquals(2, reopened.getAppendHead().getBlockNumber()); + assertEquals(2, reopened.readCommittedDiffs(0, 2).size()); + } + } + + @Test + public void provesSealedAndCurrentTailsAcrossIndependentRotation() throws Exception { + Path root = temporaryFolder.newFolder("five-lane-rotation-proof").toPath(); + byte[] baseline = hash(89); + byte[] commonTarget = hash(109); + StateArchiveFiveLaneBlockCodecV3 codec = new StateArchiveFiveLaneBlockCodecV3(); + EncodedBundle first = codec.encode(diff(1, 1_400), baseline, + StateArchiveFileFormatV3.COMPRESSION_NONE); + EncodedBundle second = codec.encode(diff(2, 0), first.getResultHistoryDigest(), + StateArchiveFileFormatV3.COMPRESSION_NONE); + ArchiveDurabilityProof proof; + + try (StateArchiveFiveLaneSegmentWriterV3 writer = + new StateArchiveFiveLaneSegmentWriterV3(root, baseline, + StateArchiveFileFormatV3.COMPRESSION_NONE, 1_500)) { + writer.appendForCheckpoint(first, 11, commonTarget); + writer.appendForCheckpoint(second, 11, commonTarget); + proof = writer.sync(11, point(second), commonTarget); + assertEquals(6, proof.getFileTails().size()); + assertEquals(2, proof.getFileTails().stream() + .filter(tail -> tail.getLaneId() == 0).count()); + assertEquals(0, proof.getFileTails().get(0).getSegmentSeq()); + assertEquals(1, proof.getFileTails().get(1).getSegmentSeq()); + writer.verifyDurabilityProof(proof); + StateArchiveFiveLaneDurabilityProofV3.publish(root, proof); + } + + assertEquals(832, Files.size( + root.resolve(StateArchiveFiveLaneDurabilityProofV3.FILE_NAME))); + try (StateArchiveFiveLaneSegmentWriterV3 reopened = + new StateArchiveFiveLaneSegmentWriterV3(root, baseline, + StateArchiveFileFormatV3.COMPRESSION_NONE, 1_500)) { + ArchiveDurabilityProof verified = StateArchiveFiveLaneDurabilityProofV3.loadAndVerify( + root, reopened, point(second), commonTarget); + assertEquals(6, verified.getFileTails().size()); + } + } + + @Test + public void failsClosedAtEveryRotationMarkerPhase() throws Exception { + for (SyncStage stage : new SyncStage[]{SyncStage.MARKER_WRITTEN, + SyncStage.DATA_FORCED, SyncStage.MARKER_VERIFIED}) { + Path root = temporaryFolder.newFolder("five-lane-rotation-fault-" + stage).toPath(); + byte[] baseline = hash(88); + byte[] commonTarget = hash(108); + StateArchiveFiveLaneBlockCodecV3 codec = new StateArchiveFiveLaneBlockCodecV3(); + EncodedBundle first = codec.encode(diff(1, 1_400), baseline, + StateArchiveFileFormatV3.COMPRESSION_NONE); + EncodedBundle second = codec.encode(diff(2, 0), first.getResultHistoryDigest(), + StateArchiveFileFormatV3.COMPRESSION_NONE); + try (StateArchiveFiveLaneSegmentWriterV3 writer = + new StateArchiveFiveLaneSegmentWriterV3(root, baseline, + StateArchiveFileFormatV3.COMPRESSION_NONE, 1_500)) { + writer.appendForCheckpoint(first, 12, commonTarget); + assertThrows(java.io.IOException.class, + () -> writer.appendForCheckpoint(second, 12, commonTarget, + (actual, laneId) -> { + if (actual == stage && laneId == 0) { + throw new java.io.IOException("injected rotation fault at " + stage); + } + })); + assertThrows(IllegalStateException.class, () -> writer.sync( + 12, point(first), commonTarget)); + } + } + } + + @Test + public void repairsPartialDataAndIndexAtFiveLaneCommonBoundary() throws Exception { + Path root = temporaryFolder.newFolder("five-lane-recovery").toPath(); + byte[] baseline = hash(80); + StateArchiveFiveLaneBlockCodecV3 codec = new StateArchiveFiveLaneBlockCodecV3(); + EncodedBundle first = codec.encode(diff(1, 10), baseline, + StateArchiveFileFormatV3.COMPRESSION_NONE); + EncodedBundle second = codec.encode(diff(2, 10), first.getResultHistoryDigest(), + StateArchiveFileFormatV3.COMPRESSION_NONE); + EncodedBundle third = codec.encode(diff(3, 10), second.getResultHistoryDigest(), + StateArchiveFileFormatV3.COMPRESSION_NONE); + try (StateArchiveFiveLaneSegmentWriterV3 writer = + new StateArchiveFiveLaneSegmentWriterV3(root, baseline, + StateArchiveFileFormatV3.COMPRESSION_NONE, 10_000)) { + writer.append(first); + writer.append(second); + } + + Path lane22Data = segment(root, 22, ".dat"); + try (FileChannel channel = FileChannel.open(lane22Data, StandardOpenOption.WRITE)) { + channel.truncate(channel.size() - 10); + channel.force(false); + } + Path lane5Index = segment(root, 5, ".bidx"); + try (FileChannel channel = FileChannel.open(lane5Index, StandardOpenOption.WRITE)) { + channel.truncate(channel.size() - 7); + channel.force(false); + } + Files.delete(segment(root, 13, ".bidx")); + byte[] extraMixedFrame = third.getLanes().stream() + .filter(lane -> lane.getLaneId() == 0).findFirst().get().getFrame(); + try (FileChannel channel = FileChannel.open(segment(root, 0, ".dat"), + StandardOpenOption.WRITE)) { + channel.position(channel.size()); + ByteBuffer bytes = ByteBuffer.wrap(extraMixedFrame); + while (bytes.hasRemaining()) { + channel.write(bytes); + } + channel.force(false); + } + + try (StateArchiveFiveLaneSegmentWriterV3 recovered = + StateArchiveFiveLaneSegmentWriterV3.recover(root, baseline, + StateArchiveFileFormatV3.COMPRESSION_NONE, 10_000, 2)) { + assertEquals(1, recovered.getAppendHead().getBlockNumber()); + assertEquals(5, recovered.getCurrentSegments().size()); + assertTrue(recovered.getCurrentSegments().stream() + .allMatch(segment -> segment.getCurrentLastBlock() == 1 + && segment.getBlockFrameCount() == 1)); + assertTrue(Files.isRegularFile(segment(root, 13, ".bidx"))); + } + + Map repairedSizes = fileSizes(root); + try (StateArchiveFiveLaneSegmentWriterV3 reopened = + new StateArchiveFiveLaneSegmentWriterV3(root, baseline, + StateArchiveFileFormatV3.COMPRESSION_NONE, 10_000)) { + assertEquals(1, reopened.getAppendHead().getBlockNumber()); + } + assertEquals(repairedSizes, fileSizes(root)); + } + + @Test + public void resumesDurableRecoveryIntentAtEveryMutationBoundary() throws Exception { + for (RecoveryStage stage : RecoveryStage.values()) { + Path root = temporaryFolder.newFolder("five-lane-intent-" + stage).toPath(); + byte[] baseline = hash(70); + StateArchiveFiveLaneBlockCodecV3 codec = new StateArchiveFiveLaneBlockCodecV3(); + EncodedBundle first = codec.encode(diff(1, 10), baseline, + StateArchiveFileFormatV3.COMPRESSION_NONE); + EncodedBundle second = codec.encode(diff(2, 10), first.getResultHistoryDigest(), + StateArchiveFileFormatV3.COMPRESSION_NONE); + prepareDamagedTail(root, baseline, first, second); + + RecoveryPoint authorized = point(second); + RecoveryPoint common = point(first); + StateArchiveFiveLaneSegmentWriterV3.RecoveryFaultHook fault = (actual, laneId) -> { + if (actual == stage) { + throw new java.io.IOException("injected recovery fault at " + stage); + } + }; + assertThrows(java.io.IOException.class, + () -> StateArchiveFiveLaneSegmentWriterV3.recover(root, baseline, + StateArchiveFileFormatV3.COMPRESSION_NONE, 10_000, + authorized, common, fault)); + + Path intent = root.resolve(StateArchiveFiveLaneRecoveryIntentV3.FILE_NAME); + Path temporary = root.resolve(StateArchiveFiveLaneRecoveryIntentV3.TEMP_FILE_NAME); + if (stage == RecoveryStage.TEMPORARY_FORCED) { + assertFalse(Files.exists(intent)); + assertTrue(Files.isRegularFile(temporary)); + try (StateArchiveFiveLaneSegmentWriterV3 recovered = + StateArchiveFiveLaneSegmentWriterV3.recover(root, baseline, + StateArchiveFileFormatV3.COMPRESSION_NONE, 10_000, + authorized, common, StateArchiveFiveLaneSegmentWriterV3.RecoveryFaultHook.NONE)) { + assertEquals(1, recovered.getAppendHead().getBlockNumber()); + } + } else { + assertFalse(Files.exists(temporary)); + assertEquals(stage != RecoveryStage.INTENT_DELETED, Files.exists(intent)); + try (StateArchiveFiveLaneSegmentWriterV3 recovered = + new StateArchiveFiveLaneSegmentWriterV3(root, baseline, + StateArchiveFileFormatV3.COMPRESSION_NONE, 10_000)) { + assertEquals(1, recovered.getAppendHead().getBlockNumber()); + assertTrue(recovered.getCurrentSegments().stream() + .allMatch(segment -> segment.getCurrentLastBlock() == 1)); + } + } + assertFalse(Files.exists(intent)); + assertFalse(Files.exists(temporary)); + Map recoveredSizes = fileSizes(root); + try (StateArchiveFiveLaneSegmentWriterV3 reopened = + new StateArchiveFiveLaneSegmentWriterV3(root, baseline, + StateArchiveFileFormatV3.COMPRESSION_NONE, 10_000)) { + assertEquals(1, reopened.getAppendHead().getBlockNumber()); + } + assertEquals(recoveredSizes, fileSizes(root)); + } + } + + @Test + public void resumesDeletePairPlanWithExplicitMissingLane() throws Exception { + Path root = temporaryFolder.newFolder("five-lane-delete-intent").toPath(); + byte[] baseline = hash(60); + StateArchiveFiveLaneBlockCodecV3 codec = new StateArchiveFiveLaneBlockCodecV3(); + EncodedBundle first = codec.encode(diff(1, 10), baseline, + StateArchiveFileFormatV3.COMPRESSION_NONE); + try (StateArchiveFiveLaneSegmentWriterV3 writer = + new StateArchiveFiveLaneSegmentWriterV3(root, baseline, + StateArchiveFileFormatV3.COMPRESSION_NONE, 10_000)) { + writer.append(first); + } + Files.delete(segment(root, 22, ".bidx")); + Files.delete(segment(root, 22, ".dat")); + + assertThrows(java.io.IOException.class, + () -> StateArchiveFiveLaneSegmentWriterV3.recover(root, baseline, + StateArchiveFileFormatV3.COMPRESSION_NONE, 10_000, point(first), null, + (stage, laneId) -> { + if (stage == RecoveryStage.LANE_INDEX_APPLIED && laneId == 0) { + throw new java.io.IOException("injected delete-pair fault"); + } + })); + assertTrue(Files.isRegularFile( + root.resolve(StateArchiveFiveLaneRecoveryIntentV3.FILE_NAME))); + assertFalse(Files.exists(segment(root, 0, ".bidx"))); + assertTrue(Files.isRegularFile(segment(root, 0, ".dat"))); + + try (StateArchiveFiveLaneSegmentWriterV3 recovered = + new StateArchiveFiveLaneSegmentWriterV3(root, baseline, + StateArchiveFileFormatV3.COMPRESSION_NONE, 10_000)) { + assertEquals(null, recovered.getAppendHead()); + assertTrue(recovered.getCurrentSegments().isEmpty()); + } + assertTrue(filesWithSuffix(root, ".dat").isEmpty()); + assertTrue(filesWithSuffix(root, ".bidx").isEmpty()); + assertFalse(Files.exists(root.resolve(StateArchiveFiveLaneRecoveryIntentV3.FILE_NAME))); + + Path emptyRoot = temporaryFolder.newFolder("five-lane-delete-empty-intent").toPath(); + try (StateArchiveFiveLaneSegmentWriterV3 writer = + new StateArchiveFiveLaneSegmentWriterV3(emptyRoot, baseline, + StateArchiveFileFormatV3.COMPRESSION_NONE, 10_000)) { + writer.append(first); + } + Files.delete(segment(emptyRoot, 22, ".bidx")); + Files.delete(segment(emptyRoot, 22, ".dat")); + assertThrows(java.io.IOException.class, + () -> StateArchiveFiveLaneSegmentWriterV3.recover(emptyRoot, baseline, + StateArchiveFileFormatV3.COMPRESSION_NONE, 10_000, point(first), null, + (stage, laneId) -> { + if (stage == RecoveryStage.LANE_DATA_APPLIED && laneId == 13) { + throw new java.io.IOException("injected final delete-pair fault"); + } + })); + assertTrue(filesWithSuffix(emptyRoot, ".dat").isEmpty()); + assertTrue(Files.isRegularFile( + emptyRoot.resolve(StateArchiveFiveLaneRecoveryIntentV3.FILE_NAME))); + try (StateArchiveFiveLaneSegmentWriterV3 recovered = + new StateArchiveFiveLaneSegmentWriterV3(emptyRoot, baseline, + StateArchiveFileFormatV3.COMPRESSION_NONE, 10_000)) { + assertEquals(null, recovered.getAppendHead()); + } + assertFalse(Files.exists( + emptyRoot.resolve(StateArchiveFiveLaneRecoveryIntentV3.FILE_NAME))); + } + + @Test + public void durableIntentRejectsTargetPrefixDrift() throws Exception { + Path root = temporaryFolder.newFolder("five-lane-intent-drift").toPath(); + byte[] baseline = hash(50); + StateArchiveFiveLaneBlockCodecV3 codec = new StateArchiveFiveLaneBlockCodecV3(); + EncodedBundle first = codec.encode(diff(1, 10), baseline, + StateArchiveFileFormatV3.COMPRESSION_NONE); + EncodedBundle second = codec.encode(diff(2, 10), first.getResultHistoryDigest(), + StateArchiveFileFormatV3.COMPRESSION_NONE); + prepareDamagedTail(root, baseline, first, second); + assertThrows(java.io.IOException.class, + () -> StateArchiveFiveLaneSegmentWriterV3.recover(root, baseline, + StateArchiveFileFormatV3.COMPRESSION_NONE, 10_000, point(second), point(first), + (stage, laneId) -> { + if (stage == RecoveryStage.INTENT_PUBLISHED) { + throw new java.io.IOException("injected published-intent fault"); + } + })); + Path lane0 = segment(root, 0, ".dat"); + try (FileChannel channel = FileChannel.open(lane0, + StandardOpenOption.READ, StandardOpenOption.WRITE)) { + long offset = StateArchiveFileFormatV3.PART_HEADER_LENGTH + 40; + channel.position(offset); + ByteBuffer value = ByteBuffer.allocate(1); + channel.read(value); + value.flip(); + value.put(0, (byte) (value.get(0) ^ 1)); + channel.position(offset); + channel.write(value); + channel.force(false); + } + assertThrows(IllegalArgumentException.class, + () -> new StateArchiveFiveLaneSegmentWriterV3(root, baseline, + StateArchiveFileFormatV3.COMPRESSION_NONE, 10_000)); + assertTrue(Files.isRegularFile( + root.resolve(StateArchiveFiveLaneRecoveryIntentV3.FILE_NAME))); + } + + @Test + public void forcesAndReverifiesCompleteFiveLaneDurabilityProof() throws Exception { + Path root = temporaryFolder.newFolder("five-lane-sync").toPath(); + byte[] baseline = hash(40); + byte[] commonTarget = hash(100); + StateArchiveFiveLaneBlockCodecV3 codec = new StateArchiveFiveLaneBlockCodecV3(); + EncodedBundle first = codec.encode(diff(1, 10), baseline, + StateArchiveFileFormatV3.COMPRESSION_NONE); + EncodedBundle second = codec.encode(diff(2, 10), first.getResultHistoryDigest(), + StateArchiveFileFormatV3.COMPRESSION_NONE); + EncodedBundle third = codec.encode(diff(3, 10), second.getResultHistoryDigest(), + StateArchiveFileFormatV3.COMPRESSION_NONE); + ArchiveDurabilityProof proof; + try (StateArchiveFiveLaneSegmentWriterV3 writer = + new StateArchiveFiveLaneSegmentWriterV3(root, baseline, + StateArchiveFileFormatV3.COMPRESSION_NONE, 10_000)) { + writer.append(first); + writer.append(second); + proof = writer.sync(7, point(second), commonTarget); + assertEquals(7, proof.getCheckpointSequence()); + assertEquals(5, proof.getFileTails().size()); + assertArrayEquals(StateArchiveFileFormatV3.compositeFormatDigest(), + proof.getFormatIdentity()); + assertArrayEquals(StateArchiveFileFormatV3.fiveLaneDescriptorDigest(), + proof.getDescriptorDigest()); + writer.verifyDurabilityProof(proof); + assertEquals(proof, writer.sync(8, point(second), commonTarget)); + writer.append(third); + } + + try (StateArchiveFiveLaneSegmentWriterV3 reopened = + new StateArchiveFiveLaneSegmentWriterV3(root, baseline, + StateArchiveFileFormatV3.COMPRESSION_NONE, 10_000)) { + reopened.verifyDurabilityProof(proof); + assertEquals(7, reopened.getLastDurabilityProof().getCheckpointSequence()); + assertEquals(3, reopened.getAppendHead().getBlockNumber()); + ArchiveDurabilityProof next = reopened.sync(8, point(third), hash(101)); + assertEquals(8, next.getCheckpointSequence()); + assertTrue(next.getFileTails().stream() + .allMatch(tail -> tail.getMarkerOffset() > proof.getFileTails().stream() + .filter(previous -> previous.getLaneId() == tail.getLaneId()) + .findFirst().get().getMarkerOffset())); + reopened.verifyDurabilityProof(next); + } + } + + @Test + public void neverReturnsProofBeforeEveryMarkerForceAndRereadCompletes() + throws Exception { + for (SyncStage expected : SyncStage.values()) { + Path root = temporaryFolder.newFolder("five-lane-sync-fault-" + expected).toPath(); + byte[] baseline = hash(30); + StateArchiveFiveLaneBlockCodecV3 codec = new StateArchiveFiveLaneBlockCodecV3(); + EncodedBundle first = codec.encode(diff(1, 10), baseline, + StateArchiveFileFormatV3.COMPRESSION_NONE); + try (StateArchiveFiveLaneSegmentWriterV3 writer = + new StateArchiveFiveLaneSegmentWriterV3(root, baseline, + StateArchiveFileFormatV3.COMPRESSION_NONE, 10_000)) { + writer.append(first); + assertThrows(java.io.IOException.class, + () -> writer.sync(1, point(first), hash(99), (actual, laneId) -> { + if (actual == expected) { + throw new java.io.IOException("injected sync fault at " + expected); + } + })); + assertThrows(IllegalStateException.class, () -> writer.append(first)); + } + } + } + + private static void prepareDamagedTail(Path root, byte[] baseline, + EncodedBundle first, EncodedBundle second) throws Exception { + try (StateArchiveFiveLaneSegmentWriterV3 writer = + new StateArchiveFiveLaneSegmentWriterV3(root, baseline, + StateArchiveFileFormatV3.COMPRESSION_NONE, 10_000)) { + writer.append(first); + writer.append(second); + } + try (FileChannel channel = FileChannel.open(segment(root, 22, ".dat"), + StandardOpenOption.WRITE)) { + channel.truncate(channel.size() - 10); + channel.force(false); + } + } + + private static RecoveryPoint point(EncodedBundle bundle) { + BlockSnapshotMeta meta = bundle.getDiff().getMeta(); + return new RecoveryPoint(meta.getEpoch(), meta.getBlockNumber(), meta.getTimestamp(), + meta.getBlockHash(), meta.getParentHash(), bundle.getResultHistoryDigest()); + } + + private static BlockReverseDiff diff(int blockNumber, int valueLength) { + byte[] value = new byte[valueLength]; + java.util.Arrays.fill(value, (byte) blockNumber); + List groups = valueLength == 0 ? Collections.emptyList() + : Collections.singletonList(new DbGroup(StateArchiveFileFormatV3.dbName(1), + Collections.singletonList(new Entry(new byte[]{1}, OldValue.present(value))))); + return new BlockReverseDiff(new BlockSnapshotMeta(blockNumber, blockNumber, + hash(blockNumber), hash(blockNumber - 1), blockNumber * 3_000L), groups); + } + + private static List filesWithSuffix(Path root, String suffix) throws Exception { + try (Stream files = Files.walk(root)) { + return files.filter(Files::isRegularFile) + .filter(path -> path.getFileName().toString().endsWith(suffix)) + .collect(Collectors.toList()); + } + } + + private static Path segment(Path root, int laneId, String suffix) { + return root.resolve("segments/shard-000000") + .resolve(String.format("lane-%04d-seg-%020d%s", laneId, 0, suffix)); + } + + private static Map fileSizes(Path root) throws Exception { + Map sizes = new HashMap<>(); + try (Stream files = Files.walk(root)) { + for (Path path : files.filter(Files::isRegularFile).collect(Collectors.toList())) { + sizes.put(root.relativize(path), Files.size(path)); + } + } + return sizes; + } + + private static byte[] hash(int suffix) { + byte[] result = new byte[32]; + result[31] = (byte) suffix; + return result; + } +} diff --git a/framework/src/test/java/org/tron/core/db2/archive/StateArchiveHotCheckpointMaterializerTest.java b/framework/src/test/java/org/tron/core/db2/archive/StateArchiveHotCheckpointMaterializerTest.java new file mode 100644 index 00000000000..4f4676fcb38 --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/archive/StateArchiveHotCheckpointMaterializerTest.java @@ -0,0 +1,175 @@ +package org.tron.core.db2.archive; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.nio.file.Path; +import java.util.Collections; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tron.core.db2.core.CommonCheckpointCapture; +import org.tron.core.db2.core.CommonCheckpointFile; +import org.tron.core.db2.core.CommonCheckpointMaterializer; +import org.tron.core.db2.core.CommonCheckpointMaterializer.Authority; +import org.tron.core.db2.core.CommonCheckpointMaterializer.Status; +import org.tron.core.db2.core.CommonCheckpointPayload; +import org.tron.core.db2.core.CommonCheckpointRedoCoordinator; +import org.tron.core.db2.core.CommonCheckpointRedoCoordinator.RecoveryAction; +import org.tron.core.db2.core.CommonCheckpointTarget; +import org.tron.core.db2.stateroot.PathStateFlushTarget; +import org.tron.core.db2.stateroot.PathStateStoreManifest.Engine; + +public class StateArchiveHotCheckpointMaterializerTest { + + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void commonParticipantOnlyVerifiesPreparedHotBodiesThenPublishes() throws Exception { + Path root = temporaryFolder.newFolder("hot-materializer").toPath(); + byte[] format = hash(7); + StateArchiveHotStore store = open(root, format); + BlockReverseDiff diff = diff(); + StateArchiveHotBatchDescriptor descriptor = store.planCheckpoint( + Collections.singletonList(diff)); + CommonCheckpointPayload payload = payload(format, descriptor); + CommonCheckpointTarget target = CommonCheckpointTarget.from(payload); + CommonCheckpointCapture capture = CommonCheckpointCapture.create(payload, + Collections.singletonList(diff), descriptor); + StateArchiveHotCheckpointMaterializer materializer = + new StateArchiveHotCheckpointMaterializer(store); + try { + assertEquals(Status.NEEDS_MATERIALIZATION, materializer.inspect(target)); + CommonCheckpointTarget foreignFormat = CommonCheckpointTarget.from( + payload(hash(8), descriptor)); + assertThrows(ArchivePersistenceException.class, + () -> materializer.inspect(foreignFormat)); + assertThrows(IllegalArgumentException.class, + () -> materializer.prepare(target, Collections.singletonList( + new BlockReverseDiff(BlockSnapshotMeta.forBlock(2, hash(2), hash(1), 6_000L), + Collections.emptyList())))); + assertThrows(ArchivePersistenceException.class, () -> store.loadBlock(1)); + assertThrows(java.io.IOException.class, + () -> materializer.materialize(payload, target)); + assertEquals(0, store.getMaterializedHead()); + + assertEquals(target, materializer.prepare(capture)); + assertEquals(target, materializer.prepare(capture)); + assertEquals(Status.MATERIALIZED, materializer.inspect(target)); + assertEquals(1, store.getMaterializedHead()); + assertEquals(0, store.getCommittedHead()); + assertFalse(store.findOldValueAfter("code", new byte[]{1}, 0).isPresent()); + materializer.materialize(payload, target); + materializer.publish(target); + assertEquals(Status.PUBLISHED, materializer.inspect(target)); + assertEquals(1, store.loadBlock(1).getMeta().getBlockNumber()); + } finally { + materializer.close(); + } + + StateArchiveHotStore reopenedStore = open(root, format); + StateArchiveHotCheckpointMaterializer reopened = + new StateArchiveHotCheckpointMaterializer(reopenedStore); + try { + assertEquals(Status.PUBLISHED, reopened.inspect(target)); + reopened.materialize(payload, target); + reopened.publish(target); + assertEquals(1, reopenedStore.getCommittedHead()); + } finally { + reopened.close(); + } + } + + @Test + public void prepreparedHotCaptureCrossesExistingCoordinatorWithoutWalBody() throws Exception { + Path root = temporaryFolder.newFolder("hot-v2-coordinator").toPath(); + byte[] format = hash(9); + StateArchiveHotStore store = open(root.resolve("hot"), format); + BlockReverseDiff diff = diff(); + StateArchiveHotBatchDescriptor descriptor = store.planCheckpoint( + Collections.singletonList(diff)); + CommonCheckpointPayload payload = payload(format, descriptor); + CommonCheckpointCapture capture = CommonCheckpointCapture.create(payload, + Collections.singletonList(diff), descriptor); + StateArchiveHotCheckpointMaterializer archive = + new StateArchiveHotCheckpointMaterializer(store); + CommonCheckpointTarget target = archive.prepare(capture); + try (CommonCheckpointRedoCoordinator coordinator = new CommonCheckpointRedoCoordinator( + new CommonCheckpointFile(root.resolve("wal")), new FakeMaterializer(Authority.CHAINBASE), + new FakeMaterializer(Authority.PATH_STATE), archive)) { + assertEquals(RecoveryAction.COMPLETED_REDO, coordinator.apply(payload)); + assertEquals(Status.PUBLISHED, archive.inspect(target)); + assertEquals(1, store.getCommittedHead()); + assertEquals(RecoveryAction.NO_CHECKPOINT, coordinator.recover()); + } + } + + private StateArchiveHotStore open(Path root, byte[] format) throws java.io.IOException { + return StateArchiveHotStore.openOrCreate(root, format, Engine.LEVELDB, + 0, hash(0), 3, 10, 1024 * 1024); + } + + private static CommonCheckpointPayload payload(byte[] format, + StateArchiveHotBatchDescriptor descriptor) { + BlockSnapshotMeta meta = BlockSnapshotMeta.forBlock(1, hash(1), hash(0), 3_000L); + PathStateFlushTarget.BlockBinding binding = mock(PathStateFlushTarget.BlockBinding.class); + when(binding.getMeta()).thenReturn(meta); + when(binding.getParentStateRoot()).thenReturn(hash(5)); + when(binding.getStateRoot()).thenReturn(hash(6)); + when(binding.getTransitionPayloadDigest()).thenReturn(hash(71)); + PathStateFlushTarget pathState = mock(PathStateFlushTarget.class); + when(pathState.getBlocks()).thenReturn(Collections.singletonList(binding)); + when(pathState.getParentStateRoot()).thenReturn(hash(5)); + when(pathState.getStateRoot()).thenReturn(hash(6)); + when(pathState.getStores()).thenReturn(Collections.emptyList()); + when(pathState.getSuperNodeMutations()).thenReturn(Collections.emptyList()); + return CommonCheckpointPayload.createV2(format, pathState, descriptor, + Collections.emptyList()); + } + + private static BlockReverseDiff diff() { + return new BlockReverseDiff(BlockSnapshotMeta.forBlock(1, hash(1), hash(0), 3_000L), + Collections.singletonList(new BlockReverseDiff.DbGroup("code", + Collections.singletonList(new BlockReverseDiff.Entry(new byte[]{1}, + OldValue.absent()))))); + } + + private static byte[] hash(int marker) { + byte[] hash = new byte[32]; + hash[31] = (byte) marker; + return hash; + } + + private static final class FakeMaterializer implements CommonCheckpointMaterializer { + private final Authority authority; + private Status status = Status.NEEDS_MATERIALIZATION; + + private FakeMaterializer(Authority authority) { + this.authority = authority; + } + + @Override + public Authority authority() { + return authority; + } + + @Override + public Status inspect(CommonCheckpointTarget target) { + return status; + } + + @Override + public void materialize(CommonCheckpointPayload payload, CommonCheckpointTarget target) { + status = Status.MATERIALIZED; + } + + @Override + public void publish(CommonCheckpointTarget target) { + status = Status.PUBLISHED; + } + } +} diff --git a/framework/src/test/java/org/tron/core/db2/archive/StateArchiveHotProcessRecoveryTest.java b/framework/src/test/java/org/tron/core/db2/archive/StateArchiveHotProcessRecoveryTest.java new file mode 100644 index 00000000000..7630a796a72 --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/archive/StateArchiveHotProcessRecoveryTest.java @@ -0,0 +1,174 @@ +package org.tron.core.db2.archive; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.io.File; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tron.core.db2.archive.BlockReverseDiff.DbGroup; +import org.tron.core.db2.archive.BlockReverseDiff.Entry; +import org.tron.core.db2.core.CommonCheckpointFile; +import org.tron.core.db2.core.CommonCheckpointHotRecovery; +import org.tron.core.db2.stateroot.PathStateStoreManifest.Engine; + +/** Verifies recovery after an actual JVM halt, not only an injected in-process exception. */ +public class StateArchiveHotProcessRecoveryTest { + + private static final int HALT_CODE = 91; + private static final byte[] FORMAT = hash(70); + private static final byte[] BASE_HASH = hash(0); + private static final byte[] TARGET = hash(80); + + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void jvmHaltAfterHotPrepareBeforeCommonWalTruncatesOrphanOnRestart() + throws Exception { + for (Engine engine : Engine.values()) { + assertHaltRecovery(engine); + } + } + + private void assertHaltRecovery(Engine engine) throws Exception { + Path root = temporaryFolder.newFolder("hot-process-recovery-" + engine.name()).toPath(); + Process child = new ProcessBuilder(javaExecutable(), "-cp", runtimeClasspath(), + StateArchiveHotProcessRecoveryTest.class.getName(), "halt-after-prepare", + root.toString(), engine.name()).redirectErrorStream(true) + .redirectOutput(root.resolve("halt-after-prepare.log").toFile()).start(); + assertTrue("child process timed out", child.waitFor(30, TimeUnit.SECONDS)); + assertEquals(HALT_CODE, child.exitValue()); + + Path hotRoot = root.resolve("hot"); + assertPreparedBatch(hotRoot, engine); + try (StateArchiveHotStore store = open(hotRoot, engine)) { + assertEquals(StateArchiveHotStore.HotCheckpointStatus.MATERIALIZED, + store.inspectCheckpoint(TARGET)); + assertEquals(0L, store.getCommittedHead()); + assertEquals(1L, store.getMaterializedHead()); + CommonCheckpointHotRecovery recovery = new CommonCheckpointHotRecovery( + new CommonCheckpointFile(root.resolve("common")), + () -> new CommonCheckpointHotRecovery.PersistentDynamicHead(0, BASE_HASH), + ignored -> meta(0, 0), store::reconcilePreparedTail); + CommonCheckpointHotRecovery.Result result = recovery.reconcileBeforeCommonRedo(); + assertEquals(CommonCheckpointHotRecovery.Source.PERSISTED_DYNAMIC, result.getSource()); + assertEquals(1L, result.getRemovedBlocks()); + assertEquals(0L, store.getMaterializedHead()); + assertEquals(StateArchiveHotStore.HotCheckpointStatus.NEEDS_MATERIALIZATION, + store.inspectCheckpoint(TARGET)); + assertThrows(ArchivePersistenceException.class, () -> store.loadBlock(1)); + } + + try (StateArchiveHotStore reopened = open(hotRoot, engine)) { + assertEquals(0L, reopened.getCommittedHead()); + assertEquals(0L, reopened.getMaterializedHead()); + assertEquals(StateArchiveHotStore.HotCheckpointStatus.NEEDS_MATERIALIZATION, + reopened.inspectCheckpoint(TARGET)); + } + } + + /** Child-process entry point used to leave only the native-sync Hot PREPARED authority. */ + public static void main(String[] args) throws Exception { + if (args.length != 3 || !"halt-after-prepare".equals(args[0])) { + throw new IllegalArgumentException("unknown process recovery mode"); + } + StateArchiveHotStore store = open(Paths.get(args[1]).resolve("hot"), + Engine.valueOf(args[2])); + store.prepareCheckpoint(TARGET, Collections.singletonList(diff(1, 0))); + Runtime.getRuntime().halt(HALT_CODE); + } + + private static StateArchiveHotStore open(Path root, Engine engine) throws Exception { + return StateArchiveHotStore.openOrCreate(root, FORMAT, engine, 0, BASE_HASH, + 3, 10, 1024 * 1024); + } + + private static void assertPreparedBatch(Path hotRoot, Engine engine) throws Exception { + if (engine != Engine.ROCKSDB) { + return; + } + Path database = hotRoot.resolve(StateArchiveHotStore.GENERATIONS) + .resolve("00000000000000000000").resolve(StateArchiveHotStore.DATABASE); + try (StateArchiveIndexDatabase.Reader reader = StateArchiveIndexDatabase.openHotReader( + database, engine, org.tron.core.config.args.StorageConfig.NativeDbConfig.large())) { + assertEquals(29, StateArchiveIndexDatabase.hotColumnFamilies().size()); + org.junit.Assert.assertArrayEquals(TARGET, reader.get( + "meta/prepared-target".getBytes(StandardCharsets.US_ASCII))); + org.junit.Assert.assertNotNull(reader.get( + StateArchiveIndexDatabase.HOT_BLOCKS_COLUMN_FAMILY, + ByteBuffer.allocate(1 + Long.BYTES).put((byte) 0x42).putLong(1).array())); + for (String store : Arrays.asList("account", "code", "storage-row")) { + org.junit.Assert.assertNotNull(reader.get( + StateArchiveIndexDatabase.hotStoreColumnFamily(store), + hotIndexKey(store, new byte[]{(byte) store.length()}, 1))); + } + } + } + + private static BlockReverseDiff diff(long block, int parent) { + return new BlockReverseDiff(meta(block, parent), Arrays.asList( + group("account"), group("code"), group("storage-row"))); + } + + private static DbGroup group(String store) { + return new DbGroup(store, Collections.singletonList( + new Entry(new byte[]{(byte) store.length()}, OldValue.absent()))); + } + + private static byte[] hotIndexKey(String store, byte[] key, long block) { + byte[] storeBytes = store.getBytes(StandardCharsets.UTF_8); + return ByteBuffer.allocate(1 + Short.BYTES + storeBytes.length + Integer.BYTES + key.length + + Long.BYTES).put((byte) 0x4b).putShort((short) storeBytes.length).put(storeBytes) + .putInt(key.length).put(key).putLong(block).array(); + } + + private static BlockSnapshotMeta meta(long block, int parent) { + return BlockSnapshotMeta.forBlock(block, hash((int) block), hash(parent), block * 3_000L); + } + + private static String javaExecutable() { + return Paths.get(System.getProperty("java.home"), "bin", "java").toString(); + } + + private static String runtimeClasspath() { + Set entries = new LinkedHashSet<>(); + String configured = System.getProperty("java.class.path", ""); + Collections.addAll(entries, configured.split(java.util.regex.Pattern.quote( + File.pathSeparator))); + for (ClassLoader loader = StateArchiveHotProcessRecoveryTest.class.getClassLoader(); + loader != null; loader = loader.getParent()) { + if (loader instanceof URLClassLoader) { + for (URL url : ((URLClassLoader) loader).getURLs()) { + if ("file".equals(url.getProtocol())) { + try { + entries.add(Paths.get(url.toURI()).toString()); + } catch (java.net.URISyntaxException invalid) { + throw new IllegalStateException("invalid test runtime classpath", invalid); + } + } + } + } + } + return String.join(File.pathSeparator, entries); + } + + private static byte[] hash(int marker) { + byte[] hash = new byte[32]; + hash[31] = (byte) marker; + return hash; + } +} diff --git a/framework/src/test/java/org/tron/core/db2/archive/StateArchiveHotStoreTest.java b/framework/src/test/java/org/tron/core/db2/archive/StateArchiveHotStoreTest.java new file mode 100644 index 00000000000..654ef1da922 --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/archive/StateArchiveHotStoreTest.java @@ -0,0 +1,508 @@ +package org.tron.core.db2.archive; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Optional; +import java.util.Set; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tron.core.config.args.StorageConfig.NativeDbConfig; +import org.tron.core.config.args.StorageConfig.StateArchiveHotStoreConfig; +import org.tron.core.db2.archive.BlockReverseDiff.DbGroup; +import org.tron.core.db2.archive.BlockReverseDiff.Entry; +import org.tron.core.db2.stateroot.PathStateStoreManifest.Engine; + +public class StateArchiveHotStoreTest { + + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void appendsSealsQueriesAndReopensAcrossBothEngines() throws Exception { + for (Engine engine : Engine.values()) { + Path root = temporaryFolder.newFolder("hot-" + engine.name()).toPath(); + byte[] format = hash(90); + try (StateArchiveHotStore store = open(root, format, engine, 0, hash(0), 3, 2)) { + store.appendSolidified(Arrays.asList( + diff(1, 0, "code", new byte[]{1}, OldValue.absent()), + diff(2, 1, "code", new byte[]{1}, OldValue.present(new byte[0])))); + assertTrue(store.shouldSealCurrent()); + assertLookup(store.findOldValueAfter("code", new byte[]{1}, 0), 1, + OldValue.absent()); + assertLookup(store.findOldValueAfter("code", new byte[]{1}, 1), 2, + OldValue.present(new byte[0])); + assertEquals(0, store.sealCurrent()); + assertEquals(Collections.singletonList(0L), store.getFrozenGenerationIds()); + assertEquals(1, store.getCurrentGenerationId()); + store.appendSolidified(Collections.singletonList( + diff(3, 2, "account", new byte[]{3}, OldValue.present(new byte[]{33})))); + } + + try (StateArchiveHotStore reopened = open(root, format, engine, 0, hash(0), 3, 2)) { + assertEquals(3, reopened.getCommittedHead()); + assertEquals(1, reopened.getCurrentGenerationId()); + assertEquals(Collections.singletonList(0L), reopened.getFrozenGenerationIds()); + assertEquals(1, reopened.loadBlock(1).getMeta().getBlockNumber()); + assertLookup(reopened.findOldValueAfter("account", new byte[]{3}, 2), 3, + OldValue.present(new byte[]{33})); + assertFalse(reopened.findOldValueAfter("missing", new byte[]{1}, 0).isPresent()); + } + + assertTrue(Files.isRegularFile(root.resolve(StateArchiveHotStore.CURRENT))); + assertTrue(Files.isDirectory(root.resolve(StateArchiveHotStore.GENERATIONS) + .resolve("00000000000000000000").resolve(StateArchiveHotStore.DATABASE))); + } + } + + @Test + public void rocksCheckpointWritesOneExactCrossColumnFamilyBatch() throws Exception { + Path root = temporaryFolder.newFolder("hot-rocks-column-families").toPath(); + byte[] format = hash(120); + byte[] target = hash(121); + BlockReverseDiff block = new BlockReverseDiff(meta(1, 0, 1), Arrays.asList( + new DbGroup("account", Collections.singletonList( + new Entry(new byte[]{1}, OldValue.absent()))), + new DbGroup("code", Collections.singletonList( + new Entry(new byte[]{2}, OldValue.present(new byte[]{22})))), + new DbGroup("storage-row", Collections.singletonList( + new Entry(new byte[]{3}, OldValue.present(new byte[0])))))); + Path database = root.resolve(StateArchiveHotStore.GENERATIONS) + .resolve("00000000000000000000").resolve(StateArchiveHotStore.DATABASE); + + try (StateArchiveHotStore store = open(root, format, Engine.ROCKSDB, + 0, hash(0), 3, 10)) { + store.prepareCheckpoint(target, Collections.singletonList(block)); + try (StateArchiveIndexDatabase.Reader reader = StateArchiveIndexDatabase.openHotReader( + database, Engine.ROCKSDB, NativeDbConfig.large())) { + assertArrayEquals(target, reader.get("meta/prepared-target" + .getBytes(StandardCharsets.US_ASCII))); + assertNotNull(reader.get(StateArchiveIndexDatabase.HOT_BLOCKS_COLUMN_FAMILY, + hotBodyKey(1))); + for (DbGroup group : block.getGroups()) { + Entry entry = group.getEntries().get(0); + assertArrayEquals(ByteBuffer.allocate(Long.BYTES).putLong(1).array(), reader.get( + StateArchiveIndexDatabase.hotStoreColumnFamily(group.getDbName()), + hotIndexKey(group.getDbName(), entry.getKey(), 1))); + } + } + store.publishCheckpoint(target); + } + + Set actual = new LinkedHashSet<>(); + try (org.rocksdb.Options options = new org.rocksdb.Options()) { + for (byte[] name : org.rocksdb.RocksDB.listColumnFamilies(options, database.toString())) { + actual.add(new String(name, StandardCharsets.UTF_8)); + } + } + assertEquals(new LinkedHashSet<>(StateArchiveIndexDatabase.hotColumnFamilies()), actual); + assertEquals(29, actual.size()); + + try (StateArchiveHotStore reopened = open(root, format, Engine.ROCKSDB, + 0, hash(0), 3, 10)) { + assertEquals(1, reopened.getCommittedHead()); + assertLookup(reopened.findOldValueAfter("account", new byte[]{1}, 0), 1, + OldValue.absent()); + assertLookup(reopened.findOldValueAfter("code", new byte[]{2}, 0), 1, + OldValue.present(new byte[]{22})); + assertLookup(reopened.findOldValueAfter("storage-row", new byte[]{3}, 0), 1, + OldValue.present(new byte[0])); + } + } + + @Test + public void rejectsDescriptorLayoutBeforeMutationDigestRemoval() throws Exception { + Path root = temporaryFolder.newFolder("hot-descriptor-version").toPath(); + byte[] format = hash(123); + try (StateArchiveHotStore store = open(root, format, Engine.LEVELDB, + 0, hash(0), 3, 10)) { + StateArchiveHotBatchDescriptor descriptor = store.planCheckpoint( + Collections.singletonList(diff(1, 0, "code", new byte[]{1}, OldValue.absent()))); + StateArchiveHotBatchDescriptorCodec codec = new StateArchiveHotBatchDescriptorCodec(); + byte[] encoded = codec.encode(descriptor); + + byte[] oldEnvelopeVersion = Arrays.copyOf(encoded, encoded.length); + ByteBuffer.wrap(oldEnvelopeVersion).putShort(Integer.BYTES, (short) 1); + assertThrows(ArchivePersistenceException.class, () -> codec.decode(oldEnvelopeVersion)); + + byte[] oldBodyVersion = Arrays.copyOf(encoded, encoded.length); + ByteBuffer.wrap(oldBodyVersion).putShort(44, (short) 1); + byte[] body = Arrays.copyOfRange(oldBodyVersion, 44, oldBodyVersion.length); + byte[] checksum = com.google.common.hash.Hashing.sha256().hashBytes(body).asBytes(); + System.arraycopy(checksum, 0, oldBodyVersion, 12, checksum.length); + assertThrows(ArchivePersistenceException.class, () -> codec.decode(oldBodyVersion)); + } + } + + @Test + public void recoversEveryRotationPublicationBoundary() throws Exception { + for (StateArchiveHotStore.Stage failedStage : Arrays.asList( + StateArchiveHotStore.Stage.AFTER_SEAL, + StateArchiveHotStore.Stage.AFTER_NEW_GENERATION, + StateArchiveHotStore.Stage.AFTER_CURRENT)) { + Path root = temporaryFolder.newFolder("fault-" + failedStage).toPath(); + byte[] format = hash(91); + StateArchiveHotStore failed = StateArchiveHotStore.openOrCreate(root, format, + Engine.LEVELDB, 0, hash(0), 2, 1, 1024 * 1024, + stage -> { + if (stage == failedStage) { + throw new IOException("injected " + stage); + } + }); + failed.appendSolidified(Collections.singletonList( + diff(1, 0, "code", new byte[]{1}, OldValue.present(new byte[]{9})))); + assertThrows(IOException.class, failed::sealCurrent); + failed.close(); + + try (StateArchiveHotStore recovered = open(root, format, Engine.LEVELDB, 0, hash(0), + 2, 1)) { + assertEquals(1, recovered.getCurrentGenerationId()); + assertEquals(Collections.singletonList(0L), recovered.getFrozenGenerationIds()); + assertLookup(recovered.findOldValueAfter("code", new byte[]{1}, 0), 1, + OldValue.present(new byte[]{9})); + } + } + } + + @Test + public void reconcilesOnlyCurrentPreparedTailAcrossBothEngines() throws Exception { + for (Engine engine : Engine.values()) { + Path root = temporaryFolder.newFolder("reconcile-" + engine.name()).toPath(); + byte[] format = hash(94); + try (StateArchiveHotStore store = open(root, format, engine, 0, hash(0), 3, 10)) { + store.appendSolidified(Arrays.asList( + diff(1, 0, "code", new byte[]{1}, OldValue.absent()), + diff(2, 1, "code", new byte[]{2}, OldValue.absent()))); + store.prepareCheckpoint(hash(110), Collections.singletonList( + diff(3, 2, "code", new byte[]{3}, OldValue.absent()))); + assertEquals(2, store.getCommittedHead()); + assertEquals(3, store.getMaterializedHead()); + assertThrows(ArchivePersistenceException.class, + () -> store.reconcilePreparedTail(meta(2, 1, 99))); + assertEquals(1, store.reconcilePreparedTail(meta(2, 1, 2))); + assertEquals(2, store.getCommittedHead()); + assertThrows(ArchivePersistenceException.class, () -> store.loadBlock(3)); + assertFalse(store.findOldValueAfter("code", new byte[]{3}, 0).isPresent()); + } + try (StateArchiveHotStore reopened = open(root, format, engine, 0, hash(0), 3, 10)) { + assertEquals(2, reopened.getCommittedHead()); + assertEquals(2, reopened.loadBlock(2).getMeta().getBlockNumber()); + assertThrows(ArchivePersistenceException.class, () -> reopened.loadBlock(3)); + } + } + } + + @Test + public void resumesEveryPreparedTailTruncateBoundary() throws Exception { + for (StateArchiveHotStore.Stage failedStage : Arrays.asList( + StateArchiveHotStore.Stage.AFTER_TRUNCATE_INTENT, + StateArchiveHotStore.Stage.AFTER_TRUNCATE_DELETE_BATCH, + StateArchiveHotStore.Stage.AFTER_TRUNCATE_METADATA)) { + Path root = temporaryFolder.newFolder("truncate-" + failedStage).toPath(); + byte[] format = hash(95); + StateArchiveHotStore failed = StateArchiveHotStore.openOrCreate(root, format, + Engine.LEVELDB, 0, hash(0), 3, 10, 1024 * 1024, + stage -> { + if (stage == failedStage) { + throw new IOException("injected " + stage); + } + }); + failed.appendSolidified(Arrays.asList( + diff(1, 0, "code", new byte[]{1}, OldValue.absent()))); + failed.prepareCheckpoint(hash(111), Collections.singletonList( + diff(2, 1, "code", new byte[]{2}, OldValue.absent()))); + assertThrows(IOException.class, () -> failed.reconcilePreparedTail(meta(1, 0, 1))); + failed.close(); + + try (StateArchiveHotStore recovered = open(root, format, Engine.LEVELDB, 0, hash(0), + 3, 10)) { + assertEquals(1, recovered.getCommittedHead()); + assertEquals(1, recovered.loadBlock(1).getMeta().getBlockNumber()); + assertThrows(ArchivePersistenceException.class, () -> recovered.loadBlock(2)); + } + } + } + + @Test + public void preparesPublishesAndReopensAcrossBothEngines() throws Exception { + for (Engine engine : Engine.values()) { + Path root = temporaryFolder.newFolder("publication-" + engine.name()).toPath(); + byte[] format = hash(97); + byte[] target = hash(112); + BlockReverseDiff block = diff(1, 0, "code", new byte[]{1}, OldValue.absent()); + StateArchiveHotBatchDescriptor descriptor; + try (StateArchiveHotStore store = open(root, format, engine, 0, hash(0), 3, 10)) { + descriptor = store.planCheckpoint(Collections.singletonList(block)); + assertEquals(StateArchiveHotStore.HotCheckpointStatus.NEEDS_MATERIALIZATION, + store.inspectCheckpoint(target)); + store.prepareCheckpoint(target, Collections.singletonList(block)); + assertEquals(StateArchiveHotStore.HotCheckpointStatus.MATERIALIZED, + store.inspectCheckpoint(target, descriptor)); + assertEquals(0, store.getCommittedHead()); + assertEquals(1, store.getMaterializedHead()); + assertEquals(0, store.getStatistics().getPublishedBlock()); + assertTrue(store.getStatistics().hasPreparedCheckpoint()); + assertThrows(ArchivePersistenceException.class, () -> store.loadBlock(1)); + assertFalse(store.findOldValueAfter("code", new byte[]{1}, 0).isPresent()); + assertThrows(ArchivePersistenceException.class, store::sealCurrent); + } + + try (StateArchiveHotStore store = open(root, format, engine, 0, hash(0), 3, 10)) { + assertEquals(StateArchiveHotStore.HotCheckpointStatus.MATERIALIZED, + store.inspectCheckpoint(target, descriptor)); + assertEquals(0, store.getCommittedHead()); + assertThrows(ArchivePersistenceException.class, () -> store.loadBlock(1)); + store.prepareCheckpoint(target, descriptor, Collections.singletonList(block)); + store.publishCheckpoint(target, descriptor); + store.publishCheckpoint(target, descriptor); + assertEquals(StateArchiveHotStore.HotCheckpointStatus.PUBLISHED, + store.inspectCheckpoint(target, descriptor)); + assertEquals(1, store.getCommittedHead()); + assertEquals(1, store.loadBlock(1).getMeta().getBlockNumber()); + assertFalse(store.getStatistics().hasPreparedCheckpoint()); + store.sealCurrent(); + } + + try (StateArchiveHotStore store = open(root, format, engine, 0, hash(0), 3, 10)) { + assertEquals(StateArchiveHotStore.HotCheckpointStatus.PUBLISHED, + store.inspectCheckpoint(target, descriptor)); + assertEquals(1, store.getCommittedHead()); + } + } + } + + @Test + public void recoversPrepareAndPublishNativeBoundaries() throws Exception { + Path preparedRoot = temporaryFolder.newFolder("fault-prepare").toPath(); + byte[] format = hash(98); + byte[] preparedTarget = hash(113); + StateArchiveHotStore failedPrepare = StateArchiveHotStore.openOrCreate(preparedRoot, format, + Engine.LEVELDB, 0, hash(0), 3, 10, 1024 * 1024, + stage -> { + if (stage == StateArchiveHotStore.Stage.AFTER_PREPARE) { + throw new IOException("injected prepare failure"); + } + }); + assertThrows(IOException.class, () -> failedPrepare.prepareCheckpoint(preparedTarget, + Collections.singletonList(diff(1, 0, "code", new byte[]{1}, OldValue.absent())))); + failedPrepare.close(); + try (StateArchiveHotStore recovered = open(preparedRoot, format, Engine.LEVELDB, + 0, hash(0), 3, 10)) { + assertEquals(StateArchiveHotStore.HotCheckpointStatus.MATERIALIZED, + recovered.inspectCheckpoint(preparedTarget)); + assertEquals(0, recovered.getCommittedHead()); + } + + Path publishedRoot = temporaryFolder.newFolder("fault-publish").toPath(); + byte[] publishedTarget = hash(114); + StateArchiveHotStore failedPublish = StateArchiveHotStore.openOrCreate(publishedRoot, format, + Engine.LEVELDB, 0, hash(0), 3, 1, 1024 * 1024, + stage -> { + if (stage == StateArchiveHotStore.Stage.AFTER_PUBLISH) { + throw new IOException("injected publish failure"); + } + }); + failedPublish.prepareCheckpoint(publishedTarget, Collections.singletonList( + diff(1, 0, "code", new byte[]{1}, OldValue.absent()))); + assertThrows(IOException.class, () -> failedPublish.publishCheckpoint(publishedTarget)); + failedPublish.close(); + try (StateArchiveHotStore recovered = open(publishedRoot, format, Engine.LEVELDB, + 0, hash(0), 3, 1)) { + assertEquals(StateArchiveHotStore.HotCheckpointStatus.PUBLISHED, + recovered.inspectCheckpoint(publishedTarget)); + assertEquals(1, recovered.getCommittedHead()); + assertEquals(1, recovered.loadBlock(1).getMeta().getBlockNumber()); + recovered.publishCheckpoint(publishedTarget); + assertEquals(1, recovered.getCurrentGenerationId()); + assertEquals(Collections.singletonList(0L), recovered.getFrozenGenerationIds()); + } + } + + @Test + public void rejectsPreparedRotationBeforeWritingWhenFrozenBacklogIsFull() throws Exception { + Path root = temporaryFolder.newFolder("prepare-full-backlog").toPath(); + try (StateArchiveHotStore store = open(root, hash(99), Engine.LEVELDB, + 0, hash(0), 1, 1)) { + store.appendSolidified(Collections.singletonList( + diff(1, 0, "code", new byte[]{1}, OldValue.absent()))); + store.sealCurrent(); + assertThrows(ArchivePersistenceException.class, () -> store.prepareCheckpoint(hash(115), + Collections.singletonList( + diff(2, 1, "code", new byte[]{2}, OldValue.absent())))); + assertEquals(1, store.getMaterializedHead()); + assertEquals(StateArchiveHotStore.HotCheckpointStatus.NEEDS_MATERIALIZATION, + store.inspectCheckpoint(hash(115))); + } + } + + @Test + public void persistsAndRevalidatesExactPreparedDescriptorAcrossBothEngines() + throws Exception { + for (Engine engine : Engine.values()) { + Path root = temporaryFolder.newFolder("descriptor-" + engine.name()).toPath(); + byte[] format = hash(100); + BlockReverseDiff original = diff(1, 0, "code", new byte[]{1}, OldValue.absent()); + StateArchiveHotBatchDescriptor descriptor; + try (StateArchiveHotStore store = open(root, format, engine, 0, hash(0), 3, 10)) { + descriptor = store.planCheckpoint(Collections.singletonList(original)); + store.prepareCheckpoint(hash(116), descriptor, Collections.singletonList(original)); + } + try (StateArchiveHotStore reopened = open(root, format, engine, 0, hash(0), 3, 10)) { + assertEquals(StateArchiveHotStore.HotCheckpointStatus.MATERIALIZED, + reopened.inspectCheckpoint(hash(116), descriptor)); + } + + Path scratch = temporaryFolder.newFolder("descriptor-other-" + engine.name()).toPath(); + StateArchiveHotBatchDescriptor different; + try (StateArchiveHotStore store = open(scratch, format, engine, 0, hash(0), 3, 10)) { + BlockReverseDiff changed = diff(1, 0, "code", new byte[]{1}, + OldValue.present(new byte[]{9})); + different = store.planCheckpoint(Collections.singletonList(changed)); + } + Path database = root.resolve(StateArchiveHotStore.GENERATIONS) + .resolve("00000000000000000000").resolve(StateArchiveHotStore.DATABASE); + try (StateArchiveIndexDatabase.Writer writer = StateArchiveIndexDatabase.openHotWriter( + database, engine, NativeDbConfig.large())) { + writer.write(Collections.singletonList(StateArchiveIndexDatabase.put( + "meta/prepared-descriptor".getBytes(StandardCharsets.US_ASCII), + new StateArchiveHotBatchDescriptorCodec().encode(different))), true); + } + assertThrows(ArchivePersistenceException.class, + () -> open(root, format, engine, 0, hash(0), 3, 10)); + } + } + + @Test + public void recoveryCeilingNeverTruncatesFrozenHistory() throws Exception { + Path root = temporaryFolder.newFolder("truncate-frozen").toPath(); + try (StateArchiveHotStore store = open(root, hash(96), Engine.LEVELDB, + 0, hash(0), 3, 1)) { + store.appendSolidified(Collections.singletonList( + diff(1, 0, "code", new byte[]{1}, OldValue.absent()))); + store.sealCurrent(); + store.appendSolidified(Collections.singletonList( + diff(2, 1, "code", new byte[]{2}, OldValue.absent()))); + assertThrows(ArchivePersistenceException.class, + () -> store.reconcilePreparedTail(meta(0, 0, 0))); + assertEquals(2, store.getCommittedHead()); + assertEquals(Collections.singletonList(0L), store.getFrozenGenerationIds()); + } + } + + @Test + public void rejectsGapsParentDriftIdentityDriftAndFrozenOverflow() throws Exception { + Path root = temporaryFolder.newFolder("reject").toPath(); + byte[] format = hash(92); + try (StateArchiveHotStore store = open(root, format, Engine.LEVELDB, 0, hash(0), 1, 1)) { + assertThrows(IllegalArgumentException.class, () -> store.appendSolidified( + Collections.singletonList(diff(2, 0, "code", new byte[]{1}, OldValue.absent())))); + store.appendSolidified(Collections.singletonList( + diff(1, 0, "code", new byte[]{1}, OldValue.absent()))); + store.sealCurrent(); + store.appendSolidified(Collections.singletonList( + diff(2, 1, "code", new byte[]{2}, OldValue.present(new byte[]{1})))); + assertThrows(ArchivePersistenceException.class, store::sealCurrent); + } + + assertThrows(ArchivePersistenceException.class, + () -> open(root, hash(99), Engine.LEVELDB, 0, hash(0), 1, 1)); + assertThrows(ArchivePersistenceException.class, + () -> open(root, format, Engine.ROCKSDB, 0, hash(0), 1, 1)); + } + + @Test + public void dedicatedConfigurationControlsRotationAndBacklogStatistics() throws Exception { + Path disabledRoot = temporaryFolder.newFolder("hot-disabled").toPath(); + StateArchiveHotStoreConfig config = new StateArchiveHotStoreConfig(); + assertThrows(IllegalStateException.class, () -> StateArchiveHotStore.openOrCreate( + disabledRoot, hash(93), Engine.LEVELDB, 0, hash(0), config)); + + config.setEnabled(true); + config.setMaxBlocks(1); + config.setMaxEncodedBytes(1024 * 1024); + config.setMaxFrozenGenerations(3); + config.setYellowFrozenGenerations(1); + config.setRedFrozenGenerations(2); + Path root = temporaryFolder.newFolder("hot-configured").toPath(); + try (StateArchiveHotStore store = StateArchiveHotStore.openOrCreate( + root, hash(93), Engine.LEVELDB, 0, hash(0), config)) { + StateArchiveHotStore.Statistics empty = store.getStatistics(); + assertEquals(StateArchiveHotStore.BacklogLevel.GREEN, empty.getBacklogLevel()); + assertEquals(0, empty.getFrozenGenerations()); + assertEquals(10_000L, new StateArchiveHotStoreConfig().getMaxBlocks()); + + store.appendSolidified(Collections.singletonList( + diff(1, 0, "code", new byte[]{1}, OldValue.absent()))); + assertTrue(store.getStatistics().isRotationDue()); + store.sealCurrent(); + StateArchiveHotStore.Statistics yellow = store.getStatistics(); + assertEquals(StateArchiveHotStore.BacklogLevel.YELLOW, yellow.getBacklogLevel()); + assertEquals(1, yellow.getFrozenGenerations()); + assertEquals(1, yellow.getFrozenBlocks()); + assertTrue(yellow.getFrozenEncodedBytes() > 0); + assertEquals(3, yellow.getMaxFrozenGenerations()); + assertEquals(1, yellow.getYellowFrozenGenerations()); + assertEquals(2, yellow.getRedFrozenGenerations()); + + store.appendSolidified(Collections.singletonList( + diff(2, 1, "code", new byte[]{2}, OldValue.absent()))); + store.sealCurrent(); + assertEquals(StateArchiveHotStore.BacklogLevel.RED, + store.getStatistics().getBacklogLevel()); + } + } + + private StateArchiveHotStore open(Path root, byte[] format, Engine engine, long baseBlock, + byte[] baseHash, int maxFrozen, long maxBlocks) throws IOException { + return StateArchiveHotStore.openOrCreate(root, format, engine, baseBlock, baseHash, + maxFrozen, maxBlocks, 1024 * 1024); + } + + private static BlockReverseDiff diff(long block, int parent, String dbName, byte[] key, + OldValue oldValue) { + return new BlockReverseDiff(BlockSnapshotMeta.forBlock(block, hash((int) block), hash(parent), + block * 3_000), Collections.singletonList(new DbGroup(dbName, + Collections.singletonList(new Entry(key, oldValue))))); + } + + private static BlockSnapshotMeta meta(long block, int parent, int hashMarker) { + return BlockSnapshotMeta.forBlock(block, hash(hashMarker), hash(parent), block * 3_000); + } + + private static byte[] hotBodyKey(long block) { + return ByteBuffer.allocate(1 + Long.BYTES).put((byte) 0x42).putLong(block).array(); + } + + private static byte[] hotIndexKey(String dbName, byte[] rawKey, long block) { + byte[] name = dbName.getBytes(StandardCharsets.UTF_8); + return ByteBuffer.allocate(1 + Short.BYTES + name.length + Integer.BYTES + rawKey.length + + Long.BYTES).put((byte) 0x4b).putShort((short) name.length).put(name) + .putInt(rawKey.length).put(rawKey).putLong(block).array(); + } + + private static void assertLookup(Optional found, + long block, OldValue oldValue) { + assertTrue(found.isPresent()); + assertEquals(block, found.get().getBlockNumber()); + assertEquals(oldValue, found.get().getOldValue()); + } + + private static byte[] hash(int marker) { + byte[] hash = new byte[32]; + hash[31] = (byte) marker; + return hash; + } +} diff --git a/framework/src/test/java/org/tron/core/db2/archive/StateArchiveIndexDatabaseTest.java b/framework/src/test/java/org/tron/core/db2/archive/StateArchiveIndexDatabaseTest.java new file mode 100644 index 00000000000..b1684127a32 --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/archive/StateArchiveIndexDatabaseTest.java @@ -0,0 +1,117 @@ +package org.tron.core.db2.archive; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import com.google.common.hash.Hashing; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.stream.Stream; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tron.core.config.args.StorageConfig.NativeDbConfig; +import org.tron.core.db2.stateroot.PathStateStoreManifest.Engine; + +public class StateArchiveIndexDatabaseTest { + + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void supportsConfiguredEngineAndRejectsEngineDrift() throws Exception { + for (Engine engine : Engine.values()) { + Path root = temporaryFolder.newFolder(engine.name().toLowerCase()).toPath(); + Path database = root.resolve("keys"); + StateArchiveIndexEngineManifest.openOrCreate(root, engine); + assertEquals(12, Files.size(root.resolve(StateArchiveIndexEngineManifest.FILE))); + try (StateArchiveIndexDatabase.Writer writer = + StateArchiveIndexDatabase.openWriter(database, engine)) { + writer.write(Arrays.asList( + StateArchiveIndexDatabase.put(new byte[]{1}, new byte[]{11}), + StateArchiveIndexDatabase.put(new byte[]{3}, new byte[]{33}))); + } + try (StateArchiveIndexDatabase.Reader reader = + StateArchiveIndexDatabase.openReader(database, engine)) { + assertArrayEquals(new byte[]{11}, reader.get(new byte[]{1})); + StateArchiveIndexDatabase.KeyValue found = reader.seek(new byte[]{2}); + assertNotNull(found); + assertArrayEquals(new byte[]{3}, found.getKey()); + assertArrayEquals(new byte[]{33}, found.getValue()); + } + Engine other = engine == Engine.LEVELDB ? Engine.ROCKSDB : Engine.LEVELDB; + assertThrows(IOException.class, () -> StateArchiveIndexEngineManifest.require(root, other)); + } + } + + @Test + public void rocksReaderAndWriterShareConfiguredDatabaseHandle() throws Exception { + Path database = temporaryFolder.newFolder("rocks-shared-handle").toPath().resolve("keys"); + try (StateArchiveIndexDatabase.Writer writer = + StateArchiveIndexDatabase.openWriter(database, Engine.ROCKSDB)) { + writer.write(Arrays.asList(StateArchiveIndexDatabase.put(new byte[]{1}, new byte[]{2}))); + try (StateArchiveIndexDatabase.Reader reader = + StateArchiveIndexDatabase.openReader(database, Engine.ROCKSDB)) { + assertArrayEquals(new byte[]{2}, reader.get(new byte[]{1})); + } + } + + String nativeOptions = new String(Files.readAllBytes(latestOptionsFile(database)), + StandardCharsets.US_ASCII); + assertTrue(nativeOptions.contains("write_buffer_size=67108864")); + assertTrue(nativeOptions.contains("block_size=4096")); + assertTrue(nativeOptions.contains("filter_policy=rocksdb.BuiltinBloomFilter")); + } + + @Test + public void hotRocksRejectsLegacySingleColumnFamilyLayout() throws Exception { + Path database = temporaryFolder.newFolder("rocks-flat-hot-reject").toPath().resolve("keys"); + try (StateArchiveIndexDatabase.Writer writer = + StateArchiveIndexDatabase.openWriter(database, Engine.ROCKSDB)) { + writer.write(Arrays.asList(StateArchiveIndexDatabase.put(new byte[]{1}, new byte[]{2}))); + } + + assertThrows(IOException.class, () -> StateArchiveIndexDatabase.openHotWriter( + database, Engine.ROCKSDB, NativeDbConfig.large())); + } + + @Test + public void rejectsExistingDatabaseWithoutEngineIdentity() throws Exception { + Path root = temporaryFolder.newFolder("missing-manifest").toPath(); + Files.createDirectory(root.resolve("keys")); + assertThrows(IOException.class, + () -> StateArchiveIndexEngineManifest.openOrCreate(root, Engine.LEVELDB)); + } + + @Test + public void acceptsPriorShaIdentityAndRejectsCrcCorruption() throws Exception { + Path legacy = temporaryFolder.newFolder("legacy-sha").toPath(); + byte[] body = ByteBuffer.allocate(8).putInt(0x53414945).putShort((short) 1) + .putShort((short) 2).array(); + Files.write(legacy.resolve(StateArchiveIndexEngineManifest.FILE), + ByteBuffer.allocate(40).put(body).put(Hashing.sha256().hashBytes(body).asBytes()).array()); + assertEquals(Engine.ROCKSDB, StateArchiveIndexEngineManifest.load(legacy)); + + Path current = temporaryFolder.newFolder("current-crc").toPath(); + StateArchiveIndexEngineManifest.openOrCreate(current, Engine.LEVELDB); + byte[] corrupt = Files.readAllBytes(current.resolve(StateArchiveIndexEngineManifest.FILE)); + corrupt[7] = 2; + Files.write(current.resolve(StateArchiveIndexEngineManifest.FILE), corrupt); + assertThrows(IOException.class, () -> StateArchiveIndexEngineManifest.load(current)); + } + + private static Path latestOptionsFile(Path directory) throws IOException { + try (Stream files = Files.list(directory)) { + return files.filter(path -> path.getFileName().toString().startsWith("OPTIONS-")) + .max(java.util.Comparator.comparing(path -> path.getFileName().toString())) + .orElseThrow(() -> new IOException("RocksDB OPTIONS file is missing")); + } + } +} diff --git a/framework/src/test/java/org/tron/core/db2/archive/StateArchiveManagerStartupIntegrationTest.java b/framework/src/test/java/org/tron/core/db2/archive/StateArchiveManagerStartupIntegrationTest.java new file mode 100644 index 00000000000..396ec5c3d5d --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/archive/StateArchiveManagerStartupIntegrationTest.java @@ -0,0 +1,2026 @@ +package org.tron.core.db2.archive; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.google.common.hash.Hashing; +import com.google.protobuf.ByteString; +import java.io.IOException; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.AbstractMap; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.mockito.ArgumentCaptor; +import org.tron.common.parameter.CommonParameter; +import org.tron.common.storage.leveldb.LevelDbDataSourceImpl; +import org.tron.common.storage.rocksdb.RocksDbDataSourceImpl; +import org.tron.common.utils.Sha256Hash; +import org.tron.core.ChainBaseManager; +import org.tron.core.capsule.BlockCapsule; +import org.tron.core.config.args.Storage; +import org.tron.core.config.args.StorageConfig; +import org.tron.core.db.Manager; +import org.tron.core.db.TronDatabase; +import org.tron.core.db.common.DbSourceInter; +import org.tron.core.db2.ISession; +import org.tron.core.db2.archive.LatestStateGenerationAdapter.SnapshotCapableStore; +import org.tron.core.db2.archive.LatestStateGenerationAdapter.StoreSnapshot; +import org.tron.core.db2.archive.StateArchiveRuntimeOwner.ReadableStateStage; +import org.tron.core.db2.archive.StateArchiveRuntimeOwner.ServingIndexStage; +import org.tron.core.db2.archive.StateArchiveRuntimeOwner.State; +import org.tron.core.db2.common.DB; +import org.tron.core.db2.common.Flusher; +import org.tron.core.db2.common.LevelDB; +import org.tron.core.db2.common.RocksDB; +import org.tron.core.db2.common.WrappedByteArray; +import org.tron.core.db2.core.Chainbase; +import org.tron.core.db2.core.SnapshotManager; +import org.tron.core.db2.core.SnapshotRoot; +import org.tron.core.db2.stateroot.PathStateStoreManifest.Engine; +import org.tron.core.store.AccountAssetStore; +import org.tron.core.store.CheckTmpStore; +import org.tron.core.store.DynamicPropertiesStore; +import org.tron.protos.Protocol.Account; + +public class StateArchiveManagerStartupIntegrationTest { + + private static final List PARTICIPANTS = participants(); + + static { + org.rocksdb.RocksDB.loadLibrary(); + } + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void automaticOverflowFlushAdvancesHistoryAndAllStoreServingIndex() throws Exception { + Path output = temporaryFolder.newFolder("overflow-manager").toPath(); + Path archive = output.resolve("state-archive"); + HistoryCommitMarker head = initializeRecoverableTail(archive, "ROCKSDB"); + SnapshotFixture fixture = snapshotFixture(); + SnapshotManager snapshots = fixture.snapshots; + installRecoveredBinding(snapshots, archive, 6, 6); + snapshots.setMaxSize(1); + Manager manager = manager(snapshots, head); + + withArchiveConfig(output, "ROCKSDB", true, () -> invoke(manager, "initStateArchive")); + + byte[] key = new byte[]{1, 6, 1, 8}; + for (int epoch = 7; epoch <= 9; epoch++) { + BlockSnapshotMeta target = new BlockSnapshotMeta(epoch, epoch, hash(epoch), + hash(epoch - 1), epoch * 1_000L); + try (ISession block = snapshots.buildSession()) { + fixture.databases.get("proposal").put(key, new byte[]{(byte) epoch}); + block.commit(target); + } + if (epoch == 9) { + BlockSnapshotMeta durable = new BlockSnapshotMeta(7, 7, hash(7), hash(6), 7_000L); + assertEquals(durable, manager.getStateArchiveRuntime().verifyNormalWriteFixedPoint()); + assertServingFixedPoint(archive, durable, 5); + } + } + + invoke(manager, "closeStateArchive"); + snapshots.shutdown(); + } + + @Test + public void batchedAutomaticOverflowFlushAdvancesHistoryAndAllStoreServingIndex() + throws Exception { + Path output = temporaryFolder.newFolder("batched-overflow-manager").toPath(); + Path archive = output.resolve("state-archive"); + HistoryCommitMarker head = initializeRecoverableTail(archive, "ROCKSDB"); + SnapshotFixture fixture = snapshotFixture(); + SnapshotManager snapshots = fixture.snapshots; + installRecoveredBinding(snapshots, archive, 6, 6); + snapshots.setMaxSize(1); + snapshots.setMaxFlushCount(2); + Manager manager = manager(snapshots, head); + + withArchiveConfig(output, "ROCKSDB", true, () -> invoke(manager, "initStateArchive")); + + byte[] key = new byte[]{1, 6, 1, 9}; + for (int epoch = 7; epoch <= 10; epoch++) { + BlockSnapshotMeta target = new BlockSnapshotMeta(epoch, epoch, hash(epoch), + hash(epoch - 1), epoch * 1_000L); + try (ISession block = snapshots.buildSession()) { + fixture.databases.get("proposal").put(key, new byte[]{(byte) epoch}); + block.commit(target); + } + } + + BlockSnapshotMeta durable = new BlockSnapshotMeta(8, 8, hash(8), hash(7), 8_000L); + assertEquals(durable, manager.getStateArchiveRuntime().verifyNormalWriteFixedPoint()); + assertServingFixedPoint(archive, durable, 5); + invoke(manager, "closeStateArchive"); + snapshots.shutdown(); + } + + @Test + public void servingIndexPublicationFailuresRetryWithoutResubmittingHistoryAndRestartCleanly() + throws Exception { + for (ServingIndexStage failureStage : ServingIndexStage.values()) { + Path output = temporaryFolder.newFolder( + "serving-failure-" + failureStage.name().toLowerCase()).toPath(); + Path archive = output.resolve("state-archive"); + HistoryCommitMarker head = initializeRecoverableTail(archive, "ROCKSDB"); + SnapshotFixture fixture = snapshotFixture(); + SnapshotManager snapshots = fixture.snapshots; + installRecoveredBinding(snapshots, archive, 6, 6); + Manager manager = manager(snapshots, head); + AtomicReference armed = new AtomicReference<>(); + setField(manager, "stateArchiveServingIndexFaultHook", + (StateArchiveRuntimeOwner.ServingIndexFaultHook) stage -> { + if (stage == armed.get() && armed.compareAndSet(stage, null)) { + throw new IOException("injected serving-index failure at " + stage); + } + }); + withArchiveConfig(output, "ROCKSDB", true, + () -> invoke(manager, "initStateArchive")); + + BlockSnapshotMeta target = new BlockSnapshotMeta(7, 7, hash(7), hash(6), 7_000L); + try (ISession block = snapshots.buildSession()) { + fixture.databases.get("proposal").put(new byte[]{7, 7}, new byte[]{7}); + block.commit(target); + } + setField(snapshots, "flushCount", 1); + armed.set(failureStage); + + assertThrows(org.tron.core.exception.TronError.class, snapshots::flushPending); + assertEquals(target, manager.getArchiveHistoryWriter().committedHeadMeta()); + assertEquals(6, snapshots.getArchiveReadableEpoch()); + if (failureStage == ServingIndexStage.CURRENT_PUBLISHED) { + assertThrows(ArchivePersistenceException.class, + () -> manager.getArchiveAccountBalance(6, + new byte[HistoricalAccountBalanceReader.ADDRESS_LENGTH])); + } else { + assertFalse(manager.getArchiveAccountBalance(6, + new byte[HistoricalAccountBalanceReader.ADDRESS_LENGTH]).isPresent()); + } + assertThrows(ArchivePersistenceException.class, manager::inspectArchiveServingIndex); + assertTrue(fixture.databases.values().stream().allMatch(database -> + failureStage == ServingIndexStage.BEFORE_BUILD + ? database.getHead() instanceof org.tron.core.db2.core.SnapshotImpl + : database.getHead() instanceof SnapshotRoot)); + + if (failureStage == ServingIndexStage.BEFORE_BUILD) { + snapshots.flushPending(); + assertEquals(target, manager.getStateArchiveRuntime().verifyNormalWriteFixedPoint()); + assertEquals(target.getEpoch(), snapshots.getArchiveReadableEpoch()); + assertFalse(manager.getArchiveAccountBalance(7, + new byte[HistoricalAccountBalanceReader.ADDRESS_LENGTH]).isPresent()); + assertServingFixedPoint(archive, target, 5); + assertEquals(1, countGenerationDirectories(archive)); + assertTrue(fixture.databases.values().stream() + .allMatch(database -> database.getHead() instanceof SnapshotRoot)); + } + @SuppressWarnings("unchecked") + ArgumentCaptor> checkpoints = ArgumentCaptor.forClass(Map.class); + verify(fixture.checkpoint, atLeastOnce()).updateByBatch(checkpoints.capture()); + Map recoveredCheckpoint = checkpoints.getAllValues() + .get(checkpoints.getAllValues().size() - 1); + + invoke(manager, "closeStateArchive"); + snapshots.shutdown(); + + SnapshotFixture restarted = snapshotFixture(recoveredCheckpoint); + invokeCheckpointRecovery(restarted.snapshots, restarted.checkpoint); + Manager restartedManager = manager(restarted.snapshots, target); + withArchiveConfig(output, "ROCKSDB", true, + () -> invoke(restartedManager, "initStateArchive")); + assertEquals(target, + restartedManager.getStateArchiveRuntime().verifyNormalWriteFixedPoint()); + assertServingFixedPoint(archive, target, 5); + assertEquals(1, countGenerationDirectories(archive)); + invoke(restartedManager, "closeStateArchive"); + restarted.snapshots.shutdown(); + } + } + + @Test + public void managerBootstrapsFreshBaseAndContinuesNormalFlush() throws Exception { + for (String engine : Arrays.asList("LEVELDB", "ROCKSDB")) { + Path output = temporaryFolder.newFolder("fresh-manager-" + engine.toLowerCase()).toPath(); + Path archive = output.resolve("state-archive"); + BlockSnapshotMeta head = new BlockSnapshotMeta(6, 6, hash(6), hash(5), 6_000L); + SnapshotFixture fixture = snapshotFixture(); + SnapshotManager snapshots = fixture.snapshots; + Manager manager = manager(snapshots, head); + + withArchiveConfig(output, engine, true, () -> invoke(manager, "initStateArchive")); + + assertEquals(State.RUNNING, manager.getStateArchiveRuntime().getState()); + assertEquals(head, manager.getStateArchiveRuntime().getRecoveredHead()); + assertEquals(0, manager.getStateArchiveRuntime().getStartupRecoveryActionCount()); + assertServingFixedPoint(archive, head, 6); + try (PersistentServingKeyIndexCatalog catalog = + PersistentServingKeyIndexCatalog.open(archive.resolve("serving-index")); + PersistentServingKeyIndexGeneration serving = catalog.pin()) { + assertEquals(Engine.valueOf(engine), serving.getEngine()); + } + assertEquals(head.getEpoch(), snapshots.getArchiveReadableEpoch()); + assertTrue(Files.isRegularFile(archive.resolve("MANIFEST"))); + assertFalse(Files.exists(archive.resolve("participants"))); + assertFalse(Files.exists(archive.resolve("progress"))); + + byte[] key = new byte[]{2, 7, 1, 8}; + BlockSnapshotMeta target = new BlockSnapshotMeta(7, 7, hash(7), hash(6), 7_000L); + try (ISession block = snapshots.buildSession()) { + fixture.databases.get("proposal").put(key, new byte[]{7}); + block.commit(target); + } + setField(snapshots, "flushCount", 1); + snapshots.flushPending(); + assertEquals(target, manager.getStateArchiveRuntime().verifyNormalWriteFixedPoint()); + assertServingFixedPoint(archive, target, 6); + try (PersistentServingKeyIndexGeneration serving = manager.getArchiveHistoryWriter() + .buildServingGeneration(output.resolve("serving-" + engine.toLowerCase()), "fresh")) { + assertEquals(6, serving.getIndexedFrom()); + assertEquals(7, serving.getIndexedThrough()); + } + + invoke(manager, "closeStateArchive"); + assertEquals(-1, snapshots.getArchiveReadableEpoch()); + assertFalse(Files.exists(archive.resolve("participants"))); + assertFalse(Files.exists(archive.resolve("progress"))); + snapshots.shutdown(); + } + } + + @Test + public void managerReadsRequestOwnedHistoricalAccountsAndDrainsBeforeRestart() + throws Exception { + Path output = temporaryFolder.newFolder("historical-account-manager").toPath(); + Path archive = output.resolve("state-archive"); + BlockSnapshotMeta head = new BlockSnapshotMeta(6, 6, hash(6), hash(5), 6_000L); + SnapshotFixture fixture = snapshotFixture(); + SnapshotManager snapshots = fixture.snapshots; + Manager manager = manager(snapshots, head); + withArchiveConfig(output, "ROCKSDB", true, () -> invoke(manager, "initStateArchive")); + + byte[] address = new byte[HistoricalAccountBalanceReader.ADDRESS_LENGTH]; + address[0] = 0x41; + assertFalse(manager.getArchiveAccountBalance(6, address).isPresent()); + + BlockSnapshotMeta target = null; + for (int epoch = 7; epoch <= 9; epoch++) { + target = new BlockSnapshotMeta(epoch, epoch, hash(epoch), hash(epoch - 1), epoch * 1_000L); + try (ISession block = snapshots.buildSession()) { + if (epoch == 7) { + fixture.databases.get("account").put(address, account(address, 10)); + } else if (epoch == 8) { + fixture.databases.get("account").put(address, account(address, 20)); + } else { + fixture.databases.get("account").delete(address); + } + block.commit(target); + } + setField(snapshots, "flushCount", 1); + snapshots.flushPending(); + } + + assertFalse(manager.getArchiveAccountBalance(6, address).isPresent()); + assertEquals(10, manager.getArchiveAccountBalance(7, address).getBalance()); + assertEquals(20, manager.getArchiveAccountBalance(8, address).getBalance()); + assertFalse(manager.getArchiveAccountBalance(9, address).isPresent()); + + try (ArchiveRuntimeQueryGate.Lease lease = + manager.getStateArchiveRuntime().pinHistoricalState(7)) { + assertThrows(IllegalStateException.class, () -> invoke(manager, "closeStateArchive")); + assertEquals(10, + HistoricalAccountBalanceReader.read(lease.getSnapshot(), address).getBalance()); + assertThrows(IllegalStateException.class, + () -> manager.getStateArchiveRuntime().pinHistoricalState(7)); + } + @SuppressWarnings("unchecked") + ArgumentCaptor> checkpoints = ArgumentCaptor.forClass(Map.class); + verify(fixture.checkpoint, times(3)).updateByBatch(checkpoints.capture()); + Map recoveredCheckpoint = checkpoints.getAllValues().get(2); + invoke(manager, "closeStateArchive"); + snapshots.shutdown(); + + SnapshotFixture restarted = snapshotFixture(recoveredCheckpoint); + invokeCheckpointRecovery(restarted.snapshots, restarted.checkpoint); + Manager restartedManager = manager(restarted.snapshots, target); + withArchiveConfig(output, "ROCKSDB", true, + () -> invoke(restartedManager, "initStateArchive")); + assertEquals(10, restartedManager.getArchiveAccountBalance(7, address).getBalance()); + assertEquals(20, restartedManager.getArchiveAccountBalance(8, address).getBalance()); + assertFalse(restartedManager.getArchiveAccountBalance(9, address).isPresent()); + invoke(restartedManager, "closeStateArchive"); + restarted.snapshots.shutdown(); + } + + @Test + public void managerReadsExactPointHistoryAcrossEveryStateStoreAndRestart() throws Exception { + Path output = temporaryFolder.newFolder("historical-exact-27-manager").toPath(); + Path archive = output.resolve("state-archive"); + BlockSnapshotMeta head = new BlockSnapshotMeta(6, 6, hash(6), hash(5), 6_000L); + SnapshotFixture fixture = snapshotFixture(); + SnapshotManager snapshots = fixture.snapshots; + Manager manager = manager(snapshots, head); + seedExact27State(fixture.databases, 6); + withArchiveConfig(output, "ROCKSDB", true, () -> invoke(manager, "initStateArchive")); + + assertExact27History(manager, 6); + BlockSnapshotMeta target = null; + for (int epoch = 7; epoch <= 8; epoch++) { + target = new BlockSnapshotMeta(epoch, epoch, hash(epoch), hash(epoch - 1), + epoch * 1_000L); + try (ISession block = snapshots.buildSession()) { + int storeIndex = 0; + for (String dbName : PARTICIPANTS) { + Chainbase database = fixture.databases.get(dbName); + database.put(exact27Key(1, storeIndex), exact27Value(epoch, storeIndex)); + database.put(exact27Key(3, storeIndex), exact27Value(epoch, storeIndex)); + if (epoch == 7) { + database.delete(exact27Key(4, storeIndex)); + database.put(exact27Key(5, storeIndex), new byte[0]); + database.put(exact27Key(6, storeIndex), exact27Value(6, storeIndex)); + } + storeIndex++; + } + block.commit(target); + } + setField(snapshots, "flushCount", 1); + snapshots.flushPending(); + assertEquals(target, manager.getStateArchiveRuntime().verifyNormalWriteFixedPoint()); + assertExact27History(manager, epoch); + } + + assertExact27History(manager, 6); + assertExact27History(manager, 7); + assertExact27ServingIndex(archive); + assertThrows(IllegalArgumentException.class, + () -> manager.getArchiveStateValue(8, "block", new byte[]{1})); + @SuppressWarnings("unchecked") + ArgumentCaptor> checkpoints = ArgumentCaptor.forClass(Map.class); + verify(fixture.checkpoint, times(2)).updateByBatch(checkpoints.capture()); + Map recoveredCheckpoint = checkpoints.getAllValues().get(1); + invoke(manager, "closeStateArchive"); + snapshots.shutdown(); + + SnapshotFixture restarted = snapshotFixture(recoveredCheckpoint); + Manager restartedManager = manager(restarted.snapshots, target); + seedExact27State(restarted.databases, 8); + invokeCheckpointRecovery(restarted.snapshots, restarted.checkpoint); + withArchiveConfig(output, "ROCKSDB", true, + () -> invoke(restartedManager, "initStateArchive")); + assertExact27History(restartedManager, 6); + assertExact27History(restartedManager, 7); + assertExact27History(restartedManager, 8); + assertExact27ServingIndex(archive); + invoke(restartedManager, "closeStateArchive"); + restarted.snapshots.shutdown(); + } + + @Test + public void managerResolvesP66AccountAssetHistoryAndRejectsInvalidLayoutsAfterRestart() + throws Exception { + Path output = temporaryFolder.newFolder("historical-p66-manager").toPath(); + BlockSnapshotMeta head = new BlockSnapshotMeta(6, 6, hash(6), hash(5), 6_000L); + SnapshotFixture fixture = snapshotFixture(); + SnapshotManager snapshots = fixture.snapshots; + Manager manager = manager(snapshots, head); + byte[] address = archiveAddress(1); + byte[] absentAddress = archiveAddress(2); + byte[] orphanAddress = archiveAddress(3); + byte[] mixedAddress = archiveAddress(4); + String tokenId = "1000001"; + P66AccountAssetCodec codec = new P66AccountAssetCodec(); + byte[] directKey = codec.assetPhysicalKey(address, tokenId); + byte[] orphanKey = codec.assetPhysicalKey(orphanAddress, tokenId); + byte[] mixedKey = codec.assetPhysicalKey(mixedAddress, tokenId); + fixture.databases.get("properties").put( + HistoricalAccountAssetBalanceResolver.proposal66PhysicalKey(), longValue(0)); + fixture.databases.get("account").put(address, + assetAccount(address, false, tokenId, 20)); + withArchiveConfig(output, "ROCKSDB", true, () -> invoke(manager, "initStateArchive")); + + assertAccountAsset(manager, 6, address, tokenId, + P66AccountAssetCodec.Phase.P66_OFF, true, 20); + assertAccountAsset(manager, 6, absentAddress, tokenId, + P66AccountAssetCodec.Phase.P66_OFF, false, 0); + + BlockSnapshotMeta target = null; + for (int epoch = 7; epoch <= 11; epoch++) { + target = new BlockSnapshotMeta(epoch, epoch, hash(epoch), hash(epoch - 1), + epoch * 1_000L); + try (ISession block = snapshots.buildSession()) { + if (epoch == 7) { + fixture.databases.get("properties").put( + HistoricalAccountAssetBalanceResolver.proposal66PhysicalKey(), longValue(1)); + fixture.databases.get("account").put(address, + assetAccount(address, true, null, 0)); + fixture.databases.get("account-asset").put(directKey, longValue(30)); + } else if (epoch == 8) { + fixture.databases.get("account-asset").put(directKey, longValue(40)); + } else if (epoch == 9) { + fixture.databases.get("account").delete(address); + fixture.databases.get("account-asset").delete(directKey); + } else if (epoch == 10) { + fixture.databases.get("account-asset").put(orphanKey, longValue(5)); + fixture.databases.get("account").put(mixedAddress, + assetAccount(mixedAddress, false, tokenId, 7)); + fixture.databases.get("account-asset").put(mixedKey, longValue(7)); + } else { + fixture.databases.get("account-asset").delete(orphanKey); + fixture.databases.get("account").delete(mixedAddress); + fixture.databases.get("account-asset").delete(mixedKey); + } + block.commit(target); + } + setField(snapshots, "flushCount", 1); + snapshots.flushPending(); + assertEquals(target, manager.getStateArchiveRuntime().verifyNormalWriteFixedPoint()); + } + + assertP66History(manager, address, absentAddress, tokenId); + assertThrows(ArchivePersistenceException.class, + () -> manager.getArchiveAccountAssetBalance(10, orphanAddress, tokenId)); + assertThrows(ArchivePersistenceException.class, + () -> manager.getArchiveAccountAssetBalance(10, mixedAddress, tokenId)); + assertThrows(ArchivePersistenceException.class, + () -> manager.getArchiveAccountAssets(10, orphanAddress, prefixLimits())); + assertThrows(ArchivePersistenceException.class, + () -> manager.getArchiveAccountAssets(10, mixedAddress, prefixLimits())); + assertAccountAsset(manager, 11, orphanAddress, tokenId, + P66AccountAssetCodec.Phase.P66_ON, false, 0); + assertAccountAsset(manager, 11, mixedAddress, tokenId, + P66AccountAssetCodec.Phase.P66_ON, false, 0); + + @SuppressWarnings("unchecked") + ArgumentCaptor> checkpoints = ArgumentCaptor.forClass(Map.class); + verify(fixture.checkpoint, times(5)).updateByBatch(checkpoints.capture()); + Map recoveredCheckpoint = checkpoints.getAllValues().get(4); + invoke(manager, "closeStateArchive"); + snapshots.shutdown(); + + SnapshotFixture restarted = snapshotFixture(recoveredCheckpoint); + Manager restartedManager = manager(restarted.snapshots, target); + restarted.databases.get("properties").put( + HistoricalAccountAssetBalanceResolver.proposal66PhysicalKey(), longValue(1)); + invokeCheckpointRecovery(restarted.snapshots, restarted.checkpoint); + withArchiveConfig(output, "ROCKSDB", true, + () -> invoke(restartedManager, "initStateArchive")); + assertP66History(restartedManager, address, absentAddress, tokenId); + assertThrows(ArchivePersistenceException.class, + () -> restartedManager.getArchiveAccountAssetBalance(10, orphanAddress, tokenId)); + assertThrows(ArchivePersistenceException.class, + () -> restartedManager.getArchiveAccountAssetBalance(10, mixedAddress, tokenId)); + assertThrows(ArchivePersistenceException.class, + () -> restartedManager.getArchiveAccountAssets(10, orphanAddress, prefixLimits())); + assertThrows(ArchivePersistenceException.class, + () -> restartedManager.getArchiveAccountAssets(10, mixedAddress, prefixLimits())); + invoke(restartedManager, "closeStateArchive"); + restarted.snapshots.shutdown(); + } + + @Test + public void managerProjectsP66HistoryFromNativeSupplementalAccountAssetAndRestart() + throws Exception { + for (String engine : Arrays.asList("LEVELDB", "ROCKSDB")) { + Path output = temporaryFolder.newFolder( + "supplemental-p66-" + engine.toLowerCase()).toPath(); + withArchiveConfig(output, engine, true, + () -> runSupplementalP66Scenario(output, engine)); + } + } + + @Test + public void postRefreshFailuresReopenNativeSupplementalP66FixedPoint() throws Exception { + for (String engine : Arrays.asList("LEVELDB", "ROCKSDB")) { + for (ReadableStateStage failureStage : ReadableStateStage.values()) { + Path output = temporaryFolder.newFolder("post-refresh-p66-" + + engine.toLowerCase() + "-" + failureStage.name().toLowerCase()).toPath(); + withArchiveConfig(output, engine, true, + () -> runPostRefreshP66Failure(output, engine, failureStage)); + } + } + } + + private void runPostRefreshP66Failure(Path output, String engine, + ReadableStateStage failureStage) throws Exception { + Path archive = output.resolve("state-archive"); + BlockSnapshotMeta base = new BlockSnapshotMeta(6, 6, hash(6), hash(5), 6_000L); + SnapshotFixture fixture = snapshotFixtureWithoutAccountAsset(Collections.emptyMap()); + AtomicLong targetOptimization = new AtomicLong(); + AtomicReference armed = new AtomicReference<>(); + TestAccountAssetStore accountAssetStore = new TestAccountAssetStore(); + Manager manager = manager(fixture.snapshots, base, accountAssetStore, targetOptimization); + setField(manager, "stateArchiveReadableStateFaultHook", + (StateArchiveRuntimeOwner.ReadableStateFaultHook) stage -> { + if (stage == armed.get() && armed.compareAndSet(stage, null)) { + throw new IOException("injected readable-state failure at " + stage); + } + }); + byte[] address = archiveAddress(12); + String tokenId = "1000012"; + byte[] directKey = new P66AccountAssetCodec().assetPhysicalKey(address, tokenId); + fixture.databases.get("properties").put( + HistoricalAccountAssetBalanceResolver.proposal66PhysicalKey(), longValue(0)); + fixture.databases.get("account").put(address, + assetAccount(address, false, tokenId, 20)); + invoke(manager, "initStateArchive"); + + targetOptimization.set(1); + BlockSnapshotMeta activation = new BlockSnapshotMeta(7, 7, hash(7), hash(6), 7_000L); + try (ISession block = fixture.snapshots.buildSession()) { + fixture.databases.get("properties").put( + HistoricalAccountAssetBalanceResolver.proposal66PhysicalKey(), longValue(1)); + fixture.databases.get("account").put(address, + assetAccount(address, false, tokenId, 30)); + block.commit(activation); + } + setField(fixture.snapshots, "flushCount", 1); + fixture.snapshots.flushPending(); + assertEquals(activation, manager.getStateArchiveRuntime().verifyNormalWriteFixedPoint()); + + BlockSnapshotMeta target = new BlockSnapshotMeta(8, 8, hash(8), hash(7), 8_000L); + try (ISession block = fixture.snapshots.buildSession()) { + fixture.databases.get("account").put(address, + assetAccount(address, true, tokenId, 40)); + block.commit(target); + } + setField(fixture.snapshots, "flushCount", 1); + armed.set(failureStage); + assertThrows(org.tron.core.exception.TronError.class, fixture.snapshots::flushPending); + + assertEquals(target, manager.getArchiveHistoryWriter().committedHeadMeta()); + assertEquals(activation.getEpoch(), fixture.snapshots.getArchiveReadableEpoch()); + assertArrayEquals(longValue(40), accountAssetStore.get(directKey)); + assertTrue(fixture.databases.values().stream() + .allMatch(database -> database.getHead() instanceof SnapshotRoot)); + if (failureStage == ReadableStateStage.CANONICAL_REFRESHED) { + assertAccountAsset(manager, 7, address, tokenId, + P66AccountAssetCodec.Phase.P66_ON, true, 30); + assertThrows(IllegalArgumentException.class, + () -> manager.getArchiveAccountAssetBalance(8, address, tokenId)); + } else { + assertThrows(ArchivePersistenceException.class, + () -> manager.getArchiveAccountAssetBalance(7, address, tokenId)); + assertThrows(ArchivePersistenceException.class, + () -> manager.getArchiveAccountAssetBalance(8, address, tokenId)); + } + Map historyAuthority = historyAuthoritySnapshot(archive); + + @SuppressWarnings("unchecked") + ArgumentCaptor> checkpoints = ArgumentCaptor.forClass(Map.class); + verify(fixture.checkpoint, times(2)).updateByBatch(checkpoints.capture()); + Map recoveredCheckpoint = checkpoints.getAllValues().get(1); + invoke(manager, "closeStateArchive"); + fixture.snapshots.shutdown(); + accountAssetStore.getDbSource().closeDB(); + assertHistoryAuthorityEquals(historyAuthority, archive); + + SnapshotFixture restarted = snapshotFixtureWithoutAccountAsset(recoveredCheckpoint); + TestAccountAssetStore reopenedAccountAssetStore = new TestAccountAssetStore(); + Manager restartedManager = manager(restarted.snapshots, target, reopenedAccountAssetStore, + targetOptimization); + invokeCheckpointRecovery(restarted.snapshots, restarted.checkpoint); + invoke(restartedManager, "initStateArchive"); + assertEquals(0, restartedManager.getStateArchiveRuntime().getStartupRecoveryActionCount()); + assertEquals(target, + restartedManager.getStateArchiveRuntime().verifyNormalWriteFixedPoint()); + assertAccountAsset(restartedManager, 6, address, tokenId, + P66AccountAssetCodec.Phase.P66_OFF, true, 20); + assertAccountAsset(restartedManager, 7, address, tokenId, + P66AccountAssetCodec.Phase.P66_ON, true, 30); + assertAccountAsset(restartedManager, 8, address, tokenId, + P66AccountAssetCodec.Phase.P66_ON, true, 40); + assertArrayEquals(longValue(40), reopenedAccountAssetStore.get(directKey)); + assertEquals(1, countGenerationDirectories(archive)); + assertHistoryAuthorityEquals(historyAuthority, archive); + + ArchiveRuntimeQueryGate.Lease active = + restartedManager.getStateArchiveRuntime().pinHistoricalState(8); + assertThrows(IllegalStateException.class, + () -> invoke(restartedManager, "closeStateArchive")); + active.close(); + invoke(restartedManager, "closeStateArchive"); + restarted.snapshots.shutdown(); + reopenedAccountAssetStore.getDbSource().closeDB(); + + SnapshotFixture secondRestart = snapshotFixtureWithoutAccountAsset(recoveredCheckpoint); + TestAccountAssetStore secondAccountAssetStore = new TestAccountAssetStore(); + Manager secondManager = manager(secondRestart.snapshots, target, secondAccountAssetStore, + targetOptimization); + invokeCheckpointRecovery(secondRestart.snapshots, secondRestart.checkpoint); + invoke(secondManager, "initStateArchive"); + assertEquals(0, secondManager.getStateArchiveRuntime().getStartupRecoveryActionCount()); + assertEquals(target, secondManager.getStateArchiveRuntime().verifyNormalWriteFixedPoint()); + assertAccountAsset(secondManager, 8, address, tokenId, + P66AccountAssetCodec.Phase.P66_ON, true, 40); + assertHistoryAuthorityEquals(historyAuthority, archive); + assertEquals("LEVELDB".equals(engine), secondAccountAssetStore.getDbSource() + instanceof org.tron.common.storage.leveldb.LevelDbDataSourceImpl); + assertEquals("ROCKSDB".equals(engine), secondAccountAssetStore.getDbSource() + instanceof org.tron.common.storage.rocksdb.RocksDbDataSourceImpl); + invoke(secondManager, "closeStateArchive"); + secondRestart.snapshots.shutdown(); + secondAccountAssetStore.getDbSource().closeDB(); + } + + @Test + public void allNativeExact27StoresReopenWithStableIdentityAndHistory() throws Exception { + for (String engine : Arrays.asList("LEVELDB", "ROCKSDB")) { + Path output = temporaryFolder.newFolder("all-native-exact27-" + + engine.toLowerCase()).toPath(); + withArchiveConfig(output, engine, true, + () -> runAllNativeExact27Scenario(output, engine)); + } + } + + private void runAllNativeExact27Scenario(Path output, String engine) throws Exception { + Path archive = output.resolve("state-archive"); + BlockSnapshotMeta base = new BlockSnapshotMeta(6, 6, hash(6), hash(5), 6_000L); + SnapshotFixture fixture = nativeSnapshotFixture(output, engine, Collections.emptyMap()); + AtomicLong targetOptimization = new AtomicLong(); + TestAccountAssetStore accountAssetStore = new TestAccountAssetStore(); + Manager manager = manager(fixture.snapshots, base, accountAssetStore, targetOptimization); + byte[] address = archiveAddress(13); + String tokenId = "1000013"; + byte[] directKey = new P66AccountAssetCodec().assetPhysicalKey(address, tokenId); + seedNativeExact27(fixture.databases, 6); + fixture.databases.get("properties").put( + HistoricalAccountAssetBalanceResolver.proposal66PhysicalKey(), longValue(0)); + fixture.databases.get("account").put(address, + assetAccount(address, false, tokenId, 20)); + Map sourceIdentities = sourceIdentities(fixture, accountAssetStore); + invoke(manager, "initStateArchive"); + + BlockSnapshotMeta target = null; + for (int epoch = 7; epoch <= 8; epoch++) { + targetOptimization.set(1); + target = new BlockSnapshotMeta(epoch, epoch, hash(epoch), hash(epoch - 1), + epoch * 1_000L); + try (ISession block = fixture.snapshots.buildSession()) { + mutateNativeExact27(fixture.databases, epoch); + if (epoch == 7) { + fixture.databases.get("properties").put( + HistoricalAccountAssetBalanceResolver.proposal66PhysicalKey(), longValue(1)); + fixture.databases.get("account").put(address, + assetAccount(address, false, tokenId, 30)); + } else { + fixture.databases.get("account").put(address, + assetAccount(address, true, tokenId, 40)); + } + block.commit(target); + } + setField(fixture.snapshots, "flushCount", 1); + fixture.snapshots.flushPending(); + assertEquals(target, manager.getStateArchiveRuntime().verifyNormalWriteFixedPoint()); + } + + assertNativeExact27History(manager, address, directKey); + assertAccountAsset(manager, 6, address, tokenId, + P66AccountAssetCodec.Phase.P66_OFF, true, 20); + assertAccountAsset(manager, 7, address, tokenId, + P66AccountAssetCodec.Phase.P66_ON, true, 30); + assertAccountAsset(manager, 8, address, tokenId, + P66AccountAssetCodec.Phase.P66_ON, true, 40); + @SuppressWarnings("unchecked") + ArgumentCaptor> checkpoints = ArgumentCaptor.forClass(Map.class); + verify(fixture.checkpoint, times(2)).updateByBatch(checkpoints.capture()); + Map recoveredCheckpoint = checkpoints.getAllValues().get(1); + invoke(manager, "closeStateArchive"); + fixture.snapshots.shutdown(); + closeNativeStores(fixture); + accountAssetStore.getDbSource().closeDB(); + + SnapshotFixture restarted = nativeSnapshotFixture(output, engine, recoveredCheckpoint); + TestAccountAssetStore reopenedAccountAssetStore = new TestAccountAssetStore(); + assertEquals(sourceIdentities, sourceIdentities(restarted, reopenedAccountAssetStore)); + Manager restartedManager = manager(restarted.snapshots, target, reopenedAccountAssetStore, + targetOptimization); + invokeCheckpointRecovery(restarted.snapshots, restarted.checkpoint); + invoke(restartedManager, "initStateArchive"); + assertEquals(0, restartedManager.getStateArchiveRuntime().getStartupRecoveryActionCount()); + assertEquals(target, + restartedManager.getStateArchiveRuntime().verifyNormalWriteFixedPoint()); + assertNativeExact27History(restartedManager, address, directKey); + assertAccountAsset(restartedManager, 6, address, tokenId, + P66AccountAssetCodec.Phase.P66_OFF, true, 20); + assertAccountAsset(restartedManager, 7, address, tokenId, + P66AccountAssetCodec.Phase.P66_ON, true, 30); + assertAccountAsset(restartedManager, 8, address, tokenId, + P66AccountAssetCodec.Phase.P66_ON, true, 40); + invoke(restartedManager, "closeStateArchive"); + restarted.snapshots.shutdown(); + closeNativeStores(restarted); + reopenedAccountAssetStore.getDbSource().closeDB(); + + SnapshotFixture secondRestart = nativeSnapshotFixture(output, engine, recoveredCheckpoint); + TestAccountAssetStore secondAccountAssetStore = new TestAccountAssetStore(); + assertEquals(sourceIdentities, sourceIdentities(secondRestart, secondAccountAssetStore)); + Manager secondManager = manager(secondRestart.snapshots, target, secondAccountAssetStore, + targetOptimization); + invokeCheckpointRecovery(secondRestart.snapshots, secondRestart.checkpoint); + invoke(secondManager, "initStateArchive"); + assertEquals(0, secondManager.getStateArchiveRuntime().getStartupRecoveryActionCount()); + assertEquals(target, secondManager.getStateArchiveRuntime().verifyNormalWriteFixedPoint()); + assertNativeExact27History(secondManager, address, directKey); + invoke(secondManager, "closeStateArchive"); + secondRestart.snapshots.shutdown(); + closeNativeStores(secondRestart); + secondAccountAssetStore.getDbSource().closeDB(); + } + + private void runSupplementalP66Scenario(Path output, String engine) throws Exception { + Path archive = output.resolve("state-archive"); + BlockSnapshotMeta head = new BlockSnapshotMeta(6, 6, hash(6), hash(5), 6_000L); + SnapshotFixture fixture = snapshotFixtureWithoutAccountAsset(Collections.emptyMap()); + AtomicLong targetOptimization = new AtomicLong(); + TestAccountAssetStore accountAssetStore = new TestAccountAssetStore(); + Manager manager = manager(fixture.snapshots, head, accountAssetStore, targetOptimization); + byte[] address = archiveAddress(11); + String tokenId = "1000011"; + byte[] directKey = new P66AccountAssetCodec().assetPhysicalKey(address, tokenId); + fixture.databases.get("properties").put( + HistoricalAccountAssetBalanceResolver.proposal66PhysicalKey(), longValue(0)); + fixture.databases.get("account").put(address, + assetAccount(address, false, tokenId, 20)); + invoke(manager, "initStateArchive"); + assertAccountAsset(manager, 6, address, tokenId, + P66AccountAssetCodec.Phase.P66_OFF, true, 20); + assertAccountAssetPrefix(manager, 6, address, tokenId, + P66AccountAssetCodec.Phase.P66_OFF, true, 20); + assertFalse(accountAssetStore.has(directKey)); + + BlockSnapshotMeta target = null; + for (int epoch = 7; epoch <= 9; epoch++) { + targetOptimization.set(1); + target = new BlockSnapshotMeta(epoch, epoch, hash(epoch), hash(epoch - 1), + epoch * 1_000L); + if (epoch == 8) { + accountAssetStore.failNextPrefixQuery(); + BlockSnapshotMeta failedTarget = target; + assertThrows(ArchivePersistenceException.class, () -> { + try (ISession block = fixture.snapshots.buildSession()) { + fixture.databases.get("account").put(address, + assetAccount(address, true, tokenId, 40)); + block.commit(failedTarget); + } + }); + assertEquals(7, manager.getArchiveHistoryWriter().committedHeadMeta().getEpoch()); + assertEquals(7, fixture.snapshots.getArchiveReadableEpoch()); + assertArrayEquals(longValue(30), accountAssetStore.get(directKey)); + } + try (ISession block = fixture.snapshots.buildSession()) { + if (epoch == 7) { + fixture.databases.get("properties").put( + HistoricalAccountAssetBalanceResolver.proposal66PhysicalKey(), longValue(1)); + fixture.databases.get("account").put(address, + assetAccount(address, false, tokenId, 30)); + } else if (epoch == 8) { + fixture.databases.get("account").put(address, + assetAccount(address, true, tokenId, 40)); + } else { + fixture.databases.get("account").delete(address); + } + block.commit(target); + } + setField(fixture.snapshots, "flushCount", 1); + fixture.snapshots.flushPending(); + assertEquals(target, manager.getStateArchiveRuntime().verifyNormalWriteFixedPoint()); + if (epoch < 9) { + assertArrayEquals(longValue(epoch == 7 ? 30 : 40), accountAssetStore.get(directKey)); + assertAccountAssetPrefix(manager, epoch, address, tokenId, + P66AccountAssetCodec.Phase.P66_ON, true, epoch == 7 ? 30 : 40); + } else { + assertFalse(accountAssetStore.has(directKey)); + } + } + + assertAccountAsset(manager, 6, address, tokenId, + P66AccountAssetCodec.Phase.P66_OFF, true, 20); + assertAccountAsset(manager, 7, address, tokenId, + P66AccountAssetCodec.Phase.P66_ON, true, 30); + assertAccountAsset(manager, 8, address, tokenId, + P66AccountAssetCodec.Phase.P66_ON, true, 40); + assertAccountAsset(manager, 9, address, tokenId, + P66AccountAssetCodec.Phase.P66_ON, false, 0); + assertAccountAssetPrefix(manager, 6, address, tokenId, + P66AccountAssetCodec.Phase.P66_OFF, true, 20); + assertAccountAssetPrefix(manager, 7, address, tokenId, + P66AccountAssetCodec.Phase.P66_ON, true, 30); + assertAccountAssetPrefix(manager, 8, address, tokenId, + P66AccountAssetCodec.Phase.P66_ON, true, 40); + assertAccountAssetPrefix(manager, 9, address, tokenId, + P66AccountAssetCodec.Phase.P66_ON, false, 0); + @SuppressWarnings("unchecked") + ArgumentCaptor> checkpoints = ArgumentCaptor.forClass(Map.class); + verify(fixture.checkpoint, times(3)).updateByBatch(checkpoints.capture()); + Map recoveredCheckpoint = checkpoints.getAllValues().get(2); + Path legacyShadow = output.resolve("legacy-v3-shadow"); + try (PersistentServingKeyIndexGeneration ignored = manager.getArchiveHistoryWriter() + .buildServingGeneration(legacyShadow, "legacy-v3")) { + // Published after the runtime releases its catalog ownership. + } + downgradeGenerationToV3(legacyShadow); + invoke(manager, "closeStateArchive"); + fixture.snapshots.shutdown(); + accountAssetStore.getDbSource().closeDB(); + assertEquals(1, countGenerationDirectories(archive)); + try (PersistentServingKeyIndexCatalog catalog = + PersistentServingKeyIndexCatalog.open(archive.resolve("serving-index"))) { + assertTrue(catalog.publish(catalog.getCurrentGenerationId(), legacyShadow)); + } + + SnapshotFixture interrupted = snapshotFixtureWithoutAccountAsset(recoveredCheckpoint); + TestAccountAssetStore interruptedAccountAssetStore = new TestAccountAssetStore(); + Manager interruptedManager = manager(interrupted.snapshots, target, + interruptedAccountAssetStore, targetOptimization); + interrupted.databases.get("properties").put( + HistoricalAccountAssetBalanceResolver.proposal66PhysicalKey(), longValue(1)); + invokeCheckpointRecovery(interrupted.snapshots, interrupted.checkpoint); + AtomicReference upgradeFailure = + new AtomicReference<>(ServingIndexStage.GENERATION_INSTALLED); + setField(interruptedManager, "stateArchiveServingIndexFaultHook", + (StateArchiveRuntimeOwner.ServingIndexFaultHook) stage -> { + if (stage == upgradeFailure.get() && upgradeFailure.compareAndSet(stage, null)) { + throw new IOException("injected v3 range-index upgrade failure"); + } + }); + assertThrows(IllegalStateException.class, + () -> invoke(interruptedManager, "initStateArchive")); + assertNull(interruptedManager.getStateArchiveRuntime()); + interrupted.snapshots.shutdown(); + interruptedAccountAssetStore.getDbSource().closeDB(); + assertEquals(2, countGenerationDirectories(archive)); + + SnapshotFixture restarted = snapshotFixtureWithoutAccountAsset(recoveredCheckpoint); + targetOptimization.set(1); + TestAccountAssetStore reopenedAccountAssetStore = new TestAccountAssetStore(); + Manager restartedManager = manager(restarted.snapshots, target, reopenedAccountAssetStore, + targetOptimization); + restarted.databases.get("properties").put( + HistoricalAccountAssetBalanceResolver.proposal66PhysicalKey(), longValue(1)); + invokeCheckpointRecovery(restarted.snapshots, restarted.checkpoint); + invoke(restartedManager, "initStateArchive"); + assertAccountAsset(restartedManager, 6, address, tokenId, + P66AccountAssetCodec.Phase.P66_OFF, true, 20); + assertAccountAsset(restartedManager, 7, address, tokenId, + P66AccountAssetCodec.Phase.P66_ON, true, 30); + assertAccountAsset(restartedManager, 8, address, tokenId, + P66AccountAssetCodec.Phase.P66_ON, true, 40); + assertAccountAsset(restartedManager, 9, address, tokenId, + P66AccountAssetCodec.Phase.P66_ON, false, 0); + assertAccountAssetPrefix(restartedManager, 6, address, tokenId, + P66AccountAssetCodec.Phase.P66_OFF, true, 20); + assertAccountAssetPrefix(restartedManager, 7, address, tokenId, + P66AccountAssetCodec.Phase.P66_ON, true, 30); + assertAccountAssetPrefix(restartedManager, 8, address, tokenId, + P66AccountAssetCodec.Phase.P66_ON, true, 40); + assertAccountAssetPrefix(restartedManager, 9, address, tokenId, + P66AccountAssetCodec.Phase.P66_ON, false, 0); + assertFalse(reopenedAccountAssetStore.has(directKey)); + assertServingGenerationSupportsRange(archive); + assertEquals("LEVELDB".equals(engine), reopenedAccountAssetStore.getDbSource() + instanceof org.tron.common.storage.leveldb.LevelDbDataSourceImpl); + assertEquals("ROCKSDB".equals(engine), reopenedAccountAssetStore.getDbSource() + instanceof org.tron.common.storage.rocksdb.RocksDbDataSourceImpl); + invoke(restartedManager, "closeStateArchive"); + restarted.snapshots.shutdown(); + reopenedAccountAssetStore.getDbSource().closeDB(); + } + + @Test + public void managerRunsTwoNormalFlushTargetsThroughExact27FixedPoint() throws Exception { + for (String engine : Arrays.asList("LEVELDB", "ROCKSDB")) { + Path output = temporaryFolder.newFolder("manager-" + engine.toLowerCase()).toPath(); + Path archive = output.resolve("state-archive"); + HistoryCommitMarker head = initializeRecoverableTail(archive, engine); + SnapshotFixture fixture = snapshotFixture(); + SnapshotManager snapshots = fixture.snapshots; + installRecoveredBinding(snapshots, archive, 6, 6); + Manager manager = manager(snapshots, head); + + withArchiveConfig(output, engine, true, () -> invoke(manager, "initStateArchive")); + Path runtimeBuilder = archive.resolve( + StateArchiveRuntimeOwner.SERVING_INDEX_RUNTIME_DIRECTORY).resolve("keys"); + Engine selectedEngine = Engine.valueOf(engine); + + assertEquals(State.RUNNING, manager.getStateArchiveRuntime().getState()); + assertEquals(1, + StateArchiveIndexDatabase.openReferenceCount(runtimeBuilder, selectedEngine)); + assertEquals(0, manager.getStateArchiveRuntime().getStartupRecoveryActionCount()); + assertEquals(head.getMeta(), manager.getStateArchiveRuntime().getRecoveredHead()); + assertNotNull(manager.getArchiveHistoryWriter()); + assertEquals(6, snapshots.getArchiveReadableEpoch()); + + byte[] key = new byte[]{3, 1, 4}; + for (int epoch = 7; epoch <= 8; epoch++) { + BlockSnapshotMeta target = new BlockSnapshotMeta(epoch, epoch, hash(epoch), + hash(epoch - 1), epoch * 1_000L); + try (ISession block = snapshots.buildSession()) { + fixture.databases.get("proposal").put(key, new byte[]{(byte) epoch}); + block.commit(target); + } + setField(snapshots, "flushCount", 1); + snapshots.flushPending(); + assertEquals(target, manager.getStateArchiveRuntime().verifyNormalWriteFixedPoint()); + assertServingFixedPoint(archive, target, 5); + StateArchiveRuntimeOwner.ServingIndexInspection inspection = + manager.inspectArchiveServingIndex(); + assertEquals(target, inspection.getReadableHead()); + assertEquals(epoch - 1, inspection.getLastApply().getIndexedFrom()); + assertEquals(epoch, inspection.getLastApply().getIndexedThrough()); + assertEquals(1, inspection.getLastApply().getValidatedCommitCount()); + assertEquals(Long.valueOf(1), inspection.getLastApply().getChangedEntriesByStore() + .get("proposal")); + assertEquals(Long.valueOf(0), inspection.getLastApply().getChangedEntriesByStore() + .get("abi")); + assertTrue(inspection.getLastApply().isGenerationCreated()); + assertTrue(inspection.getLastApply().getElapsedNanos() > 0); + assertEquals(1, inspection.getLastApply().getChangedEntryCount()); + assertTrue(inspection.getLastApply().isThroughputAvailable()); + assertTrue(inspection.getLastApply().getChangedEntriesPerSecond() > 0); + assertTrue(inspection.getLastApply().getValidatedCommitsPerSecond() > 0); + assertEquals(0, inspection.getHistoryToServingBacklog()); + assertEquals(27, inspection.getGeneration().getStores().size()); + assertEquals(epoch, inspection.getGeneration().getIndexedThrough()); + assertTrue(inspection.getGeneration().getApparentBytes() > 0); + assertEquals("ROCKSDB".equals(engine), inspection.getGeneration().getEngine() + .getEstimatedLiveDataBytes().isAvailable()); + assertEquals("ROCKSDB".equals(engine), inspection.getGeneration().getEngine() + .getTotalSstBytes().isAvailable()); + assertEquals("ROCKSDB".equals(engine), inspection.getGeneration().getEngine() + .getPendingCompactionBytes().isAvailable()); + assertEquals(1, + StateArchiveIndexDatabase.openReferenceCount(runtimeBuilder, selectedEngine)); + setField(snapshots, "size", 0); + } + + invoke(manager, "closeStateArchive"); + assertEquals(0, + StateArchiveIndexDatabase.openReferenceCount(runtimeBuilder, selectedEngine)); + assertEquals(-1, snapshots.getArchiveReadableEpoch()); + assertNull(manager.getStateArchiveRuntime()); + assertNull(manager.getArchiveHistoryWriter()); + assertFalse(Files.exists(archive.resolve("participants"))); + assertFalse(Files.exists(archive.resolve("progress"))); + snapshots.shutdown(); + } + } + + @Test + public void managerRunsMultiTargetNormalFlushThroughExact27FixedPoint() throws Exception { + for (String engine : Arrays.asList("LEVELDB", "ROCKSDB")) { + Path output = temporaryFolder.newFolder("multi-target-" + engine.toLowerCase()).toPath(); + Path archive = output.resolve("state-archive"); + HistoryCommitMarker head = initializeRecoverableTail(archive, engine); + SnapshotFixture fixture = snapshotFixture(); + SnapshotManager snapshots = fixture.snapshots; + installRecoveredBinding(snapshots, archive, 6, 6); + Manager manager = manager(snapshots, head); + + withArchiveConfig(output, engine, true, () -> invoke(manager, "initStateArchive")); + + byte[] key = new byte[]{3, 1, 5}; + BlockSnapshotMeta target = null; + for (int epoch = 7; epoch <= 8; epoch++) { + target = new BlockSnapshotMeta(epoch, epoch, hash(epoch), + hash(epoch - 1), epoch * 1_000L); + try (ISession block = snapshots.buildSession()) { + fixture.databases.get("proposal").put(key, new byte[]{(byte) epoch}); + fixture.databases.get("properties").put(new byte[]{1}, new byte[]{(byte) epoch}); + fixture.checkpointOnly.get("block").put(new byte[]{2}, new byte[]{(byte) epoch}); + fixture.checkpointOnly.get("block-index").put(new byte[]{3}, + new byte[]{(byte) epoch}); + block.commit(target); + } + } + setField(snapshots, "flushCount", 2); + + snapshots.flushPending(); + + assertEquals(target, manager.getStateArchiveRuntime().verifyNormalWriteFixedPoint()); + assertServingFixedPoint(archive, target, 5); + StateArchiveRuntimeOwner.ServingIndexInspection publishedInspection = + manager.inspectArchiveServingIndex(); + String publishedGeneration = publishedInspection.getGeneration().getGenerationId(); + ArchiveWalBinding binding = snapshots.getLatestArchiveWalBinding(); + assertNotNull(binding); + assertEquals(7, binding.getFirst().getEpoch()); + assertEquals(8, binding.getLast().getEpoch()); + assertEquals(6, binding.getPredecessorEpoch()); + assertArrayEquals(hash(6), binding.getPredecessorHash()); + @SuppressWarnings("unchecked") + ArgumentCaptor> checkpointBatch = + ArgumentCaptor.forClass(Map.class); + verify(fixture.checkpoint).updateByBatch(checkpointBatch.capture()); + ArchiveWalBinding persisted = ArchiveWalBinding.fromCheckpointBatch( + checkpointBatch.getValue()); + assertNotNull(persisted); + assertArrayEquals(binding.getBatchDigest(), persisted.getBatchDigest()); + Set checkpointDatabases = checkpointDatabases(checkpointBatch.getValue()); + assertTrue(checkpointDatabases.contains("properties")); + assertTrue(checkpointDatabases.contains("proposal")); + assertTrue(checkpointDatabases.contains("block")); + assertTrue(checkpointDatabases.contains("block-index")); + assertTrue(checkpointDatabases.contains(ArchiveWalBinding.CHECKPOINT_DATABASE)); + SnapshotFixture recovered = snapshotFixture(checkpointBatch.getValue()); + invokeCheckpointRecovery(recovered.snapshots, recovered.checkpoint); + assertNotNull(recovered.snapshots.getRecoveredArchiveWalBinding()); + assertArrayEquals(binding.getBatchDigest(), + recovered.snapshots.getRecoveredArchiveWalBinding().getBatchDigest()); + assertArrayEquals(new byte[]{8}, recovered.databases.get("proposal").get(key)); + assertArrayEquals(new byte[]{8}, recovered.databases.get("properties").get(new byte[]{1})); + assertArrayEquals(new byte[]{8}, recovered.checkpointOnly.get("block").get(new byte[]{2})); + assertArrayEquals(new byte[]{8}, + recovered.checkpointOnly.get("block-index").get(new byte[]{3})); + invokeCheckpointRecovery(recovered.snapshots, recovered.checkpoint); + assertArrayEquals(binding.getBatchDigest(), + recovered.snapshots.getRecoveredArchiveWalBinding().getBatchDigest()); + recovered.snapshots.shutdown(); + assertTrue(fixture.databases.values().stream() + .allMatch(database -> database.getHead() instanceof SnapshotRoot)); + invoke(manager, "closeStateArchive"); + assertFalse(Files.exists(archive.resolve("participants"))); + assertFalse(Files.exists(archive.resolve("progress"))); + snapshots.shutdown(); + + SnapshotFixture restarted = snapshotFixture(checkpointBatch.getValue()); + invokeCheckpointRecovery(restarted.snapshots, restarted.checkpoint); + BlockSnapshotMeta restartHead = target; + Manager restartedManager = manager(restarted.snapshots, restartHead); + withArchiveConfig(output, engine, true, + () -> invoke(restartedManager, "initStateArchive")); + assertEquals(restartHead, restartedManager.getStateArchiveRuntime().getRecoveredHead()); + assertEquals(0, + restartedManager.getStateArchiveRuntime().getStartupRecoveryActionCount()); + assertEquals(restartHead, + restartedManager.getStateArchiveRuntime().verifyNormalWriteFixedPoint()); + assertServingFixedPoint(archive, restartHead, 5); + StateArchiveRuntimeOwner.ServingIndexInspection restartedInspection = + restartedManager.inspectArchiveServingIndex(); + assertEquals(publishedGeneration, + restartedInspection.getGeneration().getGenerationId()); + assertEquals(restartHead.getEpoch(), + restartedInspection.getLastApply().getIndexedFrom()); + assertEquals(restartHead.getEpoch(), + restartedInspection.getLastApply().getIndexedThrough()); + assertEquals(0, restartedInspection.getLastApply().getValidatedCommitCount()); + assertTrue(restartedInspection.getLastApply().getChangedEntriesByStore().values() + .stream().allMatch(changes -> changes == 0)); + assertFalse(restartedInspection.getLastApply().isGenerationCreated()); + assertEquals(0, restartedInspection.getHistoryToServingBacklog()); + assertFalse(restartedInspection.getLastApply().isThroughputAvailable()); + assertThrows(IllegalStateException.class, + () -> restartedInspection.getLastApply().getChangedEntriesPerSecond()); + assertEngineMeasurementEquals(publishedInspection.getGeneration().getEngine() + .getEstimatedLiveDataBytes(), + restartedInspection.getGeneration().getEngine().getEstimatedLiveDataBytes()); + assertEngineMeasurementEquals(publishedInspection.getGeneration().getEngine() + .getTotalSstBytes(), + restartedInspection.getGeneration().getEngine().getTotalSstBytes()); + assertEngineMeasurementEquals(publishedInspection.getGeneration().getEngine() + .getPendingCompactionBytes(), + restartedInspection.getGeneration().getEngine().getPendingCompactionBytes()); + invoke(restartedManager, "closeStateArchive"); + restarted.snapshots.shutdown(); + } + } + + @Test + public void newRuntimeDoesNotOpenLegacyParticipantEvidence() throws Exception { + for (String engine : Arrays.asList("LEVELDB", "ROCKSDB")) { + Path output = temporaryFolder.newFolder("partial-" + engine.toLowerCase()).toPath(); + Path archive = output.resolve("state-archive"); + HistoryCommitMarker head = initializeHistory(archive, 7); + SnapshotManager snapshots = snapshotFixture().snapshots; + installRecoveredBinding(snapshots, archive, 7, 7); + Path failurePath = archive.resolve("participants").resolve(PARTICIPANTS.get(3)); + Files.createDirectories(failurePath.getParent()); + byte[] evidence = new byte[]{4, 5, 6, 7}; + Files.write(failurePath, evidence); + Manager manager = manager(snapshots, head); + + withArchiveConfig(output, engine, true, () -> invoke(manager, "initStateArchive")); + + assertNotNull(manager.getStateArchiveRuntime()); + assertArrayEquals(evidence, Files.readAllBytes(failurePath)); + invoke(manager, "closeStateArchive"); + snapshots.shutdown(); + } + } + + @Test + public void missingWalBindingFailsBeforeNormalWriterAttachment() throws Exception { + Path output = temporaryFolder.newFolder("missing-wal-binding").toPath(); + Path archive = output.resolve("state-archive"); + HistoryCommitMarker head = initializeRecoverableTail(archive, "LEVELDB"); + SnapshotFixture fixture = snapshotFixture(); + Manager manager = manager(fixture.snapshots, head); + + assertThrows(IllegalStateException.class, + () -> withArchiveConfig(output, "LEVELDB", true, + () -> invoke(manager, "initStateArchive"))); + + assertNull(manager.getStateArchiveRuntime()); + assertFalse(Files.exists(archive.resolve("participants"))); + fixture.snapshots.shutdown(); + } + + @Test + public void substitutedWalBindingFailsBeforeNormalWriterAttachment() throws Exception { + Path output = temporaryFolder.newFolder("substituted-wal-binding").toPath(); + Path archive = output.resolve("state-archive"); + HistoryCommitMarker head = initializeRecoverableTail(archive, "ROCKSDB"); + SnapshotFixture fixture = snapshotFixture(); + ArchiveWalBinding valid = binding(archive, 6, 6); + byte[] substitutedRefs = valid.getHistoryRefsDigest(); + substitutedRefs[0] ^= 1; + ArchiveWalBinding substituted = new ArchiveWalBinding(valid.getFirst(), valid.getLast(), + valid.getPredecessorEpoch(), valid.getPredecessorHash(), valid.getBatchDigest(), + valid.getStoreScopeDigest(), substitutedRefs, valid.getBlockIndexRefsDigest()); + setField(fixture.snapshots, "recoveredArchiveWalBinding", substituted); + Manager manager = manager(fixture.snapshots, head); + + assertThrows(IllegalStateException.class, + () -> withArchiveConfig(output, "ROCKSDB", true, + () -> invoke(manager, "initStateArchive"))); + + assertNull(manager.getStateArchiveRuntime()); + assertFalse(Files.exists(archive.resolve("participants"))); + fixture.snapshots.shutdown(); + } + + @Test + public void substitutedAllStoreServingIndexFailsBeforeRuntimeAttachment() throws Exception { + Path output = temporaryFolder.newFolder("substituted-serving-index").toPath(); + Path archive = output.resolve("state-archive"); + HistoryCommitMarker head = initializeRecoverableTail(archive, "ROCKSDB"); + SnapshotFixture initial = snapshotFixture(); + installRecoveredBinding(initial.snapshots, archive, 6, 6); + Manager initialManager = manager(initial.snapshots, head); + withArchiveConfig(output, "ROCKSDB", true, + () -> invoke(initialManager, "initStateArchive")); + invoke(initialManager, "closeStateArchive"); + initial.snapshots.shutdown(); + + Path foreignArchive = temporaryFolder.newFolder("foreign-serving-history").toPath(); + Path foreignShadow = output.resolve("foreign-serving-shadow"); + withArchiveConfig(output, "ROCKSDB", true, () -> { + try (ArchiveHistoryWriter foreign = new ArchiveHistoryWriter( + foreignArchive, 4096, ArchiveStoreScope.getStateDatabases())) { + foreign.accept(new BlockReverseDiff(head.getMeta(), Collections.singletonList( + new BlockReverseDiff.DbGroup("proposal", Collections.singletonList( + new BlockReverseDiff.Entry(new byte[]{9, 9}, OldValue.absent())))))); + try (PersistentServingKeyIndexGeneration ignored = + foreign.buildServingGeneration(foreignShadow, "foreign")) { + // Catalog publication reopens the generation after this build handle is closed. + } + } + }); + try (PersistentServingKeyIndexCatalog catalog = + PersistentServingKeyIndexCatalog.open(archive.resolve("serving-index"))) { + assertTrue(catalog.publish(catalog.getCurrentGenerationId(), foreignShadow)); + } + + SnapshotFixture restarted = snapshotFixture(); + installRecoveredBinding(restarted.snapshots, archive, 6, 6); + Manager restartedManager = manager(restarted.snapshots, head); + assertThrows(IllegalStateException.class, + () -> withArchiveConfig(output, "ROCKSDB", true, + () -> invoke(restartedManager, "initStateArchive"))); + assertNull(restartedManager.getStateArchiveRuntime()); + restarted.snapshots.shutdown(); + } + + @Test + public void disabledManagerControlDoesNotInspectOrCreateArchiveRuntime() throws Exception { + Path output = temporaryFolder.newFolder("disabled-manager").toPath(); + Path archive = output.resolve("state-archive"); + Files.createDirectories(archive); + byte[] evidence = new byte[]{8, 9, 10}; + Files.write(archive.resolve("unexpected-evidence"), evidence); + Manager manager = new Manager(); + + withArchiveConfig(output, "LEVELDB", false, () -> invoke(manager, "initStateArchive")); + + assertNull(manager.getStateArchiveRuntime()); + assertArrayEquals(evidence, Files.readAllBytes(archive.resolve("unexpected-evidence"))); + assertFalse(Files.exists(archive.resolve("participants"))); + } + + private static Manager manager(SnapshotManager snapshots, HistoryCommitMarker head) + throws Exception { + return manager(snapshots, head.getMeta()); + } + + private static Manager manager(SnapshotManager snapshots, BlockSnapshotMeta head) + throws Exception { + return manager(snapshots, head, null, null); + } + + private static Manager manager(SnapshotManager snapshots, BlockSnapshotMeta head, + AccountAssetStore accountAssetStore, AtomicLong targetOptimization) throws Exception { + DynamicPropertiesStore properties = mock(DynamicPropertiesStore.class); + when(properties.getLatestBlockHeaderNumber()).thenReturn(head.getBlockNumber()); + when(properties.getLatestBlockHeaderHash()) + .thenReturn(Sha256Hash.wrap(head.getBlockHash())); + ChainBaseManager chainBase = mock(ChainBaseManager.class); + when(chainBase.getDynamicPropertiesStore()).thenReturn(properties); + if (accountAssetStore != null) { + when(chainBase.getAccountAssetStore()).thenReturn(accountAssetStore); + when(properties.getAllowAccountAssetOptimizationFromRoot()) + .thenAnswer(ignored -> targetOptimization.get()); + when(properties.supportAllowAssetOptimization()) + .thenAnswer(ignored -> targetOptimization.get() == 1); + } + BlockCapsule headBlock = mock(BlockCapsule.class); + when(headBlock.getParentHash()).thenReturn(Sha256Hash.wrap(head.getParentHash())); + when(headBlock.getTimeStamp()).thenReturn(head.getTimestamp()); + when(chainBase.getBlockByNum(head.getBlockNumber())).thenReturn(headBlock); + ChainBaseManager.init(chainBase); + Manager manager = new Manager(); + setField(manager, "revokingStore", snapshots); + setField(manager, "chainBaseManager", chainBase); + return manager; + } + + private static SnapshotFixture snapshotFixture() { + return snapshotFixture(Collections.emptyMap()); + } + + private static SnapshotFixture snapshotFixture(Map checkpointEntries) { + return snapshotFixture(checkpointEntries, true); + } + + private static SnapshotFixture snapshotFixture(Map checkpointEntries, + boolean includeAccountAsset) { + if (CommonParameter.getInstance().getStorage() == null) { + CommonParameter.getInstance().storage = new Storage(); + } + SnapshotManager snapshots = new SnapshotManager(""); + Map databases = new LinkedHashMap<>(); + for (String participant : PARTICIPANTS) { + if (!includeAccountAsset + && AccountAssetArchiveProjector.ACCOUNT_ASSET_DB.equals(participant)) { + continue; + } + Chainbase database = new Chainbase(new SnapshotRoot(new MemoryDb(participant))); + snapshots.add(database); + databases.put(participant, database); + } + Map checkpointOnly = new LinkedHashMap<>(); + for (String name : Arrays.asList("block", "block-index")) { + Chainbase database = new Chainbase(new SnapshotRoot(new MemoryDb(name))); + snapshots.add(database); + checkpointOnly.put(name, database); + } + snapshots.enable(); + snapshots.setUnChecked(false); + CheckTmpStore checkpoint = mock(CheckTmpStore.class); + @SuppressWarnings("unchecked") + DbSourceInter checkpointDb = mock(DbSourceInter.class); + when(checkpointDb.iterator()).thenAnswer( + ignored -> checkpointEntries.entrySet().iterator()); + when(checkpoint.getDbSource()).thenReturn(checkpointDb); + snapshots.setCheckTmpStore(checkpoint); + return new SnapshotFixture(snapshots, databases, checkpointOnly, checkpoint, + Collections.emptyMap()); + } + + private static SnapshotFixture nativeSnapshotFixture(Path output, String engine, + Map checkpointEntries) { + if (CommonParameter.getInstance().getStorage() == null) { + CommonParameter.getInstance().storage = new Storage(); + } + SnapshotManager snapshots = new SnapshotManager(""); + Map databases = new LinkedHashMap<>(); + Map nativeStores = new LinkedHashMap<>(); + for (String participant : PARTICIPANTS) { + if (AccountAssetArchiveProjector.ACCOUNT_ASSET_DB.equals(participant)) { + continue; + } + DB nativeStore = openNativeStore(output, engine, participant); + Chainbase database = new Chainbase(new SnapshotRoot(nativeStore)); + snapshots.add(database); + databases.put(participant, database); + nativeStores.put(participant, (SnapshotCapableStore) nativeStore); + } + Map checkpointOnly = new LinkedHashMap<>(); + for (String name : Arrays.asList("block", "block-index")) { + DB nativeStore = openNativeStore(output, engine, name); + Chainbase database = new Chainbase(new SnapshotRoot(nativeStore)); + snapshots.add(database); + checkpointOnly.put(name, database); + nativeStores.put(name, (SnapshotCapableStore) nativeStore); + } + snapshots.enable(); + snapshots.setUnChecked(false); + CheckTmpStore checkpoint = mock(CheckTmpStore.class); + @SuppressWarnings("unchecked") + DbSourceInter checkpointDb = mock(DbSourceInter.class); + when(checkpointDb.iterator()).thenAnswer( + ignored -> checkpointEntries.entrySet().iterator()); + when(checkpoint.getDbSource()).thenReturn(checkpointDb); + snapshots.setCheckTmpStore(checkpoint); + return new SnapshotFixture(snapshots, databases, checkpointOnly, checkpoint, nativeStores); + } + + private static DB openNativeStore(Path output, String engine, String name) { + if ("LEVELDB".equals(engine)) { + return new LevelDB(new LevelDbDataSourceImpl(output.toString(), name)); + } + if ("ROCKSDB".equals(engine)) { + return new RocksDB(new RocksDbDataSourceImpl( + output.resolve("database").toString(), name)); + } + throw new IllegalArgumentException("Unsupported native fixture engine: " + engine); + } + + private static SnapshotFixture snapshotFixtureWithoutAccountAsset( + Map checkpointEntries) { + return snapshotFixture(checkpointEntries, false); + } + + private static HistoryCommitMarker initializeRecoverableTail(Path archive, String engine) + throws Exception { + return initializeHistory(archive, 6); + } + + private static void assertServingFixedPoint(Path archive, BlockSnapshotMeta expected, + long expectedFrom) throws Exception { + try (PersistentServingKeyIndexCatalog catalog = + PersistentServingKeyIndexCatalog.open(archive.resolve("serving-index")); + PersistentServingKeyIndexGeneration generation = catalog.pin()) { + assertEquals(expectedFrom, generation.getIndexedFrom()); + assertEquals(expected.getEpoch(), generation.getIndexedThrough()); + assertArrayEquals(expected.getBlockHash(), generation.getHeadHash()); + assertEquals(PARTICIPANTS, generation.getParticipatingDatabases()); + assertTrue(generation.isLatestSourceIdentityBound()); + assertTrue(generation.isExactOnlyFormat()); + assertFalse(generation.supportsRangeQueries()); + for (String participant : PARTICIPANTS) { + assertEquals(expected.getEpoch(), generation.getPersistentStoreCoverage(participant) + .getIndexedThrough()); + } + } + } + + private static void assertEngineMeasurementEquals( + PersistentServingKeyIndexGeneration.LongPropertyMeasurement expected, + PersistentServingKeyIndexGeneration.LongPropertyMeasurement actual) { + assertEquals(expected.isAvailable(), actual.isAvailable()); + if (expected.isAvailable()) { + assertEquals(expected.getValue(), actual.getValue()); + } + } + + private static long countGenerationDirectories(Path archive) throws IOException { + try (java.util.stream.Stream entries = + Files.list(archive.resolve("serving-index").resolve("generations"))) { + return entries.filter(Files::isDirectory).count(); + } + } + + private static void downgradeGenerationToV3(Path generation) throws IOException { + Path manifest = generation.resolve("generation.meta"); + byte[] encoded = Files.readAllBytes(manifest); + ByteBuffer.wrap(encoded).putShort(4, (short) 3); + int payloadLength = encoded.length - Integer.BYTES; + int checksum = Hashing.crc32c().hashBytes(encoded, 0, payloadLength).asInt(); + ByteBuffer.wrap(encoded, payloadLength, Integer.BYTES).putInt(checksum); + Files.write(manifest, encoded); + } + + private static void assertServingGenerationSupportsRange(Path archive) throws IOException { + try (PersistentServingKeyIndexCatalog catalog = + PersistentServingKeyIndexCatalog.open(archive.resolve("serving-index")); + PersistentServingKeyIndexGeneration generation = catalog.pin()) { + assertTrue(generation.isExactOnlyFormat()); + assertFalse(generation.supportsRangeQueries()); + } + } + + private static Map historyAuthoritySnapshot(Path archive) throws IOException { + Map snapshot = new LinkedHashMap<>(); + for (String relative : Arrays.asList("MANIFEST", "bootstrap.anchor", + "history.scan-anchor", "state_history.idx", "commits/commit.log")) { + Path file = archive.resolve(relative); + snapshot.put(relative, Files.readAllBytes(file)); + } + try (java.util.stream.Stream segments = Files.list(archive.resolve("history"))) { + Iterator ordered = segments.filter(Files::isRegularFile).sorted().iterator(); + while (ordered.hasNext()) { + Path file = ordered.next(); + snapshot.put("history/" + file.getFileName(), Files.readAllBytes(file)); + } + } + return snapshot; + } + + private static void assertHistoryAuthorityEquals(Map expected, Path archive) + throws IOException { + Map actual = historyAuthoritySnapshot(archive); + assertEquals(expected.keySet(), actual.keySet()); + for (String relative : expected.keySet()) { + assertArrayEquals(relative, expected.get(relative), actual.get(relative)); + } + } + + private static void installRecoveredBinding(SnapshotManager snapshots, Path archive, + long firstEpoch, long lastEpoch) throws Exception { + setField(snapshots, "recoveredArchiveWalBinding", + binding(archive, firstEpoch, lastEpoch)); + } + + private static ArchiveWalBinding binding(Path archive, long firstEpoch, long lastEpoch) + throws Exception { + List markers = new ArrayList<>(); + try (ArchiveHistoryWriter writer = new ArchiveHistoryWriter( + archive, 4096, ArchiveStoreScope.getStateDatabases())) { + for (long epoch = firstEpoch; epoch <= lastEpoch; epoch++) { + markers.add(writer.get(epoch)); + } + } + return ArchiveWalBinding.fromMarkers(markers); + } + + private static HistoryCommitMarker initializeHistory(Path archive, int epoch) + throws Exception { + HistoryCommitMarker head; + try (ArchiveHistoryWriter writer = new ArchiveHistoryWriter( + archive, 4096, ArchiveStoreScope.getStateDatabases())) { + writer.accept(new BlockReverseDiff( + new BlockSnapshotMeta(epoch, epoch, hash(epoch), hash(epoch - 1), epoch * 1_000L), + Collections.emptyList())); + head = writer.committedHead(); + } + return head; + } + + private static void withArchiveConfig(Path output, String engine, boolean enabled, + ThrowingRunnable action) throws Exception { + CommonParameter args = CommonParameter.getInstance(); + Storage oldStorage = args.getStorage(); + Storage storage = oldStorage == null ? new Storage() : oldStorage; + args.storage = storage; + String oldOutput = args.outputDirectory; + String oldDirectory = storage.getStateArchiveDirectory(); + String oldDbDirectory = storage.getDbDirectory(); + String oldEngine = storage.getDbEngine(); + long oldSegmentSize = storage.getStateArchiveMaxSegmentSize(); + int oldQueueCapacity = storage.getStateArchiveQueueCapacity(); + boolean oldEnabled = storage.isStateArchiveEnabled(); + try { + args.outputDirectory = output.toString(); + storage.setStateArchiveDirectory("state-archive"); + storage.setDbDirectory("database"); + storage.setDbEngine(engine); + storage.setDefaultDbOptions(new StorageConfig()); + storage.setStateArchiveMaxSegmentSize(4096); + storage.setStateArchiveQueueCapacity(4); + storage.setStateArchiveEnabled(enabled); + action.run(); + } finally { + args.outputDirectory = oldOutput; + storage.setStateArchiveDirectory(oldDirectory); + storage.setDbDirectory(oldDbDirectory); + storage.setDbEngine(oldEngine); + storage.setStateArchiveMaxSegmentSize(oldSegmentSize); + storage.setStateArchiveQueueCapacity(oldQueueCapacity); + storage.setStateArchiveEnabled(oldEnabled); + args.storage = oldStorage; + } + } + + private static void setField(Object target, String name, Object value) throws Exception { + Field field = target.getClass().getDeclaredField(name); + field.setAccessible(true); + field.set(target, value); + } + + private static void invoke(Manager manager, String name) throws Exception { + Method method = Manager.class.getDeclaredMethod(name); + method.setAccessible(true); + try { + method.invoke(manager); + } catch (InvocationTargetException failure) { + Throwable cause = failure.getCause(); + if (cause instanceof Exception) { + throw (Exception) cause; + } + throw failure; + } + } + + private static void invokeCheckpointRecovery(SnapshotManager snapshots, + TronDatabase checkpoint) throws Exception { + Method method = SnapshotManager.class.getDeclaredMethod("recover", TronDatabase.class); + method.setAccessible(true); + try { + method.invoke(snapshots, checkpoint); + } catch (InvocationTargetException failure) { + Throwable cause = failure.getCause(); + if (cause instanceof Exception) { + throw (Exception) cause; + } + throw failure; + } + } + + private static List participants() { + List participants = new ArrayList<>(ArchiveStoreScope.getStateDatabases()); + Collections.sort(participants); + return Collections.unmodifiableList(participants); + } + + private static byte[] hash(int suffix) { + byte[] hash = new byte[32]; + hash[31] = (byte) suffix; + return hash; + } + + private static byte[] account(byte[] address, long balance) { + return Account.newBuilder().setAddress(ByteString.copyFrom(address)).setBalance(balance) + .build().toByteArray(); + } + + private static byte[] assetAccount(byte[] address, boolean optimized, String tokenId, + long balance) { + Account.Builder builder = Account.newBuilder().setAddress(ByteString.copyFrom(address)) + .setAssetOptimized(optimized); + if (tokenId != null) { + builder.putAssetV2(tokenId, balance); + } + return builder.build().toByteArray(); + } + + private static void assertP66History(Manager manager, byte[] address, byte[] absentAddress, + String tokenId) { + assertAccountAsset(manager, 6, address, tokenId, + P66AccountAssetCodec.Phase.P66_OFF, true, 20); + assertAccountAsset(manager, 6, absentAddress, tokenId, + P66AccountAssetCodec.Phase.P66_OFF, false, 0); + assertAccountAsset(manager, 7, address, tokenId, + P66AccountAssetCodec.Phase.P66_ON, true, 30); + assertAccountAsset(manager, 8, address, tokenId, + P66AccountAssetCodec.Phase.P66_ON, true, 40); + assertAccountAsset(manager, 9, address, tokenId, + P66AccountAssetCodec.Phase.P66_ON, false, 0); + assertAccountAssetPrefix(manager, 6, address, tokenId, + P66AccountAssetCodec.Phase.P66_OFF, true, 20); + assertAccountAssetPrefix(manager, 6, absentAddress, tokenId, + P66AccountAssetCodec.Phase.P66_OFF, false, 0); + assertAccountAssetPrefix(manager, 7, address, tokenId, + P66AccountAssetCodec.Phase.P66_ON, true, 30); + assertAccountAssetPrefix(manager, 8, address, tokenId, + P66AccountAssetCodec.Phase.P66_ON, true, 40); + assertAccountAssetPrefix(manager, 9, address, tokenId, + P66AccountAssetCodec.Phase.P66_ON, false, 0); + } + + private static void assertAccountAssetPrefix(Manager manager, int targetEpoch, byte[] address, + String tokenId, P66AccountAssetCodec.Phase phase, boolean present, long balance) { + assertNotNull(tokenId); + assertNotNull(phase); + assertTrue(present || balance == 0); + assertThrows(ArchivePersistenceException.class, + () -> manager.getArchiveAccountAssets(targetEpoch, address, prefixLimits())); + } + + private static HistoricalAccountAssetPrefixResolver.Limits prefixLimits() { + return new HistoricalAccountAssetPrefixResolver.Limits(10, 10, 10, 64, 8, 1_000); + } + + private static void assertAccountAsset(Manager manager, int targetEpoch, byte[] address, + String tokenId, P66AccountAssetCodec.Phase phase, boolean present, long balance) { + HistoricalAccountAssetBalanceResolver.Result result = + manager.getArchiveAccountAssetBalance(targetEpoch, address, tokenId); + assertEquals(targetEpoch, result.getBlockNumber()); + assertArrayEquals(address, result.getAddress()); + assertEquals(tokenId, result.getTokenId()); + assertEquals(phase, result.getPhase()); + assertEquals(present, result.isAccountPresent()); + if (present) { + assertEquals(balance, result.getBalance()); + } + } + + private static byte[] archiveAddress(int suffix) { + byte[] address = new byte[HistoricalAccountBalanceReader.ADDRESS_LENGTH]; + address[0] = 0x41; + address[address.length - 1] = (byte) suffix; + return address; + } + + private static byte[] longValue(long value) { + return ByteBuffer.allocate(Long.BYTES).putLong(value).array(); + } + + private static void seedExact27State(Map databases, int latestEpoch) { + int storeIndex = 0; + for (String dbName : PARTICIPANTS) { + Chainbase database = databases.get(dbName); + database.put(exact27Key(1, storeIndex), exact27Value(latestEpoch, storeIndex)); + database.put(exact27Key(2, storeIndex), exact27Value(6, storeIndex)); + if (latestEpoch == 6) { + database.put(exact27Key(4, storeIndex), exact27Value(6, storeIndex)); + } else { + database.put(exact27Key(3, storeIndex), exact27Value(latestEpoch, storeIndex)); + database.put(exact27Key(5, storeIndex), new byte[0]); + } + database.put(exact27Key(6, storeIndex), exact27Value(6, storeIndex)); + storeIndex++; + } + } + + private static void seedNativeExact27(Map databases, int epoch) { + int storeIndex = 0; + for (String dbName : PARTICIPANTS) { + if (!AccountAssetArchiveProjector.ACCOUNT_ASSET_DB.equals(dbName) + && !"account".equals(dbName)) { + databases.get(dbName).put(nativeExactKey(dbName, storeIndex), + nativeExactValue(epoch, storeIndex)); + } + storeIndex++; + } + } + + private static void mutateNativeExact27(Map databases, int epoch) { + int storeIndex = 0; + for (String dbName : PARTICIPANTS) { + if (!AccountAssetArchiveProjector.ACCOUNT_ASSET_DB.equals(dbName) + && !"account".equals(dbName)) { + Chainbase database = databases.get(dbName); + if (epoch == 7 || storeIndex % 3 == 0) { + database.put(nativeExactKey(dbName, storeIndex), nativeExactValue(epoch, storeIndex)); + } else if (storeIndex % 3 == 1) { + database.delete(nativeExactKey(dbName, storeIndex)); + } else { + database.put(nativeExactKey(dbName, storeIndex), nativeExactValue(7, storeIndex)); + } + } + storeIndex++; + } + } + + private static void assertNativeExact27History(Manager manager, byte[] address, + byte[] directKey) { + for (int epoch = 6; epoch <= 8; epoch++) { + int storeIndex = 0; + for (String dbName : PARTICIPANTS) { + if (!AccountAssetArchiveProjector.ACCOUNT_ASSET_DB.equals(dbName) + && !"account".equals(dbName)) { + byte[] expected; + if (epoch == 6) { + expected = nativeExactValue(6, storeIndex); + } else if (epoch == 7 || storeIndex % 3 == 0) { + expected = nativeExactValue(epoch, storeIndex); + } else if (storeIndex % 3 == 1) { + expected = null; + } else { + expected = nativeExactValue(7, storeIndex); + } + assertHistoricalValue(manager, epoch, dbName, nativeExactKey(dbName, storeIndex), + expected); + } + storeIndex++; + } + assertTrue(manager.getArchiveStateValue(epoch, "account", address).isPresent()); + assertHistoricalValue(manager, epoch, AccountAssetArchiveProjector.ACCOUNT_ASSET_DB, + directKey, epoch == 6 ? null : longValue(epoch == 7 ? 30 : 40)); + } + } + + private static byte[] nativeExactKey(String dbName, int storeIndex) { + if ("market_pair_price_to_order".equals(dbName)) { + return org.tron.core.capsule.utils.MarketUtils.createPairPriceKey( + "100".getBytes(StandardCharsets.UTF_8), + "200".getBytes(StandardCharsets.UTF_8), 1000L + storeIndex, 2000L); + } + return new byte[]{(byte) 0xc1, (byte) storeIndex}; + } + + private static byte[] nativeExactValue(int epoch, int storeIndex) { + return new byte[]{(byte) 0xd1, (byte) epoch, (byte) storeIndex}; + } + + private static Map sourceIdentities(SnapshotFixture fixture, + AccountAssetStore accountAssetStore) { + Map identities = new LinkedHashMap<>(); + for (Map.Entry entry : fixture.nativeStores.entrySet()) { + identities.put(entry.getKey(), entry.getValue().getSourceIdentity()); + } + DbSourceInter source = accountAssetStore.getDbSource(); + if (source instanceof LevelDbDataSourceImpl) { + identities.put(AccountAssetArchiveProjector.ACCOUNT_ASSET_DB, + ((LevelDbDataSourceImpl) source).getSnapshotSourceIdentity()); + } else if (source instanceof RocksDbDataSourceImpl) { + identities.put(AccountAssetArchiveProjector.ACCOUNT_ASSET_DB, + ((RocksDbDataSourceImpl) source).getSnapshotSourceIdentity()); + } else { + throw new IllegalArgumentException("AccountAsset Store is not native"); + } + return identities; + } + + private static void closeNativeStores(SnapshotFixture fixture) { + for (SnapshotCapableStore store : fixture.nativeStores.values()) { + if (store instanceof LevelDB) { + ((LevelDB) store).getDb().closeDB(); + } else if (store instanceof RocksDB) { + ((RocksDB) store).getDb().closeDB(); + } + } + } + + private static void assertExact27History(Manager manager, int targetEpoch) { + int storeIndex = 0; + for (String dbName : PARTICIPANTS) { + assertHistoricalValue(manager, targetEpoch, dbName, exact27Key(1, storeIndex), + exact27Value(targetEpoch, storeIndex)); + assertHistoricalValue(manager, targetEpoch, dbName, exact27Key(2, storeIndex), + exact27Value(6, storeIndex)); + assertHistoricalValue(manager, targetEpoch, dbName, exact27Key(3, storeIndex), + targetEpoch == 6 ? null : exact27Value(targetEpoch, storeIndex)); + assertHistoricalValue(manager, targetEpoch, dbName, exact27Key(4, storeIndex), + targetEpoch == 6 ? exact27Value(6, storeIndex) : null); + assertHistoricalValue(manager, targetEpoch, dbName, exact27Key(5, storeIndex), + targetEpoch == 6 ? null : new byte[0]); + assertHistoricalValue(manager, targetEpoch, dbName, exact27Key(6, storeIndex), + exact27Value(6, storeIndex)); + storeIndex++; + } + } + + private static void assertHistoricalValue(Manager manager, int targetEpoch, String dbName, + byte[] physicalRawKey, byte[] expected) { + OldValue actual = manager.getArchiveStateValue(targetEpoch, dbName, physicalRawKey); + assertEquals(expected != null, actual.isPresent()); + assertEquals(expected != null, + manager.hasArchiveStateValue(targetEpoch, dbName, physicalRawKey)); + if (expected != null) { + assertArrayEquals(expected, actual.getValue()); + } + } + + private static void assertExact27ServingIndex(Path archive) throws IOException { + try (PersistentServingKeyIndexCatalog catalog = + PersistentServingKeyIndexCatalog.open(archive.resolve("serving-index")); + PersistentServingKeyIndexGeneration generation = catalog.pin()) { + int storeIndex = 0; + for (String dbName : PARTICIPANTS) { + for (int changedKind : Arrays.asList(1, 3, 4, 5)) { + assertEquals(7, generation.firstChangeAfter(dbName, + exact27Key(changedKind, storeIndex), 6, 8).getAsLong()); + } + assertFalse(generation.firstChangeAfter(dbName, + exact27Key(2, storeIndex), 6, 8).isPresent()); + assertFalse(generation.firstChangeAfter(dbName, + exact27Key(6, storeIndex), 6, 8).isPresent()); + storeIndex++; + } + } + } + + private static byte[] exact27Key(int kind, int storeIndex) { + return new byte[]{(byte) 0xa1, (byte) kind, (byte) storeIndex}; + } + + private static byte[] exact27Value(int epoch, int storeIndex) { + return new byte[]{(byte) 0xb1, (byte) epoch, (byte) storeIndex}; + } + + private static Set checkpointDatabases(Map batch) { + Set databases = new LinkedHashSet<>(); + for (byte[] key : batch.keySet()) { + ByteBuffer input = ByteBuffer.wrap(key); + int length = input.getInt(); + byte[] database = new byte[length]; + input.get(database); + databases.add(new String(database, StandardCharsets.UTF_8)); + } + return databases; + } + + private static final class SnapshotFixture { + private final SnapshotManager snapshots; + private final Map databases; + private final Map checkpointOnly; + private final CheckTmpStore checkpoint; + private final Map nativeStores; + + private SnapshotFixture(SnapshotManager snapshots, Map databases, + Map checkpointOnly, CheckTmpStore checkpoint, + Map nativeStores) { + this.snapshots = snapshots; + this.databases = databases; + this.checkpointOnly = checkpointOnly; + this.checkpoint = checkpoint; + this.nativeStores = nativeStores; + } + } + + private static final class TestAccountAssetStore extends AccountAssetStore { + private boolean failPrefixQuery; + + private TestAccountAssetStore() { + super(AccountAssetArchiveProjector.ACCOUNT_ASSET_DB); + } + + private void failNextPrefixQuery() { + failPrefixQuery = true; + } + + @Override + public Map prefixQuery(byte[] key) { + if (failPrefixQuery) { + failPrefixQuery = false; + throw new ArchivePersistenceException("injected supplemental prefix failure"); + } + return super.prefixQuery(key); + } + } + + private static final class MemoryDb implements DB, Flusher, + SnapshotCapableStore { + private final String name; + private final Map values = new LinkedHashMap<>(); + + private MemoryDb(String name) { + this.name = name; + } + + @Override + public byte[] get(byte[] key) { + byte[] value = values.get(WrappedByteArray.of(key)); + return value == null ? null : Arrays.copyOf(value, value.length); + } + + @Override + public void put(byte[] key, byte[] value) { + values.put(WrappedByteArray.copyOf(key), Arrays.copyOf(value, value.length)); + } + + @Override + public long size() { + return values.size(); + } + + @Override + public boolean isEmpty() { + return values.isEmpty(); + } + + @Override + public void remove(byte[] key) { + values.remove(WrappedByteArray.of(key)); + } + + @Override + public Iterator> iterator() { + List> entries = new ArrayList<>(); + values.forEach((key, value) -> entries.add(new AbstractMap.SimpleImmutableEntry<>( + key.getBytes(), Arrays.copyOf(value, value.length)))); + return entries.iterator(); + } + + @Override + public void close() { + values.clear(); + } + + @Override + public void flush(Map batch) { + batch.forEach((key, value) -> { + if (value == null || value.getBytes() == null) { + values.remove(key); + } else { + values.put(WrappedByteArray.copyOf(key.getBytes()), value.getBytes()); + } + }); + } + + @Override + public void reset() { + values.clear(); + } + + @Override + public String getDbName() { + return name; + } + + @Override + public String getSourceIdentity() { + return "memory:" + name; + } + + @Override + public StoreSnapshot pin(long blockNumber, byte[] blockHash) { + Map pinned = new LinkedHashMap<>(); + values.forEach((key, value) -> pinned.put(WrappedByteArray.copyOf(key.getBytes()), + Arrays.copyOf(value, value.length))); + byte[] pinnedHash = Arrays.copyOf(blockHash, blockHash.length); + return new StoreSnapshot() { + @Override + public String getDbName() { + return name; + } + + @Override + public String getSourceIdentity() { + return MemoryDb.this.getSourceIdentity(); + } + + @Override + public long getBlockNumber() { + return blockNumber; + } + + @Override + public byte[] getBlockHash() { + return Arrays.copyOf(pinnedHash, pinnedHash.length); + } + + @Override + public byte[] get(byte[] physicalRawKey) { + byte[] value = pinned.get(WrappedByteArray.of(physicalRawKey)); + return value == null ? null : Arrays.copyOf(value, value.length); + } + + @Override + public List> range(byte[] lowerInclusive, + byte[] upperExclusive, int maxEntries) { + if (lowerInclusive == null || maxEntries <= 0) { + throw new IllegalArgumentException("Invalid memory snapshot range"); + } + List> entries = new ArrayList<>(); + pinned.forEach((key, value) -> entries.add(new AbstractMap.SimpleImmutableEntry<>( + key.getBytes(), Arrays.copyOf(value, value.length)))); + entries.sort((left, right) -> + BlockReverseDiff.compareUnsigned(left.getKey(), right.getKey())); + List> result = new ArrayList<>(); + for (Map.Entry entry : entries) { + if (BlockReverseDiff.compareUnsigned(entry.getKey(), lowerInclusive) < 0) { + continue; + } + if (upperExclusive != null + && BlockReverseDiff.compareUnsigned(entry.getKey(), upperExclusive) >= 0) { + break; + } + if (result.size() == maxEntries) { + break; + } + result.add(entry); + } + return Collections.unmodifiableList(result); + } + + @Override + public void close() { + pinned.clear(); + } + }; + } + + @Override + public void stat() { + } + + @Override + public DB newInstance() { + return new MemoryDb(name); + } + } + + @FunctionalInterface + private interface ThrowingRunnable { + void run() throws Exception; + } +} diff --git a/framework/src/test/java/org/tron/core/db2/archive/StateArchiveRuntimeOwnerTest.java b/framework/src/test/java/org/tron/core/db2/archive/StateArchiveRuntimeOwnerTest.java new file mode 100644 index 00000000000..14f868c0d99 --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/archive/StateArchiveRuntimeOwnerTest.java @@ -0,0 +1,271 @@ +package org.tron.core.db2.archive; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.Closeable; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.OptionalLong; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tron.core.db2.archive.ArchiveReadSnapshot.PinnedHistory; +import org.tron.core.db2.archive.ArchiveReadSnapshot.PinnedLatestState; +import org.tron.core.db2.archive.ArchiveRuntimeQueryGate.Lease; +import org.tron.core.db2.archive.StateArchiveRuntimeOwner.State; +import org.tron.core.db2.core.SnapshotManager; + +public class StateArchiveRuntimeOwnerTest { + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void freshBootstrapPublishesRecoverableExact27FixedPoint() throws Exception { + for (String engine : Arrays.asList("LEVELDB", "ROCKSDB")) { + Path parent = temporaryFolder.newFolder("bootstrap-" + engine.toLowerCase()).toPath(); + Path archive = parent.resolve("state-archive"); + BlockSnapshotMeta head = BlockSnapshotMeta.forBlock(123, hash(123), hash(122), 456_000L); + SnapshotManager snapshots = new SnapshotManager(""); + + try (StateArchiveRuntimeOwner owner = StateArchiveRuntimeOwner.bootstrapAndRecover( + snapshots, archive, 4096, head)) { + assertEquals(head, owner.getRecoveredHead()); + assertEquals(0, owner.getStartupRecoveryActionCount()); + assertEquals(State.RECOVERED, owner.getState()); + } + + assertTrue(Files.isRegularFile(archive.resolve("MANIFEST"))); + assertTrue(Files.isRegularFile(archive.resolve("bootstrap.anchor"))); + assertFalse(Files.exists(archive.resolve("progress"))); + assertFalse(Files.exists(archive.resolve("participants"))); + try (java.util.stream.Stream entries = Files.list(parent)) { + assertFalse(entries.anyMatch(path -> path.getFileName().toString() + .startsWith(".state-archive.bootstrap-"))); + } + try (StateArchiveRuntimeOwner reopened = StateArchiveRuntimeOwner.recover( + snapshots, archive, 4096)) { + assertEquals(head, reopened.getRecoveredHead()); + assertEquals(0, reopened.getStartupRecoveryActionCount()); + } + try (ArchiveHistoryWriter writer = new ArchiveHistoryWriter( + archive, 4096, ArchiveStoreScope.getStateDatabases())) { + byte[] address = new byte[21]; + assertThrows(IllegalArgumentException.class, + () -> writer.readAccountAt(122, address, null)); + assertFalse(writer.readAccountAt(123, address, null).isPresent()); + } + assertThrows(ArchivePersistenceException.class, + () -> StateArchiveRuntimeOwner.bootstrapAndRecover(snapshots, archive, 4096, + head)); + } + } + + @Test + public void activeQueryStopsCloseAfterDetachAndDrainedRetryClosesInOrder() + throws Exception { + List order = new ArrayList<>(); + Fixture fixture = fixture(order, null, null, null); + Lease lease = fixture.queryGate.pin(0); + + assertThrows(IllegalStateException.class, fixture.owner::close); + + assertEquals(State.QUIESCING, fixture.owner.getState()); + assertTrue(order.isEmpty()); + assertThrows(IllegalStateException.class, () -> fixture.queryGate.pin(0)); + verify(fixture.manager).detachArchiveRuntime(fixture.attachment); + + lease.close(); + fixture.owner.close(); + fixture.owner.close(); + + assertEquals(State.CLOSED, fixture.owner.getState()); + verify(fixture.manager, times(1)).detachArchiveRuntime(fixture.attachment); + assertEquals(Arrays.asList("latest", "catalog", "participant-2", "participant-1", + "participant-0", "sink"), order); + } + + @Test + public void independentCloseFailuresAreSuppressedAndNotRetried() throws Exception { + List order = new ArrayList<>(); + IOException latestFailure = new IOException("latest failure"); + Fixture fixture = fixture(order, latestFailure, + new IllegalStateException("participant failure"), new IOException("sink failure")); + + IOException failure = assertThrows(IOException.class, fixture.owner::close); + + assertSame(latestFailure, failure); + assertEquals(2, failure.getSuppressed().length); + assertEquals("Failed to close archive participant 1", + failure.getSuppressed()[0].getMessage()); + assertEquals("sink failure", failure.getSuppressed()[1].getMessage()); + assertEquals(Arrays.asList("latest", "catalog", "participant-2", "participant-1", + "participant-0", "sink"), order); + assertEquals(State.FAILED_CLOSED, fixture.owner.getState()); + + assertSame(failure, assertThrows(IOException.class, fixture.owner::close)); + assertEquals(6, order.size()); + } + + @Test + public void rejectsNonCloseableSinkAndDuplicateOwnedResource() { + SnapshotManager manager = manager(); + ArchiveRuntimeQueryGate queryGate = new ArchiveRuntimeQueryGate(target -> snapshot()); + Closeable latest = () -> { }; + Closeable catalog = () -> { }; + DurableBlockReverseDiffSink nonCloseable = mock(DurableBlockReverseDiffSink.class); + ArchiveRuntimeAttachment invalid = attachment(nonCloseable); + + assertThrows(IllegalArgumentException.class, () -> new StateArchiveRuntimeOwner(manager, + invalid, queryGate, latest, catalog, Collections.emptyList())); + + TrackingSink sink = new TrackingSink(new ArrayList<>(), null); + ArchiveRuntimeAttachment valid = attachment(sink); + assertThrows(IllegalArgumentException.class, () -> new StateArchiveRuntimeOwner(manager, + valid, queryGate, latest, catalog, Collections.singletonList(latest))); + } + + private static Fixture fixture(List order, IOException latestFailure, + RuntimeException participantFailure, IOException sinkFailure) { + SnapshotManager manager = manager(); + TrackingSink sink = new TrackingSink(order, sinkFailure); + ArchiveRuntimeAttachment attachment = attachment(sink); + when(manager.detachArchiveRuntime(attachment)).thenReturn(attachment); + manager.attachArchiveRuntime(attachment); + ArchiveRuntimeQueryGate queryGate = new ArchiveRuntimeQueryGate(target -> snapshot()); + TrackingCloseable latest = new TrackingCloseable("latest", order, latestFailure, null); + TrackingCloseable catalog = new TrackingCloseable("catalog", order, null, null); + List participants = Arrays.asList( + new TrackingCloseable("participant-0", order, null, null), + new TrackingCloseable("participant-1", order, null, participantFailure), + new TrackingCloseable("participant-2", order, null, null)); + StateArchiveRuntimeOwner owner = new StateArchiveRuntimeOwner(manager, attachment, queryGate, + latest, catalog, participants); + return new Fixture(manager, attachment, queryGate, owner); + } + + private static SnapshotManager manager() { + return mock(SnapshotManager.class); + } + + private static ArchiveRuntimeAttachment attachment(DurableBlockReverseDiffSink sink) { + return new ArchiveRuntimeAttachment(mock(OldValueCollector.class), sink); + } + + private static ArchiveReadSnapshot snapshot() throws IOException { + byte[] hash = new byte[32]; + ServingKeyIndex serving = mock(ServingKeyIndex.class); + when(serving.getIndexedFrom()).thenReturn(0L); + when(serving.getIndexedThrough()).thenReturn(0L); + when(serving.getHeadHash()).thenReturn(hash); + when(serving.getAuthoritativePrefixDigest()).thenReturn(new byte[0]); + when(serving.firstChangeAfter(org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.any(byte[].class), org.mockito.ArgumentMatchers.anyLong(), + org.mockito.ArgumentMatchers.anyLong())).thenReturn(OptionalLong.empty()); + PinnedLatestState latest = mock(PinnedLatestState.class); + when(latest.getBlockNumber()).thenReturn(0L); + when(latest.getBlockHash()).thenReturn(hash); + PinnedHistory history = mock(PinnedHistory.class); + when(history.getIndexedFrom()).thenReturn(0L); + when(history.getIndexedThrough()).thenReturn(0L); + when(history.getHeadHash()).thenReturn(hash); + when(history.getAuthoritativePrefixDigest()).thenReturn(new byte[0]); + return ArchiveReadSnapshot.pin(0, 0, hash, serving, latest, history); + } + + private static byte[] hash(int suffix) { + byte[] hash = new byte[32]; + hash[31] = (byte) suffix; + return hash; + } + + private static final class Fixture { + private final SnapshotManager manager; + private final ArchiveRuntimeAttachment attachment; + private final ArchiveRuntimeQueryGate queryGate; + private final StateArchiveRuntimeOwner owner; + + private Fixture(SnapshotManager manager, ArchiveRuntimeAttachment attachment, + ArchiveRuntimeQueryGate queryGate, StateArchiveRuntimeOwner owner) { + this.manager = manager; + this.attachment = attachment; + this.queryGate = queryGate; + this.owner = owner; + } + } + + private static final class TrackingCloseable implements Closeable { + private final String name; + private final List order; + private final IOException ioFailure; + private final RuntimeException runtimeFailure; + + private TrackingCloseable(String name, List order, IOException ioFailure, + RuntimeException runtimeFailure) { + this.name = name; + this.order = order; + this.ioFailure = ioFailure; + this.runtimeFailure = runtimeFailure; + } + + @Override + public void close() throws IOException { + order.add(name); + if (ioFailure != null) { + throw ioFailure; + } + if (runtimeFailure != null) { + throw runtimeFailure; + } + } + } + + private static final class TrackingSink implements DurableBlockReverseDiffSink, Closeable { + private final List order; + private final IOException failure; + + private TrackingSink(List order, IOException failure) { + this.order = order; + this.failure = failure; + } + + @Override + public void accept(BlockReverseDiff diff) { + } + + @Override + public void awaitCommitted(long epoch) { + } + + @Override + public DurableHistoryMarkerRangeEvidence createMarkerRangeEvidence(int maxMarkers) { + throw new UnsupportedOperationException(); + } + + @Override + public void releaseThrough(long epoch) { + } + + @Override + public void close() throws IOException { + order.add("sink"); + if (failure != null) { + throw failure; + } + } + } + +} diff --git a/framework/src/test/java/org/tron/core/db2/archive/StateArchiveSegmentFormatV3Test.java b/framework/src/test/java/org/tron/core/db2/archive/StateArchiveSegmentFormatV3Test.java new file mode 100644 index 00000000000..7dcfbf3277d --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/archive/StateArchiveSegmentFormatV3Test.java @@ -0,0 +1,258 @@ +package org.tron.core.db2.archive; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.nio.ByteBuffer; +import org.bouncycastle.util.encoders.Hex; +import org.junit.Test; +import org.tron.core.db2.archive.StateArchiveSegmentFormatV3.BlockIndexHeader; +import org.tron.core.db2.archive.StateArchiveSegmentFormatV3.DurableMarker; +import org.tron.core.db2.archive.StateArchiveSegmentFormatV3.SealedSegment; +import org.tron.core.db2.archive.StateArchiveSegmentFormatV3.SegmentHeader; +import org.tron.core.db2.archive.StateArchiveSegmentFormatV3.SegmentManifest; +import org.tron.core.db2.archive.StateArchiveSegmentFormatV3.SegmentSeal; + +public class StateArchiveSegmentFormatV3Test { + + @Test + public void freezesSegmentLayoutAndCompositeFormatIdentity() { + assertEquals( + "534c44330003000000000040000202000140008000200100003000c00000000077359400" + + "00000000040000000000040000010001000000000000000000000000", + Hex.toHexString(StateArchiveFileFormatV3.segmentLayoutDescriptor())); + assertEquals("d0d9c2111ddb5a30b2a97632e9c776da7eadbf81c1d08ec56ddaacab7a3ccddf", + Hex.toHexString(StateArchiveFileFormatV3.segmentLayoutDigest())); + assertEquals("c5464d3c1c3b2ee2d5622dabd83169db2140ac05d7fe995866bd294524b8f6a4", + Hex.toHexString(StateArchiveFileFormatV3.compositeFormatDigest())); + } + + @Test + public void roundTripsByteExactSegmentHeaderAndRejectsCorruption() { + SegmentHeader input = new SegmentHeader(22, 7, 101, hash(1), hash(2), + StateArchiveFileFormatV3.COMPRESSION_RAW_DEFLATE_LEVEL_1); + byte[] encoded = StateArchiveSegmentFormatV3.encodeHeader(input); + assertEquals(StateArchiveFileFormatV3.PART_HEADER_LENGTH, encoded.length); + + ByteBuffer bytes = ByteBuffer.wrap(encoded); + assertEquals(StateArchiveFileFormatV3.SEGMENT_MAGIC, bytes.getInt(0)); + assertEquals(22, Short.toUnsignedInt(bytes.getShort(14))); + assertEquals(7, bytes.getLong(20)); + assertEquals(101, bytes.getLong(28)); + assertEquals(StateArchiveFileFormatV3.SEGMENT_TARGET_BYTES, bytes.getLong(44)); + assertArrayEquals(StateArchiveFileFormatV3.fiveLaneDescriptorDigest(), + slice(encoded, 60, 32)); + assertArrayEquals(StateArchiveFileFormatV3.compositeFormatDigest(), + slice(encoded, 92, 32)); + + SegmentHeader decoded = StateArchiveSegmentFormatV3.decodeHeader(encoded); + assertEquals(22, decoded.getLaneId()); + assertEquals(7, decoded.getSegmentSeq()); + assertEquals(101, decoded.getActualFirstBlock()); + assertArrayEquals(hash(1), decoded.getPreviousSegmentDigest()); + assertArrayEquals(hash(2), decoded.getPreviousHistoryDigest()); + assertArrayEquals(slice(encoded, 476, 32), decoded.getHeaderDigest()); + + byte[] corruptReserved = encoded.clone(); + corruptReserved[200] = 1; + assertThrows(IllegalArgumentException.class, + () -> StateArchiveSegmentFormatV3.decodeHeader(corruptReserved)); + byte[] corruptDigest = encoded.clone(); + corruptDigest[507] ^= 1; + assertThrows(IllegalArgumentException.class, + () -> StateArchiveSegmentFormatV3.decodeHeader(corruptDigest)); + } + + @Test + public void rotatesOnlyBeforeTheBlockAfterTargetIsReached() { + long header = StateArchiveFileFormatV3.PART_HEADER_LENGTH; + long target = 1_000; + assertFalse(StateArchiveSegmentFormatV3.shouldRotate(0, target, target)); + assertFalse(StateArchiveSegmentFormatV3.shouldRotate(1, target - 1, target)); + assertTrue(StateArchiveSegmentFormatV3.shouldRotate(1, target, target)); + assertTrue(StateArchiveSegmentFormatV3.shouldRotate(1, target + 500, target)); + assertThrows(IllegalArgumentException.class, + () -> StateArchiveSegmentFormatV3.shouldRotate(1, header - 1, target)); + } + + @Test + public void roundTripsExactSealedSegmentMapRecordAndRejectsInvalidRanges() { + SealedSegment input = new SealedSegment(4, 9, 100, 102, 3, 8_000, 224, + hash(1), hash(2), hash(3), hash(4)); + byte[] encoded = StateArchiveSegmentFormatV3.encodeSealedMapRecord(input); + assertEquals(192, encoded.length); + ByteBuffer bytes = ByteBuffer.wrap(encoded); + assertEquals(4, Short.toUnsignedInt(bytes.getShort(0))); + assertEquals(2, Short.toUnsignedInt(bytes.getShort(2))); + assertEquals(9, bytes.getLong(8)); + assertEquals(100, bytes.getLong(16)); + assertEquals(102, bytes.getLong(24)); + assertEquals(3, bytes.getLong(32)); + assertArrayEquals(hash(1), slice(encoded, 56, 32)); + assertArrayEquals(hash(4), slice(encoded, 152, 32)); + + SealedSegment decoded = StateArchiveSegmentFormatV3.decodeSealedMapRecord(encoded); + assertEquals(input.getLaneId(), decoded.getLaneId()); + assertEquals(input.getSegmentSeq(), decoded.getSegmentSeq()); + assertEquals(input.getFirstBlock(), decoded.getFirstBlock()); + assertEquals(input.getLastBlock(), decoded.getLastBlock()); + assertEquals(input.getBlockFrameCount(), decoded.getBlockFrameCount()); + assertArrayEquals(input.getSegmentContentDigest(), decoded.getSegmentContentDigest()); + + byte[] invalid = encoded.clone(); + ByteBuffer.wrap(invalid).putLong(32, 4); + assertThrows(IllegalArgumentException.class, + () -> StateArchiveSegmentFormatV3.decodeSealedMapRecord(invalid)); + } + + @Test + public void roundTripsFrozenSealedManifestAndRejectsEveryTrailerAuthority() { + SegmentManifest input = new SegmentManifest(22, 7, 100, 102, 3, 9, + 300, 2_000, 2_880, 224, hash(1), hash(2)); + byte[] encoded = StateArchiveSegmentFormatV3.encodeManifest(input); + assertEquals(304, encoded.length); + ByteBuffer bytes = ByteBuffer.wrap(encoded); + assertEquals(StateArchiveFileFormatV3.SEGMENT_MANIFEST_MAGIC, bytes.getInt(0)); + assertEquals(256, bytes.getInt(8)); + assertEquals(304, bytes.getLong(16)); + assertEquals(22, Short.toUnsignedInt(bytes.getShort(26))); + assertEquals(7, bytes.getLong(32)); + assertEquals(100, bytes.getLong(40)); + assertEquals(102, bytes.getLong(48)); + assertArrayEquals(StateArchiveFileFormatV3.fiveLaneDescriptorDigest(), + slice(encoded, 104, 32)); + assertArrayEquals(StateArchiveFileFormatV3.compositeFormatDigest(), + slice(encoded, 136, 32)); + + SegmentManifest decoded = StateArchiveSegmentFormatV3.decodeManifest(encoded); + assertEquals(7, decoded.getSegmentSeq()); + assertEquals(9, decoded.getEntryCount()); + assertArrayEquals(hash(1), decoded.getPreviousSegmentDigest()); + assertArrayEquals(hash(2), decoded.getFinalHistoryDigest()); + assertArrayEquals(slice(encoded, 256, 32), decoded.getManifestDigest()); + + for (int offset : new int[]{232, 260, 291, 299, 303}) { + byte[] corrupt = encoded.clone(); + corrupt[offset] ^= 1; + assertThrows(IllegalArgumentException.class, + () -> StateArchiveSegmentFormatV3.decodeManifest(corrupt)); + } + } + + @Test + public void freezesSealDomainSegmentChainAndLaneBaselines() { + SegmentSeal input = new SegmentSeal(0, 0, 10, 11, 2, 3, + 64, 900, 1_412, 1_780, hash(1), hash(2), hash(3), hash(4), hash(5)); + byte[] encoded = StateArchiveSegmentFormatV3.encodeSeal(input); + assertEquals(368, encoded.length); + ByteBuffer bytes = ByteBuffer.wrap(encoded); + assertEquals(StateArchiveFileFormatV3.PART_SEAL_FRAME_TYPE, bytes.getShort(8)); + assertEquals(0, Short.toUnsignedInt(bytes.getShort(32))); + assertEquals(0, bytes.getLong(36)); + assertEquals(10, bytes.getLong(44)); + assertEquals(11, bytes.getLong(52)); + assertEquals(1_412, bytes.getLong(92)); + assertEquals(1_780, bytes.getLong(100)); + + SegmentSeal decoded = StateArchiveSegmentFormatV3.decodeSeal(encoded); + assertEquals(11, decoded.getActualLastBlock()); + assertArrayEquals(slice(encoded, 320, 32), decoded.getEncodedFrameDigest()); + byte[] chain = StateArchiveSegmentFormatV3.segmentChainDigest(0, 0, + hash(6), decoded.getSegmentContentDigest(), decoded.getEncodedFrameDigest()); + assertEquals( + "3147cb009a99874675be38717a31e10655fe0be89fe5278bcb2503caa9385d2a" + + ":8f19c3370ce7750bcfad451d7d14de70cef356f1dd84a1ffda8c826cf2f70ae8" + + ":8a34caee97c24ec70adf60393672986423070d506935d9593e000594310a6798" + + ":a33fecda61e5cf29483fe4df26defa471ce3e5e6e0905c9ebfebeffe8932e0e8", + Hex.toHexString(StateArchiveSegmentFormatV3.laneBaselineDigest(0)) + ":" + + Hex.toHexString(StateArchiveSegmentFormatV3.laneBaselineDigest(4)) + ":" + + Hex.toHexString(decoded.getEncodedFrameDigest()) + ":" + + Hex.toHexString(chain)); + + byte[] corrupt = encoded.clone(); + corrupt[319] ^= 1; + assertThrows(IllegalArgumentException.class, + () -> StateArchiveSegmentFormatV3.decodeSeal(corrupt)); + } + + @Test + public void freezesCompleteBlockIndexHeader() { + BlockIndexHeader input = new BlockIndexHeader(22, 7, hash(9)); + byte[] encoded = StateArchiveSegmentFormatV3.encodeBlockIndexHeader(input); + assertEquals(128, encoded.length); + ByteBuffer bytes = ByteBuffer.wrap(encoded); + assertEquals(StateArchiveFileFormatV3.BLOCK_INDEX_MAGIC, bytes.getInt(0)); + assertEquals(128, Short.toUnsignedInt(bytes.getShort(8))); + assertEquals(32, Short.toUnsignedInt(bytes.getShort(10))); + assertEquals(22, Short.toUnsignedInt(bytes.getShort(12))); + assertEquals(7, bytes.getLong(16)); + assertArrayEquals(hash(9), slice(encoded, 24, 32)); + assertArrayEquals(StateArchiveFileFormatV3.fiveLaneDescriptorDigest(), + slice(encoded, 56, 32)); + + BlockIndexHeader decoded = StateArchiveSegmentFormatV3.decodeBlockIndexHeader(encoded); + assertEquals(22, decoded.getLaneId()); + assertEquals(7, decoded.getSegmentSeq()); + assertArrayEquals(hash(9), decoded.getDataSegmentHeaderDigest()); + assertArrayEquals(slice(encoded, 92, 32), decoded.getHeaderDigest()); + assertEquals( + "5342493300030000008000200016000200000000000000070000000000000000" + + "0000000000000000000000000000000000000000000000099a9e46c5b7fea88a" + + "262bc7e4e52582b0c0d42bd6e5e454001c1d571b77076c4a00000000c055e205" + + "9140d1afec27340529f6685ebcda60627e2a594616f08aa53bb6d37708bc208f", + Hex.toHexString(encoded)); + + byte[] corrupt = encoded.clone(); + corrupt[88] = 1; + assertThrows(IllegalArgumentException.class, + () -> StateArchiveSegmentFormatV3.decodeBlockIndexHeader(corrupt)); + } + + @Test + public void roundTripsFiveLaneDurableMarkerAndRejectsDrift() { + DurableMarker input = new DurableMarker(7, 10, 11, 10, 11, hash(1), hash(2), + 512, 1_748, 2, 64, 900, hash(3), hash(4), 22, 5); + byte[] encoded = StateArchiveSegmentFormatV3.encodeDurableMarker(input); + assertEquals(336, encoded.length); + ByteBuffer bytes = ByteBuffer.wrap(encoded); + assertEquals(StateArchiveFileFormatV3.DURABLE_MARKER_FRAME_TYPE, bytes.getShort(8)); + assertEquals(288, bytes.getInt(12)); + assertEquals(7, bytes.getLong(32)); + assertEquals(10, bytes.getLong(56)); + assertEquals(11, bytes.getLong(64)); + assertArrayEquals(StateArchiveFileFormatV3.fiveLaneDescriptorDigest(), + slice(encoded, 104, 32)); + assertEquals(22, Short.toUnsignedInt(bytes.getShort(272))); + assertEquals(5, bytes.getLong(276)); + + DurableMarker decoded = StateArchiveSegmentFormatV3.decodeDurableMarker(encoded); + assertEquals(7, decoded.getCheckpointSequence()); + assertEquals(10, decoded.getFirstBlock()); + assertEquals(11, decoded.getLastBlock()); + assertEquals(512, decoded.getCoveredStartOffset()); + assertEquals(1_748, decoded.getMarkerEndOffset()); + assertEquals(22, decoded.getLaneId()); + assertEquals(5, decoded.getSegmentSeq()); + assertArrayEquals(slice(encoded, 288, 32), decoded.getEncodedFrameDigest()); + + byte[] corrupt = encoded.clone(); + corrupt[240] ^= 1; + assertThrows(IllegalArgumentException.class, + () -> StateArchiveSegmentFormatV3.decodeDurableMarker(corrupt)); + } + + private static byte[] hash(int suffix) { + byte[] result = new byte[32]; + result[31] = (byte) suffix; + return result; + } + + private static byte[] slice(byte[] bytes, int offset, int length) { + byte[] result = new byte[length]; + System.arraycopy(bytes, offset, result, 0, length); + return result; + } +} diff --git a/framework/src/test/java/org/tron/core/db2/archive/StateArchiveServingIndexSpeedTest.java b/framework/src/test/java/org/tron/core/db2/archive/StateArchiveServingIndexSpeedTest.java new file mode 100644 index 00000000000..8a74a749f6b --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/archive/StateArchiveServingIndexSpeedTest.java @@ -0,0 +1,82 @@ +package org.tron.core.db2.archive; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tron.core.db2.archive.BlockReverseDiff.DbGroup; +import org.tron.core.db2.archive.BlockReverseDiff.Entry; +import org.tron.core.db2.archive.StateArchiveServingIndexBuildCoordinatorV3.BuildProgress; +import org.tron.core.db2.archive.StateArchiveServingIndexBuildCoordinatorV3.LiveServingIndexer; +import org.tron.core.db2.core.CommonCheckpointTarget; +import org.tron.core.db2.stateroot.PathStateStoreManifest.Engine; + +/** Bounded mechanism benchmark; it records throughput without imposing a host-sensitive gate. */ +public class StateArchiveServingIndexSpeedTest { + + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void measuresBulkThenLiveDurablePublication() throws Exception { + Path root = temporaryFolder.newFolder("serving-speed").toPath(); + List bulk = diffs(1, 1_000); + long bulkStart = System.nanoTime(); + long liveNanos = 0; + try (StateArchiveServingIndexBuildCoordinatorV3 coordinator = + new StateArchiveServingIndexBuildCoordinatorV3(root, Engine.LEVELDB, 1_000)) { + BuildProgress bulkProgress = coordinator.offerCommittedRange(bulk, + target(bulk, 1)); + long bulkEnd = System.nanoTime(); + assertEquals(1_000, bulkProgress.getIndexedThrough()); + LiveServingIndexer live = coordinator.completeInitialSync(target(bulk, 1)); + for (int block = 1_001; block <= 1_020; block++) { + List one = diffs(block, 1); + long started = System.nanoTime(); + live.indexNow(one, target(one, block)); + liveNanos += System.nanoTime() - started; + } + assertEquals(1_020, coordinator.status().getIndexedThrough()); + double bulkSeconds = (bulkEnd - bulkStart) / 1_000_000_000.0; + double liveMillis = liveNanos / 20.0 / 1_000_000.0; + System.out.printf("STATE_ARCHIVE_SERVING_SPEED bulk_blocks=1000 bulk_seconds=%.6f " + + "bulk_blocks_per_second=%.2f live_blocks=20 live_mean_ms=%.3f%n", + bulkSeconds, 1_000.0 / bulkSeconds, liveMillis); + assertTrue(bulkSeconds > 0); + assertTrue(liveMillis > 0); + } + } + + private static List diffs(int first, int count) { + List result = new ArrayList<>(); + for (int block = first; block < first + count; block++) { + BlockSnapshotMeta meta = new BlockSnapshotMeta(block, block, hash(block), + hash(block - 1), block * 3_000L); + DbGroup group = new DbGroup("code", Collections.singletonList( + new Entry(new byte[]{(byte) (block & 31)}, OldValue.absent()))); + result.add(new BlockReverseDiff(meta, Collections.singletonList(group))); + } + return result; + } + + private static CommonCheckpointTarget target(List diffs, int salt) { + return CommonCheckpointTarget.restore(hash(70), hash(80 + salt), + diffs.get(0).getMeta(), diffs.get(diffs.size() - 1).getMeta(), hash(90), hash(91)); + } + + private static byte[] hash(int value) { + byte[] result = new byte[32]; + result[24] = (byte) (value >>> 24); + result[25] = (byte) (value >>> 16); + result[26] = (byte) (value >>> 8); + result[27] = (byte) value; + result[31] = (byte) value; + return result; + } +} diff --git a/framework/src/test/java/org/tron/core/db2/archive/StateArchiveServingWorkerV3Test.java b/framework/src/test/java/org/tron/core/db2/archive/StateArchiveServingWorkerV3Test.java new file mode 100644 index 00000000000..bc7332b42d9 --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/archive/StateArchiveServingWorkerV3Test.java @@ -0,0 +1,154 @@ +package org.tron.core.db2.archive; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.Collections; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tron.core.db2.archive.StateArchiveServingIndexBuildCoordinatorV3.Mode; +import org.tron.core.db2.core.CommonCheckpointTarget; +import org.tron.core.db2.stateroot.PathStateStoreManifest.Engine; + +public class StateArchiveServingWorkerV3Test { + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test(timeout = 15000) + public void blockedBuilderAllowsCoalescedOffersAndOrderedHandoffThenLive() throws Exception { + Path root = temporaryFolder.newFolder().toPath(); + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + try (StateArchiveFiveLaneSegmentWriterV3 source = source(root)) { + append(source, 1); + StateArchiveServingWorkerV3 worker = worker(root, source, () -> { + entered.countDown(); + try { + if (!release.await(5, TimeUnit.SECONDS)) { + throw new IllegalStateException("test release timed out"); + } + } catch (InterruptedException failure) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(failure); + } + }); + try { + worker.offer(target(1, 1)); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + append(source, 2); + worker.offer(target(2, 2)); + assertEquals(2, worker.status().getCommittedThrough()); + assertEquals(-1, worker.status().getIndexedThrough()); + release.countDown(); + worker.completeInitialSync(target(2, 2)); + assertEquals(2, worker.status().getIndexedThrough()); + assertEquals(Mode.LIVE_IMMEDIATE, worker.status().getMode()); + append(source, 3); + worker.offer(target(3, 3)); + assertEquals(3, worker.status().getIndexedThrough()); + long sequence = worker.status().getBuildSequence(); + worker.offer(target(3, 3)); + assertEquals(sequence, worker.status().getBuildSequence()); + assertThrows(IOException.class, () -> worker.offer(target(5, 5))); + } finally { + release.countDown(); + worker.close(); + } + } + } + + @Test(timeout = 15000) + public void failedOwnerIsObservableAndRestartReplaysLostNotification() throws Exception { + Path root = temporaryFolder.newFolder().toPath(); + try (StateArchiveFiveLaneSegmentWriterV3 source = source(root)) { + append(source, 1); + try (StateArchiveServingWorkerV3 failed = worker(root, source, () -> { + throw new IllegalStateException("injected builder failure"); + })) { + failed.offer(target(1, 1)); + assertThrows(IOException.class, () -> failed.completeInitialSync(target(1, 1))); + assertEquals(Mode.CATCH_UP_REQUIRED, failed.status().getMode()); + assertNotNull(failed.failure()); + assertEquals(-1, failed.status().getIndexedThrough()); + } + // No volatile mailbox survives. The recovered Common target is sufficient. + try (StateArchiveServingWorkerV3 recovered = worker(root, source, () -> { })) { + recovered.offer(target(1, 1)); + recovered.completeInitialSync(target(1, 1)); + assertEquals(1, recovered.status().getIndexedThrough()); + } + try (StateArchiveServingWorkerV3 again = worker(root, source, () -> { })) { + again.offer(target(1, 1)); + again.completeInitialSync(target(1, 1)); + assertEquals(0, again.status().getBuildSequence()); + } + } + } + + @Test(timeout = 15000) + public void indexOpenFailureDoesNotThrowOnConstruction() throws Exception { + Path root = temporaryFolder.newFolder().toPath(); + try (StateArchiveFiveLaneSegmentWriterV3 source = source(root); + StateArchiveServingWorkerV3 worker = new StateArchiveServingWorkerV3(() -> { + throw new IOException("injected index open failure"); + }, source, () -> { })) { + assertThrows(IOException.class, () -> { + worker.offer(target(1, 1)); + worker.completeInitialSync(target(1, 1)); + }); + assertEquals(Mode.CATCH_UP_REQUIRED, worker.status().getMode()); + } + } + + @Test + public void readBudgetRejectsBeforeAllocatingAnOversizedFrame() throws Exception { + Path root = temporaryFolder.newFolder().toPath(); + try (StateArchiveFiveLaneSegmentWriterV3 source = source(root)) { + append(source, 1); + assertThrows(StateArchiveFiveLaneSegmentWriterV3.ServingReadBudgetException.class, + () -> source.readCommittedDiffs(0, 1, 1)); + assertEquals(0, source.getServingReadBytes()); + assertEquals(1, source.readCommittedDiffs(0, 1).size()); + } + } + + private StateArchiveServingWorkerV3 worker(Path root, + StateArchiveFiveLaneSegmentWriterV3 source, Runnable hook) { + return new StateArchiveServingWorkerV3( + () -> new StateArchiveServingIndexBuildCoordinatorV3(root, Engine.LEVELDB, 1000), + source, hook); + } + + private StateArchiveFiveLaneSegmentWriterV3 source(Path root) throws IOException { + return new StateArchiveFiveLaneSegmentWriterV3(root, hash(90), + StateArchiveFileFormatV3.COMPRESSION_NONE, 1500); + } + + private void append(StateArchiveFiveLaneSegmentWriterV3 source, int block) throws IOException { + source.append(new StateArchiveFiveLaneBlockCodecV3().encode(diff(block), + source.getResultHistoryDigest(), StateArchiveFileFormatV3.COMPRESSION_NONE)); + } + + private static BlockReverseDiff diff(int block) { + return new BlockReverseDiff(BlockSnapshotMeta.forBlock(block, hash(block), hash(block - 1), + block * 3000L), Collections.emptyList()); + } + + private static CommonCheckpointTarget target(int first, int last) { + return CommonCheckpointTarget.restore(hash(70), hash(last + 80), diff(first).getMeta(), + diff(last).getMeta(), hash(first + 30), hash(last + 31)); + } + + private static byte[] hash(int value) { + byte[] hash = new byte[32]; + hash[31] = (byte) value; + return hash; + } +} diff --git a/framework/src/test/java/org/tron/core/db2/archive/StorageRowKeyCodecTest.java b/framework/src/test/java/org/tron/core/db2/archive/StorageRowKeyCodecTest.java new file mode 100644 index 00000000000..9e4d606f54a --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/archive/StorageRowKeyCodecTest.java @@ -0,0 +1,74 @@ +package org.tron.core.db2.archive; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +import java.util.Arrays; +import org.bouncycastle.util.encoders.Hex; +import org.junit.Test; +import org.tron.common.runtime.vm.DataWord; +import org.tron.core.capsule.StorageRowCapsule; +import org.tron.core.store.StorageRowKeyCodec; +import org.tron.core.vm.program.Storage; + +public class StorageRowKeyCodecTest { + + private static final byte[] ADDRESS = Hex.decode( + "410102030405060708090a0b0c0d0e0f1011121314"); + private static final byte[] SLOT = Hex.decode( + "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"); + private static final byte[] TRANSACTION_HASH = Hex.decode( + "f0e0d0c0b0a090807060504030201000112233445566778899aabbccddeeff00"); + + @Test + public void matchesLegacyNormalVersionAndCreate2GoldenVectors() { + assertArrayEquals(Hex.decode( + "20ca5ae32eacb5480afc3d6566816bdb101112131415161718191a1b1c1d1e1f"), + StorageRowKeyCodec.physicalKey(ADDRESS, SLOT, 0, null)); + assertArrayEquals(Hex.decode( + "20ca5ae32eacb5480afc3d6566816bdbdea5e526567e92b0321816a4e895bd2d"), + StorageRowKeyCodec.physicalKey(ADDRESS, SLOT, 1, null)); + assertArrayEquals(Hex.decode( + "9397a7a785754542ff19d0968c0f92d4101112131415161718191a1b1c1d1e1f"), + StorageRowKeyCodec.physicalKey(ADDRESS, SLOT, 0, TRANSACTION_HASH)); + assertArrayEquals(Hex.decode( + "89ab580a96974c01d754858e702bc237101112131415161718191a1b1c1d1e1f"), + StorageRowKeyCodec.physicalKey(ADDRESS, SLOT, 0, new byte[32])); + } + + @Test + public void latestVmStorageUsesTheSharedCodec() { + Storage storage = new Storage(ADDRESS, null, key -> null); + storage.setContractVersion(1); + storage.generateAddrHash(TRANSACTION_HASH); + DataWord slot = new DataWord(SLOT); + storage.put(slot, new DataWord(1)); + + StorageRowCapsule row = storage.getRowCache().get(slot); + assertArrayEquals(StorageRowKeyCodec.physicalKey(ADDRESS, SLOT, 1, TRANSACTION_HASH), + row.getRowKey()); + } + + @Test + public void copiesOutputsAndRejectsInvalidComponentLengths() { + byte[] address = Arrays.copyOf(ADDRESS, ADDRESS.length); + byte[] slot = Arrays.copyOf(SLOT, SLOT.length); + byte[] transactionHash = Arrays.copyOf(TRANSACTION_HASH, TRANSACTION_HASH.length); + byte[] first = StorageRowKeyCodec.physicalKey(address, slot, 1, transactionHash); + first[0] ^= 1; + + assertArrayEquals(ADDRESS, address); + assertArrayEquals(SLOT, slot); + assertArrayEquals(TRANSACTION_HASH, transactionHash); + assertArrayEquals(Hex.decode( + "9397a7a785754542ff19d0968c0f92d4dea5e526567e92b0321816a4e895bd2d"), + StorageRowKeyCodec.physicalKey(address, slot, 1, transactionHash)); + assertEquals(StorageRowKeyCodec.KEY_BYTES, + StorageRowKeyCodec.physicalKey(address, slot, 0, null).length); + assertThrows(IllegalArgumentException.class, + () -> StorageRowKeyCodec.physicalKey(address, new byte[31], 0, null)); + assertThrows(IllegalArgumentException.class, + () -> StorageRowKeyCodec.physicalKeyFromAddressHash(new byte[31], slot, 0)); + } +} diff --git a/framework/src/test/java/org/tron/core/db2/core/ChainbaseCheckpointMaterializerTest.java b/framework/src/test/java/org/tron/core/db2/core/ChainbaseCheckpointMaterializerTest.java new file mode 100644 index 00000000000..bcdd6e593b2 --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/core/ChainbaseCheckpointMaterializerTest.java @@ -0,0 +1,963 @@ +package org.tron.core.db2.core; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tron.common.TestConstants; +import org.tron.core.config.args.Args; +import org.tron.core.db2.archive.ArchiveReadSnapshot.PinnedLatestState; +import org.tron.core.db2.archive.BlockReverseDiff; +import org.tron.core.db2.archive.BlockReverseDiff.DbGroup; +import org.tron.core.db2.archive.BlockReverseDiff.Entry; +import org.tron.core.db2.archive.BlockSnapshotMeta; +import org.tron.core.db2.archive.HistoricalRangeOverlay; +import org.tron.core.db2.archive.OldValue; +import org.tron.core.db2.archive.StateArchiveAppendCheckpointMaterializerV3; +import org.tron.core.db2.archive.StateArchiveCheckpointMaterializer; +import org.tron.core.db2.archive.StateArchiveCheckpointReadSnapshot; +import org.tron.core.db2.archive.StateArchiveFileFormatV3; +import org.tron.core.db2.archive.StateArchiveFiveLaneDurabilityProofV3; +import org.tron.core.db2.archive.StateArchiveHotCheckpointMaterializer; +import org.tron.core.db2.archive.StateArchiveHotStore; +import org.tron.core.db2.common.DB; +import org.tron.core.db2.common.Flusher; +import org.tron.core.db2.common.WrappedByteArray; +import org.tron.core.db2.core.CommonCheckpointMaterializer.Authority; +import org.tron.core.db2.core.CommonCheckpointMaterializer.Status; +import org.tron.core.db2.stateroot.PathStateCanonicalizer; +import org.tron.core.db2.stateroot.PathStateCheckpointMaterializer; +import org.tron.core.db2.stateroot.PathStateFlushTarget; +import org.tron.core.db2.stateroot.PathStateParticipantScope; +import org.tron.core.db2.stateroot.PathStatePhysicalStoreSet; +import org.tron.core.db2.stateroot.PathStateSnapshotDelta; +import org.tron.core.db2.stateroot.PathStateStoreManifest.Engine; + +public class ChainbaseCheckpointMaterializerTest { + + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @BeforeClass + public static void configure() { + Args.setParam(new String[]{}, TestConstants.TEST_CONF); + } + + @AfterClass + public static void clearConfiguration() { + Args.clearParam(); + } + + @Test + public void appliesEachStoreWithSyncBeforePublishingCurrentAndReopens() throws Exception { + Fixture fixture = fixture("normal", null); + fixture.code.put(new byte[]{9}, new byte[]{9}); + + assertEquals(Status.NEEDS_MATERIALIZATION, + fixture.materializer.inspect(fixture.target)); + fixture.materializer.materialize(fixture.payload, fixture.target); + assertEquals(Status.MATERIALIZED, fixture.materializer.inspect(fixture.target)); + assertFalse(java.nio.file.Files.exists(fixture.root.resolve( + ChainbaseCheckpointMaterializer.CURRENT_FILE))); + assertArrayEquals(new byte[]{2}, fixture.code.get(new byte[]{1})); + assertNull(fixture.code.get(new byte[]{9})); + assertArrayEquals(new byte[]{4}, fixture.storage.get(new byte[]{3})); + assertEquals(1, fixture.code.syncedFlushes); + assertEquals(1, fixture.storage.syncedFlushes); + + fixture.materializer.materialize(fixture.payload, fixture.target); + assertEquals(1, fixture.code.syncedFlushes); + fixture.materializer.publish(fixture.target); + assertEquals(Status.PUBLISHED, fixture.materializer.inspect(fixture.target)); + + ChainbaseCheckpointMaterializer reopened = new ChainbaseCheckpointMaterializer(fixture.root, + fixture.format, fixture.databases); + assertEquals(Status.PUBLISHED, reopened.inspect(fixture.target)); + CommonCheckpointPayload child = payload(fixture.format, 2, hash(1), hash(2), hash(11), + hash(12)); + CommonCheckpointTarget childTarget = CommonCheckpointTarget.from(child); + reopened.materialize(child, childTarget); + reopened.publish(childTarget); + assertEquals(Status.PUBLISHED, reopened.inspect(childTarget)); + } + + @Test + public void resumesEveryStoreAndMarkerBoundaryUsingCheckpointRedo() throws Exception { + for (ChainbaseCheckpointMaterializer.Stage stage + : ChainbaseCheckpointMaterializer.Stage.values()) { + Fixture fixture = fixture("fault-" + stage, stage); + if (stage == ChainbaseCheckpointMaterializer.Stage.AFTER_CURRENT) { + fixture.materializer.materialize(fixture.payload, fixture.target); + assertThrows(IOException.class, () -> fixture.materializer.publish(fixture.target)); + } else { + assertThrows(IOException.class, + () -> fixture.materializer.materialize(fixture.payload, fixture.target)); + } + + ChainbaseCheckpointMaterializer recovered = new ChainbaseCheckpointMaterializer( + fixture.root, fixture.format, fixture.databases); + if (recovered.inspect(fixture.target) == Status.NEEDS_MATERIALIZATION) { + recovered.materialize(fixture.payload, fixture.target); + } + recovered.publish(fixture.target); + assertEquals(Status.PUBLISHED, recovered.inspect(fixture.target)); + assertArrayEquals(new byte[]{2}, fixture.code.get(new byte[]{1})); + assertArrayEquals(new byte[]{4}, fixture.storage.get(new byte[]{3})); + } + } + + @Test + public void admitsLegacyMarkerOnlyUntilCentralSlotExists() throws Exception { + Fixture fixture = fixture("legacy-central", null); + fixture.materializer.materialize(fixture.payload, fixture.target); + fixture.materializer.publish(fixture.target); + CommonCheckpointMaterializedStore store = new CommonCheckpointMaterializedStore( + fixture.root.resolve("common")); + ChainbaseCheckpointMaterializer migrated = new ChainbaseCheckpointMaterializer( + fixture.root, fixture.format, fixture.databases, null, store); + + assertEquals(Status.PUBLISHED, migrated.inspect(fixture.target)); + store.replace(Authority.CHAINBASE, new byte[]{1}); + assertThrows(IOException.class, () -> migrated.inspect(fixture.target)); + } + + @Test + public void rejectsUnknownStoreForeignFormatAndNonParentTarget() throws Exception { + Fixture fixture = fixture("reject", null); + CommonCheckpointPayload unknown = payload(fixture.format, 1, hash(0), hash(1), hash(10), + hash(11), "unknown"); + assertThrows(IOException.class, () -> fixture.materializer.materialize(unknown, + CommonCheckpointTarget.from(unknown))); + + CommonCheckpointPayload foreign = payload(hash(99), 1, hash(0), hash(1), hash(10), + hash(11)); + assertThrows(IOException.class, () -> fixture.materializer.materialize(foreign, + CommonCheckpointTarget.from(foreign))); + + fixture.materializer.materialize(fixture.payload, fixture.target); + fixture.materializer.publish(fixture.target); + CommonCheckpointPayload nonChild = payload(fixture.format, 4, hash(8), hash(9), hash(20), + hash(21)); + assertThrows(IOException.class, + () -> fixture.materializer.inspect(CommonCheckpointTarget.from(nonChild))); + } + + @Test + public void payloadFactoryCoalescesSnapshotMutationsWithoutDurableReads() throws Exception { + MemoryDb code = new MemoryDb("code"); + MemoryDb storage = new MemoryDb("storage-row"); + Chainbase codeChainbase = new Chainbase(new SnapshotRoot(code)); + Chainbase storageChainbase = new Chainbase(new SnapshotRoot(storage)); + List databases = Arrays.asList(codeChainbase, storageChainbase); + + for (int number = 1; number <= 2; number++) { + BlockSnapshotMeta meta = BlockSnapshotMeta.forBlock(number, hash(number), hash(number - 1), + number * 3_000L); + byte[] parentRoot = hash(10 + number - 1); + byte[] stateRoot = hash(10 + number); + PathStateSnapshotDelta path = mock(PathStateSnapshotDelta.class); + when(path.getMeta()).thenReturn(meta); + when(path.getParentStateRoot()).thenReturn(parentRoot); + when(path.getStateRoot()).thenReturn(stateRoot); + when(path.getTransitionPayloadDigest()).thenReturn(hash(50 + number)); + when(path.getStores()).thenReturn(Collections.emptyList()); + when(path.getSuperNodeMutations()).thenReturn(Collections.emptyList()); + BlockReverseDiff archive = new BlockReverseDiff(meta, Collections.emptyList()); + + SnapshotImpl codeLayer = append(codeChainbase, meta, archive, path); + SnapshotImpl storageLayer = append(storageChainbase, meta, archive, path); + codeLayer.put(new byte[]{1}, new byte[]{(byte) number}); + codeLayer.put(new byte[]{(byte) (10 + number)}, new byte[]{(byte) (20 + number)}); + if (number == 1) { + storageLayer.put(new byte[]{3}, new byte[]{3}); + } else { + storageLayer.remove(new byte[]{3}); + } + } + + CommonCheckpointPayload captured = new CommonCheckpointPayloadFactory().capture(hash(80), + databases, 2); + assertEquals(2, captured.getBlocks().size()); + assertEquals(2, captured.getChainbaseStores().size()); + CommonCheckpointPayload.StoreMutations codeStore = captured.getChainbaseStores().get(0); + assertEquals("code", codeStore.getDbName()); + assertEquals(3, codeStore.getMutations().size()); + CommonCheckpointPayload.Mutation overwritten = codeStore.getMutations().stream() + .filter(mutation -> Arrays.equals(new byte[]{1}, mutation.getKey())) + .findFirst().orElseThrow(AssertionError::new); + assertArrayEquals(new byte[]{2}, overwritten.getValue()); + CommonCheckpointPayload.StoreMutations storageStore = + captured.getChainbaseStores().get(1); + assertEquals("storage-row", storageStore.getDbName()); + assertEquals(1, storageStore.getMutations().size()); + assertEquals(true, storageStore.getMutations().get(0).isDelete()); + assertEquals(0, code.getCalls); + assertEquals(0, storage.getCalls); + + java.nio.file.Path hotRoot = temporaryFolder.newFolder("capture-v2-hot").toPath(); + try (StateArchiveHotStore hotStore = StateArchiveHotStore.openOrCreate(hotRoot, hash(80), + Engine.LEVELDB, 0, hash(0), 3, 10, 1024 * 1024)) { + CommonCheckpointCapture capture = new CommonCheckpointPayloadFactory().captureV2(hash(80), + databases, 2, new StateArchiveHotCheckpointMaterializer(hotStore)); + assertEquals(CommonCheckpointPayload.COORDINATION_FORMAT_VERSION, + capture.getPayload().getVersion()); + assertEquals(2, capture.getArchiveDiffs().size()); + assertEquals(2, capture.getArchiveBinding().getBlockCount()); + assertEquals(0, hotStore.getMaterializedHead()); + assertThrows(IllegalStateException.class, + () -> capture.getPayload().getBlocks().get(0).getArchiveDiff()); + CommonCheckpointPayloadCodec codec = new CommonCheckpointPayloadCodec(); + assertEquals(capture.getArchiveBinding(), + codec.decode(codec.encode(capture.getPayload())).getArchiveBinding()); + } + } + + @Test + public void realThreeAuthorityCoordinatorCrossesBothBarriersThenRetiresWal() + throws Exception { + java.nio.file.Path root = temporaryFolder.newFolder("three-authority").toPath(); + byte[] format = hash(88); + MemoryDb code = new MemoryDb("code"); + Chainbase codeChainbase = new Chainbase(new SnapshotRoot(code)); + List databases = Collections.singletonList(codeChainbase); + PathStateParticipantScope scope = new PathStateCanonicalizer().participantScope(); + CommonCheckpointPayload payload = integratedPayload(format, scope); + CommonCheckpointTarget target = CommonCheckpointTarget.from(payload); + CommonCheckpointMaterializedStore materializedStore = + new CommonCheckpointMaterializedStore(root.resolve("wal")); + + try (PathStatePhysicalStoreSet pathStores = PathStatePhysicalStoreSet.open( + root.resolve("path-state"), scope, Engine.ROCKSDB)) { + ChainbaseCheckpointMaterializer chainbase = new ChainbaseCheckpointMaterializer( + root.resolve("chainbase"), format, databases, null, materializedStore); + PathStateCheckpointMaterializer pathState = new PathStateCheckpointMaterializer(pathStores, + scope, format, null, materializedStore); + StateArchiveCheckpointMaterializer archive = new StateArchiveCheckpointMaterializer( + root.resolve("archive"), format, null, Engine.LEVELDB, materializedStore); + CommonCheckpointFile file = new CommonCheckpointFile(root.resolve("wal")); + CommonCheckpointRedoCoordinator coordinator = new CommonCheckpointRedoCoordinator(file, + chainbase, pathState, archive); + + assertEquals(CommonCheckpointRedoCoordinator.RecoveryAction.COMPLETED_REDO, + coordinator.apply(payload)); + assertEquals(Status.PUBLISHED, chainbase.inspect(target)); + assertEquals(Status.PUBLISHED, pathState.inspect(target)); + assertEquals(Status.PUBLISHED, archive.inspect(target)); + assertEquals(CommonCheckpointRedoCoordinator.RecoveryAction.NO_CHECKPOINT, + coordinator.recover()); + assertArrayEquals(new byte[]{2}, code.get(new byte[]{1})); + assertArrayEquals(new byte[]{4}, pathStores.participant("account").getFlat( + new byte[]{3})); + assertFalse(java.nio.file.Files.exists(root.resolve("wal").resolve( + CommonCheckpointFile.FILE_NAME))); + try (java.util.stream.Stream markers = java.nio.file.Files.list( + root.resolve("wal/materialized"))) { + assertEquals(3L, markers.count()); + } + assertFalse(java.nio.file.Files.exists( + root.resolve("chainbase/chainbase-checkpoint-materialized"))); + assertFalse(java.nio.file.Files.exists( + root.resolve("path-state/checkpoint-materialized"))); + try (java.util.stream.Stream paths = java.nio.file.Files.walk( + root.resolve("archive/checkpoint-targets"))) { + assertFalse(paths.anyMatch(path -> "MATERIALIZED".equals( + path.getFileName().toString()))); + } + coordinator.close(); + CommonCheckpointMaterializedStore reopenedStore = + new CommonCheckpointMaterializedStore(root.resolve("wal")); + ChainbaseCheckpointMaterializer reopenedChainbase = + new ChainbaseCheckpointMaterializer(root.resolve("chainbase"), format, databases, + null, reopenedStore); + PathStateCheckpointMaterializer reopenedPathState = + new PathStateCheckpointMaterializer(pathStores, scope, format, null, reopenedStore); + StateArchiveCheckpointMaterializer reopenedArchive = + new StateArchiveCheckpointMaterializer(root.resolve("archive"), format, null, + Engine.LEVELDB, reopenedStore); + assertEquals(Status.PUBLISHED, reopenedChainbase.inspect(target)); + assertEquals(Status.PUBLISHED, reopenedPathState.inspect(target)); + assertEquals(Status.PUBLISHED, reopenedArchive.inspect(target)); + reopenedArchive.close(); + } + } + + @Test + public void snapshotRebaserDropsOnlyMaterializedPrefixWithoutSecondStoreWrite() + throws Exception { + java.nio.file.Path root = temporaryFolder.newFolder("snapshot-rebase").toPath(); + MemoryDb code = new MemoryDb("code"); + MemoryDb storage = new MemoryDb("storage-row"); + Chainbase codeChainbase = new Chainbase(new SnapshotRoot(code)); + Chainbase storageChainbase = new Chainbase(new SnapshotRoot(storage)); + List databases = Arrays.asList(codeChainbase, storageChainbase); + for (int number = 1; number <= 3; number++) { + BlockSnapshotMeta meta = BlockSnapshotMeta.forBlock(number, hash(number), hash(number - 1), + number * 3_000L); + PathStateSnapshotDelta path = pathDelta(meta, hash(10 + number - 1), hash(10 + number), + hash(40 + number)); + BlockReverseDiff archive = new BlockReverseDiff(meta, Collections.emptyList()); + append(codeChainbase, meta, archive, path).put(new byte[]{1}, + new byte[]{(byte) number}); + append(storageChainbase, meta, archive, path).put(new byte[]{3}, + new byte[]{(byte) number}); + } + byte[] format = hash(80); + CommonCheckpointPayload payload = new CommonCheckpointPayloadFactory().capture(format, + databases, 2); + CommonCheckpointTarget target = CommonCheckpointTarget.from(payload); + ChainbaseCheckpointMaterializer materializer = new ChainbaseCheckpointMaterializer(root, + format, databases); + materializer.materialize(payload, target); + materializer.publish(target); + assertEquals(1, code.syncedFlushes); + assertEquals(1, storage.syncedFlushes); + + new CommonCheckpointSnapshotRebaser().rebase(databases, target, 2); + assertEquals(1, code.syncedFlushes); + assertEquals(1, storage.syncedFlushes); + assertArrayEquals(new byte[]{2}, code.get(new byte[]{1})); + assertArrayEquals(new byte[]{2}, storage.get(new byte[]{3})); + assertArrayEquals(new byte[]{3}, codeChainbase.getUnchecked(new byte[]{1})); + assertArrayEquals(new byte[]{3}, storageChainbase.getUnchecked(new byte[]{3})); + assertSame(codeChainbase.getHead().getRoot(), codeChainbase.getHead().getPrevious()); + assertSame(storageChainbase.getHead().getRoot(), + storageChainbase.getHead().getPrevious()); + } + + @Test + public void snapshotRebaserPrevalidatesEveryStoreBeforeChangingAnyChain() { + MemoryDb code = new MemoryDb("code"); + MemoryDb storage = new MemoryDb("storage-row"); + Chainbase codeChainbase = new Chainbase(new SnapshotRoot(code)); + Chainbase storageChainbase = new Chainbase(new SnapshotRoot(storage)); + CommonCheckpointTarget target = CommonCheckpointTarget.from(payload(hash(80), 1, hash(0), + hash(1), hash(10), hash(11))); + SnapshotImpl codeLayer = (SnapshotImpl) codeChainbase.getHead().advance(); + codeLayer.attachBlockArtifacts(target.getFirstBlock(), null, null); + codeChainbase.setHead(codeLayer); + SnapshotImpl storageLayer = (SnapshotImpl) storageChainbase.getHead().advance(); + storageLayer.attachBlockArtifacts(BlockSnapshotMeta.forBlock(1, hash(9), hash(0), 3_000L), + null, null); + storageChainbase.setHead(storageLayer); + + assertThrows(IOException.class, () -> new CommonCheckpointSnapshotRebaser().rebase( + Arrays.asList(codeChainbase, storageChainbase), target, 1)); + assertSame(codeLayer, codeChainbase.getHead()); + assertSame(storageLayer, storageChainbase.getHead()); + } + + @Test + public void memoryRebaseFailureLeavesEverySnapshotPointerUnchanged() throws Exception { + java.nio.file.Path root = temporaryFolder.newFolder("memory-rebase-failure").toPath(); + byte[] format = hash(94); + MemoryDb code = new MemoryDb("code"); + Chainbase database = new Chainbase(new SnapshotRoot(code)); + List databases = Collections.singletonList(database); + BlockSnapshotMeta meta = BlockSnapshotMeta.forBlock(1, hash(1), hash(0), 3_000L); + byte[] view = hash(41); + PathStateSnapshotDelta path = pathDelta(meta, hash(10), hash(11), view); + BlockReverseDiff archiveBlock = new BlockReverseDiff(meta, Collections.emptyList()); + SnapshotImpl layer = append(database, meta, archiveBlock, path); + layer.put(new byte[]{1}, new byte[]{2}); + + ChainbaseCheckpointMaterializer chainbase = new ChainbaseCheckpointMaterializer( + root.resolve("chainbase"), format, databases); + PublishingMaterializer pathState = new PublishingMaterializer(Authority.PATH_STATE); + StateArchiveCheckpointMaterializer archive = new StateArchiveCheckpointMaterializer( + root.resolve("archive"), format); + CommonCheckpointRedoCoordinator coordinator = new CommonCheckpointRedoCoordinator( + new CommonCheckpointFile(root.resolve("wal")), chainbase, pathState, archive); + CommonCheckpointRuntime runtime = new CommonCheckpointRuntime( + new CommonCheckpointRuntimeOwner(coordinator), databases, root.resolve("archive"), + format, Engine.LEVELDB, + (blockNumber, blockHash) -> new TestLatest(code, blockNumber, blockHash), + target -> { + throw new IOException("injected memory rebase prepare failure"); + }); + + runtime.recoverBeforeServing(); + IOException failure = assertThrows(IOException.class, () -> runtime.checkpointAndRebase(1)); + assertEquals("injected memory rebase prepare failure", failure.getMessage()); + assertSame(layer, database.getHead()); + assertSame(layer.getRoot(), layer.getPrevious()); + assertEquals(CommonCheckpointRuntimeOwner.State.FAILED, runtime.getState()); + assertFalse(java.nio.file.Files.exists(root.resolve("wal").resolve( + CommonCheckpointFile.FILE_NAME))); + } + + @Test + public void runtimeComposesStartupCheckpointRebaseAndPointQuery() throws Exception { + java.nio.file.Path root = temporaryFolder.newFolder("composed-runtime").toPath(); + byte[] format = hash(93); + MemoryDb code = new MemoryDb("code"); + Chainbase database = new Chainbase(new SnapshotRoot(code)); + List databases = Collections.singletonList(database); + BlockSnapshotMeta meta = BlockSnapshotMeta.forBlock(1, hash(1), hash(0), 3_000L); + byte[] view = hash(41); + PathStateSnapshotDelta path = pathDelta(meta, hash(10), hash(11), view); + BlockReverseDiff archiveBlock = new BlockReverseDiff(meta, + Collections.singletonList(new DbGroup("code", Collections.singletonList( + new Entry(new byte[]{1}, OldValue.present(new byte[]{0})))))); + append(database, meta, archiveBlock, path).put(new byte[]{1}, new byte[]{2}); + + ChainbaseCheckpointMaterializer chainbase = new ChainbaseCheckpointMaterializer( + root.resolve("chainbase"), format, databases); + PublishingMaterializer pathState = new PublishingMaterializer(Authority.PATH_STATE); + StateArchiveCheckpointMaterializer archive = new StateArchiveCheckpointMaterializer( + root.resolve("archive"), format); + CommonCheckpointRedoCoordinator coordinator = new CommonCheckpointRedoCoordinator( + new CommonCheckpointFile(root.resolve("wal")), chainbase, pathState, archive); + AtomicLong clock = new AtomicLong(); + List timings = new ArrayList<>(); + CommonCheckpointRuntime runtime = new CommonCheckpointRuntime( + new CommonCheckpointRuntimeOwner(coordinator), databases, root.resolve("archive"), + format, Engine.LEVELDB, + (blockNumber, blockHash) -> new TestLatest(code, blockNumber, blockHash), + target -> () -> { }, () -> clock.addAndGet(1_000L), timings::add); + + assertEquals(CommonCheckpointRedoCoordinator.RecoveryAction.NO_CHECKPOINT, + runtime.recoverBeforeServing()); + CommonCheckpointTarget target = runtime.checkpointAndRebase(1); + assertEquals(meta, target.getLastBlock()); + assertThrows(IllegalStateException.class, target::getArchiveBinding); + assertEquals(1, timings.size()); + CommonCheckpointRuntime.Timing timing = timings.get(0); + assertEquals(1, timing.getHead()); + assertEquals(1, timing.getBlocks()); + assertEquals(1, timing.getPayloadCaptureUs()); + assertEquals(0, timing.getHotPrepareUs()); + assertTrue(timing.getOwnerApplyUs() > 0); + assertEquals(1, timing.getChainbaseRebasePrepareUs()); + assertEquals(1, timing.getPathStateRebasePrepareUs()); + assertEquals(1, timing.getChainbaseRebaseApplyUs()); + assertEquals(1, timing.getPathStateRebaseApplyUs()); + assertTrue(timing.getTotalUs() >= timing.getOwnerApplyUs()); + assertSame(database.getHead().getRoot(), database.getHead()); + assertEquals(1, code.syncedFlushes); + try (StateArchiveCheckpointReadSnapshot snapshot = runtime.pinPoint(0)) { + assertArrayEquals(new byte[]{0}, snapshot.get("code", new byte[]{1}).getValue()); + } + try (StateArchiveCheckpointReadSnapshot snapshot = runtime.pinPoint(1)) { + assertArrayEquals(new byte[]{2}, snapshot.get("code", new byte[]{1}).getValue()); + } + java.nio.file.Files.delete(root.resolve("archive/checkpoint-serving-index/ENGINE")); + try (StateArchiveCheckpointReadSnapshot snapshot = runtime.pinPoint(1)) { + assertArrayEquals(new byte[]{2}, snapshot.get("code", new byte[]{1}).getValue()); + } + assertThrows(IOException.class, () -> StateArchiveCheckpointMaterializer.loadPublishedTarget( + root.resolve("archive"), format, Engine.LEVELDB)); + runtime.close(); + assertEquals(CommonCheckpointRuntimeOwner.State.CLOSED, runtime.getState()); + } + + @Test + public void hotRuntimePreparesV2BeforeWalAndCompletesBothBarriers() throws Exception { + java.nio.file.Path root = temporaryFolder.newFolder("hot-runtime-v2").toPath(); + byte[] format = hash(94); + V2Snapshots snapshots = new V2Snapshots(); + StateArchiveHotStore hotStore = StateArchiveHotStore.openOrCreate(root.resolve("hot"), + format, Engine.LEVELDB, 0, hash(0), 3, 10, 1024 * 1024); + List timings = new ArrayList<>(); + CommonCheckpointRuntime runtime = hotRuntime(root, + new CommonCheckpointFile(root.resolve("wal")), snapshots, hotStore, format, timings); + + assertEquals(CommonCheckpointRedoCoordinator.RecoveryAction.NO_CHECKPOINT, + runtime.recoverBeforeServing()); + CommonCheckpointTarget target = runtime.checkpointAndRebase(1); + + assertEquals(snapshots.meta, target.getLastBlock()); + assertEquals(1, target.getArchiveBinding().getBlockCount()); + assertEquals(1, hotStore.getMaterializedHead()); + assertEquals(1, hotStore.getCommittedHead()); + assertFalse(java.nio.file.Files.exists(root.resolve("wal") + .resolve(CommonCheckpointFile.FILE_NAME))); + assertSame(snapshots.codeDatabase.getHead().getRoot(), snapshots.codeDatabase.getHead()); + assertSame(snapshots.propertiesDatabase.getHead().getRoot(), + snapshots.propertiesDatabase.getHead()); + assertEquals(1, timings.size()); + assertEquals(1, timings.get(0).getHotPrepareUs()); + assertThrows(IOException.class, () -> runtime.pinPoint(1)); + runtime.close(); + } + + @Test + public void appendRuntimePreparesSap3BeforeWalAndReopensPublishedTarget() throws Exception { + java.nio.file.Path root = temporaryFolder.newFolder("append-runtime-v3").toPath(); + java.nio.file.Path history = root.resolve("history"); + byte[] format = hash(96); + byte[] baselineHistory = hash(70); + V2Snapshots snapshots = new V2Snapshots(); + StateArchiveAppendCheckpointMaterializerV3 append = + new StateArchiveAppendCheckpointMaterializerV3(history, format, Engine.LEVELDB, + baselineHistory, StateArchiveFileFormatV3.COMPRESSION_NONE, 10_000); + CommonCheckpointRedoCoordinator coordinator = new CommonCheckpointRedoCoordinator( + new CommonCheckpointFile(root.resolve("wal")), + new ChainbaseCheckpointMaterializer(root.resolve("chainbase"), format, + snapshots.databases), + new PublishingMaterializer(Authority.PATH_STATE), append); + AtomicLong clock = new AtomicLong(); + List timings = new ArrayList<>(); + CommonCheckpointRuntime runtime = new CommonCheckpointRuntime( + new CommonCheckpointRuntimeOwner(coordinator), snapshots.databases, history, + format, Engine.LEVELDB, + (blockNumber, blockHash) -> new TestLatest(snapshots.code, blockNumber, blockHash), + target -> () -> { }, null, append, null, () -> clock.addAndGet(1_000L), + timings::add); + + assertEquals(CommonCheckpointRedoCoordinator.RecoveryAction.NO_CHECKPOINT, + runtime.recoverBeforeServing()); + CommonCheckpointTarget target = runtime.checkpointAndRebase(1); + + assertEquals(snapshots.meta, target.getLastBlock()); + assertEquals(1, target.getArchiveBinding().getBlockCount()); + assertEquals(Status.PUBLISHED, append.inspect(target)); + assertEquals(760, java.nio.file.Files.size( + history.resolve(StateArchiveFiveLaneDurabilityProofV3.FILE_NAME))); + assertFalse(java.nio.file.Files.exists( + root.resolve("wal").resolve(CommonCheckpointFile.FILE_NAME))); + assertSame(snapshots.codeDatabase.getHead().getRoot(), snapshots.codeDatabase.getHead()); + assertSame(snapshots.propertiesDatabase.getHead().getRoot(), + snapshots.propertiesDatabase.getHead()); + assertEquals(1, timings.size()); + assertEquals(1, timings.get(0).getHotPrepareUs()); + assertThrows(IOException.class, () -> runtime.pinPoint(1)); + runtime.close(); + + try (StateArchiveAppendCheckpointMaterializerV3 reopened = + new StateArchiveAppendCheckpointMaterializerV3(history, format, Engine.LEVELDB, + baselineHistory, StateArchiveFileFormatV3.COMPRESSION_NONE, 10_000)) { + assertEquals(Status.PUBLISHED, reopened.inspect(target)); + } + } + + @Test + public void hotRuntimeRetriesAfterPrepareButBeforeWalPublication() throws Exception { + java.nio.file.Path root = temporaryFolder.newFolder("hot-runtime-retry").toPath(); + byte[] format = hash(95); + V2Snapshots snapshots = new V2Snapshots(); + StateArchiveHotStore firstHot = StateArchiveHotStore.openOrCreate(root.resolve("hot"), + format, Engine.LEVELDB, 0, hash(0), 3, 10, 1024 * 1024); + AtomicBoolean failedOnce = new AtomicBoolean(); + CommonCheckpointFile interruptedFile = new CommonCheckpointFile(root.resolve("wal"), + CommonCheckpointPayloadCodec.DEFAULT_MAX_ENCODED_LENGTH, (stage, path) -> { + if (stage == CommonCheckpointFile.Stage.AFTER_TEMPORARY_FORCE + && failedOnce.compareAndSet(false, true)) { + throw new IOException("injected failure after Hot prepare and temporary WAL force"); + } + }); + CommonCheckpointRuntime interrupted = hotRuntime(root, interruptedFile, snapshots, firstHot, + format, new ArrayList<>()); + interrupted.recoverBeforeServing(); + + assertThrows(IOException.class, () -> interrupted.checkpointAndRebase(1)); + assertEquals(CommonCheckpointRuntimeOwner.State.FAILED, interrupted.getState()); + assertFalse(java.nio.file.Files.exists(root.resolve("wal") + .resolve(CommonCheckpointFile.FILE_NAME))); + assertTrue(java.nio.file.Files.exists(root.resolve("wal") + .resolve(CommonCheckpointFile.TEMPORARY_FILE_NAME))); + assertSame(snapshots.codeLayer, snapshots.codeDatabase.getHead()); + + StateArchiveHotStore recoveredHot = StateArchiveHotStore.openOrCreate(root.resolve("hot"), + format, Engine.LEVELDB, 0, hash(0), 3, 10, 1024 * 1024); + assertEquals(1, recoveredHot.getMaterializedHead()); + assertEquals(0, recoveredHot.getCommittedHead()); + CommonCheckpointRuntime recovered = hotRuntime(root, + new CommonCheckpointFile(root.resolve("wal")), snapshots, recoveredHot, format, + new ArrayList<>()); + assertEquals(CommonCheckpointRedoCoordinator.RecoveryAction.NO_CHECKPOINT, + recovered.recoverBeforeServing()); + assertEquals(0, recoveredHot.getMaterializedHead()); + + CommonCheckpointTarget target = recovered.checkpointAndRebase(1); + assertEquals(snapshots.meta, target.getLastBlock()); + assertEquals(1, recoveredHot.getCommittedHead()); + assertFalse(java.nio.file.Files.exists(root.resolve("wal") + .resolve(CommonCheckpointFile.TEMPORARY_FILE_NAME))); + recovered.close(); + } + + private Fixture fixture(String name, ChainbaseCheckpointMaterializer.Stage failedStage) + throws Exception { + java.nio.file.Path root = temporaryFolder.newFolder(name).toPath(); + MemoryDb code = new MemoryDb("code"); + MemoryDb storage = new MemoryDb("storage-row"); + List databases = Arrays.asList( + new Chainbase(new SnapshotRoot(code)), new Chainbase(new SnapshotRoot(storage))); + byte[] format = hash(80); + CommonCheckpointPayload payload = payload(format, 1, hash(0), hash(1), hash(10), hash(11)); + ChainbaseCheckpointMaterializer materializer = new ChainbaseCheckpointMaterializer(root, + format, databases, failAt(failedStage)); + return new Fixture(root, code, storage, databases, format, payload, materializer); + } + + private static CommonCheckpointRuntime hotRuntime(java.nio.file.Path root, + CommonCheckpointFile file, V2Snapshots snapshots, StateArchiveHotStore hotStore, + byte[] format, List timings) { + StateArchiveHotCheckpointMaterializer hotMaterializer = + new StateArchiveHotCheckpointMaterializer(hotStore); + BlockSnapshotMeta persisted = BlockSnapshotMeta.forBlock(0, hash(0), hash(-1), 0); + CommonCheckpointHotRecovery recovery = new CommonCheckpointHotRecovery(file, + () -> new CommonCheckpointHotRecovery.PersistentDynamicHead(0, hash(0)), + ignored -> persisted, hotMaterializer::reconcilePreparedTail); + CommonCheckpointRedoCoordinator coordinator = new CommonCheckpointRedoCoordinator(file, + new ChainbaseCheckpointMaterializer(root.resolve("chainbase"), format, + snapshots.databases), + new PublishingMaterializer(Authority.PATH_STATE), hotMaterializer); + AtomicLong clock = new AtomicLong(); + return new CommonCheckpointRuntime(new CommonCheckpointRuntimeOwner(coordinator), + snapshots.databases, root.resolve("legacy-archive"), format, Engine.LEVELDB, + (blockNumber, blockHash) -> new TestLatest(snapshots.code, blockNumber, blockHash), + target -> () -> { }, hotMaterializer, recovery, () -> clock.addAndGet(1_000L), + timings::add); + } + + private static ChainbaseCheckpointMaterializer.FaultHook failAt( + ChainbaseCheckpointMaterializer.Stage failedStage) { + return (stage, dbName) -> { + if (stage == failedStage) { + throw new IOException("injected " + stage + " at " + dbName); + } + }; + } + + private static CommonCheckpointPayload payload(byte[] format, long blockNumber, + byte[] parentHash, byte[] blockHash, byte[] parentRoot, byte[] stateRoot, + String... storeOverride) { + BlockSnapshotMeta meta = BlockSnapshotMeta.forBlock(blockNumber, blockHash, parentHash, + blockNumber * 3_000L); + PathStateFlushTarget.BlockBinding binding = mock(PathStateFlushTarget.BlockBinding.class); + when(binding.getMeta()).thenReturn(meta); + when(binding.getParentStateRoot()).thenReturn(parentRoot); + when(binding.getStateRoot()).thenReturn(stateRoot); + when(binding.getTransitionPayloadDigest()).thenReturn(hash(50 + (int) blockNumber)); + PathStateFlushTarget pathState = mock(PathStateFlushTarget.class); + when(pathState.getBlocks()).thenReturn(Collections.singletonList(binding)); + when(pathState.getParentStateRoot()).thenReturn(parentRoot); + when(pathState.getStateRoot()).thenReturn(stateRoot); + when(pathState.getStores()).thenReturn(Collections.emptyList()); + when(pathState.getSuperNodeMutations()).thenReturn(Collections.emptyList()); + + List stores = new ArrayList<>(); + String firstName = storeOverride.length == 0 ? "code" : storeOverride[0]; + stores.add(new CommonCheckpointPayload.StoreMutations(firstName, Arrays.asList( + new CommonCheckpointPayload.Mutation(new byte[]{1}, new byte[]{2}), + new CommonCheckpointPayload.Mutation(new byte[]{9}, null)))); + if (storeOverride.length == 0) { + stores.add(new CommonCheckpointPayload.StoreMutations("storage-row", + Collections.singletonList( + new CommonCheckpointPayload.Mutation(new byte[]{3}, new byte[]{4})))); + } + BlockReverseDiff archive = new BlockReverseDiff(meta, Collections.emptyList()); + return CommonCheckpointPayload.create(format, pathState, + Collections.singletonList(archive), stores); + } + + private static byte[] hash(int seed) { + byte[] hash = new byte[32]; + for (int index = 0; index < hash.length; index++) { + hash[index] = (byte) (seed + index); + } + return hash; + } + + private static CommonCheckpointPayload integratedPayload(byte[] format, + PathStateParticipantScope scope) { + long blockNumber = 1; + BlockSnapshotMeta meta = BlockSnapshotMeta.forBlock(blockNumber, hash(1), hash(0), 3_000L); + PathStateFlushTarget.BlockBinding binding = mock(PathStateFlushTarget.BlockBinding.class); + when(binding.getMeta()).thenReturn(meta); + when(binding.getParentStateRoot()).thenReturn(hash(10)); + when(binding.getStateRoot()).thenReturn(hash(11)); + when(binding.getTransitionPayloadDigest()).thenReturn(hash(51)); + PathStateFlushTarget.StoreTarget account = mock(PathStateFlushTarget.StoreTarget.class); + when(account.getStoreId()).thenReturn(scope.require("account").getStoreId()); + when(account.getDbName()).thenReturn("account"); + when(account.getStoreRoot()).thenReturn(hash(61)); + PathStateSnapshotDelta.Mutation flat = mockMutation(new byte[]{3}, new byte[]{4}); + PathStateSnapshotDelta.Mutation node = mockMutation(new byte[]{5}, new byte[]{6}); + PathStateSnapshotDelta.Mutation superNode = mockMutation(new byte[]{7}, new byte[]{8}); + when(account.getFlatMutations()).thenReturn(Collections.singletonList(flat)); + when(account.getNodeMutations()).thenReturn(Collections.singletonList(node)); + PathStateFlushTarget pathState = mock(PathStateFlushTarget.class); + when(pathState.getBlocks()).thenReturn(Collections.singletonList(binding)); + when(pathState.getParentStateRoot()).thenReturn(hash(10)); + when(pathState.getStateRoot()).thenReturn(hash(11)); + when(pathState.getStores()).thenReturn(Collections.singletonList(account)); + when(pathState.getSuperNodeMutations()).thenReturn(Collections.singletonList(superNode)); + List chainbase = Collections.singletonList( + new CommonCheckpointPayload.StoreMutations("code", Collections.singletonList( + new CommonCheckpointPayload.Mutation(new byte[]{1}, new byte[]{2})))); + return CommonCheckpointPayload.create(format, pathState, + Collections.singletonList(new BlockReverseDiff(meta, Collections.emptyList())), + chainbase); + } + + private static PathStateSnapshotDelta.Mutation mockMutation(byte[] key, byte[] value) { + PathStateSnapshotDelta.Mutation mutation = mock(PathStateSnapshotDelta.Mutation.class); + when(mutation.getKey()).thenReturn(key); + when(mutation.getValue()).thenReturn(value); + return mutation; + } + + private static SnapshotImpl append(Chainbase database, BlockSnapshotMeta meta, + BlockReverseDiff archive, PathStateSnapshotDelta path) { + SnapshotImpl layer = (SnapshotImpl) database.getHead().advance(); + layer.attachBlockArtifacts(meta, archive, path); + database.setHead(layer); + return layer; + } + + private static PathStateSnapshotDelta pathDelta(BlockSnapshotMeta meta, byte[] parentRoot, + byte[] stateRoot, byte[] view) { + PathStateSnapshotDelta path = mock(PathStateSnapshotDelta.class); + when(path.getMeta()).thenReturn(meta); + when(path.getParentStateRoot()).thenReturn(parentRoot); + when(path.getStateRoot()).thenReturn(stateRoot); + when(path.getTransitionPayloadDigest()).thenReturn(hash(50 + (int) meta.getBlockNumber())); + when(path.getStores()).thenReturn(Collections.emptyList()); + when(path.getSuperNodeMutations()).thenReturn(Collections.emptyList()); + return path; + } + + private static final class Fixture { + + private final java.nio.file.Path root; + private final MemoryDb code; + private final MemoryDb storage; + private final List databases; + private final byte[] format; + private final CommonCheckpointPayload payload; + private final CommonCheckpointTarget target; + private final ChainbaseCheckpointMaterializer materializer; + + private Fixture(java.nio.file.Path root, MemoryDb code, MemoryDb storage, + List databases, byte[] format, CommonCheckpointPayload payload, + ChainbaseCheckpointMaterializer materializer) { + this.root = root; + this.code = code; + this.storage = storage; + this.databases = databases; + this.format = format; + this.payload = payload; + this.target = CommonCheckpointTarget.from(payload); + this.materializer = materializer; + } + } + + private static final class V2Snapshots { + + private final MemoryDb code = new MemoryDb("code"); + private final MemoryDb properties = new MemoryDb("properties"); + private final Chainbase codeDatabase = new Chainbase(new SnapshotRoot(code)); + private final Chainbase propertiesDatabase = new Chainbase(new SnapshotRoot(properties)); + private final List databases = Arrays.asList(codeDatabase, propertiesDatabase); + private final BlockSnapshotMeta meta = BlockSnapshotMeta.forBlock(1, hash(1), hash(0), + 3_000L); + private final SnapshotImpl codeLayer; + + private V2Snapshots() { + byte[] view = hash(41); + PathStateSnapshotDelta path = pathDelta(meta, hash(10), hash(11), view); + BlockReverseDiff archive = new BlockReverseDiff(meta, + Collections.singletonList(new DbGroup("code", Collections.singletonList( + new Entry(new byte[]{1}, OldValue.present(new byte[]{0})))))); + codeLayer = append(codeDatabase, meta, archive, path); + SnapshotImpl propertiesLayer = append(propertiesDatabase, meta, archive, path); + codeLayer.put(new byte[]{1}, new byte[]{2}); + propertiesLayer.put("latest_block_header_number".getBytes(StandardCharsets.UTF_8), + ByteBuffer.allocate(Long.BYTES).putLong(1).array()); + propertiesLayer.put("latest_block_header_hash".getBytes(StandardCharsets.UTF_8), hash(1)); + } + } + + private static final class PublishingMaterializer implements CommonCheckpointMaterializer { + + private final Authority authority; + private Status status = Status.NEEDS_MATERIALIZATION; + private CommonCheckpointTarget target; + + private PublishingMaterializer(Authority authority) { + this.authority = authority; + } + + @Override + public Authority authority() { + return authority; + } + + @Override + public Status inspect(CommonCheckpointTarget expected) throws IOException { + if (target != null && !target.equals(expected)) { + throw new IOException("test materializer target mismatch"); + } + return status; + } + + @Override + public void materialize(CommonCheckpointPayload payload, CommonCheckpointTarget expected) { + target = expected; + status = Status.MATERIALIZED; + } + + @Override + public void publish(CommonCheckpointTarget expected) throws IOException { + if (status != Status.MATERIALIZED || !expected.equals(target)) { + throw new IOException("test materializer publish without materialization"); + } + status = Status.PUBLISHED; + } + } + + private static final class TestLatest implements PinnedLatestState { + + private final MemoryDb database; + private final long blockNumber; + private final byte[] blockHash; + + private TestLatest(MemoryDb database, long blockNumber, byte[] blockHash) { + this.database = database; + this.blockNumber = blockNumber; + this.blockHash = Arrays.copyOf(blockHash, blockHash.length); + } + + @Override + public long getBlockNumber() { + return blockNumber; + } + + @Override + public byte[] getBlockHash() { + return Arrays.copyOf(blockHash, blockHash.length); + } + + @Override + public OldValue get(String dbName, byte[] physicalRawKey) { + if (!"code".equals(dbName)) { + throw new IllegalArgumentException("unexpected test Store " + dbName); + } + return OldValue.fromNullable(database.get(physicalRawKey)); + } + + @Override + public List range(String dbName, byte[] lowerInclusive, + byte[] upperExclusive, int maxEntries) { + throw new UnsupportedOperationException("point-only test latest"); + } + + @Override + public void close() { + } + } + + private static final class MemoryDb implements DB, Flusher { + + private final String name; + private final Map values = new LinkedHashMap<>(); + private int syncedFlushes; + private int getCalls; + + private MemoryDb(String name) { + this.name = name; + } + + @Override + public byte[] get(byte[] key) { + getCalls++; + return values.get(WrappedByteArray.of(key)); + } + + @Override + public void put(byte[] key, byte[] value) { + values.put(WrappedByteArray.of(key), value); + } + + @Override + public long size() { + return values.size(); + } + + @Override + public boolean isEmpty() { + return values.isEmpty(); + } + + @Override + public void remove(byte[] key) { + values.remove(WrappedByteArray.of(key)); + } + + @Override + public Iterator> iterator() { + Map copy = new LinkedHashMap<>(); + values.forEach((key, value) -> copy.put(key.getBytes(), value)); + return copy.entrySet().iterator(); + } + + @Override + public void close() { + } + + @Override + public String getDbName() { + return name; + } + + @Override + public void stat() { + } + + @Override + public DB newInstance() { + throw new UnsupportedOperationException(); + } + + @Override + public void flush(Map batch) { + apply(batch); + } + + @Override + public void flushSynced(Map batch) { + syncedFlushes++; + apply(batch); + } + + @Override + public void reset() { + values.clear(); + } + + private void apply(Map batch) { + batch.forEach((key, value) -> { + if (value.getBytes() == null) { + values.remove(key); + } else { + values.put(key, value.getBytes()); + } + }); + } + } +} diff --git a/framework/src/test/java/org/tron/core/db2/core/CommonCheckpointBaselineFileTest.java b/framework/src/test/java/org/tron/core/db2/core/CommonCheckpointBaselineFileTest.java new file mode 100644 index 00000000000..e7728127c29 --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/core/CommonCheckpointBaselineFileTest.java @@ -0,0 +1,52 @@ +package org.tron.core.db2.core; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tron.core.db2.archive.BlockSnapshotMeta; + +public class CommonCheckpointBaselineFileTest { + + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void publishesOnceAndRejectsDriftOrCorruption() throws Exception { + Path root = temporaryFolder.newFolder("baseline").toPath(); + CommonCheckpointBaselineFile file = new CommonCheckpointBaselineFile(root); + CommonCheckpointBaseline expected = baseline(7); + + CommonCheckpointBaseline first = file.openOrCreate(expected); + CommonCheckpointBaseline second = file.openOrCreate(expected); + assertEquals(first.getHead(), second.getHead()); + assertArrayEquals(first.getStateRoot(), second.getStateRoot()); + assertThrows(IOException.class, () -> file.openOrCreate(baseline(8))); + + Path authority = root.resolve(CommonCheckpointBaselineFile.FILE_NAME); + byte[] corrupt = Files.readAllBytes(authority); + corrupt[corrupt.length - 1] ^= 1; + Files.write(authority, corrupt); + assertThrows(IOException.class, file::load); + } + + private static CommonCheckpointBaseline baseline(int seed) { + return new CommonCheckpointBaseline(CommonCheckpointFormat.identity(), + BlockSnapshotMeta.forBlock(seed, hash(seed), hash(seed - 1), seed * 3_000L), + hash(seed + 20)); + } + + private static byte[] hash(int seed) { + byte[] value = new byte[32]; + for (int index = 0; index < value.length; index++) { + value[index] = (byte) (seed + index); + } + return value; + } +} diff --git a/framework/src/test/java/org/tron/core/db2/core/CommonCheckpointFileTest.java b/framework/src/test/java/org/tron/core/db2/core/CommonCheckpointFileTest.java new file mode 100644 index 00000000000..affa8fd0357 --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/core/CommonCheckpointFileTest.java @@ -0,0 +1,135 @@ +package org.tron.core.db2.core; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tron.core.db2.archive.BlockReverseDiff; +import org.tron.core.db2.archive.BlockSnapshotMeta; +import org.tron.core.db2.stateroot.PathStateFlushTarget; + +public class CommonCheckpointFileTest { + + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void publishesLoadsRetriesAndRetiresOneImmutablePayload() throws Exception { + Path directory = temporaryFolder.getRoot().toPath().resolve("common-checkpoint"); + CommonCheckpointFile file = new CommonCheckpointFile(directory); + CommonCheckpointPayload payload = payload(7); + + assertNull(file.loadIfPresent()); + file.publish(payload); + assertTrue(Files.isRegularFile(file.getCheckpointPath())); + assertFalse(Files.exists(file.getTemporaryPath())); + assertArrayEquals(payload.getStateRoot(), file.loadRequired().getStateRoot()); + byte[] firstBytes = Files.readAllBytes(file.getCheckpointPath()); + + file.publish(payload); + assertArrayEquals(firstBytes, Files.readAllBytes(file.getCheckpointPath())); + assertThrows(IOException.class, () -> file.publish(payload(8))); + file.retire(); + assertNull(file.loadIfPresent()); + file.retire(); + } + + @Test + public void retriesEveryPublishCrashBoundaryWithoutAcceptingTemporaryAuthority() + throws Exception { + for (CommonCheckpointFile.Stage stage : new CommonCheckpointFile.Stage[]{ + CommonCheckpointFile.Stage.AFTER_TEMPORARY_FORCE, + CommonCheckpointFile.Stage.AFTER_ATOMIC_PUBLISH, + CommonCheckpointFile.Stage.AFTER_DIRECTORY_FORCE}) { + Path directory = temporaryFolder.getRoot().toPath().resolve("publish-" + stage); + CommonCheckpointPayload payload = payload(7); + CommonCheckpointFile interrupted = new CommonCheckpointFile(directory, + CommonCheckpointPayloadCodec.DEFAULT_MAX_ENCODED_LENGTH, failAt(stage)); + assertThrows(IOException.class, () -> interrupted.publish(payload)); + + CommonCheckpointFile recovered = new CommonCheckpointFile(directory); + if (stage == CommonCheckpointFile.Stage.AFTER_TEMPORARY_FORCE) { + assertNull(recovered.loadIfPresent()); + assertTrue(Files.isRegularFile(recovered.getTemporaryPath())); + } else { + assertNotNull(recovered.loadRequired()); + } + recovered.publish(payload); + assertNotNull(recovered.loadRequired()); + assertFalse(Files.exists(recovered.getTemporaryPath())); + } + } + + @Test + public void rejectsCorruptOrOversizedFilesAndRecoversInterruptedRetire() throws Exception { + Path directory = temporaryFolder.getRoot().toPath().resolve("retire"); + CommonCheckpointPayload payload = payload(7); + CommonCheckpointFile file = new CommonCheckpointFile(directory); + file.publish(payload); + byte[] corrupt = Files.readAllBytes(file.getCheckpointPath()); + corrupt[corrupt.length - 1] ^= 1; + Files.write(file.getCheckpointPath(), corrupt); + assertThrows(IOException.class, file::loadRequired); + + file.retire(); + file.publish(payload); + CommonCheckpointFile interrupted = new CommonCheckpointFile(directory, + CommonCheckpointPayloadCodec.DEFAULT_MAX_ENCODED_LENGTH, + failAt(CommonCheckpointFile.Stage.AFTER_RETIRE_DELETE)); + assertThrows(IOException.class, interrupted::retire); + assertNull(new CommonCheckpointFile(directory).loadIfPresent()); + new CommonCheckpointFile(directory).retire(); + + CommonCheckpointFile bounded = new CommonCheckpointFile(directory, + CommonCheckpointPayloadCodec.HEADER_LENGTH + 1, (stage, path) -> { }); + assertThrows(IllegalArgumentException.class, () -> bounded.publish(payload)); + } + + private static CommonCheckpointFile.FaultHook failAt(CommonCheckpointFile.Stage expected) { + return (actual, path) -> { + if (actual == expected) { + throw new IOException("injected " + expected); + } + }; + } + + private static CommonCheckpointPayload payload(int seed) { + BlockSnapshotMeta meta = BlockSnapshotMeta.forBlock(1, hash(1), hash(0), 3_000L); + PathStateFlushTarget.BlockBinding binding = mock(PathStateFlushTarget.BlockBinding.class); + when(binding.getMeta()).thenReturn(meta); + when(binding.getParentStateRoot()).thenReturn(hash(5)); + when(binding.getStateRoot()).thenReturn(hash(6)); + when(binding.getTransitionPayloadDigest()).thenReturn(hash(3)); + PathStateFlushTarget target = mock(PathStateFlushTarget.class); + when(target.getBlocks()).thenReturn(Collections.singletonList(binding)); + when(target.getParentStateRoot()).thenReturn(hash(5)); + when(target.getStateRoot()).thenReturn(hash(6)); + when(target.getStores()).thenReturn(Collections.emptyList()); + when(target.getSuperNodeMutations()).thenReturn(Collections.emptyList()); + BlockReverseDiff archive = new BlockReverseDiff(meta, Collections.emptyList()); + return CommonCheckpointPayload.create(hash(seed), target, + Collections.singletonList(archive), Collections.singletonList( + new CommonCheckpointPayload.StoreMutations("code", Collections.singletonList( + new CommonCheckpointPayload.Mutation(new byte[]{1}, new byte[]{2}))))); + } + + private static byte[] hash(int seed) { + byte[] hash = new byte[32]; + for (int index = 0; index < hash.length; index++) { + hash[index] = (byte) (seed + index); + } + return hash; + } +} diff --git a/framework/src/test/java/org/tron/core/db2/core/CommonCheckpointHotRecoveryTest.java b/framework/src/test/java/org/tron/core/db2/core/CommonCheckpointHotRecoveryTest.java new file mode 100644 index 00000000000..cf777a78732 --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/core/CommonCheckpointHotRecoveryTest.java @@ -0,0 +1,222 @@ +package org.tron.core.db2.core; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tron.core.db2.archive.BlockReverseDiff; +import org.tron.core.db2.archive.BlockSnapshotMeta; +import org.tron.core.db2.archive.StateArchiveHotBatchDescriptor; +import org.tron.core.db2.archive.StateArchiveHotCheckpointMaterializer; +import org.tron.core.db2.archive.StateArchiveHotStore; +import org.tron.core.db2.core.CommonCheckpointHotRecovery.PersistentDynamicHead; +import org.tron.core.db2.core.CommonCheckpointMaterializer.Authority; +import org.tron.core.db2.stateroot.PathStateFlushTarget; +import org.tron.core.db2.stateroot.PathStateStoreManifest.Engine; + +public class CommonCheckpointHotRecoveryTest { + + private static final byte[] NUMBER_KEY = bytes("latest_block_header_number"); + private static final byte[] HASH_KEY = bytes("latest_block_header_hash"); + + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void validWalSelectsTargetBeforeChainbaseBlockExists() throws Exception { + Path root = temporaryFolder.newFolder("wal-authority").toPath(); + BlockSnapshotMeta target = meta(1); + CommonCheckpointFile file = new CommonCheckpointFile(root.resolve("wal")); + file.publish(payload(root.resolve("plan"), target, dynamic(target), Engine.LEVELDB)); + AtomicBoolean dynamicRead = new AtomicBoolean(); + AtomicBoolean reconciled = new AtomicBoolean(); + CommonCheckpointHotRecovery recovery = new CommonCheckpointHotRecovery(file, + () -> { + dynamicRead.set(true); + return new PersistentDynamicHead(0, hash(0)); + }, block -> null, authority -> { + assertEquals(target, authority); + reconciled.set(true); + return 2; + }); + + CommonCheckpointHotRecovery.Result result = recovery.reconcileBeforeCommonRedo(); + + assertEquals(CommonCheckpointHotRecovery.Source.COMMON_WAL, result.getSource()); + assertEquals(target, result.getAuthority()); + assertEquals(2, result.getRemovedBlocks()); + assertFalse(dynamicRead.get()); + assertTrue(reconciled.get()); + } + + @Test + public void walRejectsDynamicAndExistingBlockIdentityDrift() throws Exception { + Path root = temporaryFolder.newFolder("wal-drift").toPath(); + BlockSnapshotMeta target = meta(1); + CommonCheckpointFile wrongDynamicFile = new CommonCheckpointFile( + root.resolve("wrong-dynamic")); + wrongDynamicFile.publish(payload(root.resolve("plan-dynamic"), target, + dynamic(target.getBlockNumber(), hash(9)), Engine.LEVELDB)); + AtomicBoolean reconciled = new AtomicBoolean(); + CommonCheckpointHotRecovery wrongDynamic = new CommonCheckpointHotRecovery( + wrongDynamicFile, () -> new PersistentDynamicHead(0, hash(0)), block -> null, + authority -> { + reconciled.set(true); + return 0; + }); + assertThrows(IOException.class, wrongDynamic::reconcileBeforeCommonRedo); + assertFalse(reconciled.get()); + + CommonCheckpointFile wrongBlockFile = new CommonCheckpointFile(root.resolve("wrong-block")); + wrongBlockFile.publish(payload(root.resolve("plan-block"), target, dynamic(target), + Engine.LEVELDB)); + BlockSnapshotMeta driftedMeta = BlockSnapshotMeta.forBlock(1, hash(1), hash(8), 3_000L); + CommonCheckpointHotRecovery wrongBlock = new CommonCheckpointHotRecovery(wrongBlockFile, + () -> new PersistentDynamicHead(0, hash(0)), block -> driftedMeta, authority -> 0); + assertThrows(IOException.class, wrongBlock::reconcileBeforeCommonRedo); + } + + @Test + public void absentWalUsesPersistedDynamicAndTruncatesOrphanIdempotently() throws Exception { + Path root = temporaryFolder.newFolder("dynamic-authority").toPath(); + byte[] format = hash(70); + BlockSnapshotMeta block = meta(1); + BlockReverseDiff diff = new BlockReverseDiff(block, Collections.emptyList()); + try (StateArchiveHotStore hot = StateArchiveHotStore.openOrCreate(root.resolve("hot"), + format, Engine.LEVELDB, 0, hash(0), 3, 10, 1024 * 1024)) { + StateArchiveHotBatchDescriptor descriptor = hot.planCheckpoint( + Collections.singletonList(diff)); + hot.prepareCheckpoint(hash(90), descriptor, Collections.singletonList(diff)); + BlockSnapshotMeta persisted = BlockSnapshotMeta.forBlock(0, hash(0), hash(-1), 0); + CommonCheckpointHotRecovery recovery = new CommonCheckpointHotRecovery( + new CommonCheckpointFile(root.resolve("wal")), + () -> new PersistentDynamicHead(0, hash(0)), ignored -> persisted, + hot::reconcilePreparedTail); + + CommonCheckpointHotRecovery.Result first = recovery.reconcileBeforeCommonRedo(); + CommonCheckpointHotRecovery.Result second = recovery.reconcileBeforeCommonRedo(); + + assertEquals(CommonCheckpointHotRecovery.Source.PERSISTED_DYNAMIC, first.getSource()); + assertEquals(1, first.getRemovedBlocks()); + assertEquals(0, second.getRemovedBlocks()); + assertEquals(0, hot.getCommittedHead()); + assertEquals(0, hot.getMaterializedHead()); + } + } + + @Test + public void runtimeInvokesOptionalHotRecoveryBeforeCommonRedo() throws Exception { + Path root = temporaryFolder.newFolder("runtime").toPath(); + CommonCheckpointFile file = new CommonCheckpointFile(root.resolve("wal")); + AtomicBoolean reconciled = new AtomicBoolean(); + BlockSnapshotMeta persisted = BlockSnapshotMeta.forBlock(0, hash(0), hash(-1), 0); + StateArchiveHotStore hotStore = StateArchiveHotStore.openOrCreate(root.resolve("hot"), + hash(70), Engine.LEVELDB, 0, hash(0), 3, 10, 1024 * 1024); + StateArchiveHotCheckpointMaterializer hotMaterializer = + new StateArchiveHotCheckpointMaterializer(hotStore); + CommonCheckpointHotRecovery hotRecovery = new CommonCheckpointHotRecovery(file, + () -> new PersistentDynamicHead(0, hash(0)), ignored -> persisted, authority -> { + reconciled.set(true); + return hotMaterializer.reconcilePreparedTail(authority); + }); + CommonCheckpointRedoCoordinator coordinator = new CommonCheckpointRedoCoordinator(file, + materializer(Authority.CHAINBASE), materializer(Authority.PATH_STATE), + hotMaterializer); + CommonCheckpointRuntime runtime = new CommonCheckpointRuntime( + new CommonCheckpointRuntimeOwner(coordinator), + Collections.singletonList(mock(Chainbase.class)), root.resolve("archive"), hash(70), + Engine.LEVELDB, (blockNumber, blockHash) -> { + throw new IOException("latest state is intentionally unavailable"); + }, target -> () -> { }, hotMaterializer, hotRecovery); + + assertEquals(CommonCheckpointRedoCoordinator.RecoveryAction.NO_CHECKPOINT, + runtime.recoverBeforeServing()); + assertTrue(reconciled.get()); + assertEquals(CommonCheckpointRuntimeOwner.State.READY, runtime.getState()); + runtime.close(); + } + + @Test + public void absentWalRejectsMissingOrDriftedBlockStoreIdentity() throws Exception { + Path root = temporaryFolder.newFolder("dynamic-drift").toPath(); + CommonCheckpointFile file = new CommonCheckpointFile(root.resolve("wal")); + CommonCheckpointHotRecovery missing = new CommonCheckpointHotRecovery(file, + () -> new PersistentDynamicHead(1, hash(1)), ignored -> null, authority -> 0); + assertThrows(IOException.class, missing::reconcileBeforeCommonRedo); + + CommonCheckpointHotRecovery drifted = new CommonCheckpointHotRecovery(file, + () -> new PersistentDynamicHead(1, hash(9)), ignored -> meta(1), authority -> 0); + assertThrows(IOException.class, drifted::reconcileBeforeCommonRedo); + } + + private CommonCheckpointPayload payload(Path hotPath, BlockSnapshotMeta meta, + List stores, Engine engine) throws Exception { + BlockReverseDiff diff = new BlockReverseDiff(meta, Collections.emptyList()); + try (StateArchiveHotStore hot = StateArchiveHotStore.openOrCreate(hotPath, hash(70), engine, + 0, hash(0), 3, 10, 1024 * 1024)) { + return CommonCheckpointPayload.createV2(hash(70), pathState(meta), + hot.planCheckpoint(Collections.singletonList(diff)), stores); + } + } + + private static List dynamic(BlockSnapshotMeta meta) { + return dynamic(meta.getBlockNumber(), meta.getBlockHash()); + } + + private static List dynamic(long number, byte[] hash) { + return Collections.singletonList(new CommonCheckpointPayload.StoreMutations("properties", + Arrays.asList(new CommonCheckpointPayload.Mutation(NUMBER_KEY, + ByteBuffer.allocate(Long.BYTES).putLong(number).array()), + new CommonCheckpointPayload.Mutation(HASH_KEY, hash)))); + } + + private static PathStateFlushTarget pathState(BlockSnapshotMeta meta) { + PathStateFlushTarget.BlockBinding binding = mock(PathStateFlushTarget.BlockBinding.class); + when(binding.getMeta()).thenReturn(meta); + when(binding.getParentStateRoot()).thenReturn(hash(50)); + when(binding.getStateRoot()).thenReturn(hash(51)); + when(binding.getTransitionPayloadDigest()).thenReturn(hash(52)); + PathStateFlushTarget pathState = mock(PathStateFlushTarget.class); + when(pathState.getBlocks()).thenReturn(Collections.singletonList(binding)); + when(pathState.getParentStateRoot()).thenReturn(hash(50)); + when(pathState.getStateRoot()).thenReturn(hash(51)); + when(pathState.getStores()).thenReturn(Collections.emptyList()); + when(pathState.getSuperNodeMutations()).thenReturn(Collections.emptyList()); + return pathState; + } + + private static CommonCheckpointMaterializer materializer(Authority authority) { + CommonCheckpointMaterializer materializer = mock(CommonCheckpointMaterializer.class); + when(materializer.authority()).thenReturn(authority); + return materializer; + } + + private static BlockSnapshotMeta meta(long block) { + return BlockSnapshotMeta.forBlock(block, hash((int) block), hash((int) block - 1), + block * 3_000L); + } + + private static byte[] bytes(String value) { + return value.getBytes(StandardCharsets.UTF_8); + } + + private static byte[] hash(int marker) { + byte[] value = new byte[32]; + value[31] = (byte) marker; + return value; + } +} diff --git a/framework/src/test/java/org/tron/core/db2/core/CommonCheckpointMaterializedStoreTest.java b/framework/src/test/java/org/tron/core/db2/core/CommonCheckpointMaterializedStoreTest.java new file mode 100644 index 00000000000..d76f1f63710 --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/core/CommonCheckpointMaterializedStoreTest.java @@ -0,0 +1,76 @@ +package org.tron.core.db2.core; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.stream.Stream; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tron.core.db2.core.CommonCheckpointMaterializer.Authority; + +public class CommonCheckpointMaterializedStoreTest { + + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void atomicallyRotatesOneSlotPerAuthorityWithoutHistoryGrowth() throws Exception { + Path root = temporaryFolder.newFolder("materialized").toPath(); + CommonCheckpointMaterializedStore store = new CommonCheckpointMaterializedStore(root); + + for (int target = 0; target < 100; target++) { + for (Authority authority : Authority.values()) { + byte[] encoded = record(target, authority); + store.replace(authority, encoded); + store.replace(authority, encoded); + assertTrue(store.matches(authority, encoded)); + } + } + + try (Stream paths = Files.list(root.resolve( + CommonCheckpointMaterializedStore.DIRECTORY))) { + assertEquals(Authority.values().length, paths.count()); + } + for (Authority authority : Authority.values()) { + assertArrayEquals(record(99, authority), Files.readAllBytes(store.path(authority))); + assertFalse(store.matches(authority, record(98, authority))); + } + } + + @Test + public void resumesEveryAtomicReplaceBoundaryWithoutGrowingTemporaryFiles() throws Exception { + for (CommonCheckpointMaterializedStore.Stage failedStage + : CommonCheckpointMaterializedStore.Stage.values()) { + Path root = temporaryFolder.newFolder("fault-" + failedStage).toPath(); + CommonCheckpointMaterializedStore original = new CommonCheckpointMaterializedStore(root); + original.replace(Authority.CHAINBASE, record(1, Authority.CHAINBASE)); + CommonCheckpointMaterializedStore interrupted = new CommonCheckpointMaterializedStore(root, + (stage, path) -> { + if (stage == failedStage) { + throw new IOException("injected " + stage); + } + }); + + assertThrows(IOException.class, + () -> interrupted.replace(Authority.CHAINBASE, record(2, Authority.CHAINBASE))); + CommonCheckpointMaterializedStore recovered = new CommonCheckpointMaterializedStore(root); + recovered.replace(Authority.CHAINBASE, record(2, Authority.CHAINBASE)); + assertTrue(recovered.matches(Authority.CHAINBASE, record(2, Authority.CHAINBASE))); + try (Stream paths = Files.list(root.resolve( + CommonCheckpointMaterializedStore.DIRECTORY))) { + assertEquals(1L, paths.count()); + } + } + } + + private static byte[] record(int target, Authority authority) { + return new byte[]{(byte) target, (byte) authority.ordinal()}; + } +} diff --git a/framework/src/test/java/org/tron/core/db2/core/CommonCheckpointPayloadV2Test.java b/framework/src/test/java/org/tron/core/db2/core/CommonCheckpointPayloadV2Test.java new file mode 100644 index 00000000000..f3cc52b0ea5 --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/core/CommonCheckpointPayloadV2Test.java @@ -0,0 +1,146 @@ +package org.tron.core.db2.core; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.nio.ByteBuffer; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Collections; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tron.core.db2.archive.ArchivePersistenceException; +import org.tron.core.db2.archive.BlockReverseDiff; +import org.tron.core.db2.archive.BlockReverseDiff.DbGroup; +import org.tron.core.db2.archive.BlockReverseDiff.Entry; +import org.tron.core.db2.archive.BlockSnapshotMeta; +import org.tron.core.db2.archive.OldValue; +import org.tron.core.db2.archive.StateArchiveHotBatchDescriptor; +import org.tron.core.db2.archive.StateArchiveHotStore; +import org.tron.core.db2.stateroot.PathStateFlushTarget; +import org.tron.core.db2.stateroot.PathStateStoreManifest.Engine; + +public class CommonCheckpointPayloadV2Test { + + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void roundTripsDigestOnlyArchiveBindingWithoutOldValueBody() throws Exception { + byte[] format = hash(7); + byte[] oldValueSentinel = new byte[96]; + for (int index = 0; index < oldValueSentinel.length; index++) { + oldValueSentinel[index] = (byte) (0xa0 + index % 31); + } + BlockSnapshotMeta meta = BlockSnapshotMeta.forBlock(1, hash(1), hash(0), 3_000L); + BlockReverseDiff diff = new BlockReverseDiff(meta, + Collections.singletonList(new DbGroup("code", Collections.singletonList( + new Entry(new byte[]{1}, OldValue.present(oldValueSentinel)))))); + Path root = temporaryFolder.newFolder("payload-v2").toPath(); + try (StateArchiveHotStore hotStore = StateArchiveHotStore.openOrCreate(root, format, + Engine.LEVELDB, 0, hash(0), 3, 10, 1024 * 1024)) { + StateArchiveHotBatchDescriptor descriptor = hotStore.planCheckpoint( + Collections.singletonList(diff)); + CommonCheckpointPayload payload = CommonCheckpointPayload.createV2(format, + pathState(meta), descriptor, Collections.emptyList()); + CommonCheckpointPayloadCodec codec = new CommonCheckpointPayloadCodec(); + byte[] encoded = codec.encode(payload); + + assertEquals(CommonCheckpointPayload.COORDINATION_FORMAT_VERSION, + ByteBuffer.wrap(encoded, Integer.BYTES, Short.BYTES).getShort()); + byte[] oldCoordinationVersion = Arrays.copyOf(encoded, encoded.length); + ByteBuffer.wrap(oldCoordinationVersion).putShort(Integer.BYTES, (short) 2); + assertThrows(IllegalArgumentException.class, + () -> codec.decode(oldCoordinationVersion)); + assertFalse(contains(encoded, oldValueSentinel)); + CommonCheckpointPayload decoded = codec.decode(encoded); + assertEquals(CommonCheckpointPayload.COORDINATION_FORMAT_VERSION, + decoded.getVersion()); + assertEquals(descriptor, decoded.getArchiveBinding()); + assertArrayEquals(descriptor.getBlocks().get(0).getArchiveRecordDigest(), + decoded.getBlocks().get(0).getArchiveRecordDigest()); + assertThrows(IllegalStateException.class, + () -> decoded.getBlocks().get(0).getArchiveDiff()); + assertArrayEquals(codec.digest(payload), codec.digest(decoded)); + CommonCheckpointFile file = new CommonCheckpointFile(root.resolve("wal")); + file.publish(payload); + assertEquals(descriptor, file.loadRequired().getArchiveBinding()); + file.retire(); + + BlockReverseDiff changedBody = new BlockReverseDiff(meta, + Collections.singletonList(new DbGroup("code", Collections.singletonList( + new Entry(new byte[]{1}, OldValue.absent()))))); + assertThrows(ArchivePersistenceException.class, + () -> hotStore.prepareCheckpoint(hash(120), descriptor, + Collections.singletonList(changedBody))); + + CommonCheckpointPayload v1 = CommonCheckpointPayload.create(format, + pathState(meta), Collections.singletonList(diff), Collections.emptyList()); + byte[] encodedV1 = codec.encode(v1); + assertEquals(CommonCheckpointPayload.FORMAT_VERSION, + ByteBuffer.wrap(encodedV1, Integer.BYTES, Short.BYTES).getShort()); + assertTrue(codec.decode(encodedV1).getBlocks().get(0).getArchiveDiff() + .getGroups().get(0).getEntries().get(0).getOldValue().isPresent()); + } + } + + @Test + public void roundTripsExplicitRocksEngineIdentity() throws Exception { + byte[] format = hash(8); + BlockSnapshotMeta meta = BlockSnapshotMeta.forBlock(1, hash(1), hash(0), 3_000L); + BlockReverseDiff diff = new BlockReverseDiff(meta, Collections.emptyList()); + Path root = temporaryFolder.newFolder("payload-v2-rocks").toPath(); + try (StateArchiveHotStore hotStore = StateArchiveHotStore.openOrCreate(root, format, + Engine.ROCKSDB, 0, hash(0), 3, 10, 1024 * 1024)) { + StateArchiveHotBatchDescriptor descriptor = hotStore.planCheckpoint( + Collections.singletonList(diff)); + CommonCheckpointPayload decoded = new CommonCheckpointPayloadCodec().decode( + new CommonCheckpointPayloadCodec().encode(CommonCheckpointPayload.createV2(format, + pathState(meta), descriptor, Collections.emptyList()))); + assertEquals(Engine.ROCKSDB, decoded.getArchiveBinding().getEngine()); + } + } + + private static PathStateFlushTarget pathState(BlockSnapshotMeta meta) { + PathStateFlushTarget.BlockBinding binding = mock(PathStateFlushTarget.BlockBinding.class); + when(binding.getMeta()).thenReturn(meta); + when(binding.getParentStateRoot()).thenReturn(hash(5)); + when(binding.getStateRoot()).thenReturn(hash(6)); + when(binding.getTransitionPayloadDigest()).thenReturn(hash(71)); + PathStateFlushTarget pathState = mock(PathStateFlushTarget.class); + when(pathState.getBlocks()).thenReturn(Collections.singletonList(binding)); + when(pathState.getParentStateRoot()).thenReturn(hash(5)); + when(pathState.getStateRoot()).thenReturn(hash(6)); + when(pathState.getStores()).thenReturn(Collections.emptyList()); + when(pathState.getSuperNodeMutations()).thenReturn(Collections.emptyList()); + return pathState; + } + + private static boolean contains(byte[] haystack, byte[] needle) { + for (int offset = 0; offset <= haystack.length - needle.length; offset++) { + boolean equal = true; + for (int index = 0; index < needle.length; index++) { + if (haystack[offset + index] != needle[index]) { + equal = false; + break; + } + } + if (equal) { + return true; + } + } + return false; + } + + private static byte[] hash(int marker) { + byte[] hash = new byte[32]; + hash[31] = (byte) marker; + return hash; + } +} diff --git a/framework/src/test/java/org/tron/core/db2/core/CommonCheckpointRecoveryStateAdapterTest.java b/framework/src/test/java/org/tron/core/db2/core/CommonCheckpointRecoveryStateAdapterTest.java new file mode 100644 index 00000000000..2019afacca6 --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/core/CommonCheckpointRecoveryStateAdapterTest.java @@ -0,0 +1,84 @@ +package org.tron.core.db2.core; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import org.junit.Test; +import org.tron.common.utils.Sha256Hash; +import org.tron.core.ChainBaseManager; +import org.tron.core.capsule.BlockCapsule; +import org.tron.core.capsule.BlockCapsule.BlockId; +import org.tron.core.db2.archive.BlockSnapshotMeta; +import org.tron.core.exception.BadItemException; +import org.tron.core.exception.ItemNotFoundException; +import org.tron.core.store.DynamicPropertiesStore; + +public class CommonCheckpointRecoveryStateAdapterTest { + + @Test + public void usesPersistentDynamicRootAndReturnsFullBlockMeta() throws Exception { + long number = 73L; + BlockId id = new BlockId(Sha256Hash.wrap(hash(7)), number); + Sha256Hash parent = Sha256Hash.wrap(hash(6)); + DynamicPropertiesStore dynamic = mock(DynamicPropertiesStore.class); + when(dynamic.getLatestBlockHeaderNumber()).thenReturn(99L); + when(dynamic.getLatestBlockHeaderHash()).thenReturn( + new BlockId(Sha256Hash.wrap(hash(9)), 99L)); + when(dynamic.getLatestBlockHeaderNumberFromDB()).thenReturn(number); + when(dynamic.getLatestBlockHeaderHashFromDB()).thenReturn(id); + + BlockCapsule block = mock(BlockCapsule.class); + when(block.getNum()).thenReturn(number); + when(block.getBlockId()).thenReturn(id); + when(block.getParentHash()).thenReturn(parent); + when(block.getTimeStamp()).thenReturn(1234L); + ChainBaseManager chainBase = mock(ChainBaseManager.class); + when(chainBase.getBlockByNum(number)).thenReturn(block); + + CommonCheckpointRecoveryStateAdapter adapter = + new CommonCheckpointRecoveryStateAdapter(dynamic, chainBase); + CommonCheckpointHotRecovery.PersistentDynamicHead head = adapter.load(); + BlockSnapshotMeta meta = adapter.loadIfPresent(number); + + assertEquals(number, head.getBlockNumber()); + assertArrayEquals(id.getBytes(), head.getBlockHash()); + assertEquals(number, meta.getBlockNumber()); + assertArrayEquals(id.getBytes(), meta.getBlockHash()); + assertArrayEquals(parent.getBytes(), meta.getParentHash()); + assertEquals(1234L, meta.getTimestamp()); + } + + @Test + public void rejectsUnavailablePersistentDynamicIdentity() throws Exception { + DynamicPropertiesStore dynamic = mock(DynamicPropertiesStore.class); + when(dynamic.getLatestBlockHeaderNumberFromDB()).thenReturn(-1L); + CommonCheckpointRecoveryStateAdapter adapter = + new CommonCheckpointRecoveryStateAdapter(dynamic, mock(ChainBaseManager.class)); + + assertThrows(java.io.IOException.class, adapter::load); + } + + @Test + public void distinguishesMissingAndCorruptBlockStoreRecords() throws Exception { + long number = 73L; + ChainBaseManager chainBase = mock(ChainBaseManager.class); + when(chainBase.getBlockByNum(number)).thenThrow(new ItemNotFoundException()); + CommonCheckpointRecoveryStateAdapter adapter = new CommonCheckpointRecoveryStateAdapter( + mock(DynamicPropertiesStore.class), chainBase); + assertNull(adapter.loadIfPresent(number)); + + doThrow(new BadItemException()).when(chainBase).getBlockByNum(number); + assertThrows(java.io.IOException.class, () -> adapter.loadIfPresent(number)); + } + + private static byte[] hash(int seed) { + byte[] value = new byte[32]; + java.util.Arrays.fill(value, (byte) seed); + return value; + } +} diff --git a/framework/src/test/java/org/tron/core/db2/core/CommonCheckpointRedoCoordinatorTest.java b/framework/src/test/java/org/tron/core/db2/core/CommonCheckpointRedoCoordinatorTest.java new file mode 100644 index 00000000000..0654c736089 --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/core/CommonCheckpointRedoCoordinatorTest.java @@ -0,0 +1,483 @@ +package org.tron.core.db2.core; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicLong; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tron.core.db2.archive.BlockReverseDiff; +import org.tron.core.db2.archive.BlockSnapshotMeta; +import org.tron.core.db2.core.CommonCheckpointMaterializer.Authority; +import org.tron.core.db2.core.CommonCheckpointMaterializer.Status; +import org.tron.core.db2.core.CommonCheckpointRedoCoordinator.RecoveryAction; +import org.tron.core.db2.stateroot.PathStateFlushTarget; + +public class CommonCheckpointRedoCoordinatorTest { + + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void appliesTwoBarriersThenRetiresAndSecondRecoveryDoesNothing() throws Exception { + Fixture fixture = fixture("normal", null); + + assertEquals(RecoveryAction.COMPLETED_REDO, fixture.coordinator.apply(fixture.payload)); + assertEquals(list("materialize-CHAINBASE", "materialize-PATH_STATE", + "materialize-STATE_ARCHIVE", "publish-CHAINBASE", "publish-PATH_STATE", + "publish-STATE_ARCHIVE"), fixture.actions); + assertFalse(Files.exists(fixture.file.getCheckpointPath())); + int actionCount = fixture.actions.size(); + assertEquals(RecoveryAction.NO_CHECKPOINT, fixture.coordinator.recover()); + assertEquals(actionCount, fixture.actions.size()); + } + + @Test + public void recordsDeterministicRedoPhaseTimingsWithoutChangingBarrierCalls() + throws Exception { + Fixture fixture = fixture("timings", null); + AtomicLong clock = new AtomicLong(); + List timings = new ArrayList<>(); + CommonCheckpointRedoCoordinator coordinator = new CommonCheckpointRedoCoordinator( + fixture.file, fixture.materializers.get(0), fixture.materializers.get(1), + fixture.materializers.get(2), stage -> { }, () -> clock.addAndGet(1_000L), + timings::add); + + assertEquals(RecoveryAction.COMPLETED_REDO, coordinator.apply(fixture.payload)); + assertEquals(1, timings.size()); + CommonCheckpointRedoCoordinator.Timing timing = timings.get(0); + assertEquals("apply", timing.getMode()); + assertEquals(1, timing.getHead()); + assertEquals(1, timing.getBlocks()); + assertEquals(1, timing.getWalPublishUs()); + assertEquals(1, timing.getWalLoadUs()); + assertEquals(1, timing.getWalRetireUs()); + for (Authority authority : Authority.values()) { + assertEquals(1, timing.begin(authority)); + assertEquals(5, timing.inspect(authority)); + assertEquals(5, timing.inspectCount(authority)); + assertEquals(1, timing.materialize(authority)); + assertEquals(1, timing.materializeCount(authority)); + assertEquals(1, timing.publish(authority)); + assertEquals(1, timing.publishCount(authority)); + assertEquals(1, timing.end(authority)); + } + assertTrue(timing.getTotalUs() > 0); + } + + @Test + public void derivedFailureOccursAfterRetirementAndCannotFailCommon() throws Exception { + Fixture fixture = fixture("derived-failure", null); + FakeMaterializer archive = fixture.materializers.get(2); + archive.afterCommitHook = () -> { + assertFalse(Files.exists(fixture.file.getCheckpointPath())); + assertFalse(archive.scopeOpen); + throw new IllegalStateException("injected derived failure"); + }; + assertEquals(RecoveryAction.COMPLETED_REDO, fixture.coordinator.apply(fixture.payload)); + assertEquals(RecoveryAction.NO_CHECKPOINT, fixture.coordinator.recover()); + assertEquals(Status.PUBLISHED, archive.status); + } + + @Test + public void ignoresTimingSinkFailureAfterDurableCheckpointCompletes() throws Exception { + Fixture fixture = fixture("timing-sink-failure", null); + AtomicLong clock = new AtomicLong(); + CommonCheckpointRedoCoordinator coordinator = new CommonCheckpointRedoCoordinator( + fixture.file, fixture.materializers.get(0), fixture.materializers.get(1), + fixture.materializers.get(2), stage -> { }, () -> clock.addAndGet(1_000L), + timing -> { + throw new IllegalStateException("injected timing sink failure"); + }); + + assertEquals(RecoveryAction.COMPLETED_REDO, coordinator.apply(fixture.payload)); + assertFalse(Files.exists(fixture.file.getCheckpointPath())); + for (FakeMaterializer materializer : fixture.materializers) { + assertEquals(Status.PUBLISHED, materializer.status); + } + } + + @Test + public void recordsRecoveryTimingsWithoutRepeatingPublishedAuthorityWork() throws Exception { + Fixture fixture = fixture("recovery-timings", + CommonCheckpointRedoCoordinator.Stage.BEFORE_CHECKPOINT_RETIRE); + assertThrows(IOException.class, () -> fixture.coordinator.apply(fixture.payload)); + assertTrue(Files.isRegularFile(fixture.file.getCheckpointPath())); + + AtomicLong clock = new AtomicLong(); + List timings = new ArrayList<>(); + CommonCheckpointRedoCoordinator recovered = new CommonCheckpointRedoCoordinator( + fixture.file, fixture.materializers.get(0), fixture.materializers.get(1), + fixture.materializers.get(2), stage -> { }, () -> clock.addAndGet(1_000L), + timings::add); + + assertEquals(RecoveryAction.COMPLETED_REDO, recovered.recover()); + assertEquals(1, timings.size()); + CommonCheckpointRedoCoordinator.Timing timing = timings.get(0); + assertEquals("recover", timing.getMode()); + assertEquals(0, timing.getWalPublishUs()); + assertEquals(1, timing.getWalLoadUs()); + assertEquals(1, timing.getWalRetireUs()); + for (Authority authority : Authority.values()) { + assertEquals(1, timing.begin(authority)); + assertEquals(3, timing.inspect(authority)); + assertEquals(3, timing.inspectCount(authority)); + assertEquals(0, timing.materialize(authority)); + assertEquals(0, timing.materializeCount(authority)); + assertEquals(0, timing.publish(authority)); + assertEquals(0, timing.publishCount(authority)); + assertEquals(1, timing.end(authority)); + } + assertTrue(timing.getTotalUs() > 0); + assertFalse(Files.exists(fixture.file.getCheckpointPath())); + } + + @Test + public void resumesEveryCoordinatorBoundaryAgainstTheSameTarget() throws Exception { + for (CommonCheckpointRedoCoordinator.Stage stage + : CommonCheckpointRedoCoordinator.Stage.values()) { + Fixture interrupted = fixture("failure-" + stage, stage); + assertThrows(IOException.class, () -> interrupted.coordinator.apply(interrupted.payload)); + + CommonCheckpointRedoCoordinator recovered = interrupted.coordinator(null); + RecoveryAction expected = stage + == CommonCheckpointRedoCoordinator.Stage.AFTER_CHECKPOINT_RETIRE + ? RecoveryAction.NO_CHECKPOINT : RecoveryAction.COMPLETED_REDO; + assertEquals(expected, recovered.recover()); + assertFalse(Files.exists(interrupted.file.getCheckpointPath())); + assertEquals(RecoveryAction.NO_CHECKPOINT, recovered.recover()); + for (FakeMaterializer materializer : interrupted.materializers) { + assertEquals(Status.PUBLISHED, materializer.status); + assertEquals(CommonCheckpointTarget.from(interrupted.payload), materializer.target); + } + } + } + + @Test + public void rejectsPublishedAuthorityBeforeGlobalMaterializationBarrier() throws Exception { + Fixture fixture = fixture("invalid-partial-publish", null); + fixture.materializers.get(0).status = Status.PUBLISHED; + + assertThrows(IOException.class, () -> fixture.coordinator.apply(fixture.payload)); + assertTrue(Files.isRegularFile(fixture.file.getCheckpointPath())); + assertTrue(fixture.actions.isEmpty()); + } + + @Test + public void rejectsWrongAuthorityAndMaterializerThatDoesNotReachExactStatus() throws Exception { + Fixture fixture = fixture("invalid-materializer", null); + assertThrows(IllegalArgumentException.class, () -> new CommonCheckpointRedoCoordinator( + fixture.file, fixture.materializers.get(1), fixture.materializers.get(0), + fixture.materializers.get(2))); + + fixture.materializers.get(0).advanceAfterMaterialize = false; + assertThrows(IOException.class, () -> fixture.coordinator.apply(fixture.payload)); + assertTrue(Files.isRegularFile(fixture.file.getCheckpointPath())); + assertEquals(Collections.singletonList("materialize-CHAINBASE"), fixture.actions); + } + + @Test + public void closesEveryCheckpointScopeAfterSuccessAndFailure() throws Exception { + Fixture completed = fixture("scope-success", null); + assertEquals(RecoveryAction.COMPLETED_REDO, completed.coordinator.apply(completed.payload)); + for (FakeMaterializer materializer : completed.materializers) { + assertEquals(1, materializer.scopesStarted); + assertEquals(1, materializer.scopesEnded); + assertFalse(materializer.scopeOpen); + } + + Fixture failed = fixture("scope-failure", + CommonCheckpointRedoCoordinator.Stage.AFTER_CHAINBASE_MATERIALIZE); + assertThrows(IOException.class, () -> failed.coordinator.apply(failed.payload)); + for (FakeMaterializer materializer : failed.materializers) { + assertEquals(1, materializer.scopesStarted); + assertEquals(1, materializer.scopesEnded); + assertFalse(materializer.scopeOpen); + } + } + + @Test + public void closesEveryAuthorityOnceWhenOneCloseFails() throws Exception { + Fixture fixture = fixture("close-failure", null); + fixture.materializers.get(1).closeFailure = true; + + assertThrows(IOException.class, fixture.coordinator::close); + fixture.coordinator.close(); + for (FakeMaterializer materializer : fixture.materializers) { + assertEquals(1, materializer.closed); + } + } + + @Test + public void runtimeOwnerRequiresStartupRecoveryAndGatesReadsAroundApply() throws Exception { + Fixture fixture = fixture("runtime-owner", null); + CommonCheckpointRuntimeOwner owner = new CommonCheckpointRuntimeOwner(fixture.coordinator); + + assertEquals(CommonCheckpointRuntimeOwner.State.NEW, owner.getState()); + assertThrows(IOException.class, () -> owner.read(() -> "unreachable")); + assertEquals(RecoveryAction.NO_CHECKPOINT, owner.recoverBeforeServing()); + assertEquals("ready", owner.read(() -> "ready")); + assertEquals(RecoveryAction.COMPLETED_REDO, owner.apply(fixture.payload)); + assertEquals(CommonCheckpointRuntimeOwner.State.READY, owner.getState()); + assertEquals("published", owner.read(() -> "published")); + owner.close(); + owner.close(); + for (FakeMaterializer materializer : fixture.materializers) { + assertEquals(1, materializer.closed); + } + assertThrows(IOException.class, () -> owner.read(() -> "unreachable")); + } + + @Test + public void runtimeOwnerRequestLeaseBlocksCheckpointUntilClosed() throws Exception { + Fixture fixture = fixture("runtime-owner-request-lease", null); + CommonCheckpointRuntimeOwner owner = new CommonCheckpointRuntimeOwner(fixture.coordinator); + assertEquals(RecoveryAction.NO_CHECKPOINT, owner.recoverBeforeServing()); + + CommonCheckpointRuntimeOwner.ReadLease lease = owner.acquireReadLease(); + CountDownLatch started = new CountDownLatch(1); + ExecutorService executor = Executors.newSingleThreadExecutor(); + Future checkpoint = executor.submit(() -> { + started.countDown(); + return owner.apply(fixture.payload); + }); + try { + assertTrue(started.await(5, TimeUnit.SECONDS)); + assertThrows(TimeoutException.class, + () -> checkpoint.get(100, TimeUnit.MILLISECONDS)); + } finally { + lease.close(); + } + try { + assertEquals(RecoveryAction.COMPLETED_REDO, checkpoint.get(5, TimeUnit.SECONDS)); + } finally { + executor.shutdownNow(); + } + } + + @Test + public void runtimeOwnerFailsClosedThenFreshOwnerRedoesDurableCheckpoint() throws Exception { + Fixture fixture = fixture("runtime-owner-failure", + CommonCheckpointRedoCoordinator.Stage.AFTER_CHAINBASE_MATERIALIZE); + CommonCheckpointRuntimeOwner failed = new CommonCheckpointRuntimeOwner(fixture.coordinator); + assertEquals(RecoveryAction.NO_CHECKPOINT, failed.recoverBeforeServing()); + assertThrows(IOException.class, () -> failed.apply(fixture.payload)); + assertEquals(CommonCheckpointRuntimeOwner.State.FAILED, failed.getState()); + for (FakeMaterializer materializer : fixture.materializers) { + assertEquals(1, materializer.closed); + } + assertThrows(IOException.class, () -> failed.read(() -> "unreachable")); + + CommonCheckpointRuntimeOwner recovered = new CommonCheckpointRuntimeOwner( + fixture.coordinator(null)); + assertEquals(RecoveryAction.COMPLETED_REDO, recovered.recoverBeforeServing()); + assertEquals(CommonCheckpointRuntimeOwner.State.READY, recovered.getState()); + assertEquals("recovered", recovered.read(() -> "recovered")); + } + + @Test + public void startupPublishedValidationRequiresEveryAuthority() throws Exception { + Fixture accepted = fixture("runtime-owner-published", null); + CommonCheckpointTarget target = CommonCheckpointTarget.from(accepted.payload); + for (FakeMaterializer materializer : accepted.materializers) { + materializer.target = target; + materializer.status = Status.PUBLISHED; + } + CommonCheckpointRuntimeOwner owner = new CommonCheckpointRuntimeOwner(accepted.coordinator); + assertEquals(RecoveryAction.NO_CHECKPOINT, owner.recoverBeforeServing()); + owner.requirePublishedBeforeServing(target); + assertEquals(CommonCheckpointRuntimeOwner.State.READY, owner.getState()); + owner.close(); + for (FakeMaterializer materializer : accepted.materializers) { + assertEquals(1, materializer.closed); + } + + Fixture rejected = fixture("runtime-owner-partial-published", null); + CommonCheckpointTarget rejectedTarget = CommonCheckpointTarget.from(rejected.payload); + for (FakeMaterializer materializer : rejected.materializers) { + materializer.target = rejectedTarget; + materializer.status = Status.PUBLISHED; + } + rejected.materializers.get(1).status = Status.MATERIALIZED; + CommonCheckpointRuntimeOwner rejectedOwner = + new CommonCheckpointRuntimeOwner(rejected.coordinator); + assertEquals(RecoveryAction.NO_CHECKPOINT, rejectedOwner.recoverBeforeServing()); + assertThrows(IOException.class, + () -> rejectedOwner.requirePublishedBeforeServing(rejectedTarget)); + assertEquals(CommonCheckpointRuntimeOwner.State.FAILED, rejectedOwner.getState()); + for (FakeMaterializer materializer : rejected.materializers) { + assertEquals(1, materializer.closed); + } + } + + private Fixture fixture(String name, CommonCheckpointRedoCoordinator.Stage failure) { + CommonCheckpointPayload payload = payload(); + CommonCheckpointFile file = new CommonCheckpointFile( + temporaryFolder.getRoot().toPath().resolve(name)); + List actions = new ArrayList<>(); + List materializers = new ArrayList<>(); + for (Authority authority : Authority.values()) { + materializers.add(new FakeMaterializer(authority, actions)); + } + return new Fixture(payload, file, actions, materializers, failure); + } + + private static CommonCheckpointPayload payload() { + BlockSnapshotMeta meta = BlockSnapshotMeta.forBlock(1, hash(1), hash(0), 3_000L); + PathStateFlushTarget.BlockBinding binding = mock(PathStateFlushTarget.BlockBinding.class); + when(binding.getMeta()).thenReturn(meta); + when(binding.getParentStateRoot()).thenReturn(hash(5)); + when(binding.getStateRoot()).thenReturn(hash(6)); + when(binding.getTransitionPayloadDigest()).thenReturn(hash(3)); + PathStateFlushTarget target = mock(PathStateFlushTarget.class); + when(target.getBlocks()).thenReturn(Collections.singletonList(binding)); + when(target.getParentStateRoot()).thenReturn(hash(5)); + when(target.getStateRoot()).thenReturn(hash(6)); + when(target.getStores()).thenReturn(Collections.emptyList()); + when(target.getSuperNodeMutations()).thenReturn(Collections.emptyList()); + return CommonCheckpointPayload.create(hash(7), target, + Collections.singletonList(new BlockReverseDiff(meta, Collections.emptyList())), + Collections.emptyList()); + } + + private static List list(String... values) { + List result = new ArrayList<>(); + Collections.addAll(result, values); + return result; + } + + private static byte[] hash(int seed) { + byte[] hash = new byte[32]; + for (int index = 0; index < hash.length; index++) { + hash[index] = (byte) (seed + index); + } + return hash; + } + + private static final class Fixture { + + private final CommonCheckpointPayload payload; + private final CommonCheckpointFile file; + private final List actions; + private final List materializers; + private final CommonCheckpointRedoCoordinator coordinator; + + private Fixture(CommonCheckpointPayload payload, CommonCheckpointFile file, + List actions, List materializers, + CommonCheckpointRedoCoordinator.Stage failure) { + this.payload = payload; + this.file = file; + this.actions = actions; + this.materializers = materializers; + this.coordinator = coordinator(failure); + } + + private CommonCheckpointRedoCoordinator coordinator( + CommonCheckpointRedoCoordinator.Stage failedStage) { + return new CommonCheckpointRedoCoordinator(file, materializers.get(0), + materializers.get(1), materializers.get(2), failAt(failedStage)); + } + } + + private static CommonCheckpointRedoCoordinator.FaultHook failAt( + CommonCheckpointRedoCoordinator.Stage failedStage) { + return stage -> { + if (stage == failedStage) { + throw new IOException("injected " + stage); + } + }; + } + + private static final class FakeMaterializer implements CommonCheckpointMaterializer { + + private final Authority authority; + private final List actions; + private Status status = Status.NEEDS_MATERIALIZATION; + private CommonCheckpointTarget target; + private boolean advanceAfterMaterialize = true; + private int scopesStarted; + private int scopesEnded; + private int closed; + private boolean closeFailure; + private boolean scopeOpen; + private Runnable afterCommitHook = () -> { }; + + @Override + public void afterCommit(CommonCheckpointTarget expected) { + afterCommitHook.run(); + } + + private FakeMaterializer(Authority authority, List actions) { + this.authority = authority; + this.actions = actions; + } + + @Override + public Authority authority() { + return authority; + } + + @Override + public void beginCheckpoint(CommonCheckpointTarget expected) { + scopesStarted++; + scopeOpen = true; + } + + @Override + public void endCheckpoint(CommonCheckpointTarget expected) { + scopesEnded++; + scopeOpen = false; + } + + @Override + public void close() throws IOException { + closed++; + if (closeFailure) { + throw new IOException("injected close failure"); + } + } + + @Override + public Status inspect(CommonCheckpointTarget expected) throws IOException { + if (target != null && !target.equals(expected)) { + throw new IOException("target mismatch"); + } + return status; + } + + @Override + public void materialize(CommonCheckpointPayload payload, CommonCheckpointTarget expected) { + actions.add("materialize-" + authority); + target = expected; + if (advanceAfterMaterialize) { + status = Status.MATERIALIZED; + } + } + + @Override + public void publish(CommonCheckpointTarget expected) throws IOException { + if (status != Status.MATERIALIZED || !expected.equals(target)) { + throw new IOException("publish without exact materialization"); + } + actions.add("publish-" + authority); + status = Status.PUBLISHED; + } + } +} diff --git a/framework/src/test/java/org/tron/core/db2/core/CommonCheckpointRuntimeAttachmentTest.java b/framework/src/test/java/org/tron/core/db2/core/CommonCheckpointRuntimeAttachmentTest.java new file mode 100644 index 00000000000..5e4c38ceca6 --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/core/CommonCheckpointRuntimeAttachmentTest.java @@ -0,0 +1,150 @@ +package org.tron.core.db2.core; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Collections; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tron.core.db2.core.CommonCheckpointMaterializer.Authority; +import org.tron.core.db2.stateroot.PathStateStoreManifest.Engine; + +public class CommonCheckpointRuntimeAttachmentTest { + + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void everyRuntimeConstructorRequiresMemoryRebaser() { + assertTrue(Arrays.stream(CommonCheckpointRuntime.class.getConstructors()) + .allMatch(constructor -> Arrays.asList(constructor.getParameterTypes()) + .contains(CommonCheckpointMemoryRebaser.class))); + } + + @Test + public void disabledAttachmentDoesNotConstructRuntimeOrCreateDirectory() throws Exception { + Path root = temporaryFolder.getRoot().toPath().resolve("disabled"); + AtomicBoolean invoked = new AtomicBoolean(); + CommonCheckpointRuntimeAttachment attachment = CommonCheckpointRuntimeAttachment.open(false, + () -> { + invoked.set(true); + Files.createDirectories(root); + return runtime(root, mock(Chainbase.class)); + }); + + assertFalse(invoked.get()); + assertFalse(Files.exists(root)); + assertFalse(attachment.isEnabled()); + assertEquals(CommonCheckpointRuntimeAttachment.State.DISABLED, attachment.getState()); + assertThrows(IllegalStateException.class, () -> attachment.checkpointAndRebase(1)); + assertThrows(IllegalStateException.class, () -> attachment.pinPoint(0)); + attachment.close(); + assertEquals(CommonCheckpointRuntimeAttachment.State.CLOSED, attachment.getState()); + assertFalse(Files.exists(root)); + } + + @Test + public void enabledAttachmentRecoversBeforeReadyAndOwnsClose() throws Exception { + Path root = temporaryFolder.getRoot().toPath().resolve("enabled"); + CommonCheckpointRuntime runtime = runtime(root, mock(Chainbase.class)); + CommonCheckpointRuntimeAttachment attachment = CommonCheckpointRuntimeAttachment.open(true, + () -> runtime); + + assertTrue(attachment.isEnabled()); + assertEquals(CommonCheckpointRuntimeAttachment.State.READY, attachment.getState()); + assertEquals(CommonCheckpointRuntimeOwner.State.READY, runtime.getState()); + assertThrows(IOException.class, () -> attachment.pinPoint(0)); + assertEquals(CommonCheckpointRuntimeAttachment.State.READY, attachment.getState()); + attachment.close(); + attachment.close(); + assertEquals(CommonCheckpointRuntimeAttachment.State.CLOSED, attachment.getState()); + assertEquals(CommonCheckpointRuntimeOwner.State.CLOSED, runtime.getState()); + } + + @Test + public void startupAndCheckpointFailuresRemainFailClosed() throws Exception { + Path corruptRoot = temporaryFolder.getRoot().toPath().resolve("corrupt"); + Path wal = corruptRoot.resolve("wal"); + Files.createDirectories(wal); + Files.write(wal.resolve(CommonCheckpointFile.FILE_NAME), new byte[]{1}); + CommonCheckpointRuntime corrupt = runtime(corruptRoot, mock(Chainbase.class)); + assertThrows(IOException.class, + () -> CommonCheckpointRuntimeAttachment.open(true, () -> corrupt)); + assertEquals(CommonCheckpointRuntimeOwner.State.CLOSED, corrupt.getState()); + + Path publishedRoot = temporaryFolder.getRoot().toPath().resolve("published-failure"); + Path archive = publishedRoot.resolve("archive"); + Files.createDirectories(archive); + Files.write(archive.resolve("READABLE"), new byte[]{1}); + CommonCheckpointMaterializer startupChainbase = materializer(Authority.CHAINBASE); + CommonCheckpointMaterializer startupPathState = materializer(Authority.PATH_STATE); + CommonCheckpointMaterializer startupStateArchive = materializer(Authority.STATE_ARCHIVE); + CommonCheckpointRuntime published = runtime(publishedRoot, mock(Chainbase.class), + startupChainbase, startupPathState, startupStateArchive); + assertThrows(IOException.class, + () -> CommonCheckpointRuntimeAttachment.open(true, () -> published)); + verify(startupChainbase).close(); + verify(startupPathState).close(); + verify(startupStateArchive).close(); + assertEquals(CommonCheckpointRuntimeOwner.State.CLOSED, published.getState()); + + Path failureRoot = temporaryFolder.getRoot().toPath().resolve("checkpoint-failure"); + Chainbase database = mock(Chainbase.class); + when(database.getHead()).thenThrow(new IllegalStateException("injected capture failure")); + CommonCheckpointMaterializer chainbase = materializer(Authority.CHAINBASE); + CommonCheckpointMaterializer pathState = materializer(Authority.PATH_STATE); + CommonCheckpointMaterializer stateArchive = materializer(Authority.STATE_ARCHIVE); + CommonCheckpointRuntimeAttachment attachment = CommonCheckpointRuntimeAttachment.open(true, + () -> runtime(failureRoot, database, chainbase, pathState, stateArchive)); + assertThrows(IllegalStateException.class, () -> attachment.checkpointAndRebase(1)); + assertEquals(CommonCheckpointRuntimeAttachment.State.FAILED, attachment.getState()); + verify(chainbase).close(); + verify(pathState).close(); + verify(stateArchive).close(); + assertThrows(IllegalStateException.class, () -> attachment.pinPoint(0)); + attachment.close(); + assertEquals(CommonCheckpointRuntimeAttachment.State.CLOSED, attachment.getState()); + } + + private static CommonCheckpointRuntime runtime(Path root, Chainbase database) { + return runtime(root, database, materializer(Authority.CHAINBASE), + materializer(Authority.PATH_STATE), materializer(Authority.STATE_ARCHIVE)); + } + + private static CommonCheckpointRuntime runtime(Path root, Chainbase database, + CommonCheckpointMaterializer chainbase, CommonCheckpointMaterializer pathState, + CommonCheckpointMaterializer stateArchive) { + CommonCheckpointRedoCoordinator coordinator = new CommonCheckpointRedoCoordinator( + new CommonCheckpointFile(root.resolve("wal")), chainbase, pathState, stateArchive); + return new CommonCheckpointRuntime(new CommonCheckpointRuntimeOwner(coordinator), + Collections.singletonList(database), root.resolve("archive"), hash(1), Engine.LEVELDB, + (blockNumber, blockHash) -> { + throw new IOException("latest state is intentionally unavailable"); + }, target -> () -> { }); + } + + private static CommonCheckpointMaterializer materializer(Authority authority) { + CommonCheckpointMaterializer materializer = mock(CommonCheckpointMaterializer.class); + when(materializer.authority()).thenReturn(authority); + return materializer; + } + + private static byte[] hash(int seed) { + byte[] value = new byte[32]; + for (int index = 0; index < value.length; index++) { + value[index] = (byte) (seed + index); + } + return value; + } +} diff --git a/framework/src/test/java/org/tron/core/db2/core/P66SnapshotManagerIntegrationTest.java b/framework/src/test/java/org/tron/core/db2/core/P66SnapshotManagerIntegrationTest.java new file mode 100644 index 00000000000..b785adf88be --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/core/P66SnapshotManagerIntegrationTest.java @@ -0,0 +1,47 @@ +package org.tron.core.db2.core; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import com.google.common.primitives.Longs; +import org.junit.Test; +import org.tron.common.BaseMethodTest; +import org.tron.core.config.args.Args; +import org.tron.core.config.args.Storage; +import org.tron.core.db2.ISession; +import org.tron.core.store.AccountAssetStore; + +/** Exercises Manager startup registration, fresh bootstrap and Store routing via Spring. */ +public class P66SnapshotManagerIntegrationTest extends BaseMethodTest { + @Override + protected void beforeContext() { + Storage storage = Args.getInstance().getStorage(); + storage.setCommonCheckpointEnabled(true); + storage.setP66SnapshotEnabled(true); + storage.setStateArchiveEnabled(true); + storage.setPathStateRootEnabled(true); + storage.setPathStateRootEngine("LEVELDB"); + storage.setStateArchiveServingIndexEngine("LEVELDB"); + } + + @Test + public void managerBootstrapsPhysicalModeAndRegistersRevocableAccountAssetStore() { + SnapshotManager snapshots = context.getBean(SnapshotManager.class); + AccountAssetStore assets = chainBaseManager.getAccountAssetStore(); + assertEquals(1, snapshots.getDbs().stream() + .filter(db -> "account-asset".equals(db.getDbName())).count()); + byte[] key = new byte[22]; + key[0] = 0x41; + key[21] = '1'; + assertNull(assets.get(key)); + try (ISession session = snapshots.buildSession()) { + assets.put(key, Longs.toByteArray(19)); + assertArrayEquals(Longs.toByteArray(19), assets.get(key)); + assertNull(assets.getFromRoot(key)); + } + assertNull(assets.get(key)); + assertTrue(dbManager.getPathStateSnapshotHead() != null); + } +} diff --git a/framework/src/test/java/org/tron/core/db2/core/P66SnapshotPipelineTest.java b/framework/src/test/java/org/tron/core/db2/core/P66SnapshotPipelineTest.java new file mode 100644 index 00000000000..cbfe5ddd452 --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/core/P66SnapshotPipelineTest.java @@ -0,0 +1,547 @@ +package org.tron.core.db2.core; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.google.common.primitives.Bytes; +import com.google.common.primitives.Longs; +import com.google.protobuf.ByteString; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tron.common.storage.leveldb.LevelDbDataSourceImpl; +import org.tron.core.config.args.Args; +import org.tron.core.db2.ISession; +import org.tron.core.db2.archive.BlockChangeView; +import org.tron.core.db2.archive.BlockReverseDiff; +import org.tron.core.db2.archive.BlockSnapshotMeta; +import org.tron.core.db2.archive.PhysicalSnapshotPathStateCollector; +import org.tron.core.db2.archive.SnapshotOldValueCollector; +import org.tron.core.db2.common.LevelDB; +import org.tron.core.db2.common.WrappedByteArray; +import org.tron.core.db2.stateroot.PathStateBlockTransition; +import org.tron.core.db2.stateroot.PathStateCanonicalizer; +import org.tron.core.db2.stateroot.PathStateCanonicalizer.P66Phase; +import org.tron.core.db2.stateroot.PathStateCheckpointMaterializer; +import org.tron.core.db2.stateroot.PathStateLayerLimits; +import org.tron.core.db2.stateroot.PathStateParticipantScope; +import org.tron.core.db2.stateroot.PathStatePhysicalOverlayHead; +import org.tron.core.db2.stateroot.PathStatePhysicalStoreSet; +import org.tron.core.db2.stateroot.PathStateRoot; +import org.tron.core.db2.stateroot.PathStateRootMetadata; +import org.tron.core.db2.stateroot.PathStateRuntimeAttachment; +import org.tron.core.db2.stateroot.PathStateSnapshotDelta; +import org.tron.core.db2.stateroot.PathStateStoreManifest.Engine; +import org.tron.protos.Protocol.Account; + +public class P66SnapshotPipelineTest { + private static final byte[] ADDRESS = address(1); + private static final byte[] ASSET = Bytes.concat(ADDRESS, new byte[]{'1'}); + private static final byte[] FLAG = "ALLOW_ASSET_OPTIMIZATION" + .getBytes(StandardCharsets.US_ASCII); + + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @BeforeClass + public static void configure() { + Args.setParam(new String[]{"--output-directory", "output_p66_pipeline"}, "config-test.conf"); + } + + @AfterClass + public static void clear() { + Args.clearParam(); + } + + @Test + public void migratesOnceBeforeFreezeAndBothConsumersSeeExactPhysicalValues() throws Exception { + try (Fixture f = fixture()) { + Account before = account(false, 5); + f.accounts.getHead().put(ADDRESS, before.toByteArray()); + AtomicReference archiveView = new AtomicReference<>(); + AtomicReference pathView = new AtomicReference<>(); + CountDownLatch archiveEntered = new CountDownLatch(1); + CountDownLatch pathEntered = new CountDownLatch(1); + f.manager.installArchiveCollector(view -> { + archiveView.set(view); + archiveEntered.countDown(); + await(pathEntered); + return new SnapshotOldValueCollector().collect(view); + }, diff -> { }); + f.manager.attachPathStateRuntime(new PathStateRuntimeAttachment(view -> { + pathView.set(view); + pathEntered.countDown(); + await(archiveEntered); + return new PhysicalSnapshotPathStateCollector().collect(view); + }, transition -> { })); + try (ISession block = f.manager.buildSession()) { + f.accounts.put(ADDRESS, account(false, 9).toByteArray()); + block.commit(meta(1)); + } + assertTrue(archiveView.get() == pathView.get()); + assertEquals(9, Longs.fromByteArray(f.assets.getUnchecked(ASSET))); + Account after = Account.parseFrom(f.accounts.getUnchecked(ADDRESS)); + assertTrue(after.getAssetOptimized()); + assertTrue(after.getAssetV2Map().isEmpty()); + assertNull(f.assets.getHead().getRoot().get(ASSET)); + assertArrayEquals(before.toByteArray(), f.accounts.getHead().getRoot().get(ADDRESS)); + BlockReverseDiff diff = ((SnapshotImpl) f.accounts.getHead()).getPreparedArchiveBlock(); + assertEquals(2, diff.getGroups().size()); + assertArrayEquals(before.toByteArray(), diff.getGroups().stream() + .filter(group -> group.getDbName().equals("account")).findFirst().get() + .getEntries().get(0).getOldValue().getValue()); + f.manager.fastPop(); + assertArrayEquals(before.toByteArray(), f.accounts.getUnchecked(ADDRESS)); + assertNull(f.assets.getUnchecked(ASSET)); + } + } + + @Test + public void activationWithoutChangedAccountsDoesNotScanOrMigrate() throws Exception { + try (Fixture f = fixture()) { + f.properties.getHead().put(FLAG, Longs.toByteArray(0)); + f.accounts.getHead().put(ADDRESS, account(false, 7).toByteArray()); + try (ISession block = f.manager.buildSession()) { + f.properties.put(FLAG, Longs.toByteArray(1)); + P66CoupledMutationMaterializer.Statistics stats = f.materialize(); + assertEquals(0, stats.changedAccounts); + assertEquals(0, stats.prefixQueries); + block.commit(meta(1)); + } + assertFalse(Account.parseFrom(f.accounts.getUnchecked(ADDRESS)).getAssetOptimized()); + assertNull(f.assets.getUnchecked(ASSET)); + } + } + + @Test + public void offEmptyAndOptimizedUnrelatedWritesDoNotQueryAssets() throws Exception { + try (Fixture f = fixture()) { + f.properties.getHead().put(FLAG, Longs.toByteArray(0)); + try (ISession block = f.manager.buildSession()) { + f.properties.put(FLAG, Longs.toByteArray(0)); + f.accounts.put(ADDRESS, account(false, 7).toByteArray()); + assertEquals(0, f.materialize().assetPuts); + assertFalse(Account.parseFrom(f.accounts.getUnchecked(ADDRESS)).getAssetOptimized()); + } + f.properties.getHead().put(FLAG, Longs.toByteArray(1)); + for (boolean optimized : new boolean[]{false, true}) { + try (ISession block = f.manager.buildSession()) { + f.accounts.put(ADDRESS, account(optimized, 0).toBuilder().clearAssetV2() + .setBalance(77).build().toByteArray()); + P66CoupledMutationMaterializer.Statistics stats = f.materialize(); + assertEquals(1, stats.emptyAssetSkips); + assertEquals(0, stats.prefixQueries); + assertEquals(0, stats.assetPuts); + assertEquals(0, stats.assetDeletes); + } + } + } + } + + @Test + public void zeroDeleteAndAccountDeleteRevokeAlongsideNestedTransactionWrites() throws Exception { + try (Fixture f = fixture()) { + f.accounts.getHead().put(ADDRESS, account(true, 0).toBuilder().clearAssetV2() + .build().toByteArray()); + f.assets.getHead().put(ASSET, Longs.toByteArray(11)); + try (ISession block = f.manager.buildSession()) { + try (ISession transaction = f.manager.buildSession()) { + f.accounts.put(ADDRESS, account(true, 0).toByteArray()); + transaction.merge(); + } + assertEquals(1, f.materialize().assetDeletes); + assertNull(f.assets.getUnchecked(ASSET)); + } + assertEquals(11, Longs.fromByteArray(f.assets.getUnchecked(ASSET))); + try (ISession block = f.manager.buildSession()) { + byte[] second = Bytes.concat(ADDRESS, new byte[]{'2'}); + f.assets.put(second, Longs.toByteArray(8)); + f.accounts.delete(ADDRESS); + P66CoupledMutationMaterializer.Statistics stats = f.materialize(); + assertEquals(1, stats.prefixQueries); + assertEquals(2, stats.assetDeletes); + assertTrue(f.assets.prefixQuery(ADDRESS).isEmpty()); + } + assertEquals(11, Longs.fromByteArray(f.assets.getUnchecked(ASSET))); + } + } + + @Test + public void failedParallelBranchNeverAttachesOrPublishesAndJoinsBeforeRevoke() throws Exception { + try (Fixture f = fixture()) { + CountDownLatch pathEntered = new CountDownLatch(1); + AtomicInteger published = new AtomicInteger(); + f.manager.installArchiveCollector(view -> { + await(pathEntered); + throw new IllegalStateException("injected archive failure"); + }, diff -> { }); + f.manager.attachPathStateRuntime(new PathStateRuntimeAttachment(view -> { + pathEntered.countDown(); + return new PhysicalSnapshotPathStateCollector().collect(view); + }, transition -> published.incrementAndGet())); + try (ISession block = f.manager.buildSession()) { + f.accounts.put(ADDRESS, account(false, 9).toByteArray()); + assertThrows(IllegalStateException.class, () -> block.commit(meta(1))); + assertNull(((SnapshotImpl) f.accounts.getHead()).getBlockSnapshotMeta()); + assertEquals(0, published.get()); + } + assertNull(f.accounts.getUnchecked(ADDRESS)); + assertNull(f.assets.getUnchecked(ASSET)); + } + } + + @Test + public void interruptedJoinWaitsForReaderThenRevokesAndPreservesInterrupt() throws Exception { + try (Fixture f = fixture()) { + CountDownLatch archiveEntered = new CountDownLatch(1); + CountDownLatch releaseArchive = new CountDownLatch(1); + CountDownLatch finished = new CountDownLatch(1); + AtomicReference unexpected = new AtomicReference<>(); + f.manager.installArchiveCollector(view -> { + archiveEntered.countDown(); + await(releaseArchive); + return new SnapshotOldValueCollector().collect(view); + }, diff -> { }); + f.manager.attachPathStateRuntime(new PathStateRuntimeAttachment( + new PhysicalSnapshotPathStateCollector(), transition -> { + throw new AssertionError("must not publish interrupted block"); + })); + Thread committer = new Thread(() -> { + try (ISession block = f.manager.buildSession()) { + f.accounts.put(ADDRESS, account(false, 9).toByteArray()); + assertThrows(IllegalStateException.class, () -> block.commit(meta(1))); + assertTrue(Thread.currentThread().isInterrupted()); + } catch (Throwable failure) { + unexpected.set(failure); + } finally { + finished.countDown(); + } + }); + committer.start(); + try { + assertTrue(archiveEntered.await(5, TimeUnit.SECONDS)); + committer.interrupt(); + assertFalse("must retain Snapshot until reader exits", finished.await(100, + TimeUnit.MILLISECONDS)); + } finally { + releaseArchive.countDown(); + committer.join(5000); + } + assertFalse(committer.isAlive()); + assertNull(unexpected.get()); + assertNull(f.assets.getUnchecked(ASSET)); + assertNull(f.accounts.getUnchecked(ADDRESS)); + } + } + + @Test + public void malformedDeletionCannotTurnIntoUnboundedAssetPrefixScan() throws Exception { + try (Fixture f = fixture(); ISession block = f.manager.buildSession()) { + f.accounts.delete(new byte[0]); + assertThrows(IllegalStateException.class, f::materialize); + } + } + + @Test + public void accountAssetStoreUsesSnapshotForReadsWritesPrefixesAndRevoke() throws Exception { + String oldOutput = Args.getInstance().getOutputDirectory(); + Args.getInstance().outputDirectory = temporaryFolder.newFolder().toString(); + SnapshotManager manager = new SnapshotManager(""); + Path path = temporaryFolder.newFolder().toPath(); + Chainbase accounts = Fixture.database(path, "account"); + Chainbase properties = Fixture.database(path, "properties"); + TestAssetStore assetStore = new TestAssetStore(); + try { + manager.add(accounts); + manager.add(properties); + assetStore.enableSnapshots(manager); + properties.put(FLAG, Longs.toByteArray(1)); + manager.enable(); + try (ISession block = manager.buildSession()) { + accounts.put(ADDRESS, account(false, 17).toByteArray()); + block.commit(meta(1)); + } + assertEquals(17, assetStore.getBalance(Account.parseFrom(accounts.getUnchecked(ADDRESS)), + new byte[]{'1'})); + assertEquals(1, assetStore.prefixQuery(ADDRESS).size()); + assertNull(assetStore.getFromRoot(ASSET)); + try (ISession pending = manager.buildSession()) { + assetStore.updateByBatch(Collections.singletonMap(ASSET, Longs.toByteArray(23))); + assertEquals(23, Longs.fromByteArray(assetStore.get(ASSET))); + } + assertEquals(17, Longs.fromByteArray(assetStore.get(ASSET))); + assertThrows(IllegalStateException.class, () -> assetStore.updateByBatchSynced( + Collections.singletonMap(ASSET, Longs.toByteArray(44)))); + manager.fastPop(); + assertNull(assetStore.get(ASSET)); + assertTrue(assetStore.prefixQuery(ADDRESS).isEmpty()); + } finally { + manager.shutdown(); + accounts.close(); + properties.close(); + assetStore.close(); + Args.getInstance().outputDirectory = oldOutput; + } + } + + @Test + public void pathFailureStillJoinsArchiveBeforeReleasingFrozenSnapshot() throws Exception { + try (Fixture f = fixture()) { + CountDownLatch archiveEntered = new CountDownLatch(1); + CountDownLatch pathFailed = new CountDownLatch(1); + AtomicInteger archiveFinished = new AtomicInteger(); + f.manager.installArchiveCollector(view -> { + archiveEntered.countDown(); + await(pathFailed); + BlockReverseDiff result = new SnapshotOldValueCollector().collect(view); + archiveFinished.incrementAndGet(); + return result; + }, diff -> { }); + f.manager.attachPathStateRuntime(new PathStateRuntimeAttachment(view -> { + await(archiveEntered); + pathFailed.countDown(); + throw new IllegalStateException("injected PathState failure"); + }, transition -> { + throw new AssertionError("must not publish"); + })); + try (ISession block = f.manager.buildSession()) { + f.accounts.put(ADDRESS, account(false, 9).toByteArray()); + assertThrows(IllegalStateException.class, () -> block.commit(meta(1))); + assertEquals(1, archiveFinished.get()); + assertNull(((SnapshotImpl) f.accounts.getHead()).getBlockSnapshotMeta()); + } + assertNull(f.assets.getUnchecked(ASSET)); + } + } + + private static final class TestAssetStore extends org.tron.core.store.AccountAssetStore { + private TestAssetStore() { + super("account-asset"); + } + } + + @Test + public void realPathStateForkRewindCheckpointAndNativeReopenMatchPhysicalOracle() + throws Exception { + Path path = temporaryFolder.newFolder().toPath(); + Path oraclePath = temporaryFolder.newFolder().toPath(); + Path checkpoint = temporaryFolder.newFolder().toPath(); + PathStateParticipantScope scope = new PathStateCanonicalizer().participantScope(); + PathStateRootMetadata baselineHead; + byte[] format = CommonCheckpointFormat.identity(); + try (PathStatePhysicalStoreSet stores = PathStatePhysicalStoreSet.open(path, scope, + Engine.LEVELDB)) { + PathStateRoot root = stores.createRoot(); + root.put("properties", FLAG, Longs.toByteArray(1)); + stores.persistFlatSnapshot(root); + } + try (PathStatePhysicalStoreSet stores = PathStatePhysicalStoreSet.open(path, scope, + Engine.LEVELDB)) { + PathStateRoot root = stores.buildRootFromFlat(); + baselineHead = PathStateRootMetadata.base(0, addressHash(0), addressHash(0), 0, + P66Phase.P66_ON, stores.getFormatDigest(), root.rootHash(), new byte[32]); + stores.publishCurrent(baselineHead); + } + CommonCheckpointBaseline baseline = new CommonCheckpointBaseline(format, + BlockSnapshotMeta.forBlock(0, addressHash(0), addressHash(0), 0), + baselineHead.getStateRoot()); + byte[] expected; + CommonCheckpointTarget target; + try (Fixture f = fixture(); + PathStatePhysicalOverlayHead head = PathStatePhysicalOverlayHead.open(path, + Engine.LEVELDB, new PathStateLayerLimits(16, 16L << 20))) { + head.admitFreshCommonBaseline(baseline); + PathStateRuntimeAttachment attachment = PathStateRuntimeAttachment.commonCheckpoint( + new PhysicalSnapshotPathStateCollector(), transition -> head.advance(transition), + head::preview, head::prepareSnapshotDelta); + attachment.synchronizeReadyHead(baselineHead); + f.manager.attachPathStateRuntime(attachment); + f.manager.installArchiveCollector(new SnapshotOldValueCollector(), diff -> { }); + try (ISession block = f.manager.buildSession()) { + f.accounts.put(ADDRESS, account(false, 9).toByteArray()); + block.commit(meta(1)); + } + f.manager.fastPop(); + attachment.synchronizeReadyHead(head.rewindTo(0, addressHash(0))); + BlockSnapshotMeta fork = BlockSnapshotMeta.forBlock(1, addressHash(7), addressHash(0), 3000); + try (ISession block = f.manager.buildSession()) { + f.accounts.put(ADDRESS, account(false, 13).toByteArray()); + block.commit(fork); + } + try (PathStatePhysicalStoreSet oracle = PathStatePhysicalStoreSet.open(oraclePath, scope, + Engine.LEVELDB)) { + PathStateRoot root = oracle.createRoot(); + root.put("properties", FLAG, Longs.toByteArray(1)); + root.put("account", ADDRESS, f.accounts.getUnchecked(ADDRESS)); + root.put("account-asset", ASSET, Longs.toByteArray(13)); + expected = root.rootHash(); + } + assertArrayEquals(expected, head.getHead().getStateRoot()); + PathStateSnapshotDelta attached = ((SnapshotImpl) f.accounts.getHead()) + .getPreparedPathStateDelta(); + assertArrayEquals(expected, attached.getStateRoot()); + CommonCheckpointPayload payload = new CommonCheckpointPayloadFactory().capture(format, + f.manager.getDbs(), 1); + new CommonCheckpointFile(checkpoint).publish(payload); + target = CommonCheckpointTarget.from(payload); + ChainbaseCheckpointMaterializer chain = new ChainbaseCheckpointMaterializer( + checkpoint.resolve("chain"), format, f.manager.getDbs(), baseline); + PathStateCheckpointMaterializer materializer = head.checkpointMaterializer(format, baseline); + chain.materialize(payload, target); + materializer.materialize(payload, target); + chain.publish(target); + materializer.publish(target); + head.prepareCommonCheckpointRebase(target).apply(); + new CommonCheckpointSnapshotRebaser().prepare(f.manager.getDbs(), target, 1).apply(); + assertEquals(13, Longs.fromByteArray(f.assets.getHead().getRoot().get(ASSET))); + } + try (PathStatePhysicalOverlayHead reopened = PathStatePhysicalOverlayHead.openCommonCheckpoint( + path, Engine.LEVELDB, new PathStateLayerLimits(16, 16L << 20), 1L << 20, 2, 2, + format, target.getLastBlock(), P66Phase.P66_ON)) { + assertArrayEquals(expected, reopened.getHead().getStateRoot()); + } + } + + @Test + public void checkpointCapturesBothStoresAndReplaysAfterPartialNativeWrite() throws Exception { + Path checkpoint = temporaryFolder.newFolder().toPath(); + CommonCheckpointPayload payload; + try (Fixture f = fixture()) { + f.manager.installArchiveCollector(new SnapshotOldValueCollector(), diff -> { }); + f.manager.attachPathStateRuntime(new PathStateRuntimeAttachment( + new PhysicalSnapshotPathStateCollector(), transition -> { }, (number, hash) -> { }, + null, (meta, transition) -> delta(meta, transition))); + try (ISession block = f.manager.buildSession()) { + f.accounts.put(ADDRESS, account(false, 9).toByteArray()); + block.commit(meta(1)); + } + payload = new CommonCheckpointPayloadFactory().capture(CommonCheckpointFormat.identity(), + f.manager.getDbs(), 1); + assertTrue(payload.getChainbaseStores().stream() + .anyMatch(store -> store.getDbName().equals("account-asset"))); + CommonCheckpointFile wal = new CommonCheckpointFile(checkpoint); + wal.publish(payload); + // Account durable, AccountAsset still absent: emulate interruption between Store batches. + Map accountBatch = new LinkedHashMap<>(); + accountBatch.put(WrappedByteArray.of(ADDRESS), + WrappedByteArray.of(f.accounts.getUnchecked(ADDRESS))); + ((SnapshotRoot) f.accounts.getHead().getRoot()).applyCheckpointMutations(accountBatch); + assertNull(f.assets.getHead().getRoot().get(ASSET)); + f.manager.fastPop(); + ChainbaseCheckpointMaterializer materializer = new ChainbaseCheckpointMaterializer( + checkpoint.resolve("chainbase"), CommonCheckpointFormat.identity(), + f.manager.getDbs()); + CommonCheckpointPayload loaded = wal.loadRequired(); + CommonCheckpointTarget target = CommonCheckpointTarget.from(loaded); + materializer.materialize(loaded, target); + materializer.publish(target); + assertEquals(9, Longs.fromByteArray(f.assets.getUnchecked(ASSET))); + materializer.materialize(loaded, target); + assertEquals(CommonCheckpointMaterializer.Status.PUBLISHED, materializer.inspect(target)); + ChainbaseCheckpointMaterializer.loadPublishedHead( + checkpoint.resolve("chainbase"), CommonCheckpointFormat.identity()); + assertThrows(java.io.IOException.class, () -> ChainbaseCheckpointMaterializer + .loadPublishedHead(checkpoint.resolve("chainbase"), addressHash(7))); + } + } + + private static PathStateSnapshotDelta delta(BlockSnapshotMeta meta, + PathStateBlockTransition transition) { + PathStateSnapshotDelta delta = mock(PathStateSnapshotDelta.class); + when(delta.getMeta()).thenReturn(meta); + when(delta.getParentStateRoot()).thenReturn(new byte[32]); + when(delta.getStateRoot()).thenReturn(addressHash(3)); + when(delta.getTransitionPayloadDigest()).thenReturn(transition.getPayloadDigest()); + when(delta.getStores()).thenReturn(Collections.emptyList()); + when(delta.getSuperNodeMutations()).thenReturn(Collections.emptyList()); + return delta; + } + + private Fixture fixture() throws Exception { + return new Fixture(temporaryFolder.newFolder().toPath()); + } + + private static void await(CountDownLatch latch) { + try { + assertTrue("both branches must run concurrently", latch.await(5, TimeUnit.SECONDS)); + } catch (InterruptedException failure) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(failure); + } + } + + private static byte[] address(int seed) { + byte[] bytes = new byte[21]; + bytes[0] = 0x41; + bytes[20] = (byte) seed; + return bytes; + } + + private static byte[] addressHash(int seed) { + byte[] bytes = new byte[32]; + bytes[31] = (byte) seed; + return bytes; + } + + private static BlockSnapshotMeta meta(int number) { + return BlockSnapshotMeta.forBlock(number, addressHash(number), addressHash(number - 1), + number * 3000L); + } + + private static Account account(boolean optimized, long balance) { + return Account.newBuilder().setAddress(ByteString.copyFrom(ADDRESS)) + .setAssetOptimized(optimized).putAssetV2("1", balance).build(); + } + + private static final class Fixture implements AutoCloseable { + private final SnapshotManager manager = new SnapshotManager(""); + private final Chainbase accounts; + private final Chainbase assets; + private final Chainbase properties; + + private Fixture(Path path) { + accounts = database(path, "account"); + assets = database(path, "account-asset"); + properties = database(path, "properties"); + manager.add(accounts); + manager.add(properties); + manager.installP66SnapshotLane(assets); + properties.getHead().put(FLAG, Longs.toByteArray(1)); + manager.enable(); + } + + private P66CoupledMutationMaterializer.Statistics materialize() { + return new P66CoupledMutationMaterializer(accounts, assets, properties).materialize(); + } + + private static Chainbase database(Path path, String name) { + return new Chainbase(new SnapshotRoot(new LevelDB( + new LevelDbDataSourceImpl(path.toString(), name)))); + } + + @Override + public void close() { + manager.shutdown(); + accounts.close(); + assets.close(); + properties.close(); + } + } +} diff --git a/framework/src/test/java/org/tron/core/db2/core/P66SnapshotRecoveryTest.java b/framework/src/test/java/org/tron/core/db2/core/P66SnapshotRecoveryTest.java new file mode 100644 index 00000000000..7f9f9b488a1 --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/core/P66SnapshotRecoveryTest.java @@ -0,0 +1,52 @@ +package org.tron.core.db2.core; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import com.google.common.primitives.Bytes; +import com.google.common.primitives.Longs; +import com.google.protobuf.ByteString; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import org.junit.Test; +import org.tron.common.BaseMethodTest; +import org.tron.core.db2.common.WrappedByteArray; +import org.tron.core.store.AccountAssetStore; +import org.tron.protos.Protocol.Account; + +public class P66SnapshotRecoveryTest extends BaseMethodTest { + + @Test + public void legacyAccountRedoCompletesBeforePhysicalSnapshotWritesTakeOver() throws Exception { + SnapshotManager manager = context.getBean(SnapshotManager.class); + AccountAssetStore assets = chainBaseManager.getAccountAssetStore(); + Chainbase accounts = manager.getDbs().stream() + .filter(db -> "account".equals(db.getDbName())).findFirst().get(); + Chainbase properties = manager.getDbs().stream() + .filter(db -> "properties".equals(db.getDbName())).findFirst().get(); + properties.getHead().getRoot().put("ALLOW_ASSET_OPTIMIZATION" + .getBytes(StandardCharsets.US_ASCII), Longs.toByteArray(1)); + assets.enableSnapshots(manager, true); + byte[] address = new byte[21]; + address[0] = 0x41; + address[20] = 99; + byte[] asset = Bytes.concat(address, new byte[]{'1'}); + Account oldWalAccount = Account.newBuilder().setAddress(ByteString.copyFrom(address)) + .putAssetV2("1", 23).build(); + SnapshotRoot root = (SnapshotRoot) accounts.getHead().getRoot(); + root.applyCheckpointMutations(Collections.singletonMap(WrappedByteArray.of(address), + WrappedByteArray.of(oldWalAccount.toByteArray()))); + Account persisted = Account.parseFrom(root.get(address)); + assertTrue(persisted.getAssetOptimized()); + assertEquals(0, persisted.getAssetV2Count()); + assertEquals(23, Longs.fromByteArray(assets.getFromRoot(asset))); + // Replaying the same pre-Snapshot WAL must remain idempotent. + root.applyCheckpointMutations(Collections.singletonMap(WrappedByteArray.of(address), + WrappedByteArray.of(oldWalAccount.toByteArray()))); + assertEquals(23, Longs.fromByteArray(assets.getFromRoot(asset))); + assets.finishSnapshotRecovery(manager); + assertThrows(IllegalStateException.class, + () -> assets.updateByBatchSynced(Collections.singletonMap(asset, Longs.toByteArray(24)))); + } +} diff --git a/framework/src/test/java/org/tron/core/db2/core/SnapshotManagerCommonCheckpointRecoveryTest.java b/framework/src/test/java/org/tron/core/db2/core/SnapshotManagerCommonCheckpointRecoveryTest.java new file mode 100644 index 00000000000..8681e15e93c --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/core/SnapshotManagerCommonCheckpointRecoveryTest.java @@ -0,0 +1,31 @@ +package org.tron.core.db2.core; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +public class SnapshotManagerCommonCheckpointRecoveryTest { + + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void legacyRecoveryStopsAfterCommonAuthorityIsDurable() throws Exception { + Path directory = temporaryFolder.newFolder("common-checkpoint").toPath(); + assertFalse(SnapshotManager.hasDurableCommonCheckpointAuthority(true, directory)); + + Path redo = directory.resolve(CommonCheckpointFile.FILE_NAME); + Files.write(redo, new byte[] {1}); + assertTrue(SnapshotManager.hasDurableCommonCheckpointAuthority(true, directory)); + assertFalse(SnapshotManager.hasDurableCommonCheckpointAuthority(false, directory)); + + Files.delete(redo); + Files.write(directory.resolve(ChainbaseCheckpointMaterializer.CURRENT_FILE), new byte[] {1}); + assertTrue(SnapshotManager.hasDurableCommonCheckpointAuthority(true, directory)); + } +} diff --git a/framework/src/test/java/org/tron/core/db2/stateroot/PathMerkleTrieTest.java b/framework/src/test/java/org/tron/core/db2/stateroot/PathMerkleTrieTest.java new file mode 100644 index 00000000000..2bbe4c0da82 --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/stateroot/PathMerkleTrieTest.java @@ -0,0 +1,551 @@ +package org.tron.core.db2.stateroot; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import org.bouncycastle.util.encoders.Hex; +import org.junit.Test; +import org.tron.common.crypto.Hash; +import org.tron.core.trie.TrieImpl; + +public class PathMerkleTrieTest { + + @Test + public void emptyAndSingleLeafMatchIndependentTrieOracle() { + InMemoryPathNodeStore store = new InMemoryPathNodeStore(); + PathMerkleTrie trie = new PathMerkleTrie(store); + assertArrayEquals(Hash.EMPTY_TRIE_HASH, trie.rootHash()); + assertTrue(store.nodes.isEmpty()); + + byte[] key = filledKey(0x11); + byte[] value = Hex.decode("c20180"); + trie.put(key, value); + + assertArrayEquals(referenceRoot(new byte[][]{key}, new byte[][]{value}), trie.rootHash()); + assertArrayEquals(value, trie.get(key)); + assertEquals(1, trie.size()); + assertTrue(store.nodes.containsKey("")); + } + + @Test + public void mutationOrderProducesSameRootAndPathNodeSet() { + byte[][] keys = {filledKey(0x11), keyWithTail(0x11, 0x12), filledKey(0x21), + keyWithTail(0x21, 0x22)}; + byte[][] values = {value("one"), value("two"), value("three"), value("four")}; + + InMemoryPathNodeStore forwardStore = new InMemoryPathNodeStore(); + PathMerkleTrie forward = new PathMerkleTrie(forwardStore); + for (int i = 0; i < keys.length; i++) { + forward.put(keys[i], values[i]); + } + + InMemoryPathNodeStore reverseStore = new InMemoryPathNodeStore(); + PathMerkleTrie reverse = new PathMerkleTrie(reverseStore); + for (int i = keys.length - 1; i >= 0; i--) { + reverse.put(keys[i], values[i]); + } + + byte[] expected = referenceRoot(keys, values); + assertArrayEquals( + Hex.decode("dc1c7bfcacb455baeca2454d8aedbe4b17da4a66364fbc0baa7e5919cacf2bdc"), + expected); + assertArrayEquals(expected, forward.rootHash()); + assertArrayEquals(expected, reverse.rootHash()); + assertNodeMapsEqual(forwardStore.nodes, reverseStore.nodes); + } + + @Test + public void updateAndDeleteCompressCanonicalPaths() { + byte[] first = filledKey(0x33); + byte[] second = keyWithTail(0x33, 0x34); + byte[] third = filledKey(0x44); + InMemoryPathNodeStore store = new InMemoryPathNodeStore(); + PathMerkleTrie trie = new PathMerkleTrie(store); + trie.put(first, value("first")); + trie.put(second, value("second")); + trie.put(third, value("third")); + trie.rootHash(); + int expandedNodeCount = store.nodes.size(); + + trie.put(first, value("updated")); + trie.delete(second); + byte[] expected = referenceRoot(new byte[][]{first, third}, + new byte[][]{value("updated"), value("third")}); + assertArrayEquals(expected, trie.rootHash()); + assertNull(trie.get(second)); + assertTrue(store.nodes.size() < expandedNodeCount); + + trie.delete(first); + trie.delete(third); + assertArrayEquals(Hash.EMPTY_TRIE_HASH, trie.rootHash()); + assertTrue(store.nodes.isEmpty()); + } + + @Test + public void rejectsInvalidKeysAndEmptyValues() { + PathMerkleTrie trie = new PathMerkleTrie(new InMemoryPathNodeStore()); + assertThrows(NullPointerException.class, () -> new PathMerkleTrie(null)); + assertThrows(IllegalArgumentException.class, () -> trie.put(new byte[31], value("x"))); + assertThrows(IllegalArgumentException.class, + () -> trie.put(new byte[PathMerkleTrie.SECURE_KEY_LENGTH], new byte[0])); + assertThrows(NullPointerException.class, () -> trie.delete(null)); + } + + @Test + public void rejectsNonCanonicalRlpWithoutDecodeReencoding() { + InMemoryPathNodeStore store = new InMemoryPathNodeStore(); + byte[] nonCanonicalLeaf = new byte[37]; + nonCanonicalLeaf[0] = (byte) 0xe4; + nonCanonicalLeaf[1] = (byte) 0xa1; + nonCanonicalLeaf[2] = 0x20; + nonCanonicalLeaf[35] = (byte) 0x81; + nonCanonicalLeaf[36] = 0x01; + store.nodes.put("", nonCanonicalLeaf); + + PathMerkleTrie restored = new PathMerkleTrie(store); + restored.restoreRoot(); + assertThrows(IllegalStateException.class, () -> restored.get(new byte[32])); + } + + @Test + public void detectsMissingCorruptAndDirtyCommittedNodes() { + InMemoryPathNodeStore corruptStore = new InMemoryPathNodeStore(); + PathMerkleTrie corruptTrie = new PathMerkleTrie(corruptStore); + corruptTrie.put(filledKey(0x55), value("value")); + corruptTrie.rootHash(); + corruptTrie.verifyNodeStore(); + corruptStore.nodes.put("", new byte[]{1}); + assertThrows(IllegalStateException.class, corruptTrie::verifyNodeStore); + + InMemoryPathNodeStore missingStore = new InMemoryPathNodeStore(); + PathMerkleTrie missingTrie = new PathMerkleTrie(missingStore); + missingTrie.put(filledKey(0x66), value("value")); + missingTrie.rootHash(); + missingStore.nodes.remove(""); + assertThrows(IllegalStateException.class, missingTrie::verifyNodeStore); + + PathMerkleTrie dirtyTrie = new PathMerkleTrie(new InMemoryPathNodeStore()); + dirtyTrie.put(filledKey(0x77), value("value")); + assertThrows(IllegalStateException.class, dirtyTrie::verifyNodeStore); + } + + @Test + public void singleLeafUpdateRewritesOnlyItsMaterializedPath() { + int leafCount = 32; + byte[][] keys = new byte[leafCount][]; + byte[][] values = new byte[leafCount][]; + InMemoryPathNodeStore store = new InMemoryPathNodeStore(); + PathMerkleTrie trie = new PathMerkleTrie(store); + for (int i = 0; i < leafCount; i++) { + keys[i] = filledKey(i); + values[i] = value("value-" + i); + trie.put(keys[i], values[i]); + } + trie.rootHash(); + int fullNodeCount = store.nodes.size(); + + values[17] = value("updated"); + trie.put(keys[17], values[17]); + + assertArrayEquals(referenceRoot(keys, values), trie.rootHash()); + assertTrue(fullNodeCount > 3); + assertEquals(3, trie.getLastNodePuts()); + assertEquals(3, trie.getLastNodeDeletes()); + trie.verifyNodeStore(); + } + + @Test + public void restoresRootAndLoadsOnlyTheChangedPath() { + int leafCount = 1_000; + byte[][] keys = new byte[leafCount][]; + byte[][] values = new byte[leafCount][]; + InMemoryPathNodeStore sourceStore = new InMemoryPathNodeStore(); + PathMerkleTrie source = new PathMerkleTrie(sourceStore); + for (int index = 0; index < leafCount; index++) { + keys[index] = indexedKey(index); + values[index] = value("value-" + index); + source.put(keys[index], values[index]); + } + byte[] originalRoot = source.rootHash(); + + InMemoryPathNodeStore lazyStore = new InMemoryPathNodeStore(); + lazyStore.nodes.putAll(sourceStore.nodes); + PathMerkleTrie restored = new PathMerkleTrie(lazyStore); + restored.restoreRoot(originalRoot); + assertEquals(1, lazyStore.gets); + assertArrayEquals(values[517], restored.get(keys[517])); + assertTrue(lazyStore.gets <= PathMerkleTrie.SECURE_KEY_LENGTH * 2 + 2); + + values[517] = value("updated-lazily"); + restored.put(keys[517], values[517]); + assertArrayEquals(referenceRoot(keys, values), restored.rootHash()); + assertTrue(restored.getLastNodePuts() <= PathMerkleTrie.SECURE_KEY_LENGTH * 2 + 1); + } + + @Test + public void leavesHashedSiblingsUnresolvedUntilTheirPathIsVisited() { + byte[][] keys = new byte[16][]; + byte[][] values = new byte[16][]; + InMemoryPathNodeStore sourceStore = new InMemoryPathNodeStore(); + PathMerkleTrie source = new PathMerkleTrie(sourceStore); + for (int index = 0; index < keys.length; index++) { + keys[index] = filledKey(index << 4); + values[index] = value("hashed-child-value-that-is-long-enough-" + index); + source.put(keys[index], values[index]); + } + byte[] root = source.rootHash(); + + InMemoryPathNodeStore restoredStore = new InMemoryPathNodeStore(); + restoredStore.nodes.putAll(sourceStore.nodes); + PathMerkleTrie restored = new PathMerkleTrie(restoredStore); + restored.restoreRoot(root); + int readsAfterRoot = restoredStore.gets; + String corruptSibling = Hex.toHexString(new byte[]{(byte) 0x0a}); + assertTrue(restoredStore.nodes.containsKey(corruptSibling)); + restoredStore.nodes.put(corruptSibling, new byte[]{1}); + + assertArrayEquals(values[3], restored.get(keys[3])); + assertEquals(readsAfterRoot + 1, restoredStore.gets); + assertEquals(16, restored.getHashReferenceCreateCount()); + assertEquals(1, restored.getHashReferenceResolveCount()); + assertThrows(IllegalStateException.class, restored::verifyAllNodeStore); + } + + @Test + public void lazyDenseBranchHasAStableEagerReadBaseline() { + byte[][] keys = new byte[16][]; + byte[][] values = new byte[16][]; + InMemoryPathNodeStore sourceStore = new InMemoryPathNodeStore(); + PathMerkleTrie source = new PathMerkleTrie(sourceStore); + for (int index = 0; index < keys.length; index++) { + keys[index] = filledKey(index << 4); + values[index] = value("hashed-child-value-that-is-long-enough-" + index); + source.put(keys[index], values[index]); + } + byte[] root = source.rootHash(); + + InMemoryPathNodeStore eagerStore = new InMemoryPathNodeStore(); + eagerStore.nodes.putAll(copyNodeMap(sourceStore.nodes)); + PathMerkleTrie eager = new PathMerkleTrie(eagerStore, false); + eager.restoreRoot(root); + assertArrayEquals(values[3], eager.get(keys[3])); + + InMemoryPathNodeStore lazyStore = new InMemoryPathNodeStore(); + lazyStore.nodes.putAll(copyNodeMap(sourceStore.nodes)); + PathMerkleTrie lazy = new PathMerkleTrie(lazyStore); + lazy.restoreRoot(root); + assertArrayEquals(values[3], lazy.get(keys[3])); + + assertEquals(17, eagerStore.gets); + assertEquals(2, lazyStore.gets); + assertEquals(17, eager.getNodeHashVerifyCount()); + assertEquals(2, lazy.getNodeHashVerifyCount()); + assertEquals(eager.getNodeDecodeCount(), lazy.getNodeDecodeCount()); + assertEquals(16, lazy.getHashReferenceCreateCount()); + assertEquals(1, lazy.getHashReferenceResolveCount()); + + assertArrayEquals(values[3], lazy.get(keys[3])); + assertEquals(2, lazyStore.gets); + assertEquals(2, lazy.getNodeHashVerifyCount()); + assertEquals(1, lazy.getHashReferenceResolveCount()); + } + + @Test + public void lazyRestoreProducesTheSameUpdatedPathNodeSet() { + int leafCount = 128; + byte[][] keys = new byte[leafCount][]; + byte[][] values = new byte[leafCount][]; + InMemoryPathNodeStore sourceStore = new InMemoryPathNodeStore(); + PathMerkleTrie source = new PathMerkleTrie(sourceStore); + for (int index = 0; index < leafCount; index++) { + keys[index] = Hash.sha3(value("key-" + index)); + values[index] = value("initial-value-" + index); + source.put(keys[index], values[index]); + } + byte[] initialRoot = source.rootHash(); + Map initialNodes = copyNodeMap(sourceStore.nodes); + + values[17] = value("updated-value"); + InMemoryPathNodeStore eagerStore = new InMemoryPathNodeStore(); + eagerStore.nodes.putAll(copyNodeMap(initialNodes)); + PathMerkleTrie eager = new PathMerkleTrie(eagerStore, false); + eager.restoreRoot(initialRoot); + eager.put(keys[17], values[17]); + eager.delete(keys[33]); + byte[] expectedRoot = eager.rootHash(); + + InMemoryPathNodeStore lazyStore = new InMemoryPathNodeStore(); + lazyStore.nodes.putAll(initialNodes); + PathMerkleTrie lazy = new PathMerkleTrie(lazyStore); + lazy.restoreRoot(initialRoot); + lazy.put(keys[17], values[17]); + lazy.delete(keys[33]); + + assertArrayEquals(expectedRoot, lazy.rootHash()); + assertNodeMapsEqual(eagerStore.nodes, lazyStore.nodes); + } + + @Test + public void prefixBatchMatchesSequentialMixedChangesFromLazyRoot() { + int leafCount = 512; + byte[][] keys = new byte[leafCount][]; + byte[][] values = new byte[leafCount][]; + InMemoryPathNodeStore sourceStore = new InMemoryPathNodeStore(); + PathMerkleTrie source = new PathMerkleTrie(sourceStore); + for (int index = 0; index < leafCount; index++) { + keys[index] = Hash.sha3(value("batch-key-" + index)); + values[index] = value("batch-value-" + index); + source.put(keys[index], values[index]); + } + byte[] initialRoot = source.rootHash(); + Map initialNodes = copyNodeMap(sourceStore.nodes); + + InMemoryPathNodeStore sequentialStore = new InMemoryPathNodeStore(); + sequentialStore.nodes.putAll(copyNodeMap(initialNodes)); + PathMerkleTrie sequential = new PathMerkleTrie(sequentialStore); + sequential.restoreRoot(initialRoot); + List changes = new ArrayList<>(); + for (int index = 0; index < 40; index++) { + byte[] updated = value("batch-updated-" + index); + sequential.put(keys[index], updated); + changes.add(new PathMerkleTrie.BatchMutation(keys[index], updated)); + } + for (int index = 40; index < 60; index++) { + sequential.delete(keys[index]); + changes.add(new PathMerkleTrie.BatchMutation(keys[index], null)); + } + for (int index = 0; index < 40; index++) { + byte[] insertedKey = Hash.sha3(value("batch-inserted-key-" + index)); + byte[] insertedValue = value("batch-inserted-value-" + index); + sequential.put(insertedKey, insertedValue); + changes.add(new PathMerkleTrie.BatchMutation(insertedKey, insertedValue)); + } + byte[] expectedRoot = sequential.rootHash(); + + InMemoryPathNodeStore batchStore = new InMemoryPathNodeStore(); + batchStore.nodes.putAll(copyNodeMap(initialNodes)); + PathMerkleTrie batch = new PathMerkleTrie(batchStore); + batch.restoreRoot(initialRoot); + ExecutorService executor = Executors.newFixedThreadPool(4); + try { + batch.applyBatch(changes, executor); + assertArrayEquals(expectedRoot, batch.rootHash()); + assertNodeMapsEqual(sequentialStore.nodes, batchStore.nodes); + assertTrue(batchStore.gets <= sequentialStore.gets); + } finally { + executor.shutdownNow(); + } + } + + @Test + public void blockScopedEncodingEncodesOnlyTheFrozenNodeGraph() { + int leafCount = 512; + byte[][] keys = new byte[leafCount][]; + InMemoryPathNodeStore sourceStore = new InMemoryPathNodeStore(); + PathMerkleTrie source = new PathMerkleTrie(sourceStore); + for (int index = 0; index < leafCount; index++) { + keys[index] = Hash.sha3(value("arena-key-" + index)); + source.put(keys[index], value("arena-value-" + index)); + } + byte[] initialRoot = source.rootHash(); + Map initialNodes = copyNodeMap(sourceStore.nodes); + List changes = new ArrayList<>(); + for (int index = 0; index < 96; index++) { + changes.add(new PathMerkleTrie.BatchMutation(keys[index], + value("arena-updated-" + index))); + } + for (int index = 96; index < 128; index++) { + changes.add(new PathMerkleTrie.BatchMutation(keys[index], null)); + } + + InMemoryPathNodeStore eagerStore = new InMemoryPathNodeStore(); + eagerStore.nodes.putAll(copyNodeMap(initialNodes)); + PathMerkleTrie eager = new PathMerkleTrie(eagerStore); + eager.restoreRoot(initialRoot); + ExecutorService eagerExecutor = Executors.newFixedThreadPool(4); + long eagerEncodesBefore = PathMerkleTrie.nodeEncodeCountTotal(); + long eagerEncodes; + try { + eager.applyBatch(changes, eagerExecutor, false); + eager.rootHash(); + eagerEncodes = PathMerkleTrie.nodeEncodeCountTotal() - eagerEncodesBefore; + } finally { + eagerExecutor.shutdownNow(); + } + + InMemoryPathNodeStore arenaStore = new InMemoryPathNodeStore(); + arenaStore.nodes.putAll(copyNodeMap(initialNodes)); + PathMerkleTrie arena = new PathMerkleTrie(arenaStore); + arena.restoreRoot(initialRoot); + ExecutorService arenaExecutor = Executors.newFixedThreadPool(4); + long arenaCreatesBefore = PathMerkleTrie.nodeCreateCountTotal(); + long arenaEncodesBefore = PathMerkleTrie.nodeEncodeCountTotal(); + long arenaCreates; + long arenaEncodes; + try { + arena.applyBatch(changes, arenaExecutor, true); + assertArrayEquals(eager.rootHash(), arena.rootHash()); + arenaCreates = PathMerkleTrie.nodeCreateCountTotal() - arenaCreatesBefore; + arenaEncodes = PathMerkleTrie.nodeEncodeCountTotal() - arenaEncodesBefore; + long encodesAfterFreeze = PathMerkleTrie.nodeEncodeCountTotal(); + arena.rootHash(); + arena.snapshot(); + assertEquals(encodesAfterFreeze, PathMerkleTrie.nodeEncodeCountTotal()); + } finally { + arenaExecutor.shutdownNow(); + } + + assertNodeMapsEqual(eagerStore.nodes, arenaStore.nodes); + assertTrue(arenaCreates > arenaEncodes); + assertTrue(arenaEncodes < eagerEncodes); + assertTrue(arenaEncodes >= arena.getLastNodePuts()); + } + + @Test + public void resolvedSubtreeSurvivesConsecutiveSnapshotSwitches() { + byte[][] keys = new byte[16][]; + byte[][] values = new byte[16][]; + InMemoryPathNodeStore store = new InMemoryPathNodeStore(); + PathMerkleTrie source = new PathMerkleTrie(store); + for (int index = 0; index < keys.length; index++) { + keys[index] = filledKey(index << 4); + values[index] = value("snapshot-value-long-enough-" + index); + source.put(keys[index], values[index]); + } + byte[] initialRoot = source.rootHash(); + + PathMerkleTrie restored = new PathMerkleTrie(store); + restored.restoreRoot(initialRoot); + assertArrayEquals(values[3], restored.get(keys[3])); + PathMerkleTrie.Snapshot parent = restored.snapshot(); + + PathMerkleTrie firstBlock = PathMerkleTrie.fromSnapshot(store, parent); + values[4] = value("snapshot-block-one-updated"); + firstBlock.put(keys[4], values[4]); + firstBlock.rootHash(); + PathMerkleTrie.Snapshot firstChild = firstBlock.snapshot(); + + int readsBeforeInheritedLookup = store.gets; + PathMerkleTrie secondBlock = PathMerkleTrie.fromSnapshot(store, firstChild); + assertArrayEquals(values[3], secondBlock.get(keys[3])); + assertEquals(readsBeforeInheritedLookup, store.gets); + + values[5] = value("snapshot-block-two-updated"); + secondBlock.put(keys[5], values[5]); + assertArrayEquals(referenceRoot(keys, values), secondBlock.rootHash()); + } + + @Test + public void detachedSnapshotDoesNotDuplicateTheResolvedNodeIndex() { + int leafCount = 512; + byte[][] keys = new byte[leafCount][]; + byte[][] values = new byte[leafCount][]; + InMemoryPathNodeStore store = new InMemoryPathNodeStore(); + PathMerkleTrie source = new PathMerkleTrie(store); + for (int index = 0; index < leafCount; index++) { + keys[index] = Hash.sha3(value("detached-key-" + index)); + values[index] = value("detached-value-" + index); + source.put(keys[index], values[index]); + } + byte[] root = source.rootHash(); + + PathMerkleTrie restored = new PathMerkleTrie(store); + restored.restoreRoot(root); + for (int index = 0; index < leafCount; index++) { + assertArrayEquals(values[index], restored.get(keys[index])); + } + PathMerkleTrie.Snapshot resolved = restored.snapshot(); + assertTrue(resolved.materializedIndexSize() > leafCount); + + PathMerkleTrie.Snapshot detached = resolved.detach(); + assertEquals(1, detached.depth()); + assertEquals(0, detached.materializedIndexSize()); + + PathMerkleTrie next = PathMerkleTrie.fromSnapshot(store, detached); + values[17] = value("detached-updated-value"); + next.put(keys[17], values[17]); + assertArrayEquals(referenceRoot(keys, values), next.rootHash()); + } + + private static byte[] referenceRoot(byte[][] keys, byte[][] values) { + TrieImpl reference = new TrieImpl(); + reference.setAsync(false); + for (int i = 0; i < keys.length; i++) { + reference.put(keys[i], values[i]); + } + return reference.getRootHash(); + } + + private static byte[] filledKey(int value) { + byte[] key = new byte[PathMerkleTrie.SECURE_KEY_LENGTH]; + Arrays.fill(key, (byte) value); + return key; + } + + private static byte[] keyWithTail(int prefix, int tail) { + byte[] key = filledKey(prefix); + key[key.length - 1] = (byte) tail; + return key; + } + + private static byte[] indexedKey(int index) { + byte[] key = new byte[PathMerkleTrie.SECURE_KEY_LENGTH]; + ByteBuffer.wrap(key, key.length - Integer.BYTES, Integer.BYTES).putInt(index); + return key; + } + + private static byte[] value(String value) { + return PathStateCommitmentCodec.presentLeafValue(value.getBytes(StandardCharsets.UTF_8)); + } + + private static void assertNodeMapsEqual(Map expected, + Map actual) { + assertEquals(expected.keySet(), actual.keySet()); + for (String path : expected.keySet()) { + assertArrayEquals(expected.get(path), actual.get(path)); + } + } + + private static Map copyNodeMap(Map source) { + Map copy = new LinkedHashMap<>(); + for (Map.Entry entry : source.entrySet()) { + copy.put(entry.getKey(), Arrays.copyOf(entry.getValue(), entry.getValue().length)); + } + return copy; + } + + private static final class InMemoryPathNodeStore implements PathNodeStore { + + private final Map nodes = new LinkedHashMap<>(); + private int gets; + + @Override + public byte[] get(byte[] path) { + gets++; + byte[] value = nodes.get(Hex.toHexString(path)); + return value == null ? null : Arrays.copyOf(value, value.length); + } + + @Override + public void put(byte[] path, byte[] encodedNode) { + nodes.put(Hex.toHexString(path), Arrays.copyOf(encodedNode, encodedNode.length)); + } + + @Override + public void delete(byte[] path) { + nodes.remove(Hex.toHexString(path)); + } + } +} diff --git a/framework/src/test/java/org/tron/core/db2/stateroot/PathNodeStoreEngineTest.java b/framework/src/test/java/org/tron/core/db2/stateroot/PathNodeStoreEngineTest.java new file mode 100644 index 00000000000..190fd72ab9c --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/stateroot/PathNodeStoreEngineTest.java @@ -0,0 +1,305 @@ +package org.tron.core.db2.stateroot; + +import static org.fusesource.leveldbjni.JniDBFactory.factory; +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.bouncycastle.util.encoders.Hex; +import org.iq80.leveldb.DBIterator; +import org.iq80.leveldb.Options; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.rocksdb.RocksDBException; +import org.rocksdb.RocksIterator; +import org.tron.common.arch.Arch; + +/** Test-only LevelDB/RocksDB evidence for the backend-neutral TASK-016 node boundary. */ +public class PathNodeStoreEngineTest { + + private static final String LEVELDB = "LEVELDB"; + private static final String ROCKSDB = "ROCKSDB"; + private static final PathStateParticipant ABI = participant(1, "abi"); + private static final PathStateParticipant ACCOUNT = participant(4, "account"); + private static final PathStateParticipant ASSET_ISSUE = participant(6, "asset-issue"); + private static final PathStateParticipant ASSET_ISSUE_V2 = participant(7, "asset-issue-v2"); + private static final PathStateParticipant STORAGE = participant(22, "storage-row"); + + static { + org.rocksdb.RocksDB.loadLibrary(); + } + + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void levelDbAndRocksDbProduceIdenticalRootAndPathNodes() throws Exception { + org.junit.Assume.assumeFalse(Arch.isArm64()); + EngineFixture level = new EngineFixture(LEVELDB, + temporaryFolder.newFolder("path-state-level")); + EngineFixture rocks = new EngineFixture(ROCKSDB, + temporaryFolder.newFolder("path-state-rocks")); + try { + List mutations = mutations(); + level.root.apply(mutations); + List reversed = new ArrayList<>(mutations); + Collections.reverse(reversed); + rocks.root.apply(reversed); + + byte[] expected = Hex.decode( + "16a59be5527b6c746e4bc2b0a67046989116f7f855a10ae0fb65263e9fb7bfda"); + assertEquals(Hex.toHexString(expected), Hex.toHexString(level.root.rootHash())); + assertArrayEquals(expected, rocks.root.rootHash()); + level.root.verifyNodeStores(); + rocks.root.verifyNodeStores(); + assertEquals(level.snapshotNodes(), rocks.snapshotNodes()); + + byte[] originalSuperRootNode = rocks.superStore.get(new byte[0]); + rocks.superStore.put(new byte[0], new byte[]{1}); + assertThrows(IllegalStateException.class, rocks.root::verifyNodeStores); + rocks.superStore.put(new byte[0], originalSuperRootNode); + rocks.root.verifyNodeStores(); + } finally { + rocks.close(); + level.close(); + } + } + + @Test + public void engineAdaptersPreserveRootPathBytesAcrossReopen() throws Exception { + for (String engine : availableEngines()) { + File parent = temporaryFolder.newFolder("path-reopen-" + engine.toLowerCase()); + byte[] rootPath = new byte[0]; + byte[] encodedNode = Hex.decode("c22080"); + EnginePathNodeStore first = open(engine, parent, "root"); + first.put(rootPath, encodedNode); + first.close(); + + EnginePathNodeStore reopened = open(engine, parent, "root"); + try { + assertArrayEquals(encodedNode, reopened.get(rootPath)); + reopened.delete(rootPath); + assertNull(reopened.get(rootPath)); + } finally { + reopened.close(); + } + } + } + + private static List availableEngines() { + return Arch.isArm64() ? Collections.singletonList(ROCKSDB) + : Arrays.asList(LEVELDB, ROCKSDB); + } + + private static List participants() { + return Arrays.asList(ABI, ACCOUNT, ASSET_ISSUE, ASSET_ISSUE_V2, STORAGE); + } + + private static List mutations() { + return Arrays.asList( + mutation("abi", "contract", "abi-v1"), + mutation("asset-issue", "asset", "legacy"), + mutation("asset-issue-v2", "asset", "v2"), + mutation("account", "address", "account-value"), + mutation("storage-row", "slot", "storage-value")); + } + + private static PathStateMutation mutation(String dbName, String key, String value) { + return PathStateMutation.put(dbName, bytes(key), bytes(value)); + } + + private static byte[] bytes(String value) { + return value.getBytes(java.nio.charset.StandardCharsets.UTF_8); + } + + private static PathStateParticipant participant(int storeId, String dbName) { + return new PathStateParticipant(storeId, dbName, 1); + } + + private static EnginePathNodeStore open(String engine, File parent, String dbName) { + Path directory = new File(parent, dbName).toPath(); + return LEVELDB.equals(engine) ? new LevelPathNodeStore(directory) + : new RocksPathNodeStore(directory); + } + + private static final class EngineFixture implements AutoCloseable { + + private final String engine; + private final File parent; + private final Map participantStores = new LinkedHashMap<>(); + private final List stores = new ArrayList<>(); + private final EnginePathNodeStore superStore; + private final PathStateRoot root; + + private EngineFixture(String engine, File parent) { + this.engine = engine; + this.parent = parent; + PathStateParticipantScope scope = new PathStateParticipantScope(participants()); + superStore = create("super"); + root = new PathStateRoot(scope, participant -> { + EnginePathNodeStore store = create("store-" + participant.getStoreId()); + participantStores.put(participant.getDbName(), store); + return store; + }, superStore); + } + + private EnginePathNodeStore create(String dbName) { + EnginePathNodeStore store = open(engine, parent, dbName); + stores.add(store); + return store; + } + + private Map> snapshotNodes() { + Map> snapshot = new LinkedHashMap<>(); + for (Map.Entry entry : participantStores.entrySet()) { + snapshot.put(entry.getKey(), entry.getValue().snapshot()); + } + snapshot.put("super", superStore.snapshot()); + return snapshot; + } + + @Override + public void close() { + for (int i = stores.size() - 1; i >= 0; i--) { + stores.get(i).close(); + } + } + } + + private abstract static class EnginePathNodeStore implements PathNodeStore, AutoCloseable { + + abstract Map snapshot(); + + @Override + public abstract void close(); + } + + private static final class LevelPathNodeStore extends EnginePathNodeStore { + + private final Options options = new Options().createIfMissing(true); + private final org.iq80.leveldb.DB database; + + private LevelPathNodeStore(Path directory) { + try { + Files.createDirectories(directory); + database = factory.open(directory.toFile(), options); + } catch (IOException failure) { + throw new IllegalStateException("failed to open test LevelDB", failure); + } + } + + @Override + public byte[] get(byte[] path) { + return database.get(path); + } + + @Override + public void put(byte[] path, byte[] encodedNode) { + database.put(path, encodedNode); + } + + @Override + public void delete(byte[] path) { + database.delete(path); + } + + @Override + Map snapshot() { + Map nodes = new LinkedHashMap<>(); + try (DBIterator iterator = database.iterator()) { + iterator.seekToFirst(); + while (iterator.hasNext()) { + Map.Entry entry = iterator.next(); + nodes.put(Hex.toHexString(entry.getKey()), Hex.toHexString(entry.getValue())); + } + } catch (IOException failure) { + throw new IllegalStateException("failed to iterate test LevelDB", failure); + } + return nodes; + } + + @Override + public void close() { + try { + database.close(); + } catch (IOException failure) { + throw new IllegalStateException("failed to close test LevelDB", failure); + } + } + } + + private static final class RocksPathNodeStore extends EnginePathNodeStore { + + private final org.rocksdb.Options options = new org.rocksdb.Options().setCreateIfMissing(true); + private final org.rocksdb.RocksDB database; + + private RocksPathNodeStore(Path directory) { + try { + Files.createDirectories(directory); + database = org.rocksdb.RocksDB.open(options, directory.toString()); + } catch (IOException | RocksDBException failure) { + options.close(); + throw new IllegalStateException("failed to open test RocksDB", failure); + } + } + + @Override + public byte[] get(byte[] path) { + try { + return database.get(path); + } catch (RocksDBException failure) { + throw new IllegalStateException("failed to read test RocksDB", failure); + } + } + + @Override + public void put(byte[] path, byte[] encodedNode) { + try { + database.put(path, encodedNode); + } catch (RocksDBException failure) { + throw new IllegalStateException("failed to write test RocksDB", failure); + } + } + + @Override + public void delete(byte[] path) { + try { + database.delete(path); + } catch (RocksDBException failure) { + throw new IllegalStateException("failed to delete test RocksDB", failure); + } + } + + @Override + Map snapshot() { + Map nodes = new LinkedHashMap<>(); + try (RocksIterator iterator = database.newIterator()) { + for (iterator.seekToFirst(); iterator.isValid(); iterator.next()) { + nodes.put(Hex.toHexString(iterator.key()), Hex.toHexString(iterator.value())); + } + iterator.status(); + } catch (RocksDBException failure) { + throw new IllegalStateException("failed to iterate test RocksDB", failure); + } + return nodes; + } + + @Override + public void close() { + database.close(); + options.close(); + } + } +} diff --git a/framework/src/test/java/org/tron/core/db2/stateroot/PathStateBaseCompactionTest.java b/framework/src/test/java/org/tron/core/db2/stateroot/PathStateBaseCompactionTest.java new file mode 100644 index 00000000000..160f7fe128b --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/stateroot/PathStateBaseCompactionTest.java @@ -0,0 +1,188 @@ +package org.tron.core.db2.stateroot; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.Arrays; +import java.util.Collections; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tron.common.arch.Arch; +import org.tron.core.db2.stateroot.PathStateBaseCompaction.RecoveryAction; +import org.tron.core.db2.stateroot.PathStateBaseCompaction.Stage; +import org.tron.core.db2.stateroot.PathStateCanonicalizer.P66Phase; +import org.tron.core.db2.stateroot.PathStateRootMetadata.Kind; +import org.tron.core.db2.stateroot.PathStateStoreManifest.Engine; + +public class PathStateBaseCompactionTest { + + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void materializesPrefixOneLayerAtATimeAcrossEngines() throws Exception { + for (Engine engine : availableEngines()) { + Fixture fixture = fixture("compact-" + engine, engine); + PathStateBaseCompaction compaction = new PathStateBaseCompaction( + fixture.manifest, fixture.limits); + + PathStateRootMetadata compacted = compaction.compactThrough( + fixture.second.getBlockNumber(), fixture.second.getBlockHash()); + + assertEquals(Kind.BASE, compacted.getKind()); + assertEquals(102, compacted.getBlockNumber()); + assertArrayEquals(fixture.second.getStateRoot(), compacted.getStateRoot()); + assertFalse(Files.exists(fixture.manifest.getLayerDirectory( + fixture.first.getBlockNumber(), fixture.first.getBlockHash()))); + assertFalse(Files.exists(fixture.manifest.getLayerDirectory( + fixture.second.getBlockNumber(), fixture.second.getBlockHash()))); + assertArrayEquals(fixture.head.encode(), + new PathStateCurrentStore(fixture.manifest).current().encode()); + try (PathStateNodeStoreSet stores = PathStateNodeStoreSet.openCurrent(fixture.manifest)) { + PathStateRoot restored = stores.createRoot(); + assertArrayEquals(fixture.head.getStateRoot(), restored.rootHash()); + restored.verifyNodeStores(); + } + assertEquals(RecoveryAction.NONE, compaction.recover()); + } + } + + @Test + public void everyDurableStageRecoversToTheSameBase() throws Exception { + for (Engine engine : availableEngines()) { + for (Stage failure : Stage.values()) { + Fixture fixture = fixture("recover-" + engine + "-" + failure, engine); + PathStateBaseCompaction interrupted = new PathStateBaseCompaction( + fixture.manifest, fixture.limits, failAfter(failure)); + assertThrows(IOException.class, () -> interrupted.compactThrough( + fixture.first.getBlockNumber(), fixture.first.getBlockHash())); + + PathStateBaseCompaction recovery = new PathStateBaseCompaction( + fixture.manifest, fixture.limits); + RecoveryAction expected = failure == Stage.AFTER_RETIRE + ? RecoveryAction.NONE : RecoveryAction.COMPLETED_COMPACTION; + assertEquals(expected, recovery.recover()); + assertEquals(RecoveryAction.NONE, recovery.recover()); + PathStateRootMetadata base = PathStateMetadataFile.load(fixture.manifest + .getBaseDirectory().resolve(PathStateCurrentStore.METADATA_FILE)); + assertEquals(Kind.BASE, base.getKind()); + assertArrayEquals(fixture.first.getStateRoot(), base.getStateRoot()); + assertArrayEquals(fixture.head.encode(), + new PathStateCurrentStore(fixture.manifest).current().encode()); + assertFalse(Files.exists(fixture.manifest.getDirectory() + .resolve(PathStateBaseCompaction.INTENT_FILE))); + assertFalse(Files.exists(fixture.manifest.getDirectory() + .resolve(PathStateBaseCompaction.NEXT_DIRECTORY))); + assertFalse(Files.exists(fixture.manifest.getDirectory() + .resolve(PathStateBaseCompaction.PREVIOUS_DIRECTORY))); + } + } + } + + @Test + public void refusesToCompactTheReversibleHead() throws Exception { + Fixture fixture = fixture("retain-head", Engine.ROCKSDB); + PathStateBaseCompaction compaction = new PathStateBaseCompaction( + fixture.manifest, fixture.limits); + + assertThrows(IOException.class, () -> compaction.compactThrough( + fixture.head.getBlockNumber(), fixture.head.getBlockHash())); + assertArrayEquals(fixture.base.encode(), PathStateMetadataFile.load(fixture.manifest + .getBaseDirectory().resolve(PathStateCurrentStore.METADATA_FILE)).encode()); + } + + @Test + public void snapshotStartupRecoversTheDirectorySwapGap() throws Exception { + Fixture fixture = fixture("startup-recovery", Engine.ROCKSDB); + PathStateBaseCompaction interrupted = new PathStateBaseCompaction( + fixture.manifest, fixture.limits, failAfter(Stage.AFTER_OLD_BASE)); + assertThrows(IOException.class, () -> interrupted.compactThrough( + fixture.first.getBlockNumber(), fixture.first.getBlockHash())); + + PathStateStoreManifest validated = PathStateStoreManifest.validateExisting( + fixture.manifest.getDirectory(), Engine.ROCKSDB); + PathStateSnapshotHead owner = PathStateSnapshotHead.open(validated, fixture.limits); + + assertArrayEquals(fixture.head.encode(), owner.getHead().encode()); + assertEquals(Kind.BASE, PathStateMetadataFile.load(validated.getBaseDirectory() + .resolve(PathStateCurrentStore.METADATA_FILE)).getKind()); + } + + private Fixture fixture(String name, Engine engine) throws Exception { + PathStateStoreManifest manifest = PathStateStoreManifest.createOrOpen( + new File(temporaryFolder.getRoot(), name).toPath(), engine); + PathStateRootMetadata base; + try (PathStateNodeStoreSet stores = PathStateNodeStoreSet.openBase(manifest)) { + PathStateRoot root = stores.createRoot(); + root.apply(Arrays.asList( + PathStateMutation.put("proposal", new byte[]{1}, new byte[]{2}), + PathStateMutation.put("account", new byte[]{3}, new byte[]{4}))); + base = PathStateRootMetadata.base(100, bytes(1), bytes(2), 300, + P66Phase.P66_ON, manifest.getIdentityDigest(), root.rootHash(), bytes(3)); + new PathStateBasePublication(manifest).publish(stores, base); + } + PathStateLayerLimits limits = new PathStateLayerLimits(10, Long.MAX_VALUE); + PathStateRootMetadata first = append(manifest, base, 101, 11, limits); + PathStateRootMetadata second = append(manifest, first, 102, 12, limits); + PathStateRootMetadata head = append(manifest, second, 103, 13, limits); + return new Fixture(manifest, limits, base, first, second, head); + } + + private static PathStateRootMetadata append(PathStateStoreManifest manifest, + PathStateRootMetadata parent, long blockNumber, int seed, PathStateLayerLimits limits) + throws Exception { + try (PathStateLayer layer = PathStateLayer.begin(manifest, parent, blockNumber, bytes(seed), + parent.getBlockHash(), blockNumber * 3, P66Phase.P66_ON, bytes(seed + 1), limits)) { + layer.apply(Collections.singletonList( + PathStateMutation.put("proposal", new byte[]{1}, new byte[]{(byte) seed}))); + return layer.commit(); + } + } + + private static Engine[] availableEngines() { + return Arch.isArm64() ? new Engine[]{Engine.ROCKSDB} + : new Engine[]{Engine.LEVELDB, Engine.ROCKSDB}; + } + + private static PathStateBaseCompaction.FaultHook failAfter(Stage failure) { + return stage -> { + if (stage == failure) { + throw new IOException("injected after " + stage); + } + }; + } + + private static byte[] bytes(int seed) { + byte[] value = new byte[32]; + for (int index = 0; index < value.length; index++) { + value[index] = (byte) (seed + index); + } + return value; + } + + private static final class Fixture { + private final PathStateStoreManifest manifest; + private final PathStateLayerLimits limits; + private final PathStateRootMetadata base; + private final PathStateRootMetadata first; + private final PathStateRootMetadata second; + private final PathStateRootMetadata head; + + private Fixture(PathStateStoreManifest manifest, PathStateLayerLimits limits, + PathStateRootMetadata base, PathStateRootMetadata first, + PathStateRootMetadata second, PathStateRootMetadata head) { + this.manifest = manifest; + this.limits = limits; + this.base = base; + this.first = first; + this.second = second; + this.head = head; + } + } +} diff --git a/framework/src/test/java/org/tron/core/db2/stateroot/PathStateBasePublicationTest.java b/framework/src/test/java/org/tron/core/db2/stateroot/PathStateBasePublicationTest.java new file mode 100644 index 00000000000..024380e3cc9 --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/stateroot/PathStateBasePublicationTest.java @@ -0,0 +1,163 @@ +package org.tron.core.db2.stateroot; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tron.common.arch.Arch; +import org.tron.core.db2.stateroot.PathStateBasePublication.RecoveryAction; +import org.tron.core.db2.stateroot.PathStateBasePublication.Stage; +import org.tron.core.db2.stateroot.PathStateCanonicalizer.P66Phase; +import org.tron.core.db2.stateroot.PathStateStoreManifest.Engine; + +public class PathStateBasePublicationTest { + + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void faultsBeforeNativeMarkerRollBackIntentIdempotently() throws Exception { + for (Engine engine : availableEngines()) { + Fixture fixture = fixture("before-marker-" + engine, engine, Stage.AFTER_INTENT); + assertThrows(IOException.class, fixture::publish); + fixture.close(); + + PathStateBasePublication recovery = new PathStateBasePublication(fixture.manifest); + org.junit.Assert.assertEquals(RecoveryAction.ROLLED_BACK_INTENT, recovery.recover()); + org.junit.Assert.assertEquals(RecoveryAction.NONE, recovery.recover()); + assertFalse(new PathStateCurrentStore(fixture.manifest).isInitialized()); + assertNull(PathStateNodeStoreSet.loadProgress(fixture.manifest.getBaseDirectory(), + fixture.manifest)); + } + } + + @Test + public void faultsAfterNativeMarkerCompletePublicationIdempotently() throws Exception { + for (Engine engine : availableEngines()) { + for (Stage stage : new Stage[]{Stage.AFTER_NODE_PROGRESS, Stage.AFTER_METADATA, + Stage.AFTER_CURRENT}) { + Fixture fixture = fixture("complete-" + engine + "-" + stage, engine, stage); + assertThrows(IOException.class, fixture::publish); + fixture.close(); + + PathStateBasePublication recovery = new PathStateBasePublication(fixture.manifest); + org.junit.Assert.assertEquals(RecoveryAction.COMPLETED_PUBLICATION, recovery.recover()); + org.junit.Assert.assertEquals(RecoveryAction.NONE, recovery.recover()); + assertSettled(fixture); + } + } + } + + @Test + public void faultAfterRetireIsAlreadySettled() throws Exception { + Fixture fixture = fixture("after-retire", Engine.ROCKSDB, Stage.AFTER_RETIRE); + assertThrows(IOException.class, fixture::publish); + fixture.close(); + + PathStateBasePublication recovery = new PathStateBasePublication(fixture.manifest); + org.junit.Assert.assertEquals(RecoveryAction.NONE, recovery.recover()); + assertSettled(fixture); + } + + @Test + public void recoveryRejectsNativeProgressWithoutIntentOrAuthority() throws Exception { + Fixture fixture = fixture("orphan-progress", Engine.ROCKSDB, Stage.AFTER_NODE_PROGRESS); + assertThrows(IOException.class, fixture::publish); + fixture.close(); + Files.delete(fixture.manifest.getBaseDirectory().resolve(PathStateBasePublication.INTENT_FILE)); + + assertThrows(IOException.class, new PathStateBasePublication(fixture.manifest)::recover); + } + + @Test + public void publicationRejectsNodeSetFromAnotherDirectoryBeforeIntent() throws Exception { + Fixture source = fixture("directory-source", Engine.ROCKSDB, null); + PathStateStoreManifest target = PathStateStoreManifest.createOrOpen( + temporaryFolder.newFolder("directory-target").toPath(), Engine.ROCKSDB); + PathStateRootMetadata targetMetadata = PathStateRootMetadata.base(100, bytes(1), bytes(2), 300, + P66Phase.P66_ON, target.getIdentityDigest(), source.metadata.getStateRoot(), bytes(3)); + + assertThrows(IllegalArgumentException.class, + () -> new PathStateBasePublication(target).publish(source.stores, targetMetadata)); + assertFalse(Files.exists( + target.getBaseDirectory().resolve(PathStateBasePublication.INTENT_FILE))); + source.close(); + } + + private Fixture fixture(String name, Engine engine, Stage failure) throws Exception { + Path root = new File(temporaryFolder.getRoot(), name).toPath(); + PathStateStoreManifest manifest = PathStateStoreManifest.createOrOpen(root, engine); + PathStateNodeStoreSet stores = PathStateNodeStoreSet.openBase(manifest); + PathStateRoot stateRoot = stores.createRoot(); + stateRoot.apply(Collections.singletonList( + PathStateMutation.put("proposal", new byte[]{1}, new byte[]{2}))); + PathStateRootMetadata metadata = PathStateRootMetadata.base(100, bytes(1), bytes(2), 300, + P66Phase.P66_ON, manifest.getIdentityDigest(), stateRoot.rootHash(), bytes(3)); + PathStateBasePublication publication = new PathStateBasePublication(manifest, stage -> { + if (stage == failure) { + throw new IOException("injected after " + stage); + } + }); + return new Fixture(manifest, stores, metadata, publication); + } + + private static void assertSettled(Fixture fixture) throws Exception { + PathStateRootMetadata current = new PathStateCurrentStore(fixture.manifest).current(); + assertArrayEquals(fixture.metadata.encode(), current.encode()); + assertArrayEquals(fixture.metadata.encode(), PathStateNodeStoreSet.loadProgress( + fixture.manifest.getBaseDirectory(), fixture.manifest).encode()); + assertFalse(Files.exists( + fixture.manifest.getBaseDirectory().resolve(PathStateBasePublication.INTENT_FILE))); + try (PathStateNodeStoreSet reopened = PathStateNodeStoreSet.openBase(fixture.manifest)) { + PathStateRoot restored = reopened.createRoot(); + assertArrayEquals(fixture.metadata.getStateRoot(), restored.rootHash()); + restored.verifyNodeStores(); + } + } + + private static Engine[] availableEngines() { + return Arch.isArm64() ? new Engine[]{Engine.ROCKSDB} + : new Engine[]{Engine.LEVELDB, Engine.ROCKSDB}; + } + + private static byte[] bytes(int seed) { + byte[] value = new byte[32]; + for (int index = 0; index < value.length; index++) { + value[index] = (byte) (seed + index); + } + return value; + } + + private static final class Fixture { + + private final PathStateStoreManifest manifest; + private final PathStateNodeStoreSet stores; + private final PathStateRootMetadata metadata; + private final PathStateBasePublication publication; + + private Fixture(PathStateStoreManifest manifest, PathStateNodeStoreSet stores, + PathStateRootMetadata metadata, PathStateBasePublication publication) { + this.manifest = manifest; + this.stores = stores; + this.metadata = metadata; + this.publication = publication; + } + + private void publish() throws IOException { + publication.publish(stores, metadata); + } + + private void close() throws IOException { + stores.close(); + } + } +} diff --git a/framework/src/test/java/org/tron/core/db2/stateroot/PathStateBlockTransitionTest.java b/framework/src/test/java/org/tron/core/db2/stateroot/PathStateBlockTransitionTest.java new file mode 100644 index 00000000000..620be9d1e62 --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/stateroot/PathStateBlockTransitionTest.java @@ -0,0 +1,162 @@ +package org.tron.core.db2.stateroot; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import com.google.common.hash.Hashing; +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import org.junit.Test; +import org.tron.common.utils.ByteArray; +import org.tron.core.db2.stateroot.PathStateCanonicalizer.P66Phase; + +public class PathStateBlockTransitionTest { + + private static final byte[] BLOCK_HASH = bytes(1); + private static final byte[] PARENT_HASH = bytes(33); + + @Test + public void canonicalEvidenceIsOriginFreeAndMutationOrderInvariant() { + PathStateMutation proposal = PathStateMutation.put("proposal", new byte[]{2}, new byte[]{3}); + PathStateMutation accountDelete = PathStateMutation.delete("account", new byte[]{1}); + + PathStateBlockTransition pushed = transition(Arrays.asList(proposal, accountDelete)); + PathStateBlockTransition forkReapplied = transition(Arrays.asList(accountDelete, proposal)); + + assertArrayEquals(pushed.getPayloadDigest(), forkReapplied.getPayloadDigest()); + assertEquals("account", pushed.getMutations().get(0).getDbName()); + assertEquals("proposal", pushed.getMutations().get(1).getDbName()); + assertEquals(PathStateParticipantDescriptor.SCOPE_ID, pushed.getScopeId()); + assertEquals( + "b216f6028db74c2457f1fd625ff4218ad1c358f74d016baa4bc17828e9b4ac7a", + ByteArray.toHexString(pushed.getPayloadDigest())); + } + + @Test + public void digestBindsBlockPhaseAndMutationSemantics() { + PathStateMutation put = PathStateMutation.put("proposal", new byte[]{2}, new byte[0]); + PathStateBlockTransition baseline = transition(Collections.singletonList(put)); + PathStateBlockTransition otherBlock = new PathStateBlockTransition(43, BLOCK_HASH, PARENT_HASH, + 1234, P66Phase.P66_ON, Collections.singletonList(put)); + PathStateBlockTransition otherPhase = new PathStateBlockTransition(42, BLOCK_HASH, PARENT_HASH, + 1234, P66Phase.P66_ACTIVATION, Collections.singletonList(put)); + PathStateBlockTransition delete = transition(Collections.singletonList( + PathStateMutation.delete("proposal", new byte[]{2}))); + + assertNotEquals(ByteArray.toHexString(baseline.getPayloadDigest()), + ByteArray.toHexString(otherBlock.getPayloadDigest())); + assertNotEquals(ByteArray.toHexString(baseline.getPayloadDigest()), + ByteArray.toHexString(otherPhase.getPayloadDigest())); + assertNotEquals(ByteArray.toHexString(baseline.getPayloadDigest()), + ByteArray.toHexString(delete.getPayloadDigest())); + } + + @Test + public void noOpBlockHasDeterministicIndependentOracleDigest() throws Exception { + PathStateBlockTransition transition = transition(Collections.emptyList()); + + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + DataOutputStream output = new DataOutputStream(bytes); + writeIdentity(output, "java-tron/path-state/block-transition"); + output.writeShort(1); + writeIdentity(output, PathStateParticipantDescriptor.SCOPE_ID); + output.writeByte(2); + output.writeLong(42); + output.write(BLOCK_HASH); + output.write(PARENT_HASH); + output.writeLong(1234); + output.writeInt(0); + + assertArrayEquals(Hashing.sha256().hashBytes(bytes.toByteArray()).asBytes(), + transition.getPayloadDigest()); + } + + @Test + public void rejectsDuplicateOrOutOfScopeMutations() { + PathStateMutation first = PathStateMutation.put("proposal", new byte[]{1}, new byte[]{2}); + PathStateMutation duplicate = PathStateMutation.delete("proposal", new byte[]{1}); + + assertThrows(IllegalArgumentException.class, + () -> transition(Arrays.asList(first, duplicate))); + assertThrows(IllegalArgumentException.class, + () -> transition(Collections.singletonList( + PathStateMutation.put("unknown", new byte[]{1}, new byte[]{2})))); + } + + @Test + public void lengthDelimitedEmptyKeyIsDeterministic() { + PathStateBlockTransition first = transition(Collections.singletonList( + PathStateMutation.put("accountid-index", new byte[0], new byte[]{2}))); + PathStateBlockTransition second = transition(Collections.singletonList( + PathStateMutation.put("accountid-index", new byte[0], new byte[]{2}))); + + assertArrayEquals(first.getPayloadDigest(), second.getPayloadDigest()); + } + + @Test + public void ownsBlockAndMutationBytes() { + byte[] blockHash = Arrays.copyOf(BLOCK_HASH, BLOCK_HASH.length); + byte[] key = new byte[]{1}; + byte[] value = new byte[]{2}; + PathStateBlockTransition transition = new PathStateBlockTransition(42, blockHash, PARENT_HASH, + 1234, P66Phase.P66_ON, + Collections.singletonList(PathStateMutation.put("proposal", key, value))); + byte[] digest = transition.getPayloadDigest(); + + blockHash[0] = 99; + key[0] = 99; + value[0] = 99; + transition.getBlockHash()[0] = 98; + transition.getMutations().get(0).getCanonicalKey()[0] = 98; + transition.getPayloadDigest()[0] = 98; + + assertArrayEquals(BLOCK_HASH, transition.getBlockHash()); + assertArrayEquals(new byte[]{1}, transition.getMutations().get(0).getCanonicalKey()); + assertArrayEquals(new byte[]{2}, transition.getMutations().get(0).getCanonicalValue()); + assertArrayEquals(digest, transition.getPayloadDigest()); + assertThrows(UnsupportedOperationException.class, + () -> transition.getMutations().add(PathStateMutation.delete("proposal", new byte[]{3}))); + } + + @Test + public void ownsAnOptionalAuthoritativePreviousValue() { + byte[] previous = new byte[]{3, 4}; + PathStateMutation mutation = PathStateMutation.put( + "proposal", new byte[]{1}, new byte[]{2}).withPreviousPhysicalValue(previous); + + previous[0] = 9; + assertTrue(mutation.isPreviousValueKnown()); + assertArrayEquals(new byte[]{3, 4}, mutation.getPreviousPhysicalValue()); + mutation.getPreviousPhysicalValue()[0] = 8; + assertArrayEquals(new byte[]{3, 4}, mutation.getPreviousPhysicalValue()); + + PathStateMutation unknown = PathStateMutation.delete("proposal", new byte[]{1}); + assertThrows(IllegalStateException.class, unknown::getPreviousPhysicalValue); + } + + private static PathStateBlockTransition transition(List mutations) { + return new PathStateBlockTransition(42, BLOCK_HASH, PARENT_HASH, 1234, + P66Phase.P66_ON, mutations); + } + + private static void writeIdentity(DataOutputStream output, String value) throws Exception { + byte[] encoded = value.getBytes(StandardCharsets.UTF_8); + output.writeShort(encoded.length); + output.write(encoded); + } + + private static byte[] bytes(int seed) { + byte[] value = new byte[32]; + for (int i = 0; i < value.length; i++) { + value[i] = (byte) (seed + i); + } + return value; + } +} diff --git a/framework/src/test/java/org/tron/core/db2/stateroot/PathStateCanonicalSwitchTest.java b/framework/src/test/java/org/tron/core/db2/stateroot/PathStateCanonicalSwitchTest.java new file mode 100644 index 00000000000..eb5e66f4cd4 --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/stateroot/PathStateCanonicalSwitchTest.java @@ -0,0 +1,163 @@ +package org.tron.core.db2.stateroot; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Collections; +import java.util.stream.Stream; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tron.common.arch.Arch; +import org.tron.core.db2.stateroot.PathStateCanonicalizer.P66Phase; +import org.tron.core.db2.stateroot.PathStateStoreManifest.Engine; + +public class PathStateCanonicalSwitchTest { + + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void switchesToAncestorThenBuildsAndRestoresCanonicalSiblingFork() throws Exception { + for (Engine engine : availableEngines()) { + Fixture fixture = fixture("fork-" + engine, engine); + PathStateLayerLimits limits = new PathStateLayerLimits(10, Long.MAX_VALUE); + PathStateRootMetadata firstA = append(fixture.manifest, fixture.base, 101, 11, limits); + PathStateRootMetadata secondA = append(fixture.manifest, firstA, 102, 13, limits); + PathStateCurrentStore currentStore = new PathStateCurrentStore(fixture.manifest); + + assertArrayEquals(fixture.base.encode(), + currentStore.switchToAncestor(fixture.base, limits).encode()); + PathStateRootMetadata firstB = append(fixture.manifest, fixture.base, 101, 21, limits); + PathStateRootMetadata secondB = append(fixture.manifest, firstB, 102, 23, limits); + + assertArrayEquals(secondB.encode(), currentStore.current().encode()); + assertThrows(IOException.class, () -> currentStore.switchToAncestor(secondA, limits)); + assertArrayEquals(secondB.encode(), currentStore.current().encode()); + assertTrue(Files.exists(fixture.manifest.getLayerDirectory(102, secondA.getBlockHash()) + .resolve(PathStateCurrentStore.METADATA_FILE))); + try (PathStateNodeStoreSet stores = PathStateNodeStoreSet.openCurrent(fixture.manifest)) { + PathStateRoot restored = stores.createRoot(); + assertArrayEquals(secondB.getStateRoot(), restored.rootHash()); + restored.verifyNodeStores(); + } + assertArrayEquals(secondB.encode(), new PathStateCurrentStore( + PathStateStoreManifest.validateExisting(fixture.manifest.getDirectory(), engine)) + .current().encode()); + } + } + + @Test + public void deepReorgBeyondConfiguredWindowFailsClosed() throws Exception { + Fixture fixture = fixture("deep-reorg", Engine.ROCKSDB); + PathStateLayerLimits roomy = new PathStateLayerLimits(10, Long.MAX_VALUE); + PathStateRootMetadata first = append(fixture.manifest, fixture.base, 101, 11, roomy); + PathStateRootMetadata second = append(fixture.manifest, first, 102, 13, roomy); + PathStateCurrentStore currentStore = new PathStateCurrentStore(fixture.manifest); + + assertThrows(IOException.class, () -> currentStore.switchToAncestor(fixture.base, + new PathStateLayerLimits(1, Long.MAX_VALUE))); + assertArrayEquals(second.encode(), currentStore.current().encode()); + } + + @Test + public void corruptAncestorNodesRejectSwitchWithoutMovingCurrent() throws Exception { + Fixture fixture = fixture("corrupt-target", Engine.ROCKSDB); + PathStateLayerLimits limits = new PathStateLayerLimits(10, Long.MAX_VALUE); + PathStateRootMetadata current = append(fixture.manifest, fixture.base, 101, 11, limits); + Path nodes = fixture.manifest.getBaseDirectory().resolve(PathStateNodeStoreSet.NODES_DIRECTORY); + try (PathStateNativeNodeStore store = PathStateNativeNodeStore.open(nodes, Engine.ROCKSDB)) { + store.delete(durableLeafKey(21, + PathStateCommitmentCodec.storeLeafKey(21, new byte[]{1}))); + } + + PathStateCurrentStore currentStore = new PathStateCurrentStore(fixture.manifest); + assertThrows(IOException.class, () -> currentStore.switchToAncestor(fixture.base, limits)); + assertArrayEquals(current.encode(), currentStore.current().encode()); + } + + @Test + public void failedCurrentReplacementPreservesOldCanonicalHead() throws Exception { + Fixture fixture = fixture("switch-fault", Engine.ROCKSDB); + PathStateLayerLimits limits = new PathStateLayerLimits(10, Long.MAX_VALUE); + PathStateRootMetadata current = append(fixture.manifest, fixture.base, 101, 11, limits); + PathStateCurrentStore currentStore = new PathStateCurrentStore(fixture.manifest); + + assertThrows(IOException.class, () -> currentStore.switchToAncestor(fixture.base, limits, + temporary -> { + assertTrue(Files.exists(temporary)); + throw new IOException("injected after temporary force"); + })); + assertArrayEquals(current.encode(), currentStore.current().encode()); + try (Stream paths = Files.list(fixture.manifest.getDirectory())) { + assertTrue(paths.noneMatch(path -> path.getFileName().toString().startsWith(".CURRENT-"))); + } + assertArrayEquals(fixture.base.encode(), + currentStore.switchToAncestor(fixture.base, limits).encode()); + } + + private Fixture fixture(String name, Engine engine) throws Exception { + PathStateStoreManifest manifest = PathStateStoreManifest.createOrOpen( + new File(temporaryFolder.getRoot(), name).toPath(), engine); + try (PathStateNodeStoreSet stores = PathStateNodeStoreSet.openBase(manifest)) { + PathStateRoot root = stores.createRoot(); + root.apply(Arrays.asList( + PathStateMutation.put("proposal", new byte[]{1}, new byte[]{2}), + PathStateMutation.put("account", new byte[]{3}, new byte[]{4}))); + PathStateRootMetadata base = PathStateRootMetadata.base(100, bytes(1), bytes(2), 300, + P66Phase.P66_ON, manifest.getIdentityDigest(), root.rootHash(), bytes(3)); + new PathStateBasePublication(manifest).publish(stores, base); + return new Fixture(manifest, base); + } + } + + private static PathStateRootMetadata append(PathStateStoreManifest manifest, + PathStateRootMetadata parent, long blockNumber, int seed, PathStateLayerLimits limits) + throws Exception { + try (PathStateLayer layer = PathStateLayer.begin(manifest, parent, blockNumber, bytes(seed), + parent.getBlockHash(), blockNumber * 3, P66Phase.P66_ON, bytes(seed + 1), limits)) { + layer.apply(Collections.singletonList( + PathStateMutation.put("proposal", new byte[]{1}, new byte[]{(byte) seed}))); + return layer.commit(); + } + } + + private static byte[] durableLeafKey(int storeId, byte[] secureKey) { + return java.nio.ByteBuffer.allocate(Integer.BYTES * 2 + secureKey.length) + .putInt(-2) + .putInt(storeId) + .put(secureKey) + .array(); + } + + private static Engine[] availableEngines() { + return Arch.isArm64() ? new Engine[]{Engine.ROCKSDB} + : new Engine[]{Engine.LEVELDB, Engine.ROCKSDB}; + } + + private static byte[] bytes(int seed) { + byte[] value = new byte[32]; + for (int index = 0; index < value.length; index++) { + value[index] = (byte) (seed + index); + } + return value; + } + + private static final class Fixture { + + private final PathStateStoreManifest manifest; + private final PathStateRootMetadata base; + + private Fixture(PathStateStoreManifest manifest, PathStateRootMetadata base) { + this.manifest = manifest; + this.base = base; + } + } +} diff --git a/framework/src/test/java/org/tron/core/db2/stateroot/PathStateCanonicalizerTest.java b/framework/src/test/java/org/tron/core/db2/stateroot/PathStateCanonicalizerTest.java new file mode 100644 index 00000000000..b0f00f6efcd --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/stateroot/PathStateCanonicalizerTest.java @@ -0,0 +1,207 @@ +package org.tron.core.db2.stateroot; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import com.google.protobuf.ByteString; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import org.junit.Test; +import org.tron.common.utils.ByteArray; +import org.tron.core.db2.stateroot.PathStateCanonicalizer.P66Phase; +import org.tron.protos.Protocol.Account; +import org.tron.protos.contract.AssetIssueContractOuterClass.AssetIssueContract; + +public class PathStateCanonicalizerTest { + + private final PathStateCanonicalizer canonicalizer = new PathStateCanonicalizer(); + + @Test + public void assignsFormatsToTheExact27Scope() { + PathStateParticipantScope scope = canonicalizer.participantScope(); + assertEquals(27, scope.getParticipants().size()); + assertEquals(PathStateCanonicalizer.ABI_PROTOBUF, + canonicalizer.requireFormat("abi").getCodecId()); + assertEquals(PathStateCanonicalizer.P66_ACCOUNT, + canonicalizer.requireFormat("account").getCodecId()); + assertEquals(PathStateCanonicalizer.P66_ACCOUNT_ASSET, + canonicalizer.requireFormat("account-asset").getCodecId()); + assertEquals(PathStateCanonicalizer.LEGACY_ASSET_PROTOBUF, + canonicalizer.requireFormat("asset-issue").getCodecId()); + assertEquals(PathStateCanonicalizer.ASSET_V2_PROTOBUF, + canonicalizer.requireFormat("asset-issue-v2").getCodecId()); + assertEquals(PathStateCanonicalizer.STORAGE_ROW, + canonicalizer.requireFormat("storage-row").getCodecId()); + assertEquals(PathStateCanonicalizer.PHYSICAL_RAW, + canonicalizer.requireFormat("proposal").getCodecId()); + } + + @Test + public void p66OffPreservesAccountBytesAndRejectsDirectRows() throws Exception { + byte[] address = address(1); + byte[] raw = account(address).toBuilder() + .putAsset("legacy-name", 7L) + .putAssetV2("1000001", 11L) + .build() + .toByteArray(); + + PathStateMutation account = canonicalizer.put(P66Phase.P66_OFF, "account", address, raw); + assertArrayEquals(raw, account.getCanonicalValue()); + assertThrows(IllegalArgumentException.class, + () -> canonicalizer.accountAsset(P66Phase.P66_OFF, address, "1000001", 11L)); + assertThrows(IllegalArgumentException.class, + () -> canonicalizer.put(P66Phase.P66_OFF, "account-asset", + accountAssetKey(address, "1000001"), longBytes(11L))); + + byte[] mixed = Account.parseFrom(raw).toBuilder() + .setAssetOptimized(true) + .build() + .toByteArray(); + assertThrows(IllegalArgumentException.class, + () -> canonicalizer.put(P66Phase.P66_OFF, "account", address, mixed)); + } + + @Test + public void activationAndOnProduceTheSameCanonicalAccountGolden() throws Exception { + byte[] address = address(2); + byte[] raw = account(address).toBuilder() + .setBalance(99L) + .putAsset("legacy-name", 5L) + .putAssetV2("1000001", 17L) + .build() + .toByteArray(); + + PathStateMutation activation = canonicalizer.put(P66Phase.P66_ACTIVATION, + "account", address, raw); + PathStateMutation on = canonicalizer.put(P66Phase.P66_ON, "account", address, raw); + assertArrayEquals(activation.getCanonicalValue(), on.getCanonicalValue()); + Account canonical = Account.parseFrom(activation.getCanonicalValue()); + assertTrue(canonical.getAssetOptimized()); + assertTrue(canonical.getAssetMap().isEmpty()); + assertTrue(canonical.getAssetV2Map().isEmpty()); + assertEquals("1a154100000000000000000000000000000000000000022063e00301", + ByteArray.toHexString(activation.getCanonicalValue())); + assertEquals(1, canonicalizer.projectSnapshotAccountAssets( + P66Phase.P66_ON, address, raw).size()); + assertTrue(canonicalizer.projectSnapshotAccountAssets( + P66Phase.P66_ON, address, on.getCanonicalValue()).isEmpty()); + } + + @Test + public void accountAssetZeroIsDeleteAndPresentRowsAreExactSignedLongs() { + byte[] address = address(3); + PathStateMutation present = canonicalizer.accountAsset( + P66Phase.P66_ON, address, "1000001", -9L); + assertFalse(present.isDelete()); + assertArrayEquals(accountAssetKey(address, "1000001"), present.getCanonicalKey()); + assertArrayEquals(longBytes(-9L), present.getCanonicalValue()); + + PathStateMutation zero = canonicalizer.accountAsset( + P66Phase.P66_ON, address, "1000001", 0L); + assertTrue(zero.isDelete()); + assertThrows(IllegalArgumentException.class, + () -> canonicalizer.put(P66Phase.P66_ON, "account-asset", + accountAssetKey(address, "1000001"), longBytes(0L))); + assertThrows(IllegalArgumentException.class, + () -> canonicalizer.accountAsset(P66Phase.P66_ON, address, "01", 1L)); + } + + @Test + public void storageZeroIsDeleteWhileNonzeroWordIsExact() { + byte[] key = new byte[32]; + key[31] = 1; + byte[] word = new byte[32]; + word[31] = 2; + PathStateMutation present = canonicalizer.put( + P66Phase.P66_ON, "storage-row", key, word); + assertArrayEquals(word, present.getCanonicalValue()); + assertTrue(canonicalizer.delete(P66Phase.P66_ON, "storage-row", key).isDelete()); + assertThrows(IllegalArgumentException.class, + () -> canonicalizer.put(P66Phase.P66_ON, "storage-row", key, new byte[32])); + assertThrows(IllegalArgumentException.class, + () -> canonicalizer.put(P66Phase.P66_ON, "storage-row", new byte[31], word)); + } + + @Test + public void abiAllowsPresentEmptyButRejectsMalformedProtobufAndAddress() { + byte[] address = address(4); + PathStateMutation cleared = canonicalizer.put(P66Phase.P66_ON, "abi", address, new byte[0]); + assertFalse(cleared.isDelete()); + assertEquals(0, cleared.getCanonicalValue().length); + assertThrows(IllegalArgumentException.class, + () -> canonicalizer.put(P66Phase.P66_ON, "abi", address, new byte[]{-1})); + assertThrows(IllegalArgumentException.class, + () -> canonicalizer.put(P66Phase.P66_ON, "abi", new byte[20], new byte[0])); + } + + @Test + public void assetStoresRetainIndependentPhysicalIdentities() { + AssetIssueContract asset = AssetIssueContract.newBuilder() + .setName(ByteString.copyFromUtf8("legacy-name")) + .setId("1000001") + .build(); + byte[] raw = asset.toByteArray(); + PathStateMutation legacy = canonicalizer.put(P66Phase.P66_ON, "asset-issue", + "legacy-name".getBytes(StandardCharsets.UTF_8), raw); + PathStateMutation v2 = canonicalizer.put(P66Phase.P66_ON, "asset-issue-v2", + "1000001".getBytes(StandardCharsets.US_ASCII), raw); + assertFalse(legacy.isDelete()); + assertFalse(v2.isDelete()); + assertThrows(IllegalArgumentException.class, + () -> canonicalizer.put(P66Phase.P66_ON, "asset-issue", + "other".getBytes(StandardCharsets.UTF_8), raw)); + assertThrows(IllegalArgumentException.class, + () -> canonicalizer.put(P66Phase.P66_ON, "asset-issue-v2", + "01000001".getBytes(StandardCharsets.US_ASCII), raw)); + } + + @Test + public void genericPresentEmptyRemainsDistinctFromDelete() { + byte[] key = new byte[]{1}; + PathStateMutation present = canonicalizer.put( + P66Phase.P66_ON, "proposal", key, new byte[0]); + PathStateMutation absent = canonicalizer.delete(P66Phase.P66_ON, "proposal", key); + assertFalse(present.isDelete()); + assertTrue(absent.isDelete()); + assertArrayEquals(new byte[0], present.getCanonicalValue()); + assertThrows(IllegalArgumentException.class, + () -> canonicalizer.put(P66Phase.P66_ON, "unknown", key, new byte[0])); + } + + @Test + public void genericIndexStoresPreserveEmptyPhysicalKeys() { + PathStateMutation accountId = canonicalizer.put( + P66Phase.P66_ON, "accountid-index", new byte[0], address(5)); + PathStateMutation accountName = canonicalizer.put( + P66Phase.P66_ON, "account-index", new byte[0], address(6)); + + assertArrayEquals(new byte[0], accountId.getCanonicalKey()); + assertArrayEquals(new byte[0], accountName.getCanonicalKey()); + } + + private static Account account(byte[] address) { + return Account.newBuilder().setAddress(ByteString.copyFrom(address)).build(); + } + + private static byte[] address(int suffix) { + byte[] address = new byte[21]; + address[0] = 0x41; + address[20] = (byte) suffix; + return address; + } + + private static byte[] accountAssetKey(byte[] address, String tokenId) { + byte[] token = tokenId.getBytes(StandardCharsets.US_ASCII); + return ByteBuffer.allocate(address.length + token.length) + .put(address) + .put(token) + .array(); + } + + private static byte[] longBytes(long value) { + return ByteBuffer.allocate(Long.BYTES).putLong(value).array(); + } +} diff --git a/framework/src/test/java/org/tron/core/db2/stateroot/PathStateCheckpointMaterializerTest.java b/framework/src/test/java/org/tron/core/db2/stateroot/PathStateCheckpointMaterializerTest.java new file mode 100644 index 00000000000..c9258aa9f4b --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/stateroot/PathStateCheckpointMaterializerTest.java @@ -0,0 +1,218 @@ +package org.tron.core.db2.stateroot; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tron.core.db2.archive.BlockReverseDiff; +import org.tron.core.db2.archive.BlockSnapshotMeta; +import org.tron.core.db2.core.CommonCheckpointMaterializer.Status; +import org.tron.core.db2.core.CommonCheckpointPayload; +import org.tron.core.db2.core.CommonCheckpointTarget; +import org.tron.core.db2.stateroot.PathStateStoreManifest.Engine; + +public class PathStateCheckpointMaterializerTest { + + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void separatesSyncedMaterializationFromCurrentPublicationAcrossReopen() + throws Exception { + Fixture fixture = fixture("normal", null); + try { + assertEquals(Status.NEEDS_MATERIALIZATION, + fixture.materializer.inspect(fixture.target)); + fixture.materializer.materialize(fixture.payload, fixture.target); + assertEquals(Status.MATERIALIZED, fixture.materializer.inspect(fixture.target)); + assertFalse(Files.exists(fixture.root.resolve( + PathStateCheckpointMaterializer.CURRENT_FILE))); + assertArrayEquals(new byte[]{2}, fixture.stores.participant("account") + .getFlat(new byte[]{1})); + assertArrayEquals(new byte[]{4}, fixture.stores.participant("account") + .nodeStore().get(new byte[]{3})); + assertArrayEquals(new byte[]{6}, fixture.stores.superStore().nodeStore() + .get(new byte[]{5})); + long accountBatches = fixture.stores.participant("account").getSyncedWriteBatchCalls(); + long superBatches = fixture.stores.superStore().getSyncedWriteBatchCalls(); + fixture.materializer.materialize(fixture.payload, fixture.target); + assertEquals(accountBatches, + fixture.stores.participant("account").getSyncedWriteBatchCalls()); + assertEquals(superBatches, fixture.stores.superStore().getSyncedWriteBatchCalls()); + fixture.materializer.publish(fixture.target); + assertEquals(Status.PUBLISHED, fixture.materializer.inspect(fixture.target)); + } finally { + fixture.stores.close(); + } + + try (PathStatePhysicalStoreSet reopened = PathStatePhysicalStoreSet.openExisting(fixture.root, + fixture.scope, Engine.ROCKSDB)) { + PathStateCheckpointMaterializer recovered = new PathStateCheckpointMaterializer(reopened, + fixture.scope, fixture.formatIdentity); + assertEquals(Status.PUBLISHED, recovered.inspect(fixture.target)); + recovered.publish(fixture.target); + + CommonCheckpointPayload child = payload(fixture.formatIdentity, 2, hash(1), hash(2), + hash(11), hash(12)); + CommonCheckpointTarget childTarget = CommonCheckpointTarget.from(child); + assertEquals(Status.NEEDS_MATERIALIZATION, recovered.inspect(childTarget)); + recovered.materialize(child, childTarget); + recovered.publish(childTarget); + assertEquals(Status.PUBLISHED, recovered.inspect(childTarget)); + } + } + + @Test + public void resumesEveryStoreAndMarkerBoundaryWithoutRepeatingExactBatches() + throws Exception { + for (PathStateCheckpointMaterializer.Stage stage + : PathStateCheckpointMaterializer.Stage.values()) { + Fixture fixture = fixture("fault-" + stage, stage); + try { + if (stage == PathStateCheckpointMaterializer.Stage.AFTER_CURRENT) { + fixture.materializer.materialize(fixture.payload, fixture.target); + assertThrows(IOException.class, () -> fixture.materializer.publish(fixture.target)); + } else { + assertThrows(IOException.class, + () -> fixture.materializer.materialize(fixture.payload, fixture.target)); + } + } finally { + fixture.stores.close(); + } + + try (PathStatePhysicalStoreSet reopened = PathStatePhysicalStoreSet.openExisting(fixture.root, + fixture.scope, Engine.ROCKSDB)) { + PathStateCheckpointMaterializer recovered = new PathStateCheckpointMaterializer(reopened, + fixture.scope, fixture.formatIdentity); + Status status = recovered.inspect(fixture.target); + if (status == Status.NEEDS_MATERIALIZATION) { + recovered.materialize(fixture.payload, fixture.target); + } + recovered.publish(fixture.target); + assertEquals(Status.PUBLISHED, recovered.inspect(fixture.target)); + } + } + } + + @Test + public void rejectsForeignFormatCorruptCurrentAndNonParentTarget() throws Exception { + Fixture fixture = fixture("reject", null); + try { + CommonCheckpointPayload foreign = payload(hash(9), 1, hash(0), hash(1), hash(10), hash(11)); + assertThrows(IOException.class, () -> fixture.materializer.materialize(foreign, + CommonCheckpointTarget.from(foreign))); + + fixture.materializer.materialize(fixture.payload, fixture.target); + fixture.materializer.publish(fixture.target); + CommonCheckpointPayload nonChild = payload(fixture.formatIdentity, 3, hash(8), hash(9), + hash(12), hash(13)); + assertThrows(IOException.class, + () -> fixture.materializer.inspect(CommonCheckpointTarget.from(nonChild))); + + byte[] corrupt = Files.readAllBytes(fixture.root.resolve( + PathStateCheckpointMaterializer.CURRENT_FILE)); + corrupt[corrupt.length - 1] ^= 1; + Files.write(fixture.root.resolve(PathStateCheckpointMaterializer.CURRENT_FILE), corrupt); + assertThrows(IOException.class, () -> fixture.materializer.inspect(fixture.target)); + assertTrue(Files.isRegularFile(fixture.root.resolve( + PathStateCheckpointMaterializer.CURRENT_FILE))); + } finally { + fixture.stores.close(); + } + } + + private Fixture fixture(String name, PathStateCheckpointMaterializer.Stage failedStage) + throws Exception { + Path root = temporaryFolder.newFolder(name).toPath(); + PathStateParticipantScope scope = new PathStateCanonicalizer().participantScope(); + PathStatePhysicalStoreSet stores = PathStatePhysicalStoreSet.open(root, scope, Engine.ROCKSDB); + byte[] formatIdentity = hash(7); + CommonCheckpointPayload payload = payload(formatIdentity, 1, hash(0), hash(1), + hash(10), hash(11)); + PathStateCheckpointMaterializer materializer = new PathStateCheckpointMaterializer(stores, + scope, formatIdentity, failAt(failedStage)); + return new Fixture(root, scope, stores, formatIdentity, payload, materializer); + } + + private static PathStateCheckpointMaterializer.FaultHook failAt( + PathStateCheckpointMaterializer.Stage failedStage) { + return (stage, storeId) -> { + if (stage == failedStage) { + throw new IOException("injected " + stage + " at " + storeId); + } + }; + } + + private static CommonCheckpointPayload payload(byte[] formatIdentity, long blockNumber, + byte[] parentHash, byte[] blockHash, byte[] parentRoot, byte[] stateRoot) { + BlockSnapshotMeta meta = BlockSnapshotMeta.forBlock(blockNumber, blockHash, parentHash, + blockNumber * 3_000L); + PathStateFlushTarget.BlockBinding binding = mock(PathStateFlushTarget.BlockBinding.class); + when(binding.getMeta()).thenReturn(meta); + when(binding.getParentStateRoot()).thenReturn(parentRoot); + when(binding.getStateRoot()).thenReturn(stateRoot); + when(binding.getTransitionPayloadDigest()).thenReturn(hash((int) blockNumber + 30)); + + PathStateFlushTarget.StoreTarget store = mock(PathStateFlushTarget.StoreTarget.class); + when(store.getStoreId()).thenReturn(4); + when(store.getDbName()).thenReturn("account"); + when(store.getStoreRoot()).thenReturn(hash((int) blockNumber + 40)); + when(store.getFlatMutations()).thenReturn(Collections.singletonList( + new PathStateSnapshotDelta.Mutation(new byte[]{1}, new byte[]{2}))); + when(store.getNodeMutations()).thenReturn(Collections.singletonList( + new PathStateSnapshotDelta.Mutation(new byte[]{3}, new byte[]{4}))); + + PathStateFlushTarget target = mock(PathStateFlushTarget.class); + when(target.getBlocks()).thenReturn(Collections.singletonList(binding)); + when(target.getParentStateRoot()).thenReturn(parentRoot); + when(target.getStateRoot()).thenReturn(stateRoot); + when(target.getStores()).thenReturn(Collections.singletonList(store)); + when(target.getSuperNodeMutations()).thenReturn(Collections.singletonList( + new PathStateSnapshotDelta.Mutation(new byte[]{5}, new byte[]{6}))); + return CommonCheckpointPayload.create(formatIdentity, target, + Collections.singletonList(new BlockReverseDiff(meta, Collections.emptyList())), + Collections.emptyList()); + } + + private static byte[] hash(int seed) { + byte[] hash = new byte[32]; + for (int index = 0; index < hash.length; index++) { + hash[index] = (byte) (seed + index); + } + return hash; + } + + private static final class Fixture { + + private final Path root; + private final PathStateParticipantScope scope; + private final PathStatePhysicalStoreSet stores; + private final byte[] formatIdentity; + private final CommonCheckpointPayload payload; + private final CommonCheckpointTarget target; + private final PathStateCheckpointMaterializer materializer; + + private Fixture(Path root, PathStateParticipantScope scope, + PathStatePhysicalStoreSet stores, byte[] formatIdentity, + CommonCheckpointPayload payload, PathStateCheckpointMaterializer materializer) { + this.root = root; + this.scope = scope; + this.stores = stores; + this.formatIdentity = formatIdentity; + this.payload = payload; + this.target = CommonCheckpointTarget.from(payload); + this.materializer = materializer; + } + } +} diff --git a/framework/src/test/java/org/tron/core/db2/stateroot/PathStateCommitmentCodecTest.java b/framework/src/test/java/org/tron/core/db2/stateroot/PathStateCommitmentCodecTest.java new file mode 100644 index 00000000000..fb54c1ba4f9 --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/stateroot/PathStateCommitmentCodecTest.java @@ -0,0 +1,143 @@ +package org.tron.core.db2.stateroot; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; + +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import org.bouncycastle.jcajce.provider.digest.Keccak; +import org.bouncycastle.util.encoders.Hex; +import org.junit.Test; +import org.tron.common.crypto.Hash; +import org.tron.core.capsule.utils.RLP; + +public class PathStateCommitmentCodecTest { + + private static final byte[] STORE_DOMAIN = + "java-tron/path-state/store-leaf-key".getBytes(StandardCharsets.US_ASCII); + private static final byte[] SUPER_DOMAIN = + "java-tron/path-state/super-leaf-key".getBytes(StandardCharsets.US_ASCII); + + @Test + public void fixedStoreKeyGoldensMatchIndependentKeccakOracle() throws Exception { + byte[] accountKey = Hex.decode("410000000000000000000000000000000000000001"); + byte[] storageKey = new byte[32]; + for (int i = 0; i < storageKey.length; i++) { + storageKey[i] = (byte) i; + } + + assertGolden("0b7f18d3381a9e44da93058f4214d7f0d818d1824be0f30839ed5200eb7f946a", + 4, accountKey); + assertGolden("90ad9575451bd26f005db5063deaeac71d8b221edd0a0bb0a345f21b40c16ff5", + 22, storageKey); + assertGolden("ec6a48ade48f24cd456a89de3a5f86d282b0ae9892f265be58e5e8a704856350", + 21, new byte[]{1}); + } + + @Test + public void approvedAbiAndAssetIssueStoresHaveIndependentLeafDomains() throws Exception { + assertGolden("14af9866899065b509f6ea5d45902d443a4357efad5e6478c47ef108d603b8d7", + 1, new byte[]{1}); + assertGolden("5c0a5639b07fa98f7e067c3e0c1d067ca1de752810b7576bc20fd2c75ba89eb5", + 6, new byte[]{1}); + assertGolden("99951328ba6d7d4a7fa199cf727a7c4494bd885ce2ac8c04475dd1fd699af7de", + 7, new byte[]{1}); + } + + @Test + public void lengthDelimitedEmptyKeyHasAnIndependentLeafIdentity() throws Exception { + byte[] empty = PathStateCommitmentCodec.storeLeafKey(2, new byte[0]); + assertArrayEquals(referenceStoreKey(2, new byte[0]), empty); + assertFalse(Arrays.equals(empty, + PathStateCommitmentCodec.storeLeafKey(2, new byte[]{0}))); + } + + @Test + public void presentValuesKeepEmptyAndZeroDistinct() { + assertArrayEquals(Hex.decode("c20180"), + PathStateCommitmentCodec.presentLeafValue(new byte[0])); + assertArrayEquals(Hex.decode("c20100"), + PathStateCommitmentCodec.presentLeafValue(new byte[]{0})); + assertArrayEquals(Hex.decode("c7018568656c6c6f"), + PathStateCommitmentCodec.presentLeafValue("hello".getBytes(StandardCharsets.US_ASCII))); + assertFalse(Arrays.equals(PathStateCommitmentCodec.presentLeafValue(new byte[0]), + PathStateCommitmentCodec.presentLeafValue(new byte[]{0}))); + } + + @Test + public void superLeafGoldensBindStableIdentityFormatAndRoot() throws Exception { + byte[] storeRoot = new byte[32]; + for (int i = 0; i < storeRoot.length; i++) { + storeRoot[i] = (byte) i; + } + + assertArrayEquals( + Hex.decode("cf8715b85b2ac18d2b63e57b9e8902887f1986df7dc6a46da01fbc1f8f99f8bf"), + PathStateCommitmentCodec.superLeafKey(4)); + assertArrayEquals(referenceSuperKey(4), PathStateCommitmentCodec.superLeafKey(4)); + assertArrayEquals(Hex.decode("f38400000004876163636f756e748400000001a0000102030405060708090a0b" + + "0c0d0e0f101112131415161718191a1b1c1d1e1f"), + PathStateCommitmentCodec.superLeafValue(4, "account", 1, storeRoot)); + assertArrayEquals(referenceSuperValue(4, "account", 1, storeRoot), + PathStateCommitmentCodec.superLeafValue(4, "account", 1, storeRoot)); + } + + @Test + public void rejectsAmbiguousOrUnboundInputs() { + assertThrows(IllegalArgumentException.class, + () -> PathStateCommitmentCodec.storeLeafKey(0, new byte[]{1})); + assertThrows(NullPointerException.class, + () -> PathStateCommitmentCodec.presentLeafValue(null)); + assertThrows(IllegalArgumentException.class, + () -> PathStateCommitmentCodec.superLeafValue(1, "", 1, new byte[32])); + assertThrows(IllegalArgumentException.class, + () -> PathStateCommitmentCodec.superLeafValue(1, "account", 0, new byte[32])); + assertThrows(IllegalArgumentException.class, + () -> PathStateCommitmentCodec.superLeafValue(1, "account", 1, new byte[31])); + } + + private static void assertGolden(String expectedHex, int storeId, byte[] key) + throws IOException { + byte[] actual = PathStateCommitmentCodec.storeLeafKey(storeId, key); + assertEquals(expectedHex, Hex.toHexString(actual)); + assertArrayEquals(referenceStoreKey(storeId, key), actual); + } + + private static byte[] referenceStoreKey(int storeId, byte[] key) throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (DataOutputStream output = new DataOutputStream(bytes)) { + output.writeShort(STORE_DOMAIN.length); + output.write(STORE_DOMAIN); + output.writeShort(PathStateCommitmentCodec.FORMAT_VERSION); + output.writeInt(storeId); + output.writeInt(key.length); + output.write(key); + } + return new Keccak.Digest256().digest(bytes.toByteArray()); + } + + private static byte[] referenceSuperKey(int storeId) throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (DataOutputStream output = new DataOutputStream(bytes)) { + output.writeShort(SUPER_DOMAIN.length); + output.write(SUPER_DOMAIN); + output.writeShort(PathStateCommitmentCodec.FORMAT_VERSION); + output.writeInt(storeId); + } + return new Keccak.Digest256().digest(bytes.toByteArray()); + } + + private static byte[] referenceSuperValue(int storeId, String dbName, int formatVersion, + byte[] storeRoot) { + return RLP.encodeList(Hash.encodeElement(ByteBuffer.allocate(4).putInt(storeId).array()), + Hash.encodeElement(dbName.getBytes(StandardCharsets.UTF_8)), + Hash.encodeElement(ByteBuffer.allocate(4).putInt(formatVersion).array()), + Hash.encodeElement(storeRoot)); + } +} diff --git a/framework/src/test/java/org/tron/core/db2/stateroot/PathStateCurrentOnlyContractTest.java b/framework/src/test/java/org/tron/core/db2/stateroot/PathStateCurrentOnlyContractTest.java new file mode 100644 index 00000000000..d1237dec953 --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/stateroot/PathStateCurrentOnlyContractTest.java @@ -0,0 +1,169 @@ +package org.tron.core.db2.stateroot; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; + +import java.io.File; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Collections; +import java.util.Set; +import java.util.TreeSet; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tron.common.arch.Arch; +import org.tron.core.db2.stateroot.PathStateCanonicalizer.P66Phase; +import org.tron.core.db2.stateroot.PathStateStoreManifest.Engine; + +public class PathStateCurrentOnlyContractTest { + + private static final Class[] DURABLE_API = new Class[]{ + PathStateBasePublication.class, + PathStateCatchUpQueue.class, + PathStateCurrentStore.class, + PathStateLayer.class, + PathStateLayerLimits.class, + PathStateLayerPublication.class, + PathStateLayerRetirement.class, + PathStateNodeStoreSet.class, + PathStateRootMetadata.class, + PathStateStoreManifest.class + }; + + private static final String[] FORBIDDEN_API_TOKENS = new String[]{ + "getrootat", "histor", "proof", "segment" + }; + + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void durablePublicApiExposesCurrentAuthorityButNoHistoricalService() throws Exception { + assertEquals(setOf("appendLayer", "current", "isInitialized", "publishBase"), + publicMethodNames(PathStateCurrentStore.class)); + assertEquals(setOf("close", "commit", "createRoot", "getDirectory", "getProgress", + "openBase", "openCurrent", "openLayer"), + publicMethodNames(PathStateNodeStoreSet.class)); + assertEquals(setOf("recover", "switchToAncestor"), + publicMethodNames(PathStateLayerRetirement.class)); + for (Class type : DURABLE_API) { + for (Method method : type.getDeclaredMethods()) { + if (Modifier.isPublic(method.getModifiers())) { + String name = method.getName().toLowerCase(java.util.Locale.ROOT); + for (String forbidden : FORBIDDEN_API_TOKENS) { + assertFalse(type.getSimpleName() + "." + method.getName(), + name.contains(forbidden)); + } + } + } + } + + assertFalse(Modifier.isPublic(PathStateNodeStoreSet.class.getDeclaredMethod( + "openPublished", PathStateStoreManifest.class, PathStateRootMetadata.class) + .getModifiers())); + assertFalse(Modifier.isPublic(PathStateCurrentStore.class.getDeclaredMethod( + "layersAboveAncestor", PathStateRootMetadata.class, PathStateLayerLimits.class) + .getModifiers())); + assertFalse(Modifier.isPublic(PathStateCurrentStore.class.getDeclaredMethod( + "switchToAncestor", PathStateRootMetadata.class, PathStateLayerLimits.class) + .getModifiers())); + } + + @Test + public void completedForkLifecycleKeepsExactCurrentOnlyRootLayout() throws Exception { + for (Engine engine : availableEngines()) { + PathStateStoreManifest manifest = PathStateStoreManifest.createOrOpen( + new File(temporaryFolder.getRoot(), "layout-" + engine).toPath(), engine); + PathStateRootMetadata base = publishBase(manifest); + PathStateLayerLimits limits = new PathStateLayerLimits(2, Long.MAX_VALUE); + PathStateRootMetadata firstA = append(manifest, base, 101, 11, limits); + PathStateRootMetadata secondA = append(manifest, firstA, 102, 13, limits); + new PathStateLayerRetirement(manifest, limits).switchToAncestor(base); + PathStateRootMetadata current = append(manifest, base, 101, 21, limits); + + assertEquals(setOf("CURRENT", "MANIFEST", "base", "layers"), + children(manifest.getDirectory())); + assertEquals(setOf("METADATA", "nodes"), children(manifest.getBaseDirectory())); + assertEquals(Collections.singleton(layerDirectory(manifest, current) + .getFileName().toString()), + children(manifest.getLayersDirectory())); + assertEquals(setOf("METADATA", "nodes"), children( + manifest.getLayerDirectory(current.getBlockNumber(), current.getBlockHash()))); + assertFalse(Files.exists(manifest.getDirectory().resolve("history"))); + assertFalse(Files.exists(manifest.getDirectory().resolve("segments"))); + assertFalse(Files.exists(manifest.getDirectory().resolve("index"))); + assertFalse(Files.exists(manifest.getDirectory().resolve("proofs"))); + assertFalse(Files.exists(layerDirectory(manifest, firstA))); + assertFalse(Files.exists(layerDirectory(manifest, secondA))); + assertArrayEquals(current.encode(), new PathStateCurrentStore(manifest).current().encode()); + } + } + + private static PathStateRootMetadata publishBase(PathStateStoreManifest manifest) + throws Exception { + try (PathStateNodeStoreSet stores = PathStateNodeStoreSet.openBase(manifest)) { + PathStateRoot root = stores.createRoot(); + root.apply(Arrays.asList( + PathStateMutation.put("proposal", new byte[]{1}, new byte[]{2}), + PathStateMutation.put("account", new byte[]{3}, new byte[]{4}))); + PathStateRootMetadata base = PathStateRootMetadata.base(100, bytes(1), bytes(2), 300, + P66Phase.P66_ON, manifest.getIdentityDigest(), root.rootHash(), bytes(3)); + new PathStateBasePublication(manifest).publish(stores, base); + return base; + } + } + + private static PathStateRootMetadata append(PathStateStoreManifest manifest, + PathStateRootMetadata parent, long blockNumber, int seed, PathStateLayerLimits limits) + throws Exception { + try (PathStateLayer layer = PathStateLayer.begin(manifest, parent, blockNumber, bytes(seed), + parent.getBlockHash(), blockNumber * 3, P66Phase.P66_ON, bytes(seed + 1), limits)) { + layer.apply(Collections.singletonList( + PathStateMutation.put("proposal", new byte[]{1}, new byte[]{(byte) seed}))); + return layer.commit(); + } + } + + private static Set children(Path directory) throws Exception { + try (Stream paths = Files.list(directory)) { + return paths.map(path -> path.getFileName().toString()).collect( + Collectors.toCollection(TreeSet::new)); + } + } + + private static Set setOf(String... values) { + return new TreeSet<>(Arrays.asList(values)); + } + + private static Set publicMethodNames(Class type) { + return Arrays.stream(type.getDeclaredMethods()) + .filter(method -> Modifier.isPublic(method.getModifiers())) + .map(Method::getName) + .collect(Collectors.toCollection(TreeSet::new)); + } + + private static Path layerDirectory(PathStateStoreManifest manifest, + PathStateRootMetadata metadata) { + return manifest.getLayerDirectory(metadata.getBlockNumber(), metadata.getBlockHash()); + } + + private static Engine[] availableEngines() { + return Arch.isArm64() ? new Engine[]{Engine.ROCKSDB} + : new Engine[]{Engine.LEVELDB, Engine.ROCKSDB}; + } + + private static byte[] bytes(int seed) { + byte[] value = new byte[32]; + for (int index = 0; index < value.length; index++) { + value[index] = (byte) (seed + index); + } + return value; + } +} diff --git a/framework/src/test/java/org/tron/core/db2/stateroot/PathStateCurrentStoreTest.java b/framework/src/test/java/org/tron/core/db2/stateroot/PathStateCurrentStoreTest.java new file mode 100644 index 00000000000..5f44d6f5a86 --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/stateroot/PathStateCurrentStoreTest.java @@ -0,0 +1,202 @@ +package org.tron.core.db2.stateroot; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Locale; +import java.util.stream.Stream; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tron.core.db2.stateroot.PathStateCanonicalizer.P66Phase; +import org.tron.core.db2.stateroot.PathStateRootMetadata.Kind; +import org.tron.core.db2.stateroot.PathStateStoreManifest.Engine; + +public class PathStateCurrentStoreTest { + + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void publishesBaseAndLayersThenVerifiesCurrentAfterReopen() throws Exception { + PathStateStoreManifest manifest = manifest("normal-chain"); + PathStateCurrentStore store = new PathStateCurrentStore(manifest); + assertFalse(store.isInitialized()); + + PathStateRootMetadata base = base(manifest, 100); + PathStateRootMetadata first = layer(manifest, 101, base, 11); + PathStateRootMetadata second = layer(manifest, 102, first, 12); + store.publishBase(base); + store.appendLayer(first); + store.appendLayer(second); + + PathStateCurrentStore reopened = new PathStateCurrentStore( + PathStateStoreManifest.validateExisting(manifest.getDirectory(), Engine.LEVELDB)); + PathStateRootMetadata current = reopened.current(); + assertTrue(reopened.isInitialized()); + assertEquals(102, current.getBlockNumber()); + assertEquals(Kind.LAYER, current.getKind()); + assertArrayEquals(second.getStateRoot(), current.getStateRoot()); + assertEquals(2, childDirectoryCount(manifest.getLayersDirectory())); + } + + @Test + public void exactPublicationRetriesAreIdempotent() throws Exception { + PathStateStoreManifest manifest = manifest("idempotent"); + PathStateCurrentStore store = new PathStateCurrentStore(manifest); + PathStateRootMetadata base = base(manifest, 100); + PathStateRootMetadata child = layer(manifest, 101, base, 11); + + store.publishBase(base); + store.publishBase(base); + store.appendLayer(child); + store.appendLayer(child); + + assertEquals(101, store.current().getBlockNumber()); + assertEquals(1, childDirectoryCount(manifest.getLayersDirectory())); + } + + @Test + public void rejectsLayerThatDoesNotExtendCurrentBeforePublication() throws Exception { + PathStateStoreManifest manifest = manifest("discontinuous"); + PathStateCurrentStore store = new PathStateCurrentStore(manifest); + PathStateRootMetadata base = base(manifest, 100); + store.publishBase(base); + PathStateRootMetadata wrongParent = PathStateRootMetadata.layer(101, hash(101), hash(99), + 303, P66Phase.P66_ON, manifest.getIdentityDigest(), root(100), root(101), digest(101)); + + assertThrows(IOException.class, () -> store.appendLayer(wrongParent)); + assertEquals(100, store.current().getBlockNumber()); + assertEquals(0, childDirectoryCount(manifest.getLayersDirectory())); + } + + @Test + public void rejectsMetadataFromAnotherManifestIdentity() throws Exception { + PathStateStoreManifest manifest = manifest("foreign-format"); + PathStateCurrentStore store = new PathStateCurrentStore(manifest); + PathStateRootMetadata foreign = PathStateRootMetadata.base(100, hash(100), hash(99), 300, + P66Phase.P66_ON, digest(17), root(100), digest(100)); + + assertThrows(IOException.class, () -> store.publishBase(foreign)); + assertFalse(store.isInitialized()); + } + + @Test + public void startupFailsClosedWhenMiddleLayerIsMissing() throws Exception { + PathStateStoreManifest manifest = manifest("missing-middle"); + PathStateCurrentStore store = new PathStateCurrentStore(manifest); + PathStateRootMetadata base = base(manifest, 100); + PathStateRootMetadata first = layer(manifest, 101, base, 11); + PathStateRootMetadata second = layer(manifest, 102, first, 12); + store.publishBase(base); + store.appendLayer(first); + store.appendLayer(second); + + Path middle = onlyMetadataForHeight(manifest.getLayersDirectory(), 101); + Files.delete(middle); + + assertThrows(IOException.class, store::current); + } + + @Test + public void immutableMetadataRejectsDifferentRetryWithoutRewrite() throws Exception { + Path path = new File(temporaryFolder.getRoot(), "immutable/METADATA").toPath(); + PathStateRootMetadata original = rawBase(100); + PathStateMetadataFile.publishImmutable(path, original); + byte[] encoded = Files.readAllBytes(path); + + assertThrows(IOException.class, + () -> PathStateMetadataFile.publishImmutable(path, rawBase(101))); + assertArrayEquals(encoded, Files.readAllBytes(path)); + } + + @Test + public void failedCurrentReplacementPreservesPreviousAuthorityAndCleansTemporary() + throws Exception { + PathStateStoreManifest manifest = manifest("current-fault"); + PathStateCurrentStore store = new PathStateCurrentStore(manifest); + PathStateRootMetadata base = base(manifest, 100); + store.publishBase(base); + Path current = manifest.getDirectory().resolve(PathStateCurrentStore.CURRENT_FILE); + byte[] previous = Files.readAllBytes(current); + PathStateRootMetadata child = layer(manifest, 101, base, 11); + + assertThrows(IOException.class, () -> PathStateMetadataFile.replaceCurrent(current, child, + temporary -> { + assertTrue(Files.exists(temporary)); + throw new IOException("injected after temporary force"); + })); + + assertArrayEquals(previous, Files.readAllBytes(current)); + try (Stream paths = Files.list(manifest.getDirectory())) { + assertFalse(paths.anyMatch(path -> path.getFileName().toString().startsWith(".CURRENT-"))); + } + assertEquals(100, store.current().getBlockNumber()); + } + + private PathStateStoreManifest manifest(String name) throws Exception { + Path root = temporaryFolder.newFolder(name).toPath(); + return PathStateStoreManifest.createOrOpen(root, Engine.LEVELDB); + } + + private static PathStateRootMetadata base(PathStateStoreManifest manifest, long blockNumber) { + return PathStateRootMetadata.base(blockNumber, hash(blockNumber), hash(blockNumber - 1), + blockNumber * 3, P66Phase.P66_ON, manifest.getIdentityDigest(), root(blockNumber), + digest(blockNumber)); + } + + private static PathStateRootMetadata layer(PathStateStoreManifest manifest, long blockNumber, + PathStateRootMetadata parent, int salt) { + return PathStateRootMetadata.layer(blockNumber, hash(blockNumber), parent.getBlockHash(), + blockNumber * 3, P66Phase.P66_ON, manifest.getIdentityDigest(), parent.getStateRoot(), + root(blockNumber), digest(salt)); + } + + private static PathStateRootMetadata rawBase(long blockNumber) { + return PathStateRootMetadata.base(blockNumber, hash(blockNumber), hash(blockNumber - 1), + blockNumber * 3, P66Phase.P66_ON, digest(17), root(blockNumber), digest(blockNumber)); + } + + private static Path onlyMetadataForHeight(Path layers, long blockNumber) throws Exception { + String prefix = String.format(Locale.ROOT, "%020d-", blockNumber); + try (Stream paths = Files.list(layers)) { + Path directory = paths.filter(path -> path.getFileName().toString().startsWith(prefix)) + .findFirst() + .orElseThrow(() -> new AssertionError("layer directory not found")); + return directory.resolve(PathStateCurrentStore.METADATA_FILE); + } + } + + private static long childDirectoryCount(Path path) throws Exception { + try (Stream paths = Files.list(path)) { + return paths.filter(Files::isDirectory).count(); + } + } + + private static byte[] hash(long value) { + return bytes((int) value); + } + + private static byte[] root(long value) { + return bytes((int) value + 64); + } + + private static byte[] digest(long value) { + return bytes((int) value + 96); + } + + private static byte[] bytes(int seed) { + byte[] value = new byte[32]; + for (int i = 0; i < value.length; i++) { + value[i] = (byte) (seed + i); + } + return value; + } +} diff --git a/framework/src/test/java/org/tron/core/db2/stateroot/PathStateLayerLimitsTest.java b/framework/src/test/java/org/tron/core/db2/stateroot/PathStateLayerLimitsTest.java new file mode 100644 index 00000000000..d43bbe9b43c --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/stateroot/PathStateLayerLimitsTest.java @@ -0,0 +1,153 @@ +package org.tron.core.db2.stateroot; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.io.File; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Collections; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tron.common.arch.Arch; +import org.tron.core.db2.stateroot.PathStateCanonicalizer.P66Phase; +import org.tron.core.db2.stateroot.PathStateStoreManifest.Engine; + +public class PathStateLayerLimitsTest { + + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void countLimitRejectsSecondLayerBeforeCreatingItsDirectory() throws Exception { + for (Engine engine : availableEngines()) { + Fixture fixture = fixture("count-" + engine, engine); + PathStateLayerLimits limits = new PathStateLayerLimits(1, Long.MAX_VALUE); + PathStateRootMetadata first = append(fixture.manifest, fixture.base, 101, 11, limits); + Path rejected = fixture.manifest.getLayerDirectory(102, bytes(13)); + + assertThrows(IOException.class, () -> PathStateLayer.begin(fixture.manifest, first, 102, + bytes(13), first.getBlockHash(), 306, P66Phase.P66_ON, bytes(14), limits)); + assertFalse(Files.exists(rejected)); + assertArrayEquals(first.encode(), + new PathStateCurrentStore(fixture.manifest).current().encode()); + Long logicalBytes = PathStateNodeStoreSet.loadLogicalBytes( + fixture.manifest.getLayerDirectory(101, first.getBlockHash()), fixture.manifest); + assertNotNull(logicalBytes); + assertTrue(logicalBytes > 0); + } + } + + @Test + public void byteLimitRejectsCommitBeforeIntentOrNativeProgress() throws Exception { + Fixture fixture = fixture("bytes", Engine.ROCKSDB); + PathStateLayerLimits limits = new PathStateLayerLimits(10, 1); + Path directory = fixture.manifest.getLayerDirectory(101, bytes(11)); + try (PathStateLayer layer = PathStateLayer.begin(fixture.manifest, fixture.base, 101, + bytes(11), fixture.base.getBlockHash(), 303, P66Phase.P66_ON, bytes(12), limits)) { + layer.apply(Collections.singletonList( + PathStateMutation.put("proposal", new byte[]{1}, new byte[]{5}))); + assertThrows(IOException.class, layer::commit); + } + + assertFalse(Files.exists(directory.resolve(PathStateLayerPublication.INTENT_FILE))); + assertFalse(Files.exists(directory.resolve(PathStateCurrentStore.METADATA_FILE))); + assertNull(PathStateNodeStoreSet.loadProgress(directory, fixture.manifest)); + assertNull(PathStateNodeStoreSet.loadLogicalBytes(directory, fixture.manifest)); + assertArrayEquals(fixture.base.encode(), + new PathStateCurrentStore(fixture.manifest).current().encode()); + } + + @Test + public void restartRejectsStoredUsageAboveConfiguredLimits() throws Exception { + Fixture fixture = fixture("restart-limit", Engine.ROCKSDB); + PathStateLayerLimits roomy = new PathStateLayerLimits(10, Long.MAX_VALUE); + PathStateRootMetadata first = append(fixture.manifest, fixture.base, 101, 11, roomy); + append(fixture.manifest, first, 102, 13, roomy); + + assertThrows(IOException.class, () -> new PathStateLayerPublication(fixture.manifest, + new PathStateLayerLimits(1, Long.MAX_VALUE)).recover()); + assertThrows(IOException.class, () -> new PathStateLayerPublication(fixture.manifest, + new PathStateLayerLimits(10, 1)).recover()); + } + + @Test + public void corruptLogicalBytesMarkerFailsClosed() throws Exception { + Fixture fixture = fixture("corrupt-marker", Engine.ROCKSDB); + PathStateRootMetadata layer = append(fixture.manifest, fixture.base, 101, 11, + PathStateLayerLimits.defaults()); + Path nodes = fixture.manifest.getLayerDirectory(101, layer.getBlockHash()) + .resolve(PathStateNodeStoreSet.NODES_DIRECTORY); + try (PathStateNativeNodeStore store = PathStateNativeNodeStore.open(nodes, Engine.ROCKSDB)) { + store.put(logicalBytesKey(), ByteBuffer.allocate(Long.BYTES).putLong(1).array()); + } + + assertThrows(IOException.class, + new PathStateLayerPublication(fixture.manifest)::recover); + } + + private Fixture fixture(String name, Engine engine) throws Exception { + PathStateStoreManifest manifest = PathStateStoreManifest.createOrOpen( + new File(temporaryFolder.getRoot(), name).toPath(), engine); + try (PathStateNodeStoreSet stores = PathStateNodeStoreSet.openBase(manifest)) { + PathStateRoot root = stores.createRoot(); + root.apply(Arrays.asList( + PathStateMutation.put("proposal", new byte[]{1}, new byte[]{2}), + PathStateMutation.put("account", new byte[]{3}, new byte[]{4}))); + PathStateRootMetadata base = PathStateRootMetadata.base(100, bytes(1), bytes(2), 300, + P66Phase.P66_ON, manifest.getIdentityDigest(), root.rootHash(), bytes(3)); + new PathStateBasePublication(manifest).publish(stores, base); + return new Fixture(manifest, base); + } + } + + private static PathStateRootMetadata append(PathStateStoreManifest manifest, + PathStateRootMetadata parent, long blockNumber, int seed, PathStateLayerLimits limits) + throws Exception { + try (PathStateLayer layer = PathStateLayer.begin(manifest, parent, blockNumber, bytes(seed), + parent.getBlockHash(), blockNumber * 3, P66Phase.P66_ON, bytes(seed + 1), limits)) { + layer.apply(Collections.singletonList( + PathStateMutation.put("proposal", new byte[]{1}, new byte[]{(byte) seed}))); + return layer.commit(); + } + } + + private static Engine[] availableEngines() { + return Arch.isArm64() ? new Engine[]{Engine.ROCKSDB} + : new Engine[]{Engine.LEVELDB, Engine.ROCKSDB}; + } + + private static byte[] logicalBytesKey() { + byte[] suffix = "logical-bytes".getBytes(StandardCharsets.US_ASCII); + return ByteBuffer.allocate(Integer.BYTES + suffix.length) + .putInt(-1).put(suffix).array(); + } + + private static byte[] bytes(int seed) { + byte[] value = new byte[32]; + for (int index = 0; index < value.length; index++) { + value[index] = (byte) (seed + index); + } + return value; + } + + private static final class Fixture { + + private final PathStateStoreManifest manifest; + private final PathStateRootMetadata base; + + private Fixture(PathStateStoreManifest manifest, PathStateRootMetadata base) { + this.manifest = manifest; + this.base = base; + } + } +} diff --git a/framework/src/test/java/org/tron/core/db2/stateroot/PathStateLayerPublicationTest.java b/framework/src/test/java/org/tron/core/db2/stateroot/PathStateLayerPublicationTest.java new file mode 100644 index 00000000000..afb42bc7381 --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/stateroot/PathStateLayerPublicationTest.java @@ -0,0 +1,187 @@ +package org.tron.core.db2.stateroot; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Collections; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tron.common.arch.Arch; +import org.tron.core.db2.stateroot.PathStateCanonicalizer.P66Phase; +import org.tron.core.db2.stateroot.PathStateLayerPublication.RecoveryAction; +import org.tron.core.db2.stateroot.PathStateLayerPublication.Stage; +import org.tron.core.db2.stateroot.PathStateStoreManifest.Engine; + +public class PathStateLayerPublicationTest { + + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void faultsBeforeNativeProgressRollBackIntentIdempotently() throws Exception { + for (Engine engine : availableEngines()) { + Fixture fixture = fixture("before-progress-" + engine, engine, Stage.AFTER_INTENT); + assertThrows(IOException.class, fixture::publish); + fixture.close(); + + PathStateLayerPublication recovery = new PathStateLayerPublication(fixture.manifest); + assertEquals(RecoveryAction.ROLLED_BACK_INTENT, recovery.recover()); + assertEquals(RecoveryAction.NONE, recovery.recover()); + assertArrayEquals(fixture.base.encode(), + new PathStateCurrentStore(fixture.manifest).current().encode()); + assertFalse(Files.exists(fixture.intentPath())); + } + } + + @Test + public void faultsAfterNativeProgressCompletePublicationIdempotently() throws Exception { + for (Engine engine : availableEngines()) { + for (Stage stage : new Stage[]{Stage.AFTER_NODE_PROGRESS, Stage.AFTER_METADATA, + Stage.AFTER_CURRENT}) { + Fixture fixture = fixture("complete-" + engine + "-" + stage, engine, stage); + assertThrows(IOException.class, fixture::publish); + fixture.close(); + + PathStateLayerPublication recovery = new PathStateLayerPublication(fixture.manifest); + assertEquals(RecoveryAction.COMPLETED_PUBLICATION, recovery.recover()); + assertEquals(RecoveryAction.NONE, recovery.recover()); + assertSettled(fixture); + } + } + } + + @Test + public void faultAfterRetireIsAlreadySettled() throws Exception { + Fixture fixture = fixture("after-retire", Engine.ROCKSDB, Stage.AFTER_RETIRE); + assertThrows(IOException.class, fixture::publish); + fixture.close(); + + PathStateLayerPublication recovery = new PathStateLayerPublication(fixture.manifest); + assertEquals(RecoveryAction.NONE, recovery.recover()); + assertSettled(fixture); + } + + @Test + public void recoveryRejectsNativeProgressWithoutIntentOrMetadata() throws Exception { + Fixture fixture = fixture("orphan-progress", Engine.ROCKSDB, Stage.AFTER_NODE_PROGRESS); + assertThrows(IOException.class, fixture::publish); + fixture.close(); + Files.delete(fixture.intentPath()); + + assertThrows(IOException.class, + new PathStateLayerPublication(fixture.manifest)::recover); + } + + @Test + public void staleForkIntentCannotAdvanceCurrent() throws Exception { + Fixture stale = fixture("stale-intent", Engine.ROCKSDB, Stage.AFTER_INTENT); + assertThrows(IOException.class, stale::publish); + stale.close(); + + try (PathStateLayer canonical = PathStateLayer.begin(stale.manifest, stale.base, 101, + bytes(21), stale.base.getBlockHash(), 303, P66Phase.P66_ON, bytes(22))) { + canonical.apply(Collections.singletonList( + PathStateMutation.put("proposal", new byte[]{1}, new byte[]{7}))); + canonical.commit(); + } + + assertThrows(IOException.class, new PathStateLayerPublication(stale.manifest)::recover); + assertArrayEquals(bytes(21), + new PathStateCurrentStore(stale.manifest).current().getBlockHash()); + } + + private Fixture fixture(String name, Engine engine, Stage failure) throws Exception { + PathStateStoreManifest manifest = PathStateStoreManifest.createOrOpen( + new File(temporaryFolder.getRoot(), name).toPath(), engine); + PathStateRootMetadata base = publishBase(manifest); + PathStateLayer layer = PathStateLayer.begin(manifest, base, 101, bytes(11), + base.getBlockHash(), 303, P66Phase.P66_ON, bytes(12), stage -> { + if (stage == failure) { + throw new IOException("injected after " + stage); + } + }); + layer.apply(Arrays.asList( + PathStateMutation.put("proposal", new byte[]{1}, new byte[]{5}), + PathStateMutation.delete("account", new byte[]{3}))); + return new Fixture(manifest, base, layer, layer.rootHash()); + } + + private static PathStateRootMetadata publishBase(PathStateStoreManifest manifest) + throws Exception { + try (PathStateNodeStoreSet stores = PathStateNodeStoreSet.openBase(manifest)) { + PathStateRoot root = stores.createRoot(); + root.apply(Arrays.asList( + PathStateMutation.put("proposal", new byte[]{1}, new byte[]{2}), + PathStateMutation.put("account", new byte[]{3}, new byte[]{4}))); + PathStateRootMetadata base = PathStateRootMetadata.base(100, bytes(1), bytes(2), 300, + P66Phase.P66_ON, manifest.getIdentityDigest(), root.rootHash(), bytes(3)); + new PathStateBasePublication(manifest).publish(stores, base); + return base; + } + } + + private static void assertSettled(Fixture fixture) throws Exception { + PathStateRootMetadata current = new PathStateCurrentStore(fixture.manifest).current(); + assertEquals(101, current.getBlockNumber()); + assertArrayEquals(fixture.expectedRoot, current.getStateRoot()); + Path layerDirectory = fixture.manifest.getLayerDirectory(101, current.getBlockHash()); + assertArrayEquals(current.encode(), + PathStateNodeStoreSet.loadProgress(layerDirectory, fixture.manifest).encode()); + assertFalse(Files.exists(layerDirectory.resolve(PathStateLayerPublication.INTENT_FILE))); + try (PathStateNodeStoreSet stores = PathStateNodeStoreSet.openCurrent(fixture.manifest)) { + PathStateRoot restored = stores.createRoot(); + assertArrayEquals(fixture.expectedRoot, restored.rootHash()); + restored.verifyNodeStores(); + } + } + + private static Engine[] availableEngines() { + return Arch.isArm64() ? new Engine[]{Engine.ROCKSDB} + : new Engine[]{Engine.LEVELDB, Engine.ROCKSDB}; + } + + private static byte[] bytes(int seed) { + byte[] value = new byte[32]; + for (int index = 0; index < value.length; index++) { + value[index] = (byte) (seed + index); + } + return value; + } + + private static final class Fixture { + + private final PathStateStoreManifest manifest; + private final PathStateRootMetadata base; + private final PathStateLayer layer; + private final byte[] expectedRoot; + + private Fixture(PathStateStoreManifest manifest, PathStateRootMetadata base, + PathStateLayer layer, byte[] expectedRoot) { + this.manifest = manifest; + this.base = base; + this.layer = layer; + this.expectedRoot = Arrays.copyOf(expectedRoot, expectedRoot.length); + } + + private void publish() throws IOException { + layer.commit(); + } + + private void close() throws IOException { + layer.close(); + } + + private Path intentPath() { + return manifest.getLayerDirectory(101, bytes(11)) + .resolve(PathStateLayerPublication.INTENT_FILE); + } + } +} diff --git a/framework/src/test/java/org/tron/core/db2/stateroot/PathStateLayerRetirementTest.java b/framework/src/test/java/org/tron/core/db2/stateroot/PathStateLayerRetirementTest.java new file mode 100644 index 00000000000..b9c4c31bee0 --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/stateroot/PathStateLayerRetirementTest.java @@ -0,0 +1,193 @@ +package org.tron.core.db2.stateroot; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Collections; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tron.common.arch.Arch; +import org.tron.core.db2.stateroot.PathStateCanonicalizer.P66Phase; +import org.tron.core.db2.stateroot.PathStateLayerRetirement.RecoveryAction; +import org.tron.core.db2.stateroot.PathStateLayerRetirement.Stage; +import org.tron.core.db2.stateroot.PathStateStoreManifest.Engine; + +public class PathStateLayerRetirementTest { + + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void retiredForkReleasesLayerBudgetForCanonicalSibling() throws Exception { + for (Engine engine : availableEngines()) { + Fixture fixture = fixture("bounded-fork-" + engine, engine); + PathStateLayerLimits limits = new PathStateLayerLimits(2, Long.MAX_VALUE); + PathStateRootMetadata firstA = append(fixture.manifest, fixture.base, 101, 11, limits); + PathStateRootMetadata secondA = append(fixture.manifest, firstA, 102, 13, limits); + + PathStateLayerRetirement retirement = new PathStateLayerRetirement( + fixture.manifest, limits); + assertArrayEquals(fixture.base.encode(), + retirement.switchToAncestor(fixture.base).encode()); + assertFalse(Files.exists(layerDirectory(fixture.manifest, firstA))); + assertFalse(Files.exists(layerDirectory(fixture.manifest, secondA))); + + PathStateRootMetadata firstB = append(fixture.manifest, fixture.base, 101, 21, limits); + PathStateRootMetadata secondB = append(fixture.manifest, firstB, 102, 23, limits); + assertArrayEquals(secondB.encode(), new PathStateCurrentStore(fixture.manifest) + .current().encode()); + assertEquals(RecoveryAction.NONE, retirement.recover()); + try (PathStateNodeStoreSet stores = PathStateNodeStoreSet.openCurrent(fixture.manifest)) { + PathStateRoot restored = stores.createRoot(); + assertArrayEquals(secondB.getStateRoot(), restored.rootHash()); + restored.verifyNodeStores(); + } + } + } + + @Test + public void everyRetirementFaultRecoversIdempotently() throws Exception { + for (Engine engine : availableEngines()) { + for (Stage failure : Stage.values()) { + Fixture fixture = fixture("recover-" + engine + "-" + failure, engine); + PathStateLayerLimits limits = new PathStateLayerLimits(10, Long.MAX_VALUE); + PathStateRootMetadata first = append(fixture.manifest, fixture.base, 101, 11, limits); + PathStateRootMetadata second = append(fixture.manifest, first, 102, 13, limits); + PathStateLayerRetirement retirement = new PathStateLayerRetirement( + fixture.manifest, limits, stage -> { + if (stage == failure) { + throw new IOException("injected after " + stage); + } + }); + + assertThrows(IOException.class, () -> retirement.switchToAncestor(fixture.base)); + PathStateLayerRetirement recovery = new PathStateLayerRetirement( + fixture.manifest, limits); + RecoveryAction expected = failure == Stage.AFTER_RETIRE + ? RecoveryAction.NONE : RecoveryAction.COMPLETED_RETIREMENT; + assertEquals(expected, recovery.recover()); + assertEquals(RecoveryAction.NONE, recovery.recover()); + assertArrayEquals(fixture.base.encode(), new PathStateCurrentStore(fixture.manifest) + .current().encode()); + assertFalse(Files.exists(layerDirectory(fixture.manifest, first))); + assertFalse(Files.exists(layerDirectory(fixture.manifest, second))); + assertFalse(Files.exists(fixture.manifest.getDirectory() + .resolve(PathStateLayerRetirement.INTENT_FILE))); + } + } + } + + @Test + public void corruptRetirementIntentFailsClosedBeforeCurrentMoves() throws Exception { + Fixture fixture = fixture("corrupt-intent", Engine.ROCKSDB); + PathStateLayerLimits limits = new PathStateLayerLimits(10, Long.MAX_VALUE); + PathStateRootMetadata first = append(fixture.manifest, fixture.base, 101, 11, limits); + PathStateRootMetadata second = append(fixture.manifest, first, 102, 13, limits); + PathStateLayerRetirement retirement = new PathStateLayerRetirement(fixture.manifest, limits, + stage -> { + if (stage == Stage.AFTER_INTENT) { + throw new IOException("injected after intent"); + } + }); + assertThrows(IOException.class, () -> retirement.switchToAncestor(fixture.base)); + Path intent = fixture.manifest.getDirectory().resolve(PathStateLayerRetirement.INTENT_FILE); + byte[] corrupt = Files.readAllBytes(intent); + corrupt[corrupt.length - 1] ^= 1; + Files.write(intent, corrupt); + + assertThrows(IOException.class, + new PathStateLayerRetirement(fixture.manifest, limits)::recover); + assertArrayEquals(second.encode(), new PathStateCurrentStore(fixture.manifest) + .current().encode()); + assertTrue(Files.exists(layerDirectory(fixture.manifest, first))); + assertTrue(Files.exists(layerDirectory(fixture.manifest, second))); + } + + @Test + public void retirementCannotDeleteAfterAnotherCanonicalBranchAdvances() throws Exception { + Fixture fixture = fixture("stale-retirement", Engine.ROCKSDB); + PathStateLayerLimits limits = new PathStateLayerLimits(10, Long.MAX_VALUE); + PathStateRootMetadata firstA = append(fixture.manifest, fixture.base, 101, 11, limits); + PathStateRootMetadata secondA = append(fixture.manifest, firstA, 102, 13, limits); + PathStateLayerRetirement retirement = new PathStateLayerRetirement(fixture.manifest, limits, + stage -> { + if (stage == Stage.AFTER_INTENT) { + throw new IOException("injected after intent"); + } + }); + assertThrows(IOException.class, () -> retirement.switchToAncestor(fixture.base)); + + new PathStateCurrentStore(fixture.manifest).switchToAncestor(fixture.base, limits); + PathStateRootMetadata firstB = append(fixture.manifest, fixture.base, 101, 21, limits); + assertThrows(IOException.class, + new PathStateLayerRetirement(fixture.manifest, limits)::recover); + assertArrayEquals(firstB.encode(), new PathStateCurrentStore(fixture.manifest) + .current().encode()); + assertTrue(Files.exists(layerDirectory(fixture.manifest, firstA))); + assertTrue(Files.exists(layerDirectory(fixture.manifest, secondA))); + } + + private Fixture fixture(String name, Engine engine) throws Exception { + PathStateStoreManifest manifest = PathStateStoreManifest.createOrOpen( + new File(temporaryFolder.getRoot(), name).toPath(), engine); + try (PathStateNodeStoreSet stores = PathStateNodeStoreSet.openBase(manifest)) { + PathStateRoot root = stores.createRoot(); + root.apply(Arrays.asList( + PathStateMutation.put("proposal", new byte[]{1}, new byte[]{2}), + PathStateMutation.put("account", new byte[]{3}, new byte[]{4}))); + PathStateRootMetadata base = PathStateRootMetadata.base(100, bytes(1), bytes(2), 300, + P66Phase.P66_ON, manifest.getIdentityDigest(), root.rootHash(), bytes(3)); + new PathStateBasePublication(manifest).publish(stores, base); + return new Fixture(manifest, base); + } + } + + private static PathStateRootMetadata append(PathStateStoreManifest manifest, + PathStateRootMetadata parent, long blockNumber, int seed, PathStateLayerLimits limits) + throws Exception { + try (PathStateLayer layer = PathStateLayer.begin(manifest, parent, blockNumber, bytes(seed), + parent.getBlockHash(), blockNumber * 3, P66Phase.P66_ON, bytes(seed + 1), limits)) { + layer.apply(Collections.singletonList( + PathStateMutation.put("proposal", new byte[]{1}, new byte[]{(byte) seed}))); + return layer.commit(); + } + } + + private static Path layerDirectory(PathStateStoreManifest manifest, + PathStateRootMetadata metadata) { + return manifest.getLayerDirectory(metadata.getBlockNumber(), metadata.getBlockHash()); + } + + private static Engine[] availableEngines() { + return Arch.isArm64() ? new Engine[]{Engine.ROCKSDB} + : new Engine[]{Engine.LEVELDB, Engine.ROCKSDB}; + } + + private static byte[] bytes(int seed) { + byte[] value = new byte[32]; + for (int index = 0; index < value.length; index++) { + value[index] = (byte) (seed + index); + } + return value; + } + + private static final class Fixture { + + private final PathStateStoreManifest manifest; + private final PathStateRootMetadata base; + + private Fixture(PathStateStoreManifest manifest, PathStateRootMetadata base) { + this.manifest = manifest; + this.base = base; + } + } +} diff --git a/framework/src/test/java/org/tron/core/db2/stateroot/PathStateLayerTest.java b/framework/src/test/java/org/tron/core/db2/stateroot/PathStateLayerTest.java new file mode 100644 index 00000000000..d82efb13e87 --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/stateroot/PathStateLayerTest.java @@ -0,0 +1,241 @@ +package org.tron.core.db2.stateroot; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Collections; +import java.util.stream.Stream; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tron.common.arch.Arch; +import org.tron.core.db2.stateroot.PathStateCanonicalizer.P66Phase; +import org.tron.core.db2.stateroot.PathStateStoreManifest.Engine; + +public class PathStateLayerTest { + + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void layersInheritPublishedParentAndRestoreCurrentAcrossReopen() throws Exception { + for (Engine engine : availableEngines()) { + Fixture fixture = publishedBase("inherit-" + engine, engine); + PathStateRootMetadata first; + PathStateRoot.Snapshot firstSnapshot; + byte[] firstRoot; + try (PathStateLayer layer = PathStateLayer.begin(fixture.manifest, fixture.base, 101, + bytes(11), fixture.base.getBlockHash(), 303, P66Phase.P66_ON, bytes(12))) { + layer.apply(Arrays.asList( + PathStateMutation.put("proposal", new byte[]{1}, new byte[]{5}), + PathStateMutation.delete("account", new byte[]{3}))); + firstRoot = layer.rootHash(); + first = layer.commit(); + firstSnapshot = layer.snapshot(); + assertArrayEquals(firstRoot, firstSnapshot.getStateRoot()); + assertArrayEquals(first.encode(), layer.commit().encode()); + assertThrows(IllegalStateException.class, () -> layer.apply(Collections.singletonList( + PathStateMutation.delete("proposal", new byte[]{1})))); + } + + assertCurrentRoot(fixture.manifest, first, firstRoot); + assertTrue(nodeEntryCount(fixture.manifest.getLayerDirectory(101, first.getBlockHash()), + engine) < nodeEntryCount(fixture.manifest.getBaseDirectory(), engine)); + assertTrue(nodeTombstoneCount( + fixture.manifest.getLayerDirectory(101, first.getBlockHash()), engine) > 0); + assertTrue(leafEntryCount(fixture.manifest.getLayerDirectory(101, first.getBlockHash()), + engine) < leafEntryCount(fixture.manifest.getBaseDirectory(), engine)); + assertTrue(leafTombstoneCount( + fixture.manifest.getLayerDirectory(101, first.getBlockHash()), engine) > 0); + + PathStateRootMetadata second; + byte[] secondRoot; + try (PathStateLayer layer = PathStateLayer.beginFromSnapshot(fixture.manifest, first, + firstSnapshot, 102, bytes(13), first.getBlockHash(), 306, P66Phase.P66_ON, + bytes(14))) { + layer.apply(Collections.singletonList( + PathStateMutation.put("account", new byte[]{7}, new byte[]{8}))); + secondRoot = layer.rootHash(); + second = layer.commit(); + } + + assertCurrentRoot(fixture.manifest, second, secondRoot); + try (Stream layers = Files.list(fixture.manifest.getLayersDirectory())) { + assertEquals(2, layers.count()); + } + } + } + + @Test + public void staleParentFailsBeforeCreatingLayerDirectory() throws Exception { + Fixture fixture = publishedBase("stale-parent", Engine.ROCKSDB); + PathStateRootMetadata first; + try (PathStateLayer layer = PathStateLayer.begin(fixture.manifest, fixture.base, 101, + bytes(11), fixture.base.getBlockHash(), 303, P66Phase.P66_ON, bytes(12))) { + layer.apply(Collections.singletonList( + PathStateMutation.put("proposal", new byte[]{1}, new byte[]{5}))); + first = layer.commit(); + } + + Path rejected = fixture.manifest.getLayerDirectory(102, bytes(13)); + assertThrows(IOException.class, () -> PathStateLayer.begin(fixture.manifest, fixture.base, 102, + bytes(13), first.getBlockHash(), 306, P66Phase.P66_ON, bytes(14))); + assertFalse(Files.exists(rejected)); + } + + @Test + public void preparedLayerValidationDoesNotReadNodeStores() throws Exception { + Fixture fixture = publishedBase("prepared-zero-read", Engine.ROCKSDB); + PathStateNodeStoreSet parentStores = PathStateNodeStoreSet.openPublished( + fixture.manifest, fixture.base); + PathStateNodeStoreSet childStores = null; + try { + PathStateRoot parentRoot = parentStores.createRoot(); + PathStateBlockTransition transition = new PathStateBlockTransition(101, bytes(11), + fixture.base.getBlockHash(), 303, P66Phase.P66_ON, Collections.singletonList( + PathStateMutation.put("proposal", new byte[]{1}, new byte[]{5}))); + PreparedPathStateTransition prepared = PreparedPathStateTransition.prepare( + fixture.base, parentRoot.snapshot(), transition); + PathStateRootMetadata identity = PathStateRootMetadata.layer(101, bytes(11), + fixture.base.getBlockHash(), 303, P66Phase.P66_ON, + fixture.manifest.getIdentityDigest(), fixture.base.getStateRoot(), + prepared.getStateRoot(), transition.getPayloadDigest()); + childStores = PathStateNodeStoreSet.beginLayer(fixture.manifest, identity, parentStores); + long readsBefore = childStores.nodeStoreGetCalls(); + + assertArrayEquals(prepared.getStateRoot(), + childStores.createRootFrom(prepared).rootHash()); + assertEquals(readsBefore, childStores.nodeStoreGetCalls()); + } finally { + if (childStores != null) { + childStores.close(); + } else { + parentStores.close(); + } + } + } + + @Test + public void currentLayerRestoreFailsClosedWhenDurableLeafIsMissing() throws Exception { + Fixture fixture = publishedBase("corrupt-layer", Engine.ROCKSDB); + PathStateRootMetadata layer; + try (PathStateLayer child = PathStateLayer.begin(fixture.manifest, fixture.base, 101, + bytes(11), fixture.base.getBlockHash(), 303, P66Phase.P66_ON, bytes(12))) { + child.apply(Collections.singletonList( + PathStateMutation.put("proposal", new byte[]{1}, new byte[]{5}))); + layer = child.commit(); + } + + Path nodes = fixture.manifest.getLayerDirectory(101, layer.getBlockHash()) + .resolve(PathStateNodeStoreSet.NODES_DIRECTORY); + try (PathStateNativeNodeStore nativeStore = + PathStateNativeNodeStore.open(nodes, Engine.ROCKSDB)) { + nativeStore.delete(durableLeafKey(21, + PathStateCommitmentCodec.storeLeafKey(21, new byte[]{1}))); + } + try (PathStateNodeStoreSet current = PathStateNodeStoreSet.openCurrent(fixture.manifest)) { + assertThrows(IllegalStateException.class, current::createRoot); + } + } + + private Fixture publishedBase(String name, Engine engine) throws Exception { + Path rootDirectory = new File(temporaryFolder.getRoot(), name).toPath(); + PathStateStoreManifest manifest = PathStateStoreManifest.createOrOpen(rootDirectory, engine); + PathStateRootMetadata base; + try (PathStateNodeStoreSet stores = PathStateNodeStoreSet.openBase(manifest)) { + PathStateRoot root = stores.createRoot(); + root.apply(Arrays.asList( + PathStateMutation.put("proposal", new byte[]{1}, new byte[]{2}), + PathStateMutation.put("account", new byte[]{3}, new byte[]{4}))); + base = PathStateRootMetadata.base(100, bytes(1), bytes(2), 300, P66Phase.P66_ON, + manifest.getIdentityDigest(), root.rootHash(), bytes(3)); + new PathStateBasePublication(manifest).publish(stores, base); + } + return new Fixture(manifest, base); + } + + private static void assertCurrentRoot(PathStateStoreManifest manifest, + PathStateRootMetadata expected, byte[] expectedRoot) throws Exception { + assertArrayEquals(expected.encode(), new PathStateCurrentStore(manifest).current().encode()); + try (PathStateNodeStoreSet current = PathStateNodeStoreSet.openCurrent(manifest)) { + PathStateRoot restored = current.createRoot(); + assertArrayEquals(expectedRoot, restored.rootHash()); + restored.verifyNodeStores(); + } + } + + private static Engine[] availableEngines() { + return Arch.isArm64() ? new Engine[]{Engine.ROCKSDB} + : new Engine[]{Engine.LEVELDB, Engine.ROCKSDB}; + } + + private static byte[] durableLeafKey(int storeId, byte[] secureKey) { + return java.nio.ByteBuffer.allocate(Integer.BYTES * 2 + secureKey.length) + .putInt(-2) + .putInt(storeId) + .put(secureKey) + .array(); + } + + private static long nodeEntryCount(Path owner, Engine engine) throws Exception { + try (PathStateNativeNodeStore store = PathStateNativeNodeStore.open( + owner.resolve(PathStateNodeStoreSet.NODES_DIRECTORY), engine)) { + return store.scanAll().stream() + .filter(entry -> java.nio.ByteBuffer.wrap(entry.getKey()).getInt() >= 0) + .count(); + } + } + + private static long nodeTombstoneCount(Path owner, Engine engine) throws Exception { + try (PathStateNativeNodeStore store = PathStateNativeNodeStore.open( + owner.resolve(PathStateNodeStoreSet.NODES_DIRECTORY), engine)) { + return store.scanAll().stream() + .filter(entry -> java.nio.ByteBuffer.wrap(entry.getKey()).getInt() == -3) + .count(); + } + } + + private static long leafEntryCount(Path owner, Engine engine) throws Exception { + return domainEntryCount(owner, engine, -2); + } + + private static long leafTombstoneCount(Path owner, Engine engine) throws Exception { + return domainEntryCount(owner, engine, -4); + } + + private static long domainEntryCount(Path owner, Engine engine, int domain) throws Exception { + try (PathStateNativeNodeStore store = PathStateNativeNodeStore.open( + owner.resolve(PathStateNodeStoreSet.NODES_DIRECTORY), engine)) { + return store.scanAll().stream() + .filter(entry -> java.nio.ByteBuffer.wrap(entry.getKey()).getInt() == domain) + .count(); + } + } + + private static byte[] bytes(int seed) { + byte[] value = new byte[32]; + for (int index = 0; index < value.length; index++) { + value[index] = (byte) (seed + index); + } + return value; + } + + private static final class Fixture { + + private final PathStateStoreManifest manifest; + private final PathStateRootMetadata base; + + private Fixture(PathStateStoreManifest manifest, PathStateRootMetadata base) { + this.manifest = manifest; + this.base = base; + } + } +} diff --git a/framework/src/test/java/org/tron/core/db2/stateroot/PathStateManagerStartupIntegrationTest.java b/framework/src/test/java/org/tron/core/db2/stateroot/PathStateManagerStartupIntegrationTest.java new file mode 100644 index 00000000000..0575cc16536 --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/stateroot/PathStateManagerStartupIntegrationTest.java @@ -0,0 +1,818 @@ +package org.tron.core.db2.stateroot; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.withSettings; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Collections; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tron.common.parameter.CommonParameter; +import org.tron.common.utils.Sha256Hash; +import org.tron.core.ChainBaseManager; +import org.tron.core.capsule.BlockCapsule; +import org.tron.core.capsule.BlockCapsule.BlockId; +import org.tron.core.config.args.Storage; +import org.tron.core.config.args.StorageConfig.StateArchiveHotStoreConfig; +import org.tron.core.db.Manager; +import org.tron.core.db2.ISession; +import org.tron.core.db2.archive.BlockReverseDiff; +import org.tron.core.db2.archive.BlockSnapshotMeta; +import org.tron.core.db2.archive.LatestStateGenerationAdapter.SnapshotCapableStore; +import org.tron.core.db2.archive.LatestStateGenerationAdapter.StoreSnapshot; +import org.tron.core.db2.common.DB; +import org.tron.core.db2.core.Chainbase; +import org.tron.core.db2.core.ChainbaseCheckpointMaterializer; +import org.tron.core.db2.core.CommonCheckpointBaseline; +import org.tron.core.db2.core.CommonCheckpointBaselineFile; +import org.tron.core.db2.core.CommonCheckpointFile; +import org.tron.core.db2.core.CommonCheckpointFormat; +import org.tron.core.db2.core.CommonCheckpointMaterializedStore; +import org.tron.core.db2.core.CommonCheckpointPayload; +import org.tron.core.db2.core.CommonCheckpointTarget; +import org.tron.core.db2.core.SnapshotManager; +import org.tron.core.db2.core.SnapshotRoot; +import org.tron.core.db2.stateroot.PathStateCanonicalizer.P66Phase; +import org.tron.core.db2.stateroot.PathStateStoreManifest.Engine; +import org.tron.core.store.AccountAssetStore; +import org.tron.core.store.DynamicPropertiesStore; + +public class PathStateManagerStartupIntegrationTest { + + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void disabledAndMissingStartupDoNotCreatePathStateDirectory() throws Exception { + Path output = temporaryFolder.newFolder("startup-gates").toPath(); + Manager disabled = new Manager(); + withConfig(output, false, () -> invoke(disabled, "initPathStateRoot")); + assertNull(disabled.getPathStateSnapshotHead()); + assertFalse(Files.exists(output.resolve("path-state-root"))); + + Manager missing = new Manager(); + assertThrows(IllegalStateException.class, + () -> withConfig(output, true, () -> invoke(missing, "initPathStateRoot"))); + assertNull(missing.getPathStateSnapshotHead()); + assertFalse(Files.exists(output.resolve("path-state-root"))); + } + + @Test + public void readyCurrentAttachesExactCanonicalHeadAndCloses() throws Exception { + Path output = temporaryFolder.newFolder("startup-ready").toPath(); + Path root = output.resolve("path-state-root"); + PathStateRootMetadata base = publishEmptyPhysicalCurrent(root, 100, 1, 2); + + DynamicPropertiesStore dynamic = mock(DynamicPropertiesStore.class); + when(dynamic.getLatestBlockHeaderNumber()).thenReturn(100L); + when(dynamic.getLatestBlockHeaderHash()).thenReturn(Sha256Hash.wrap(base.getBlockHash())); + ChainBaseManager chainBase = mock(ChainBaseManager.class); + when(chainBase.getDynamicPropertiesStore()).thenReturn(dynamic); + when(chainBase.getAccountAssetStore()).thenReturn(mock(AccountAssetStore.class)); + Manager manager = new Manager(); + setChainBaseManager(manager, chainBase); + setField(manager, "revokingStore", new SnapshotManager("")); + + withConfig(output, true, () -> invoke(manager, "initPathStateRoot")); + assertNotNull(manager.getPathStateSnapshotHead()); + assertNotNull(manager.getPathStateRuntime()); + assertArrayEquals(base.encode(), manager.getPathStateSnapshotHead().getHead().encode()); + assertEquals(PathStateRuntimeAttachment.State.READY, + manager.getPathStateRuntime().status().getState()); + assertEquals(100L, manager.getPathStateRuntime().status().getReadyBlockNumber()); + invoke(manager, "closePathStateRoot"); + assertNull(manager.getPathStateSnapshotHead()); + assertNull(manager.getPathStateRuntime()); + + withConfig(output, true, () -> invoke(manager, "initPathStateRoot")); + assertArrayEquals(base.encode(), manager.getPathStateSnapshotHead().getHead().encode()); + assertEquals(PathStateRuntimeAttachment.State.READY, + manager.getPathStateRuntime().status().getState()); + invoke(manager, "closePathStateRoot"); + + when(dynamic.getLatestBlockHeaderNumber()).thenReturn(101L); + assertThrows(IllegalStateException.class, + () -> withConfig(output, true, () -> invoke(manager, "initPathStateRoot"))); + assertNull(manager.getPathStateSnapshotHead()); + } + + @Test + public void readyManagerPublishesOneBlockFinalAndRestartsAtTheNewHead() throws Exception { + Path output = temporaryFolder.newFolder("startup-one-block").toPath(); + Path root = output.resolve("path-state-root"); + PathStateRootMetadata base = publishEmptyPhysicalCurrent(root, 100, 1, 2); + + DynamicPropertiesStore dynamic = mock(DynamicPropertiesStore.class); + when(dynamic.getLatestBlockHeaderNumber()).thenReturn(100L); + when(dynamic.getLatestBlockHeaderHash()).thenReturn(Sha256Hash.wrap(base.getBlockHash())); + ChainBaseManager chainBase = mock(ChainBaseManager.class); + when(chainBase.getDynamicPropertiesStore()).thenReturn(dynamic); + when(chainBase.getAccountAssetStore()).thenReturn(mock(AccountAssetStore.class)); + Manager manager = new Manager(); + setChainBaseManager(manager, chainBase); + SnapshotManager[] snapshotHolder = new SnapshotManager[1]; + + byte[] childHash = bytes(9); + withConfig(output, true, () -> { + SnapshotManager snapshots = new SnapshotManager(""); + snapshots.getDbs().add(propertiesStoreWithP66Enabled()); + snapshots.enable(); + snapshotHolder[0] = snapshots; + setField(manager, "revokingStore", snapshots); + invoke(manager, "initPathStateRoot"); + }); + SnapshotManager snapshots = snapshotHolder[0]; + try (ISession session = snapshots.buildSession()) { + byte[] preview = snapshots.previewPathStateRoot(BlockSnapshotMeta.forBlock( + 101, childHash, base.getBlockHash(), 303)); + assertNotNull(preview); + assertArrayEquals(base.getStateRoot(), preview); + session.commit(BlockSnapshotMeta.forBlock(101, childHash, base.getBlockHash(), 303)); + } + assertEquals(101, manager.getPathStateSnapshotHead().getHead().getBlockNumber()); + assertEquals(PathStateRuntimeAttachment.State.READY, + manager.getPathStateRuntime().status().getState()); + invoke(manager, "closePathStateRoot"); + + when(dynamic.getLatestBlockHeaderNumber()).thenReturn(101L); + when(dynamic.getLatestBlockHeaderHash()).thenReturn(Sha256Hash.wrap(childHash)); + withConfig(output, true, () -> invoke(manager, "initPathStateRoot")); + assertEquals(101, manager.getPathStateSnapshotHead().getHead().getBlockNumber()); + assertArrayEquals(childHash, manager.getPathStateSnapshotHead().getHead().getBlockHash()); + invoke(manager, "closePathStateRoot"); + } + + @Test + public void shortReorgBeyondPhysicalJournalFailsClosed() throws Exception { + Path output = temporaryFolder.newFolder("short-reorg").toPath(); + Path root = output.resolve("path-state-root"); + PathStateRootMetadata base = publishEmptyPhysicalCurrent(root, 100, 1, 2); + + DynamicPropertiesStore dynamic = mock(DynamicPropertiesStore.class); + when(dynamic.getLatestBlockHeaderNumber()).thenReturn(100L); + when(dynamic.getLatestBlockHeaderHash()).thenReturn(Sha256Hash.wrap(base.getBlockHash())); + ChainBaseManager chainBase = mock(ChainBaseManager.class); + when(chainBase.getDynamicPropertiesStore()).thenReturn(dynamic); + when(chainBase.getAccountAssetStore()).thenReturn(mock(AccountAssetStore.class)); + Manager manager = new Manager(); + setChainBaseManager(manager, chainBase); + setField(manager, "revokingStore", new SnapshotManager("")); + + withConfig(output, true, () -> invoke(manager, "initPathStateRoot")); + when(dynamic.getLatestBlockHeaderNumber()).thenReturn(99L); + when(dynamic.getLatestBlockHeaderHash()).thenReturn(Sha256Hash.wrap(bytes(99))); + invoke(manager, "rewindPathStateRootAfterPop"); + assertNotNull(manager.getPathStateRuntime().getFailure()); + assertEquals(PathStateRuntimeAttachment.FailureStage.REORG, + manager.getPathStateRuntime().status().getFailureStage()); + assertEquals(99L, manager.getPathStateRuntime().status().getObservedBlockNumber()); + assertEquals(1L, manager.getPathStateRuntime().status().getRootLag()); + assertArrayEquals(base.encode(), manager.getPathStateSnapshotHead().getHead().encode()); + invoke(manager, "closePathStateRoot"); + try (PathStatePhysicalSnapshotHead reopened = PathStatePhysicalSnapshotHead.open( + root, Engine.ROCKSDB)) { + assertArrayEquals(base.encode(), reopened.getHead().encode()); + } + } + + @Test + public void managerPopsPhysicalChildAdvancesSiblingAndRestarts() throws Exception { + Path output = temporaryFolder.newFolder("physical-manager-short-reorg").toPath(); + Path root = output.resolve("path-state-root"); + PathStateRootMetadata base = publishEmptyPhysicalCurrent(root, 100, 1, 2); + + DynamicPropertiesStore dynamic = mock(DynamicPropertiesStore.class); + when(dynamic.getLatestBlockHeaderNumber()).thenReturn(100L); + when(dynamic.getLatestBlockHeaderHash()).thenReturn(Sha256Hash.wrap(base.getBlockHash())); + ChainBaseManager chainBase = mock(ChainBaseManager.class); + when(chainBase.getDynamicPropertiesStore()).thenReturn(dynamic); + when(chainBase.getAccountAssetStore()).thenReturn(mock(AccountAssetStore.class)); + SnapshotManager snapshots = new SnapshotManager(""); + Manager manager = new Manager(); + setChainBaseManager(manager, chainBase); + + withConfig(output, true, () -> { + snapshots.getDbs().add(propertiesStoreWithP66Enabled()); + snapshots.enable(); + setField(manager, "revokingStore", snapshots); + invoke(manager, "initPathStateRoot"); + }); + byte[] oldChildHash = bytes(21); + try (ISession session = snapshots.buildSession()) { + session.commit(BlockSnapshotMeta.forBlock(101, oldChildHash, base.getBlockHash(), 303)); + } + assertEquals(101, manager.getPathStateSnapshotHead().getHead().getBlockNumber()); + + snapshots.fastPop(); + invoke(manager, "rewindPathStateRootAfterPop"); + assertEquals(PathStateRuntimeAttachment.State.READY, + manager.getPathStateRuntime().status().getState()); + assertArrayEquals(base.encode(), manager.getPathStateSnapshotHead().getHead().encode()); + + byte[] siblingHash = bytes(22); + try (ISession session = snapshots.buildSession()) { + session.commit(BlockSnapshotMeta.forBlock(101, siblingHash, base.getBlockHash(), 306)); + } + assertArrayEquals(siblingHash, manager.getPathStateSnapshotHead().getHead().getBlockHash()); + invoke(manager, "closePathStateRoot"); + + when(dynamic.getLatestBlockHeaderNumber()).thenReturn(101L); + when(dynamic.getLatestBlockHeaderHash()).thenReturn(Sha256Hash.wrap(siblingHash)); + withConfig(output, true, () -> invoke(manager, "initPathStateRoot")); + assertArrayEquals(siblingHash, manager.getPathStateSnapshotHead().getHead().getBlockHash()); + assertEquals(PathStateRuntimeAttachment.State.READY, + manager.getPathStateRuntime().status().getState()); + invoke(manager, "closePathStateRoot"); + } + + @Test + public void missingCurrentRebuildsExactNativeSnapshotAndAttaches() throws Exception { + Path output = temporaryFolder.newFolder("startup-rebuild").toPath(); + long blockNumber = 100L; + long timestamp = 300L; + BlockId blockId = new BlockId(Sha256Hash.wrap(bytes(1)), blockNumber); + Sha256Hash parentHash = Sha256Hash.wrap(bytes(2)); + DynamicPropertiesStore dynamic = mock(DynamicPropertiesStore.class); + when(dynamic.getLatestBlockHeaderNumber()).thenReturn(blockNumber); + when(dynamic.getLatestBlockHeaderHash()).thenReturn(blockId); + when(dynamic.getLatestBlockHeaderTimestamp()).thenReturn(timestamp); + when(dynamic.getAllowAccountAssetOptimizationFromRoot()).thenReturn(1L); + BlockCapsule block = mock(BlockCapsule.class); + when(block.getNum()).thenReturn(blockNumber); + when(block.getBlockId()).thenReturn(blockId); + when(block.getParentHash()).thenReturn(parentHash); + when(block.getTimeStamp()).thenReturn(timestamp); + ChainBaseManager chainBase = mock(ChainBaseManager.class); + when(chainBase.getDynamicPropertiesStore()).thenReturn(dynamic); + when(chainBase.getBlockByNum(blockNumber)).thenReturn(block); + when(chainBase.getAccountAssetStore()).thenReturn(mock(AccountAssetStore.class)); + + AtomicInteger closed = new AtomicInteger(); + Manager manager = new Manager(); + setChainBaseManager(manager, chainBase); + SnapshotManager[] snapshotHolder = new SnapshotManager[1]; + + withConfig(output, true, () -> { + SnapshotManager snapshots = new SnapshotManager(""); + for (PathStateParticipantDescriptor.StoreIdentity participant + : PathStateParticipantDescriptor.current().getStores()) { + snapshots.getDbs().add(emptyNativeStore(participant.getDbName(), blockNumber, + blockId.getBytes(), closed)); + } + snapshots.enable(); + snapshotHolder[0] = snapshots; + setField(manager, "revokingStore", snapshots); + invoke(manager, "initPathStateRoot"); + }); + assertNotNull(manager.getPathStateSnapshotHead()); + PathStateRootMetadata head = manager.getPathStateSnapshotHead().getHead(); + assertArrayEquals(blockId.getBytes(), head.getBlockHash()); + assertArrayEquals(parentHash.getBytes(), head.getParentHash()); + assertNotNull(PathStatePhysicalStoreManifest.validateExisting( + output.resolve("path-state-root"), Engine.ROCKSDB)); + assertFalse(Files.exists(output.resolve("path-state-root/base/nodes"))); + assertFalse(Files.exists(output.resolve("path-state-root/rebuild-spool"))); + assertEquals(PathStateParticipantDescriptor.current().getStores().size(), closed.get()); + + byte[] childHash = bytes(4); + try (ISession session = snapshotHolder[0].buildSession()) { + BlockSnapshotMeta child = BlockSnapshotMeta.forBlock(101, childHash, + blockId.getBytes(), 303); + assertNotNull(snapshotHolder[0].previewPathStateRoot(child)); + session.commit(child); + } + assertEquals(101, manager.getPathStateSnapshotHead().getHead().getBlockNumber()); + invoke(manager, "closePathStateRoot"); + when(dynamic.getLatestBlockHeaderNumber()).thenReturn(101L); + when(dynamic.getLatestBlockHeaderHash()).thenReturn(Sha256Hash.wrap(childHash)); + withConfig(output, true, () -> invoke(manager, "initPathStateRoot")); + assertEquals(101, manager.getPathStateSnapshotHead().getHead().getBlockNumber()); + assertFalse(Files.exists(output.resolve("path-state-root/base/nodes"))); + assertFalse(Files.exists(output.resolve("path-state-root/rebuild-spool"))); + invoke(manager, "closePathStateRoot"); + } + + @Test + public void commonCheckpointFreshFlushAndRestartUseOneDurableBoundary() throws Exception { + Path output = temporaryFolder.newFolder("common-checkpoint-startup").toPath(); + long baseNumber = 100L; + long timestamp = 300L; + BlockId baseId = new BlockId(Sha256Hash.wrap(bytes(31)), baseNumber); + Sha256Hash baseParent = Sha256Hash.wrap(bytes(30)); + DynamicPropertiesStore dynamic = mock(DynamicPropertiesStore.class); + when(dynamic.getLatestBlockHeaderNumber()).thenReturn(baseNumber); + when(dynamic.getLatestBlockHeaderHash()).thenReturn(baseId); + when(dynamic.getLatestBlockHeaderTimestamp()).thenReturn(timestamp); + when(dynamic.getAllowAccountAssetOptimizationFromRoot()).thenReturn(1L); + BlockCapsule baseBlock = mock(BlockCapsule.class); + when(baseBlock.getNum()).thenReturn(baseNumber); + when(baseBlock.getBlockId()).thenReturn(baseId); + when(baseBlock.getParentHash()).thenReturn(baseParent); + when(baseBlock.getTimeStamp()).thenReturn(timestamp); + ChainBaseManager chainBase = mock(ChainBaseManager.class); + when(chainBase.getDynamicPropertiesStore()).thenReturn(dynamic); + when(chainBase.getBlockByNum(baseNumber)).thenReturn(baseBlock); + when(chainBase.getAccountAssetStore()).thenReturn(mock(AccountAssetStore.class)); + + AtomicInteger closed = new AtomicInteger(); + Manager manager = new Manager(); + setChainBaseManager(manager, chainBase); + SnapshotManager[] holder = new SnapshotManager[1]; + withCommonConfig(output, () -> { + SnapshotManager snapshots = new SnapshotManager(""); + for (PathStateParticipantDescriptor.StoreIdentity participant + : PathStateParticipantDescriptor.current().getStores()) { + snapshots.getDbs().add(emptyNativeStore(participant.getDbName(), baseNumber, + baseId.getBytes(), closed)); + } + snapshots.enable(); + snapshots.setUnChecked(false); + holder[0] = snapshots; + setField(manager, "revokingStore", snapshots); + invoke(manager, "initCommonCheckpoint"); + }); + SnapshotManager snapshots = holder[0]; + assertNotNull(manager.getCommonCheckpointRuntime()); + assertNotNull(manager.getPathStateSnapshotHead()); + assertTrue(Files.isRegularFile(output.resolve("common-checkpoint") + .resolve(CommonCheckpointBaselineFile.FILE_NAME))); + assertTrue(Files.isRegularFile(output.resolve("path-state-root") + .resolve(PathStateCheckpointMaterializer.COMMON_MODE_FILE))); + assertFalse(Files.exists(output.resolve("path-state-root/CURRENT"))); + assertFalse(Files.exists(output.resolve("state-archive/hot"))); + + BlockId childId = new BlockId(Sha256Hash.wrap(bytes(32)), 101L); + byte[] childHash = childId.getBytes(); + try (ISession session = snapshots.buildSession()) { + session.commit(BlockSnapshotMeta.forBlock(101, childHash, baseId.getBytes(), 303L)); + } + setSnapshotField(snapshots, "flushCount", 1); + snapshots.flush(); + assertTrue(Files.isRegularFile(output.resolve("path-state-root/CURRENT"))); + assertTrue(Files.isRegularFile(output.resolve("state-archive/READABLE"))); + assertFalse(Files.exists(output.resolve("common-checkpoint/COMMON_CHECKPOINT"))); + + BlockCapsule childBlock = mock(BlockCapsule.class); + when(childBlock.getNum()).thenReturn(101L); + when(childBlock.getBlockId()).thenReturn(childId); + when(childBlock.getParentHash()).thenReturn(baseId); + when(childBlock.getTimeStamp()).thenReturn(303L); + when(dynamic.getLatestBlockHeaderNumber()).thenReturn(101L); + when(dynamic.getLatestBlockHeaderHash()).thenReturn(childId); + when(dynamic.getLatestBlockHeaderTimestamp()).thenReturn(303L); + when(chainBase.getBlockByNum(101L)).thenReturn(childBlock); + + BlockId pendingId = new BlockId(Sha256Hash.wrap(bytes(33)), 102L); + try (ISession session = snapshots.buildSession()) { + session.commit(BlockSnapshotMeta.forBlock(102, pendingId.getBytes(), childHash, 306L)); + } + setSnapshotField(snapshots, "flushCount", 1); + BlockCapsule pendingBlock = mock(BlockCapsule.class); + when(pendingBlock.getNum()).thenReturn(102L); + when(pendingBlock.getBlockId()).thenReturn(pendingId); + when(pendingBlock.getParentHash()).thenReturn(childId); + when(pendingBlock.getTimeStamp()).thenReturn(306L); + when(dynamic.getLatestBlockHeaderNumber()).thenReturn(102L); + when(dynamic.getLatestBlockHeaderHash()).thenReturn(pendingId); + when(dynamic.getLatestBlockHeaderTimestamp()).thenReturn(306L); + when(chainBase.getBlockByNum(102L)).thenReturn(pendingBlock); + invoke(manager, "closeCommonCheckpoint"); + invoke(manager, "closePathStateRoot"); + + withCommonConfig(output, () -> { + CommonParameter.getInstance().getStorage().setP66SnapshotEnabled(true); + invoke(manager, "initCommonCheckpoint"); + }); + assertEquals(102L, manager.getPathStateSnapshotHead().getHead().getBlockNumber()); + assertArrayEquals(pendingId.getBytes(), + manager.getPathStateSnapshotHead().getHead().getBlockHash()); + invoke(manager, "closeCommonCheckpoint"); + invoke(manager, "closePathStateRoot"); + } + + @Test + public void hotCommonCheckpointUsesDualGateAndPersistentRecoverySources() throws Exception { + Path output = temporaryFolder.newFolder("hot-common-checkpoint-startup").toPath(); + long baseNumber = 100L; + BlockId baseId = new BlockId(Sha256Hash.wrap(bytes(61)), baseNumber); + DynamicPropertiesStore dynamic = mock(DynamicPropertiesStore.class); + when(dynamic.getLatestBlockHeaderNumber()).thenReturn(baseNumber); + when(dynamic.getLatestBlockHeaderHash()).thenReturn(baseId); + when(dynamic.getLatestBlockHeaderTimestamp()).thenReturn(300L); + when(dynamic.getLatestBlockHeaderNumberFromDB()).thenReturn(baseNumber); + when(dynamic.getLatestBlockHeaderHashFromDB()).thenReturn(baseId); + when(dynamic.getAllowAccountAssetOptimizationFromRoot()).thenReturn(1L); + BlockCapsule baseBlock = mock(BlockCapsule.class); + when(baseBlock.getNum()).thenReturn(baseNumber); + when(baseBlock.getBlockId()).thenReturn(baseId); + when(baseBlock.getParentHash()).thenReturn(Sha256Hash.wrap(bytes(60))); + when(baseBlock.getTimeStamp()).thenReturn(300L); + ChainBaseManager chainBase = mock(ChainBaseManager.class); + when(chainBase.getDynamicPropertiesStore()).thenReturn(dynamic); + when(chainBase.getBlockByNum(baseNumber)).thenReturn(baseBlock); + when(chainBase.getAccountAssetStore()).thenReturn(mock(AccountAssetStore.class)); + + Manager manager = new Manager(); + setChainBaseManager(manager, chainBase); + withHotCommonConfig(output, () -> { + SnapshotManager snapshots = new SnapshotManager(""); + AtomicInteger closed = new AtomicInteger(); + for (PathStateParticipantDescriptor.StoreIdentity participant + : PathStateParticipantDescriptor.current().getStores()) { + snapshots.getDbs().add(emptyNativeStore(participant.getDbName(), baseNumber, + baseId.getBytes(), closed)); + } + snapshots.enable(); + snapshots.setUnChecked(false); + setField(manager, "revokingStore", snapshots); + invoke(manager, "initCommonCheckpoint"); + }); + + assertNotNull(manager.getCommonCheckpointRuntime()); + assertTrue(Files.isRegularFile(output.resolve("state-archive/hot/CURRENT"))); + assertFalse(Files.exists(output.resolve("state-archive/READABLE"))); + assertFalse(Files.exists(output.resolve("state-archive/checkpoint-targets"))); + invoke(manager, "closeCommonCheckpoint"); + invoke(manager, "closePathStateRoot"); + } + + @Test + public void commonCheckpointRedoPrecedesPathStateOpenAfterPartialMaterialization() + throws Exception { + Path output = temporaryFolder.newFolder("common-checkpoint-partial-path-redo").toPath(); + Path pathDirectory = output.resolve("path-state-root"); + Path checkpointDirectory = output.resolve("common-checkpoint"); + Path archiveDirectory = output.resolve("state-archive"); + long baseNumber = 100L; + BlockId baseId = new BlockId(Sha256Hash.wrap(bytes(61)), baseNumber); + Sha256Hash baseParent = Sha256Hash.wrap(bytes(60)); + DynamicPropertiesStore dynamic = mock(DynamicPropertiesStore.class); + when(dynamic.getLatestBlockHeaderNumber()).thenReturn(baseNumber); + when(dynamic.getLatestBlockHeaderHash()).thenReturn(baseId); + when(dynamic.getLatestBlockHeaderTimestamp()).thenReturn(300L); + when(dynamic.getAllowAccountAssetOptimizationFromRoot()).thenReturn(1L); + BlockCapsule baseBlock = block(baseNumber, baseId, baseParent, 300L); + ChainBaseManager chainBase = mock(ChainBaseManager.class); + when(chainBase.getDynamicPropertiesStore()).thenReturn(dynamic); + when(chainBase.getBlockByNum(baseNumber)).thenReturn(baseBlock); + when(chainBase.getAccountAssetStore()).thenReturn(mock(AccountAssetStore.class)); + + AtomicInteger closed = new AtomicInteger(); + SnapshotManager[] holder = new SnapshotManager[1]; + Manager manager = new Manager(); + setChainBaseManager(manager, chainBase); + withCommonConfig(output, "LEVELDB", () -> { + SnapshotManager snapshots = new SnapshotManager(""); + for (PathStateParticipantDescriptor.StoreIdentity participant + : PathStateParticipantDescriptor.current().getStores()) { + snapshots.getDbs().add(emptyNativeStore(participant.getDbName(), baseNumber, + baseId.getBytes(), closed)); + } + snapshots.enable(); + snapshots.setUnChecked(false); + holder[0] = snapshots; + setField(manager, "revokingStore", snapshots); + invoke(manager, "initCommonCheckpoint"); + }); + SnapshotManager snapshots = holder[0]; + BlockId firstId = new BlockId(Sha256Hash.wrap(bytes(62)), 101L); + try (ISession session = snapshots.buildSession()) { + session.commit(BlockSnapshotMeta.forBlock(101L, firstId.getBytes(), baseId.getBytes(), + 303L)); + } + setSnapshotField(snapshots, "flushCount", 1); + snapshots.flush(); + invoke(manager, "closeCommonCheckpoint"); + invoke(manager, "closePathStateRoot"); + + byte[] formatIdentity = CommonCheckpointFormat.identity(); + CommonCheckpointBaseline baseline = new CommonCheckpointBaselineFile(checkpointDirectory) + .load(); + BlockId targetId = new BlockId(Sha256Hash.wrap(bytes(63)), 102L); + BlockSnapshotMeta targetMeta = BlockSnapshotMeta.forBlock(102L, targetId.getBytes(), + firstId.getBytes(), 306L); + CommonCheckpointPayload payload; + try (PathStatePhysicalOverlayHead head = PathStatePhysicalOverlayHead.openCommonCheckpoint( + pathDirectory, Engine.LEVELDB, new PathStateLayerLimits(8, 1L << 20), 1L << 20, + 2, 2, formatIdentity, targetMeta, P66Phase.P66_ON)) { + PathStateBlockTransition transition = new PathStateBlockTransition(102L, + targetId.getBytes(), firstId.getBytes(), 306L, P66Phase.P66_ON, + Collections.singletonList( + PathStateMutation.put("code", new byte[]{1}, new byte[]{2}))); + PathStateSnapshotDelta delta = head.prepareSnapshotDelta(targetMeta, transition); + PathStateFlushTarget target = PathStateFlushTarget.coalesce( + Collections.singletonList(delta)); + payload = CommonCheckpointPayload.create(formatIdentity, target, + Collections.singletonList(new BlockReverseDiff(targetMeta, Collections.emptyList())), + Collections.emptyList()); + } + CommonCheckpointTarget target = CommonCheckpointTarget.from(payload); + CommonCheckpointMaterializedStore materializedStore = + new CommonCheckpointMaterializedStore(checkpointDirectory); + new CommonCheckpointFile(checkpointDirectory).publish(payload); + new ChainbaseCheckpointMaterializer(checkpointDirectory, formatIdentity, + snapshots.getDbs(), baseline, materializedStore).materialize(payload, target); + PathStateParticipantScope scope = new PathStateCanonicalizer().participantScope(); + try (PathStatePhysicalStoreSet stores = PathStatePhysicalStoreSet.openExisting(pathDirectory, + scope, Engine.LEVELDB)) { + PathStateCheckpointMaterializer interrupted = new PathStateCheckpointMaterializer(stores, + scope, formatIdentity, baseline, materializedStore, failAfterParticipantBatch()); + assertThrows(java.io.IOException.class, + () -> interrupted.materialize(payload, target)); + } + assertThrows(IllegalStateException.class, + () -> PathStatePhysicalOverlayHead.openCommonCheckpoint(pathDirectory, Engine.LEVELDB, + new PathStateLayerLimits(8, 1L << 20), 1L << 20, 2, 2, formatIdentity, + targetMeta, P66Phase.P66_ON)); + + when(dynamic.getLatestBlockHeaderNumber()).thenReturn(102L); + when(dynamic.getLatestBlockHeaderHash()).thenReturn(targetId); + when(dynamic.getLatestBlockHeaderTimestamp()).thenReturn(306L); + BlockCapsule targetBlock = block(102L, targetId, firstId, 306L); + when(chainBase.getBlockByNum(102L)).thenReturn(targetBlock); + withCommonConfig(output, "LEVELDB", () -> invoke(manager, "initCommonCheckpoint")); + assertFalse(Files.exists(checkpointDirectory.resolve("COMMON_CHECKPOINT"))); + assertEquals(102L, manager.getPathStateSnapshotHead().getHead().getBlockNumber()); + assertArrayEquals(targetId.getBytes(), + manager.getPathStateSnapshotHead().getHead().getBlockHash()); + invoke(manager, "closeCommonCheckpoint"); + invoke(manager, "closePathStateRoot"); + + withCommonConfig(output, "LEVELDB", () -> invoke(manager, "initCommonCheckpoint")); + assertEquals(102L, manager.getPathStateSnapshotHead().getHead().getBlockNumber()); + assertFalse(Files.exists(checkpointDirectory.resolve("COMMON_CHECKPOINT"))); + invoke(manager, "closeCommonCheckpoint"); + invoke(manager, "closePathStateRoot"); + } + + @Test + public void commonCheckpointAdoptsCompatibleLegacyCurrentWithoutRebuild() throws Exception { + Path output = temporaryFolder.newFolder("common-checkpoint-legacy-current").toPath(); + long baseNumber = 100L; + long timestamp = 300L; + Path root = output.resolve("path-state-root"); + BlockId baseId = new BlockId(Sha256Hash.wrap(bytes(41)), baseNumber); + byte[] parentHash = bytes(40); + PathStateRootMetadata legacy = publishEmptyPhysicalCurrent(root, baseNumber, + baseId.getBytes(), parentHash); + + DynamicPropertiesStore dynamic = mock(DynamicPropertiesStore.class); + when(dynamic.getLatestBlockHeaderNumber()).thenReturn(baseNumber); + when(dynamic.getLatestBlockHeaderHash()).thenReturn(baseId); + when(dynamic.getLatestBlockHeaderTimestamp()).thenReturn(timestamp); + when(dynamic.getAllowAccountAssetOptimizationFromRoot()).thenReturn(1L); + BlockCapsule baseBlock = mock(BlockCapsule.class); + when(baseBlock.getNum()).thenReturn(baseNumber); + when(baseBlock.getBlockId()).thenReturn(baseId); + when(baseBlock.getParentHash()).thenReturn(Sha256Hash.wrap(parentHash)); + when(baseBlock.getTimeStamp()).thenReturn(timestamp); + ChainBaseManager chainBase = mock(ChainBaseManager.class); + when(chainBase.getDynamicPropertiesStore()).thenReturn(dynamic); + when(chainBase.getBlockByNum(baseNumber)).thenReturn(baseBlock); + when(chainBase.getAccountAssetStore()).thenReturn(mock(AccountAssetStore.class)); + + AtomicInteger pinnedSources = new AtomicInteger(); + Manager manager = new Manager(); + setChainBaseManager(manager, chainBase); + withCommonConfig(output, () -> { + SnapshotManager snapshots = new SnapshotManager(""); + for (PathStateParticipantDescriptor.StoreIdentity participant + : PathStateParticipantDescriptor.current().getStores()) { + snapshots.getDbs().add(emptyNativeStore(participant.getDbName(), baseNumber, + baseId.getBytes(), pinnedSources)); + } + snapshots.enable(); + snapshots.setUnChecked(false); + setField(manager, "revokingStore", snapshots); + invoke(manager, "initCommonCheckpoint"); + }); + + assertEquals(0, pinnedSources.get()); + assertArrayEquals(legacy.getStateRoot(), manager.getPathStateSnapshotHead().getHead() + .getStateRoot()); + assertTrue(Files.isRegularFile(output.resolve("common-checkpoint") + .resolve(CommonCheckpointBaselineFile.FILE_NAME))); + assertTrue(Files.isRegularFile(root.resolve(PathStateCheckpointMaterializer.COMMON_MODE_FILE))); + assertTrue(Files.isRegularFile(root.resolve("LEGACY_BASELINE"))); + assertTrue(Files.isRegularFile(root.resolve( + PathStateCheckpointMaterializer.COMMON_BASELINE_HEAD_FILE))); + assertFalse(Files.exists(root.resolve("CURRENT"))); + invoke(manager, "closeCommonCheckpoint"); + invoke(manager, "closePathStateRoot"); + } + + @SuppressWarnings("unchecked") + private static Chainbase propertiesStoreWithP66Enabled() { + DB database = mock(DB.class); + when(database.getDbName()).thenReturn("properties"); + when(database.iterator()).thenReturn(Collections.emptyIterator()); + when(database.get(org.mockito.ArgumentMatchers.any(byte[].class))) + .thenAnswer(invocation -> Arrays.equals((byte[]) invocation.getArgument(0), + "ALLOW_ASSET_OPTIMIZATION".getBytes(java.nio.charset.StandardCharsets.UTF_8)) + ? java.nio.ByteBuffer.allocate(Long.BYTES).putLong(1L).array() : null); + return new Chainbase(new SnapshotRoot(database)); + } + + @SuppressWarnings("unchecked") + private static Chainbase emptyNativeStore(String dbName, long blockNumber, byte[] blockHash, + AtomicInteger closed) throws Exception { + DB database = mock(DB.class, + withSettings().extraInterfaces(SnapshotCapableStore.class)); + SnapshotCapableStore capable = (SnapshotCapableStore) database; + when(database.getDbName()).thenReturn(dbName); + when(database.iterator()).thenReturn(Collections.emptyIterator()); + if ("properties".equals(dbName)) { + when(database.get(org.mockito.ArgumentMatchers.any(byte[].class))) + .thenAnswer(invocation -> Arrays.equals((byte[]) invocation.getArgument(0), + "ALLOW_ASSET_OPTIMIZATION".getBytes(java.nio.charset.StandardCharsets.UTF_8)) + ? java.nio.ByteBuffer.allocate(Long.BYTES).putLong(1L).array() : null); + } + when(capable.getDbName()).thenReturn(dbName); + when(capable.getSourceIdentity()).thenReturn("source-" + dbName); + StoreSnapshot snapshot = mock(StoreSnapshot.class); + when(snapshot.getDbName()).thenReturn(dbName); + when(snapshot.getSourceIdentity()).thenReturn("source-" + dbName); + when(snapshot.getBlockNumber()).thenReturn(blockNumber); + when(snapshot.getBlockHash()).thenReturn(blockHash); + when(snapshot.range(org.mockito.ArgumentMatchers.any(byte[].class), + org.mockito.ArgumentMatchers.isNull(), org.mockito.ArgumentMatchers.anyInt())) + .thenReturn(Collections.emptyList()); + org.mockito.Mockito.doAnswer(invocation -> { + closed.incrementAndGet(); + return null; + }).when(snapshot).close(); + when(capable.pin(org.mockito.ArgumentMatchers.eq(blockNumber), + org.mockito.ArgumentMatchers.any(byte[].class))).thenReturn(snapshot); + return new Chainbase(new SnapshotRoot(database)); + } + + private static BlockCapsule block(long number, BlockId id, Sha256Hash parent, long timestamp) { + BlockCapsule block = mock(BlockCapsule.class); + when(block.getNum()).thenReturn(number); + when(block.getBlockId()).thenReturn(id); + when(block.getParentHash()).thenReturn(parent); + when(block.getTimeStamp()).thenReturn(timestamp); + return block; + } + + private static PathStateCheckpointMaterializer.FaultHook failAfterParticipantBatch() { + return (stage, storeId) -> { + if (stage == PathStateCheckpointMaterializer.Stage.AFTER_PARTICIPANT_BATCH) { + throw new java.io.IOException("simulated process death during PathState"); + } + }; + } + + private static void withConfig(Path output, boolean enabled, ThrowingRunnable action) + throws Exception { + CommonParameter args = CommonParameter.getInstance(); + Storage oldStorage = args.getStorage(); + Storage storage = new Storage(); + String oldOutput = args.outputDirectory; + try { + args.outputDirectory = output.toString(); + args.storage = storage; + storage.setDbEngine("ROCKSDB"); + storage.setPathStateRootEnabled(enabled); + storage.setPathStateRootDirectory("path-state-root"); + storage.setPathStateRootReversibleLayerLimit(8); + storage.setPathStateRootReversibleLayerBytes(1L << 20); + action.run(); + } finally { + args.outputDirectory = oldOutput; + args.storage = oldStorage; + } + } + + private static void withCommonConfig(Path output, ThrowingRunnable action) throws Exception { + withCommonConfig(output, "ROCKSDB", false, action); + } + + private static void withCommonConfig(Path output, boolean hotEnabled, + ThrowingRunnable action) throws Exception { + withCommonConfig(output, "ROCKSDB", hotEnabled, action); + } + + private static void withCommonConfig(Path output, String engine, ThrowingRunnable action) + throws Exception { + withCommonConfig(output, engine, false, action); + } + + private static void withCommonConfig(Path output, String engine, boolean hotEnabled, + ThrowingRunnable action) throws Exception { + CommonParameter args = CommonParameter.getInstance(); + Storage oldStorage = args.getStorage(); + String oldOutput = args.outputDirectory; + try { + Storage storage = new Storage(); + args.outputDirectory = output.toString(); + args.storage = storage; + storage.setDbEngine(engine); + storage.setStateArchiveEnabled(true); + storage.setStateArchiveDirectory("state-archive"); + StateArchiveHotStoreConfig hotConfig = new StateArchiveHotStoreConfig(); + hotConfig.setEnabled(hotEnabled); + storage.setStateArchiveHotStoreSettings(hotConfig); + storage.setCommonCheckpointEnabled(true); + storage.setCommonCheckpointDirectory("common-checkpoint"); + storage.setPathStateRootEnabled(true); + storage.setPathStateRootDirectory("path-state-root"); + storage.setPathStateRootReversibleLayerLimit(8); + storage.setPathStateRootReversibleLayerBytes(1L << 20); + storage.setPathStateRootNodeCacheBytes(1L << 20); + storage.setPathStateRootParticipantThreads(2); + storage.setPathStateRootBranchThreads(2); + action.run(); + } finally { + args.outputDirectory = oldOutput; + args.storage = oldStorage; + } + } + + private static void withHotCommonConfig(Path output, ThrowingRunnable action) throws Exception { + withCommonConfig(output, true, action); + } + + private static void setChainBaseManager(Manager manager, ChainBaseManager chainBase) + throws Exception { + setField(manager, "chainBaseManager", chainBase); + } + + private static void setField(Manager manager, String name, Object value) throws Exception { + java.lang.reflect.Field field = Manager.class.getDeclaredField(name); + field.setAccessible(true); + field.set(manager, value); + } + + private static void setSnapshotField(SnapshotManager manager, String name, int value) + throws Exception { + java.lang.reflect.Field field = SnapshotManager.class.getDeclaredField(name); + field.setAccessible(true); + field.setInt(manager, value); + } + + private static void invoke(Manager manager, String methodName) throws Exception { + Method method = Manager.class.getDeclaredMethod(methodName); + method.setAccessible(true); + try { + method.invoke(manager); + } catch (InvocationTargetException failure) { + Throwable cause = failure.getCause(); + if (cause instanceof Exception) { + throw (Exception) cause; + } + throw failure; + } + } + + private static byte[] bytes(int seed) { + byte[] value = new byte[32]; + for (int index = 0; index < value.length; index++) { + value[index] = (byte) (seed + index); + } + return value; + } + + private static PathStateRootMetadata publishEmptyPhysicalCurrent(Path root, + long blockNumber, int blockSeed, int parentSeed) throws Exception { + return publishEmptyPhysicalCurrent(root, blockNumber, bytes(blockSeed), bytes(parentSeed)); + } + + private static PathStateRootMetadata publishEmptyPhysicalCurrent(Path root, + long blockNumber, byte[] blockHash, byte[] parentHash) throws Exception { + try (PathStatePhysicalStoreSet stores = PathStatePhysicalStoreSet.open(root, + new PathStateCanonicalizer().participantScope(), Engine.ROCKSDB)) { + PathStateRoot state = stores.buildRootFromFlat(); + PathStateRootMetadata metadata = PathStateRootMetadata.base(blockNumber, blockHash, + parentHash, 300, P66Phase.P66_ON, stores.getFormatDigest(), state.rootHash(), + bytes(3)); + stores.publishCurrent(metadata); + return metadata; + } + } + + private static PathStateBlockTransition transition(long blockNumber, int seed, + byte[] parentHash) { + return new PathStateBlockTransition(blockNumber, bytes(seed), parentHash, + blockNumber * 3, P66Phase.P66_ON, Collections.emptyList()); + } + + @FunctionalInterface + private interface ThrowingRunnable { + void run() throws Exception; + } +} diff --git a/framework/src/test/java/org/tron/core/db2/stateroot/PathStateNativeNodeStoreTest.java b/framework/src/test/java/org/tron/core/db2/stateroot/PathStateNativeNodeStoreTest.java new file mode 100644 index 00000000000..b82da8f8c65 --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/stateroot/PathStateNativeNodeStoreTest.java @@ -0,0 +1,2050 @@ +package org.tron.core.db2.stateroot; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Stream; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tron.common.arch.Arch; +import org.tron.core.config.args.StorageConfig.NativeDbConfig; +import org.tron.core.db2.archive.BlockReverseDiff; +import org.tron.core.db2.archive.BlockSnapshotMeta; +import org.tron.core.db2.core.CommonCheckpointBaseline; +import org.tron.core.db2.core.CommonCheckpointPayload; +import org.tron.core.db2.core.CommonCheckpointTarget; +import org.tron.core.db2.stateroot.PathStateCanonicalizer.P66Phase; +import org.tron.core.db2.stateroot.PathStateRebuildCoordinator.EntryConsumer; +import org.tron.core.db2.stateroot.PathStateRebuildCoordinator.SnapshotIdentity; +import org.tron.core.db2.stateroot.PathStateRebuildCoordinator.SnapshotSource; +import org.tron.core.db2.stateroot.PathStateStoreManifest.Engine; + +public class PathStateNativeNodeStoreTest { + + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void nativeStoresOwnBytesAndPreserveSyncedMutationsAcrossReopen() throws Exception { + for (Engine engine : availableEngines()) { + Path directory = new File(temporaryFolder.getRoot(), "native-" + engine).toPath(); + byte[] path = new byte[]{1, 2, 3}; + byte[] node = new byte[]{4, 5, 6}; + try (PathStateNativeNodeStore store = PathStateNativeNodeStore.open(directory, engine)) { + store.put(path, node); + path[0] = 15; + node[0] = 15; + byte[] returned = store.get(new byte[]{1, 2, 3}); + returned[0] = 15; + assertArrayEquals(new byte[]{4, 5, 6}, store.get(new byte[]{1, 2, 3})); + } + try (PathStateNativeNodeStore reopened = PathStateNativeNodeStore.open(directory, engine)) { + assertArrayEquals(new byte[]{4, 5, 6}, reopened.get(new byte[]{1, 2, 3})); + reopened.delete(new byte[]{1, 2, 3}); + } + try (PathStateNativeNodeStore reopened = PathStateNativeNodeStore.open(directory, engine)) { + assertNull(reopened.get(new byte[]{1, 2, 3})); + } + } + } + + @Test + public void nativeStoreRejectsInvalidEntriesAndUseAfterClose() throws Exception { + Path directory = temporaryFolder.newFolder("native-invalid").toPath(); + PathStateNativeNodeStore store = PathStateNativeNodeStore.open(directory, Engine.ROCKSDB); + assertThrows(IllegalArgumentException.class, () -> store.put(new byte[0], new byte[]{1})); + assertThrows(IllegalArgumentException.class, () -> store.put(new byte[]{1}, new byte[0])); + store.close(); + store.close(); + assertThrows(IllegalStateException.class, () -> store.get(new byte[0])); + } + + @Test + public void physicalStoresUseFixedSmallLargeAndGiantProfiles() { + assertEquals("giant", PathStatePhysicalStoreSet.storageProfileNameFor("account")); + assertEquals("giant", PathStatePhysicalStoreSet.storageProfileNameFor("account-asset")); + assertEquals("giant", PathStatePhysicalStoreSet.storageProfileNameFor("storage-row")); + assertEquals("large", PathStatePhysicalStoreSet.storageProfileNameFor("code")); + assertEquals("large", PathStatePhysicalStoreSet.storageProfileNameFor("contract")); + assertEquals("large", PathStatePhysicalStoreSet.storageProfileNameFor("delegation")); + assertEquals("small", PathStatePhysicalStoreSet.storageProfileNameFor("proposal")); + } + + @Test + public void rocksProfileIsPersistedInNativeOptions() throws Exception { + Path directory = temporaryFolder.newFolder("rocks-profile-options").toPath(); + try (PathStateNativeNodeStore store = PathStateNativeNodeStore.open(directory, + Engine.ROCKSDB, "giant", NativeDbConfig.giant())) { + assertEquals("giant", store.getStorageProfile()); + store.put(new byte[]{1}, new byte[]{2}); + } + + String nativeOptions = new String(Files.readAllBytes(latestOptionsFile(directory)), + StandardCharsets.US_ASCII); + assertTrue(nativeOptions.contains("write_buffer_size=67108864")); + assertTrue(nativeOptions.contains("max_write_buffer_number=2")); + assertTrue(nativeOptions.contains("compression=kSnappyCompression")); + assertTrue(nativeOptions.contains("block_size=4096")); + assertTrue(nativeOptions.contains("filter_policy=rocksdb.BuiltinBloomFilter")); + assertTrue(nativeOptions.contains("checksum=kCRC32c")); + } + + @Test + public void transitionRecordingStoreCachesBaseReadsAndOwnsReturnedBytes() { + AtomicInteger reads = new AtomicInteger(); + PathNodeStore base = new PathNodeStore() { + @Override + public byte[] get(byte[] path) { + reads.incrementAndGet(); + return path[0] == 1 ? new byte[]{4, 5, 6} : null; + } + + @Override + public void put(byte[] path, byte[] encodedNode) { + throw new UnsupportedOperationException(); + } + + @Override + public void delete(byte[] path) { + throw new UnsupportedOperationException(); + } + }; + PathStatePhysicalStoreSet.RecordingNodeStore store = + new PathStatePhysicalStoreSet.RecordingNodeStore(base); + + byte[] first = store.get(new byte[]{1}); + first[0] = 9; + assertArrayEquals(new byte[]{4, 5, 6}, store.get(new byte[]{1})); + assertNull(store.get(new byte[]{2})); + assertNull(store.get(new byte[]{2})); + assertEquals(2, reads.get()); + assertEquals(2, store.getBaseReadMisses()); + assertEquals(2, store.getBaseReadHits()); + + store.put(new byte[]{1}, new byte[]{7, 8}); + byte[] changed = store.get(new byte[]{1}); + changed[0] = 9; + assertArrayEquals(new byte[]{7, 8}, store.get(new byte[]{1})); + assertEquals(2, reads.get()); + assertEquals(2, store.getBaseReadMisses()); + assertEquals(2, store.getBaseReadHits()); + } + + @Test + public void residentNodeStoreIsBoundedAndTracksCommittedValues() { + java.util.Map durable = new java.util.HashMap<>(); + durable.put("1", new byte[]{4}); + AtomicInteger reads = new AtomicInteger(); + PathNodeStore base = new PathNodeStore() { + @Override + public byte[] get(byte[] path) { + reads.incrementAndGet(); + byte[] value = durable.get(Integer.toString(path[0])); + return value == null ? null : Arrays.copyOf(value, value.length); + } + + @Override + public void put(byte[] path, byte[] encodedNode) { + durable.put(Integer.toString(path[0]), Arrays.copyOf(encodedNode, encodedNode.length)); + } + + @Override + public void delete(byte[] path) { + durable.remove(Integer.toString(path[0])); + } + }; + PathStatePhysicalStoreSet.ResidentNodeCache cache = + new PathStatePhysicalStoreSet.ResidentNodeCache(150); + PathStatePhysicalStoreSet.ResidentNodeStore store = + new PathStatePhysicalStoreSet.ResidentNodeStore(base, cache, 1); + + assertArrayEquals(new byte[]{4}, store.get(new byte[]{1})); + assertArrayEquals(new byte[]{4}, store.get(new byte[]{1})); + assertNull(store.get(new byte[]{2})); + assertEquals(2, reads.get()); + assertEquals(1, store.getHits()); + assertEquals(2, store.getNativeReads()); + + store.put(new byte[]{1}, new byte[]{7}); + assertArrayEquals(new byte[]{7}, store.get(new byte[]{1})); + store.delete(new byte[]{2}); + assertNull(store.get(new byte[]{2})); + assertEquals(2, reads.get()); + + assertNull(store.get(new byte[]{3})); + assertTrue(cache.bytes() <= 150); + assertTrue(cache.size() <= 2); + assertArrayEquals(new byte[]{7}, store.get(new byte[]{1})); + assertTrue(cache.bytes() <= 150); + assertTrue(cache.evictions() > 0); + } + + @Test + public void residentNodeCacheSharesBudgetWithoutCrossStoreAliasing() { + AtomicInteger firstReads = new AtomicInteger(); + AtomicInteger secondReads = new AtomicInteger(); + PathNodeStore firstBase = fixedNodeStore(new byte[]{1}, firstReads); + PathNodeStore secondBase = fixedNodeStore(new byte[]{2}, secondReads); + PathStatePhysicalStoreSet.ResidentNodeCache cache = + new PathStatePhysicalStoreSet.ResidentNodeCache(300); + PathStatePhysicalStoreSet.ResidentNodeStore first = + new PathStatePhysicalStoreSet.ResidentNodeStore(firstBase, cache, 1); + PathStatePhysicalStoreSet.ResidentNodeStore second = + new PathStatePhysicalStoreSet.ResidentNodeStore(secondBase, cache, 2); + + byte[] samePath = new byte[]{7}; + assertArrayEquals(new byte[]{1}, first.get(samePath)); + assertArrayEquals(new byte[]{2}, second.get(samePath)); + assertArrayEquals(new byte[]{1}, first.get(samePath)); + assertArrayEquals(new byte[]{2}, second.get(samePath)); + assertEquals(1, firstReads.get()); + assertEquals(1, secondReads.get()); + assertTrue(cache.bytes() <= 300); + + first.clear(); + assertArrayEquals(new byte[]{2}, second.get(samePath)); + assertEquals(1, secondReads.get()); + assertArrayEquals(new byte[]{1}, first.get(samePath)); + assertEquals(2, firstReads.get()); + } + + private static PathNodeStore fixedNodeStore(byte[] fixedValue, AtomicInteger reads) { + return new PathNodeStore() { + @Override + public byte[] get(byte[] path) { + reads.incrementAndGet(); + return Arrays.copyOf(fixedValue, fixedValue.length); + } + + @Override + public void put(byte[] path, byte[] encodedNode) { + } + + @Override + public void delete(byte[] path) { + } + }; + } + + @Test + public void streamsNativeScansWithoutCollectingTheResultSet() throws Exception { + for (Engine engine : availableEngines()) { + Path directory = new File(temporaryFolder.getRoot(), "stream-" + engine).toPath(); + List mutations = new ArrayList<>(); + for (int index = 0; index < 64; index++) { + mutations.add(PathStateNativeNodeStore.BatchMutation.put( + new byte[]{1, (byte) index}, new byte[]{(byte) (index + 1)})); + } + try (PathStateNativeNodeStore store = PathStateNativeNodeStore.open(directory, engine)) { + store.writeBatch(mutations); + AtomicInteger prefixCount = new AtomicInteger(); + AtomicInteger allCount = new AtomicInteger(); + store.scanPrefix(new byte[]{1}, entry -> prefixCount.incrementAndGet()); + store.scanAll(entry -> allCount.incrementAndGet()); + assertEquals(64, prefixCount.get()); + assertEquals(64, allCount.get()); + } + } + } + + @Test + public void baseStoreSetCreatesExact27PlusSuperAndPersistsRootNodes() throws Exception { + PathStateStoreManifest manifest = manifest("base-set", Engine.ROCKSDB); + byte[] rootHash; + PathStateRootMetadata progress; + try (PathStateNodeStoreSet stores = PathStateNodeStoreSet.openBase(manifest)) { + PathStateRoot root = stores.createRoot(); + root.apply(Collections.singletonList( + PathStateMutation.put("proposal", new byte[]{1}, new byte[]{2}))); + rootHash = root.rootHash(); + progress = PathStateRootMetadata.base(100, bytes(1), bytes(2), 300, + P66Phase.P66_ON, manifest.getIdentityDigest(), rootHash, bytes(3)); + stores.commit(progress); + assertArrayEquals(progress.encode(), stores.getProgress().encode()); + root.verifyNodeStores(); + assertThrows(IllegalStateException.class, stores::createRoot); + } + + assertEquals(1, childDirectoryCount(manifest.getBaseDirectory())); + try (PathStateNativeNodeStore nodes = PathStateNativeNodeStore.open( + manifest.getBaseDirectory().resolve("nodes"), Engine.ROCKSDB)) { + assertNotNull(nodes.get(namespaceRootKey(21))); + assertNotNull(nodes.get(namespaceRootKey(0))); + assertEquals(32, rootHash.length); + } + try (PathStateNodeStoreSet reopened = PathStateNodeStoreSet.openBase(manifest)) { + assertArrayEquals(progress.encode(), reopened.getProgress().encode()); + PathStateRoot restored = reopened.createRoot(); + assertArrayEquals(rootHash, restored.rootHash()); + restored.verifyNodeStores(); + } + } + + @Test + public void missingDurableLeafFailsClosedDuringRootRestore() throws Exception { + PathStateStoreManifest manifest = manifest("missing-leaf", Engine.ROCKSDB); + try (PathStateNodeStoreSet stores = PathStateNodeStoreSet.openBase(manifest)) { + PathStateRoot root = stores.createRoot(); + root.apply(Collections.singletonList( + PathStateMutation.put("proposal", new byte[]{1}, new byte[]{2}))); + byte[] stateRoot = root.rootHash(); + stores.commit(PathStateRootMetadata.base(100, bytes(1), bytes(2), 300, + P66Phase.P66_ON, manifest.getIdentityDigest(), stateRoot, bytes(3))); + } + try (PathStateNativeNodeStore nodes = PathStateNativeNodeStore.open( + manifest.getBaseDirectory().resolve("nodes"), Engine.ROCKSDB)) { + nodes.delete(durableLeafKey(21, + PathStateCommitmentCodec.storeLeafKey(21, new byte[]{1}))); + } + try (PathStateNodeStoreSet reopened = PathStateNodeStoreSet.openBase(manifest)) { + assertThrows(IllegalStateException.class, reopened::createRoot); + } + } + + @Test + public void restoredBaseCommitsLeafUpdatesAndDeletesAcrossSecondReopen() throws Exception { + for (Engine engine : availableEngines()) { + PathStateStoreManifest manifest = manifest("leaf-delta-" + engine, engine); + byte[] firstRoot; + try (PathStateNodeStoreSet stores = PathStateNodeStoreSet.openBase(manifest)) { + PathStateRoot root = stores.createRoot(); + root.apply(Arrays.asList( + PathStateMutation.put("proposal", new byte[]{1}, new byte[]{2}), + PathStateMutation.put("account", new byte[]{3}, new byte[]{4}))); + firstRoot = root.rootHash(); + stores.commit(PathStateRootMetadata.base(100, bytes(1), bytes(2), 300, + P66Phase.P66_ON, manifest.getIdentityDigest(), firstRoot, bytes(3))); + } + + byte[] secondRoot; + try (PathStateNodeStoreSet stores = PathStateNodeStoreSet.openBase(manifest)) { + PathStateRoot root = stores.createRoot(); + assertArrayEquals(firstRoot, root.rootHash()); + root.apply(Arrays.asList( + PathStateMutation.put("proposal", new byte[]{1}, new byte[]{5}), + PathStateMutation.delete("account", new byte[]{3}))); + secondRoot = root.rootHash(); + stores.commit(PathStateRootMetadata.base(101, bytes(4), bytes(1), 303, + P66Phase.P66_ON, manifest.getIdentityDigest(), secondRoot, bytes(6))); + } + + try (PathStateNodeStoreSet stores = PathStateNodeStoreSet.openBase(manifest)) { + PathStateRoot root = stores.createRoot(); + assertArrayEquals(secondRoot, root.rootHash()); + root.verifyNodeStores(); + } + } + } + + @Test + public void rejectedProgressDoesNotFlushPendingNodes() throws Exception { + PathStateStoreManifest manifest = manifest("rejected-progress", Engine.ROCKSDB); + try (PathStateNodeStoreSet stores = PathStateNodeStoreSet.openBase(manifest)) { + PathStateRoot root = stores.createRoot(); + root.apply(Collections.singletonList( + PathStateMutation.put("proposal", new byte[]{1}, new byte[]{2}))); + root.rootHash(); + PathStateRootMetadata mismatch = PathStateRootMetadata.base(100, bytes(1), bytes(2), 300, + P66Phase.P66_ON, manifest.getIdentityDigest(), bytes(9), bytes(3)); + assertThrows(IllegalArgumentException.class, () -> stores.commit(mismatch)); + assertNull(stores.getProgress()); + } + + try (PathStateNativeNodeStore nodes = PathStateNativeNodeStore.open( + manifest.getBaseDirectory().resolve("nodes"), Engine.ROCKSDB)) { + assertNull(nodes.get(namespaceRootKey(21))); + assertNull(nodes.get(namespaceRootKey(0))); + } + } + + @Test + public void levelAndRocksStoreSetsProduceTheSameCurrentRoot() throws Exception { + org.junit.Assume.assumeFalse(Arch.isArm64()); + byte[] level = rootFor(manifest("set-level", Engine.LEVELDB)); + byte[] rocks = rootFor(manifest("set-rocks", Engine.ROCKSDB)); + assertArrayEquals(level, rocks); + } + + @Test + public void layerStoreSetIsBoundToMetadataAndCanonicalDirectory() throws Exception { + PathStateStoreManifest manifest = manifest("layer-set", Engine.ROCKSDB); + PathStateRootMetadata layer = PathStateRootMetadata.layer(101, bytes(1), bytes(2), 300, + P66Phase.P66_ON, manifest.getIdentityDigest(), bytes(3), bytes(4), bytes(5)); + try (PathStateNodeStoreSet stores = PathStateNodeStoreSet.openLayer(manifest, layer)) { + assertEquals(manifest.getLayerDirectory(101, bytes(1)).resolve("nodes"), + stores.getDirectory()); + } + + PathStateRootMetadata foreign = PathStateRootMetadata.layer(101, bytes(1), bytes(2), 300, + P66Phase.P66_ON, bytes(9), bytes(3), bytes(4), bytes(5)); + assertThrows(java.io.IOException.class, + () -> PathStateNodeStoreSet.openLayer(manifest, foreign)); + } + + @Test + public void immutableLayerMetadataSealsTheWritableNodeSet() throws Exception { + PathStateStoreManifest manifest = manifest("sealed-layer", Engine.ROCKSDB); + PathStateRootMetadata layer = PathStateRootMetadata.layer(101, bytes(1), bytes(2), 300, + P66Phase.P66_ON, manifest.getIdentityDigest(), bytes(3), bytes(4), bytes(5)); + Path layerDirectory = manifest.getLayerDirectory(101, bytes(1)); + try (PathStateNodeStoreSet ignored = PathStateNodeStoreSet.openLayer(manifest, layer)) { + assertNotNull(ignored); + } + PathStateMetadataFile.publishImmutable( + layerDirectory.resolve(PathStateCurrentStore.METADATA_FILE), layer); + + assertThrows(java.io.IOException.class, + () -> PathStateNodeStoreSet.openLayer(manifest, layer)); + } + + @Test + public void physicalStoreSetCreatesExact27PlusSuperWithDisjointFNMKeyspaces() throws Exception { + Path root = temporaryFolder.newFolder("physical-27-plus-super").toPath(); + PathStateParticipantScope scope = new PathStateCanonicalizer().participantScope(); + byte[] secureKey = PathStateCommitmentCodec.storeLeafKey(4, new byte[]{1, 2, 3}); + byte[] sameSuffix = new byte[]{7, 8, 9}; + try (PathStatePhysicalStoreSet stores = PathStatePhysicalStoreSet.open(root, scope, + Engine.ROCKSDB)) { + PathStatePhysicalStoreSet.PhysicalStore account = stores.participant("account"); + account.putFlat(secureKey, sameSuffix); + account.nodeStore().put(secureKey, new byte[]{4, 5, 6}); + account.putMetadata(secureKey, new byte[]{1}); + assertArrayEquals(sameSuffix, account.getFlat(secureKey)); + assertArrayEquals(new byte[]{4, 5, 6}, account.nodeStore().get(secureKey)); + assertArrayEquals(new byte[]{1}, account.getMetadata(secureKey)); + + stores.superStore().putFlat(secureKey, new byte[]{2}); + assertArrayEquals(new byte[]{2}, stores.superStore().getFlat(secureKey)); + assertNull(stores.superStore().nodeStore().get(secureKey)); + assertEquals(27, childDirectoryCount(root.resolve("stores"))); + AtomicInteger flatEntries = new AtomicInteger(); + account.scanFlat(ignored -> flatEntries.incrementAndGet()); + assertEquals(1, flatEntries.get()); + assertEquals(32, stores.getFormatDigest().length); + + PathStateRoot stateRoot = stores.createRoot(); + stateRoot.put("account", new byte[]{1, 2, 3}, new byte[]{4, 5, 6}); + assertEquals(32, stateRoot.rootHash().length); + assertNotNull(account.nodeStore().get(new byte[0])); + assertNotNull(stores.superStore().nodeStore().get(new byte[0])); + assertThrows(IllegalStateException.class, stores::createRoot); + } + try (PathStatePhysicalStoreSet reopened = PathStatePhysicalStoreSet.open(root, scope, + Engine.ROCKSDB)) { + assertArrayEquals(sameSuffix, reopened.participant("account").getFlat(secureKey)); + assertArrayEquals(new byte[]{4, 5, 6}, reopened.participant("account").nodeStore() + .get(secureKey)); + AtomicInteger flatEntries = new AtomicInteger(); + reopened.participant("account").scanFlat(ignored -> flatEntries.incrementAndGet()); + assertEquals(1, flatEntries.get()); + } + } + + @Test + public void physicalStoreSetRejectsLegacySharedBaseNodes() throws Exception { + Path root = temporaryFolder.newFolder("physical-legacy-rejection").toPath(); + Files.createDirectories(root.resolve("base").resolve("nodes")); + assertThrows(java.io.IOException.class, + () -> PathStatePhysicalStoreSet.open(root, new PathStateCanonicalizer().participantScope(), + Engine.ROCKSDB)); + } + + @Test + public void physicalStoreSetRejectsOldSharedManifestEvenWithoutNodeFiles() throws Exception { + Path root = temporaryFolder.newFolder("physical-old-manifest-rejection").toPath(); + PathStateStoreManifest.createOrOpen(root, Engine.ROCKSDB); + assertThrows(java.io.IOException.class, + () -> PathStatePhysicalStoreSet.open(root, new PathStateCanonicalizer().participantScope(), + Engine.ROCKSDB)); + } + + @Test + public void physicalStoreSetRejectsAnExactNameScopeWithAChangedStableStoreId() + throws Exception { + Path root = temporaryFolder.newFolder("physical-store-id-rejection").toPath(); + List changed = new ArrayList<>(); + for (PathStateParticipant participant + : new PathStateCanonicalizer().participantScope().getParticipants()) { + changed.add("proposal".equals(participant.getDbName()) + ? new PathStateParticipant(99, participant.getDbName(), + participant.getStoreFormatVersion()) : participant); + } + PathStateParticipantScope changedScope = new PathStateParticipantScope(changed); + assertThrows(IllegalArgumentException.class, + () -> PathStatePhysicalStoreSet.open(root, changedScope, Engine.ROCKSDB)); + } + + @Test + public void physicalFlatSnapshotRestoresAndVerifiesTheRoot() throws Exception { + Path root = temporaryFolder.newFolder("physical-flat-restore").toPath(); + PathStateParticipantScope scope = new PathStateCanonicalizer().participantScope(); + byte[] expectedRoot; + try (PathStatePhysicalStoreSet stores = PathStatePhysicalStoreSet.open(root, scope, + Engine.ROCKSDB)) { + PathStateRoot stateRoot = stores.createRoot(); + stateRoot.put("account", new byte[]{1, 2}, new byte[]{3, 4}); + stateRoot.put("proposal", new byte[]{5, 6}, new byte[]{7, 8}); + expectedRoot = stateRoot.rootHash(); + stores.persistFlatSnapshot(stateRoot); + } + try (PathStatePhysicalStoreSet reopened = PathStatePhysicalStoreSet.open(root, scope, + Engine.ROCKSDB)) { + assertArrayEquals(expectedRoot, reopened.restoreRootFromFlat().rootHash()); + } + } + + @Test + public void physicalFlatRestoreFailsClosedForMissingOrCorruptLeaf() throws Exception { + Path root = temporaryFolder.newFolder("physical-flat-corruption").toPath(); + PathStateParticipantScope scope = new PathStateCanonicalizer().participantScope(); + byte[] secureKey = PathStateCommitmentCodec.storeLeafKey(4, new byte[]{1, 2}); + try (PathStatePhysicalStoreSet stores = PathStatePhysicalStoreSet.open(root, scope, + Engine.ROCKSDB)) { + PathStateRoot stateRoot = stores.createRoot(); + stateRoot.put("account", new byte[]{1, 2}, new byte[]{3, 4}); + stateRoot.rootHash(); + stores.persistFlatSnapshot(stateRoot); + } + try (PathStatePhysicalStoreSet corrupted = PathStatePhysicalStoreSet.open(root, scope, + Engine.ROCKSDB)) { + corrupted.participant("account").putFlat(secureKey, new byte[]{1}); + assertThrows(IllegalStateException.class, corrupted::restoreRootFromFlat); + } + try (PathStatePhysicalStoreSet missing = PathStatePhysicalStoreSet.open(root, scope, + Engine.ROCKSDB)) { + missing.participant("account").deleteFlat(secureKey); + assertThrows(IllegalStateException.class, missing::restoreRootFromFlat); + } + } + + @Test + public void physicalFlatBuildStreamsIntoNAndReusesPerStoreCompletionOnRetry() throws Exception { + Path root = temporaryFolder.newFolder("physical-flat-stream-build").toPath(); + PathStateParticipantScope scope = new PathStateCanonicalizer().participantScope(); + byte[] expectedRoot; + try (PathStatePhysicalStoreSet stores = PathStatePhysicalStoreSet.open(root, scope, + Engine.ROCKSDB)) { + PathStateRoot stateRoot = stores.createRoot(); + stateRoot.put("account", new byte[]{1, 2}, new byte[]{3, 4}); + stateRoot.put("proposal", new byte[]{5, 6}, new byte[]{7, 8}); + expectedRoot = stateRoot.rootHash(); + stores.persistFlatSnapshot(stateRoot); + } + try (PathStatePhysicalStoreSet rebuilt = PathStatePhysicalStoreSet.open(root, scope, + Engine.ROCKSDB)) { + assertArrayEquals(expectedRoot, rebuilt.buildRootFromFlat().rootHash()); + assertNotNull(rebuilt.participant("account").getMetadata( + "flat-complete".getBytes(java.nio.charset.StandardCharsets.US_ASCII))); + assertNotNull(rebuilt.participant("proposal").getMetadata( + "flat-complete".getBytes(java.nio.charset.StandardCharsets.US_ASCII))); + } + try (PathStatePhysicalStoreSet retried = PathStatePhysicalStoreSet.open(root, scope, + Engine.ROCKSDB)) { + assertArrayEquals(expectedRoot, retried.buildRootFromFlat().rootHash()); + } + } + + @Test + public void physicalFlatBuildClearsIncompleteNodesAndRetriesWithoutSource() throws Exception { + Path root = temporaryFolder.newFolder("physical-flat-incomplete-retry").toPath(); + PathStateParticipantScope scope = new PathStateCanonicalizer().participantScope(); + byte[] stalePath = new byte[]{15, 15, 15, 15}; + byte[] expectedRoot; + try (PathStatePhysicalStoreSet stores = PathStatePhysicalStoreSet.open(root, scope, + Engine.ROCKSDB)) { + PathStateRoot stateRoot = stores.createRoot(); + stateRoot.put("account", new byte[]{1, 2}, new byte[]{3, 4}); + stateRoot.put("proposal", new byte[]{5, 6}, new byte[]{7, 8}); + expectedRoot = stateRoot.rootHash(); + stores.persistFlatSnapshot(stateRoot); + } + + AtomicInteger injectedFailures = new AtomicInteger(); + try (PathStatePhysicalStoreSet interrupted = PathStatePhysicalStoreSet.open(root, scope, + Engine.ROCKSDB)) { + assertThrows(java.io.IOException.class, () -> interrupted.buildRootFromFlat( + (participant, storeRoot) -> { + if ("proposal".equals(participant.getDbName()) + && injectedFailures.getAndIncrement() == 0) { + throw new java.io.IOException("injected failure before FLAT_COMPLETE"); + } + })); + interrupted.participant("proposal").nodeStore().put(stalePath, new byte[]{99}); + } + assertEquals(1, injectedFailures.get()); + + try (PathStatePhysicalStoreSet retried = PathStatePhysicalStoreSet.open(root, scope, + Engine.ROCKSDB)) { + assertNotNull(retried.participant("proposal").nodeStore().get(stalePath)); + assertArrayEquals(expectedRoot, retried.buildRootFromFlat().rootHash()); + assertNull(retried.participant("proposal").nodeStore().get(stalePath)); + assertNotNull(retried.participant("proposal").getMetadata( + "flat-complete".getBytes(java.nio.charset.StandardCharsets.US_ASCII))); + } + + try (PathStatePhysicalStoreSet reopened = PathStatePhysicalStoreSet.open(root, scope, + Engine.ROCKSDB)) { + assertArrayEquals(expectedRoot, reopened.buildRootFromFlat().rootHash()); + } + } + + @Test + public void physicalGlobalPublicationRecoversEveryIntentCurrentCrashWindow() throws Exception { + PathStateParticipantScope scope = new PathStateCanonicalizer().participantScope(); + for (PathStatePhysicalStoreSet.PublicationStage stage + : PathStatePhysicalStoreSet.PublicationStage.values()) { + Path root = new File(temporaryFolder.getRoot(), "physical-publish-" + stage).toPath(); + byte[] expectedRoot = preparePhysicalTarget(root, scope); + + try (PathStatePhysicalStoreSet interrupted = PathStatePhysicalStoreSet.open(root, scope, + Engine.ROCKSDB)) { + assertThrows(java.io.IOException.class, () -> interrupted.publishCurrent(present -> { + if (present == stage) { + throw new java.io.IOException("injected publication failure at " + stage); + } + })); + } + + try (PathStatePhysicalStoreSet recovered = PathStatePhysicalStoreSet.open(root, scope, + Engine.ROCKSDB)) { + PathStatePhysicalStoreSet.PublicationRecovery action = recovered.recoverPublication(); + assertEquals(stage == PathStatePhysicalStoreSet.PublicationStage.AFTER_RETIRE + ? PathStatePhysicalStoreSet.PublicationRecovery.NONE + : PathStatePhysicalStoreSet.PublicationRecovery.COMPLETED_INTENT, + action); + assertEquals(PathStatePhysicalStoreSet.PublicationRecovery.NONE, + recovered.recoverPublication()); + } + assertFalse(Files.exists(root.resolve(PathStatePhysicalStoreSet.INTENT_FILE))); + assertTrue(Files.isRegularFile(root.resolve(PathStatePhysicalStoreSet.CURRENT_FILE))); + PathStatePhysicalGlobalIntent current = PathStatePhysicalGlobalIntent.decode( + Files.readAllBytes(root.resolve(PathStatePhysicalStoreSet.CURRENT_FILE))); + assertArrayEquals(expectedRoot, current.getSuperRoot()); + assertEquals(27, current.getParticipants().size()); + } + } + + @Test + public void physicalBlockFinalTransitionPreviewsPublishesAndRestarts() throws Exception { + PathStateParticipantScope scope = new PathStateCanonicalizer().participantScope(); + Path root = temporaryFolder.newFolder("physical-block-final").toPath(); + preparePublishedPhysicalTarget(root, scope); + byte[] key = new byte[]{1, 2, 3}; + byte[] blockHash = bytes(31); + + try (PathStatePhysicalStoreSet stores = PathStatePhysicalStoreSet.open(root, scope, + Engine.ROCKSDB)) { + PathStateRootMetadata parent = stores.currentMetadata(); + PathStateBlockTransition transition = new PathStateBlockTransition(1, blockHash, + parent.getBlockHash(), 3, P66Phase.P66_ON, + Collections.singletonList(PathStateMutation.put("code", key, new byte[]{4, 5}))); + PathStateRootMetadata preview = stores.previewTransition(transition); + assertEquals(0, stores.currentMetadata().getBlockNumber()); + assertEquals(0, stores.participant("code").getSyncedWriteBatchCalls()); + assertEquals(0, stores.participant("code").getUnsyncedWriteBatchCalls()); + PathStateRootMetadata committed = stores.applyAndPublish(transition); + assertArrayEquals(preview.encode(), committed.encode()); + assertEquals(1, committed.getBlockNumber()); + assertEquals(0, stores.participant("code").getSyncedWriteBatchCalls()); + assertEquals(1, stores.participant("code").getUnsyncedWriteBatchCalls()); + assertEquals(0, stores.superStore().getSyncedWriteBatchCalls()); + assertEquals(1, stores.superStore().getUnsyncedWriteBatchCalls()); + assertArrayEquals(PathStateCommitmentCodec.presentLeafValue(new byte[]{4, 5}), + stores.participant("code").getFlat( + PathStateCommitmentCodec.storeLeafKey(scope.require("code").getStoreId(), key))); + } + + try (PathStatePhysicalSnapshotHead head = PathStatePhysicalSnapshotHead.open(root, + Engine.ROCKSDB)) { + assertEquals(1, head.getHead().getBlockNumber()); + PathStateBlockTransition update = new PathStateBlockTransition(2, bytes(32), blockHash, + 6, P66Phase.P66_ON, Arrays.asList( + PathStateMutation.put("code", key, new byte[]{6}), + PathStateMutation.put("proposal", new byte[]{7}, new byte[]{8}))); + byte[] preview = head.preview(update); + PathStateSnapshotDelta delta = head.prepareSnapshotDelta( + BlockSnapshotMeta.forBlock(2, bytes(32), blockHash, 6), update); + assertArrayEquals(preview, delta.getStateRoot()); + assertArrayEquals(delta.getStateRoot(), head.advance(update).getStateRoot()); + PathStateBlockTransition delete = new PathStateBlockTransition(3, bytes(33), bytes(32), + 9, P66Phase.P66_ON, + Collections.singletonList(PathStateMutation.delete("code", key))); + head.advance(delete); + assertEquals(3, head.getHead().getBlockNumber()); + } + + try (PathStatePhysicalStoreSet reopened = PathStatePhysicalStoreSet.openExisting(root, scope, + Engine.ROCKSDB)) { + assertEquals(PathStatePhysicalStoreSet.PublicationRecovery.NONE, + reopened.recoverPublication()); + assertEquals(3, reopened.currentMetadata().getBlockNumber()); + assertNull(reopened.participant("code").getFlat( + PathStateCommitmentCodec.storeLeafKey(scope.require("code").getStoreId(), key))); + } + } + + @Test + public void physicalSnapshotDeltaPreparesWithoutWritesAndReusesPlanForPublication() + throws Exception { + PathStateParticipantScope scope = new PathStateCanonicalizer().participantScope(); + Path root = temporaryFolder.newFolder("physical-snapshot-delta").toPath(); + preparePublishedPhysicalTarget(root, scope); + byte[] key = new byte[]{1, 2, 3}; + byte[] blockHash = bytes(35); + + try (PathStatePhysicalStoreSet stores = PathStatePhysicalStoreSet.open(root, scope, + Engine.ROCKSDB)) { + PathStateBlockTransition transition = new PathStateBlockTransition(1, blockHash, + new byte[32], 3, P66Phase.P66_ON, + Collections.singletonList(PathStateMutation.put("code", key, new byte[]{4, 5}))); + PathStatePhysicalStoreSet.PreparedPhysicalTransition prepared = + stores.prepareSnapshotDelta(BlockSnapshotMeta.forBlock(1, blockHash, new byte[32], 3), + transition); + PathStateSnapshotDelta delta = prepared.getSnapshotDelta(); + + assertEquals(0, stores.currentMetadata().getBlockNumber()); + assertEquals(0, stores.participant("code").getUnsyncedWriteBatchCalls()); + assertEquals(0, stores.superStore().getUnsyncedWriteBatchCalls()); + assertEquals(1, delta.getStores().size()); + assertEquals("code", delta.getStores().get(0).getDbName()); + assertArrayEquals(PathStateCommitmentCodec.storeLeafKey( + scope.require("code").getStoreId(), key), + delta.getStores().get(0).getFlatMutations().get(0).getKey()); + assertTrue(Arrays.stream(PathStateSnapshotDelta.class.getDeclaredFields()) + .noneMatch(field -> field.getType() == PathStateRoot.Snapshot.class)); + + PathStateRootMetadata committed = stores.applyAndPublish(prepared, + PathStateLayerLimits.defaults()); + assertArrayEquals(delta.getStateRoot(), committed.getStateRoot()); + assertEquals(1, stores.participant("code").getUnsyncedWriteBatchCalls()); + assertEquals(1, stores.superStore().getUnsyncedWriteBatchCalls()); + + PathStateBlockTransition next = new PathStateBlockTransition(2, bytes(36), blockHash, + 6, P66Phase.P66_ON, + Collections.singletonList(PathStateMutation.put("code", key, new byte[]{6}))); + PathStatePhysicalStoreSet.PreparedPhysicalTransition nextPrepared = + stores.prepareSnapshotDelta(BlockSnapshotMeta.forBlock(2, bytes(36), blockHash, 6), + next); + assertTrue(nextPrepared.reusedTrieSnapshot()); + assertEquals(1, stores.participant("code").getUnsyncedWriteBatchCalls()); + stores.applyAndPublish(nextPrepared, PathStateLayerLimits.defaults()); + assertEquals(2, stores.participant("code").getUnsyncedWriteBatchCalls()); + } + } + + @Test + public void volatileOverlayAdvancesAndRewindsWithoutJournalOrDurableWrites() + throws Exception { + PathStateParticipantScope scope = new PathStateCanonicalizer().participantScope(); + Path root = temporaryFolder.newFolder("physical-volatile-overlay").toPath(); + Path legacyRoot = temporaryFolder.newFolder("physical-volatile-overlay-legacy").toPath(); + preparePublishedPhysicalTarget(root, scope); + preparePublishedPhysicalTarget(legacyRoot, scope); + byte[] durableCurrent = Files.readAllBytes( + root.resolve(PathStatePhysicalStoreSet.CURRENT_FILE)); + byte[] firstHash = bytes(37); + byte[] secondHash = bytes(38); + + try (PathStatePhysicalOverlayHead head = PathStatePhysicalOverlayHead.open(root, + Engine.ROCKSDB, new PathStateLayerLimits(4, 1L << 20)); + PathStatePhysicalSnapshotHead legacy = PathStatePhysicalSnapshotHead.open(legacyRoot, + Engine.ROCKSDB, new PathStateLayerLimits(4, 1L << 20))) { + PathStateBlockTransition first = new PathStateBlockTransition(1, firstHash, + new byte[32], 3, P66Phase.P66_ON, Collections.singletonList( + PathStateMutation.put("code", new byte[]{1}, new byte[]{2}))); + PathStateSnapshotDelta firstDelta = head.prepareSnapshotDelta( + BlockSnapshotMeta.forBlock(1, firstHash, new byte[32], 3), first); + PathStateRootMetadata firstOverlay = head.advance(first); + assertArrayEquals(firstDelta.getStateRoot(), firstOverlay.getStateRoot()); + assertArrayEquals(legacy.advance(first).getStateRoot(), firstOverlay.getStateRoot()); + + PathStateBlockTransition second = new PathStateBlockTransition(2, secondHash, + firstHash, 6, P66Phase.P66_ON, Arrays.asList( + PathStateMutation.put("code", new byte[]{1}, new byte[]{3}), + PathStateMutation.put("proposal", new byte[]{4}, new byte[]{5}))); + head.prepareSnapshotDelta(BlockSnapshotMeta.forBlock(2, secondHash, firstHash, 6), second); + PathStateRootMetadata secondOverlay = head.advance(second); + assertEquals(2, secondOverlay.getBlockNumber()); + assertArrayEquals(legacy.advance(second).getStateRoot(), secondOverlay.getStateRoot()); + assertEquals(0, head.durableWriteBatchCalls()); + assertArrayEquals(durableCurrent, + Files.readAllBytes(root.resolve(PathStatePhysicalStoreSet.CURRENT_FILE))); + Path reverse = root.resolve("reverse"); + assertFalse(Files.exists(reverse)); + + PathStateRootMetadata rewound = head.rewindTo(1, firstHash); + assertEquals(1, rewound.getBlockNumber()); + assertArrayEquals(firstDelta.getStateRoot(), rewound.getStateRoot()); + assertEquals(0, head.durableWriteBatchCalls()); + } + + try (PathStatePhysicalStoreSet reopened = PathStatePhysicalStoreSet.openExisting(root, scope, + Engine.ROCKSDB)) { + assertEquals(0, reopened.currentMetadata().getBlockNumber()); + assertNull(reopened.participant("code").getFlat( + PathStateCommitmentCodec.storeLeafKey(scope.require("code").getStoreId(), + new byte[]{1}))); + } + } + + @Test + public void volatileOverlayBoundsSnapshotParentDepthAfterHistoryTrim() throws Exception { + PathStateParticipantScope scope = new PathStateCanonicalizer().participantScope(); + Path root = temporaryFolder.newFolder("physical-volatile-retention").toPath(); + preparePublishedPhysicalTarget(root, scope); + + int maxHistory = 4; + try (PathStatePhysicalOverlayHead head = PathStatePhysicalOverlayHead.open(root, + Engine.ROCKSDB, new PathStateLayerLimits(maxHistory, 1L << 20))) { + byte[] parentHash = head.getHead().getBlockHash(); + for (int number = 1; number <= 20; number++) { + byte[] blockHash = bytes(140 + number); + PathStateBlockTransition transition = new PathStateBlockTransition(number, blockHash, + parentHash, number * 3L, P66Phase.P66_ON, Collections.singletonList( + PathStateMutation.put("code", new byte[]{1}, new byte[]{(byte) number}))); + head.prepareSnapshotDelta( + BlockSnapshotMeta.forBlock(number, blockHash, parentHash, number * 3L), transition); + head.advance(transition); + parentHash = blockHash; + } + + PathStatePhysicalOverlayHead.RetentionCensus census = head.retentionCensus(); + assertEquals(maxHistory, census.getSuffixBlocks()); + assertTrue("snapshot parent depth must stay bounded: " + census.getMaxSnapshotDepth(), + census.getMaxSnapshotDepth() <= maxHistory * 2); + + PathStateRootMetadata rewound = head.rewindTo(18, bytes(158)); + assertEquals(18, rewound.getBlockNumber()); + PathStateBlockTransition sibling = new PathStateBlockTransition(19, bytes(180), bytes(158), + 60, P66Phase.P66_ON, Collections.singletonList( + PathStateMutation.put("proposal", new byte[]{2}, new byte[]{9}))); + head.prepareSnapshotDelta(BlockSnapshotMeta.forBlock(19, bytes(180), bytes(158), 60), + sibling); + assertEquals(19, head.advance(sibling).getBlockNumber()); + assertTrue(head.retentionCensus().getMaxSnapshotDepth() <= maxHistory * 2); + } + } + + @Test + public void commonOverlayReplacesInMemoryTrieAfterStartupRedo() throws Exception { + PathStateParticipantScope scope = new PathStateCanonicalizer().participantScope(); + Path root = temporaryFolder.newFolder("physical-common-redo-overlay").toPath(); + preparePublishedPhysicalTarget(root, scope); + byte[] formatIdentity = bytes(73); + byte[] blockHash = bytes(74); + + try (PathStatePhysicalOverlayHead head = PathStatePhysicalOverlayHead.open(root, + Engine.ROCKSDB, new PathStateLayerLimits(4, 1L << 20))) { + PathStateRootMetadata baselineHead = head.getHead(); + CommonCheckpointBaseline baseline = new CommonCheckpointBaseline(formatIdentity, + BlockSnapshotMeta.forBlock(baselineHead.getBlockNumber(), baselineHead.getBlockHash(), + baselineHead.getParentHash(), baselineHead.getTimestamp()), + baselineHead.getStateRoot()); + head.admitFreshCommonBaseline(baseline); + + PathStateBlockTransition transition = new PathStateBlockTransition(1, blockHash, + baselineHead.getBlockHash(), 3, P66Phase.P66_ON, Collections.singletonList( + PathStateMutation.put("code", new byte[]{1}, new byte[]{2}))); + BlockSnapshotMeta block = BlockSnapshotMeta.forBlock(1, blockHash, + baselineHead.getBlockHash(), 3); + PathStateSnapshotDelta delta = head.prepareSnapshotDelta(block, transition); + PathStateFlushTarget target = PathStateFlushTarget.coalesce( + Collections.singletonList(delta)); + CommonCheckpointPayload payload = CommonCheckpointPayload.create(formatIdentity, target, + Collections.singletonList(new BlockReverseDiff(block, Collections.emptyList())), + Collections.emptyList()); + CommonCheckpointTarget checkpointTarget = CommonCheckpointTarget.from(payload); + PathStateCheckpointMaterializer materializer = head.checkpointMaterializer(formatIdentity, + baseline); + materializer.materialize(payload, checkpointTarget); + materializer.publish(checkpointTarget); + + head.synchronizePublishedCheckpoint(formatIdentity, block, P66Phase.P66_ON); + assertEquals(1, head.getHead().getBlockNumber()); + assertArrayEquals(blockHash, head.getHead().getBlockHash()); + assertArrayEquals(delta.getStateRoot(), head.getHead().getStateRoot()); + } + } + + @Test + public void commonCheckpointRebasePreservesSuffixAndCutsSnapshotParents() throws Exception { + PathStateParticipantScope scope = new PathStateCanonicalizer().participantScope(); + Path root = temporaryFolder.newFolder("physical-common-memory-rebase").toPath(); + preparePublishedPhysicalTarget(root, scope); + byte[] formatIdentity = bytes(110); + + try (PathStatePhysicalOverlayHead head = PathStatePhysicalOverlayHead.open(root, + Engine.ROCKSDB, new PathStateLayerLimits(8, 1L << 20))) { + PathStateRootMetadata initial = head.getHead(); + CommonCheckpointBaseline baseline = commonBaseline(formatIdentity, initial); + head.admitFreshCommonBaseline(baseline); + PathStateCheckpointMaterializer materializer = head.checkpointMaterializer(formatIdentity, + baseline); + List deltas = new ArrayList<>(); + for (int number = 1; number <= 3; number++) { + byte[] blockHash = bytes(110 + number); + byte[] parentHash = number == 1 ? initial.getBlockHash() : bytes(109 + number); + PathStateBlockTransition transition = new PathStateBlockTransition(number, blockHash, + parentHash, number * 3L, P66Phase.P66_ON, Collections.singletonList( + PathStateMutation.put("code", new byte[]{1}, new byte[]{(byte) number}))); + BlockSnapshotMeta meta = BlockSnapshotMeta.forBlock(number, blockHash, parentHash, + number * 3L); + deltas.add(head.prepareSnapshotDelta(meta, transition)); + head.advance(transition); + } + PathStateRootMetadata liveHead = head.getHead(); + assertEquals(4, head.retentionCensus().getMaxSnapshotDepth()); + + CommonCheckpointPayload payload = commonPayload(formatIdentity, + deltas.subList(0, 2)); + CommonCheckpointTarget target = CommonCheckpointTarget.from(payload); + materializer.materialize(payload, target); + materializer.publish(target); + head.prepareCommonCheckpointRebase(target).apply(); + + PathStatePhysicalOverlayHead.RetentionCensus census = head.retentionCensus(); + assertEquals(3, census.getHeadBlockNumber()); + assertEquals(1, census.getSuffixBlocks()); + assertEquals(28, census.getTrieCount()); + assertEquals(2, census.getMaxSnapshotDepth()); + assertArrayEquals(liveHead.encode(), head.getHead().encode()); + + PathStateRootMetadata rewound = head.rewindTo(2, bytes(112)); + assertArrayEquals(target.getStateRoot(), rewound.getStateRoot()); + PathStateBlockTransition sibling = new PathStateBlockTransition(3, bytes(119), bytes(112), + 10, P66Phase.P66_ON, Collections.singletonList( + PathStateMutation.put("proposal", new byte[]{2}, new byte[]{9}))); + head.prepareSnapshotDelta(BlockSnapshotMeta.forBlock(3, bytes(119), bytes(112), 10), + sibling); + assertEquals(3, head.advance(sibling).getBlockNumber()); + } + } + + @Test + public void repeatedCommonCheckpointRebaseKeepsParentDepthConstant() throws Exception { + PathStateParticipantScope scope = new PathStateCanonicalizer().participantScope(); + Path root = temporaryFolder.newFolder("physical-common-memory-rebase-long").toPath(); + preparePublishedPhysicalTarget(root, scope); + byte[] formatIdentity = bytes(120); + + try (PathStatePhysicalOverlayHead head = PathStatePhysicalOverlayHead.open(root, + Engine.ROCKSDB, new PathStateLayerLimits(8, 1L << 20))) { + PathStateRootMetadata initial = head.getHead(); + CommonCheckpointBaseline baseline = commonBaseline(formatIdentity, initial); + head.admitFreshCommonBaseline(baseline); + PathStateCheckpointMaterializer materializer = head.checkpointMaterializer(formatIdentity, + baseline); + byte[] parentHash = initial.getBlockHash(); + for (int number = 1; number <= 100; number++) { + byte[] blockHash = bytes(120 + number); + PathStateBlockTransition transition = new PathStateBlockTransition(number, blockHash, + parentHash, number * 3L, P66Phase.P66_ON, Collections.singletonList( + PathStateMutation.put("code", new byte[]{1}, new byte[]{(byte) number}))); + BlockSnapshotMeta meta = BlockSnapshotMeta.forBlock(number, blockHash, parentHash, + number * 3L); + PathStateSnapshotDelta delta = head.prepareSnapshotDelta(meta, transition); + head.advance(transition); + CommonCheckpointPayload payload = commonPayload(formatIdentity, + Collections.singletonList(delta)); + CommonCheckpointTarget target = CommonCheckpointTarget.from(payload); + materializer.materialize(payload, target); + materializer.publish(target); + head.prepareCommonCheckpointRebase(target).apply(); + PathStatePhysicalOverlayHead.RetentionCensus census = head.retentionCensus(); + assertEquals(0, census.getSuffixBlocks()); + assertEquals(1, census.getMaxSnapshotDepth()); + parentHash = blockHash; + } + assertEquals(100, head.getHead().getBlockNumber()); + assertEquals(100, head.retentionCensus().getHeadBlockNumber()); + } + } + + @Test + public void asyncPrepareQueuesTransitionAndCompletesOffCallerThread() throws Exception { + PathStateParticipantScope scope = new PathStateCanonicalizer().participantScope(); + Path root = temporaryFolder.newFolder("physical-async-overlay").toPath(); + preparePublishedPhysicalTarget(root, scope); + byte[] durableCurrent = Files.readAllBytes( + root.resolve(PathStatePhysicalStoreSet.CURRENT_FILE)); + byte[] blockHash = bytes(39); + PathStateBlockTransition transition = new PathStateBlockTransition(1, blockHash, + new byte[32], 3, P66Phase.P66_ON, Collections.singletonList( + PathStateMutation.put("code", new byte[]{1}, new byte[]{2}))); + + PathStatePhysicalOverlayHead overlay = PathStatePhysicalOverlayHead.open(root, + Engine.ROCKSDB, new PathStateLayerLimits(4, 1L << 20)); + try (PathStateAsyncPrepareHead async = new PathStateAsyncPrepareHead(overlay, 2)) { + assertNull(async.prepareSnapshotDelta( + BlockSnapshotMeta.forBlock(1, blockHash, new byte[32], 3), transition)); + assertEquals(0, async.advance(transition).getBlockNumber()); + PathStateRootMetadata completed = async.flushBaseThrough(1, blockHash); + assertEquals(1, completed.getBlockNumber()); + assertArrayEquals(blockHash, async.getHead().getBlockHash()); + assertEquals(0, overlay.durableWriteBatchCalls()); + assertArrayEquals(durableCurrent, + Files.readAllBytes(root.resolve(PathStatePhysicalStoreSet.CURRENT_FILE))); + assertFalse(Files.exists(root.resolve("reverse"))); + } + } + + @Test + public void physicalBlockFinalCrashAfterSuperCompletesIntentOnRestart() throws Exception { + PathStateParticipantScope scope = new PathStateCanonicalizer().participantScope(); + Path root = temporaryFolder.newFolder("physical-block-final-super-crash").toPath(); + preparePublishedPhysicalTarget(root, scope); + PathStateBlockTransition transition = new PathStateBlockTransition(1, bytes(41), + new byte[32], 3, P66Phase.P66_ON, + Collections.singletonList(PathStateMutation.put("code", new byte[]{1}, new byte[]{2}))); + try (PathStatePhysicalStoreSet stores = PathStatePhysicalStoreSet.open(root, scope, + Engine.ROCKSDB)) { + assertThrows(java.io.IOException.class, () -> stores.applyAndPublish(transition, stage -> { + if (stage == PathStatePhysicalStoreSet.TransitionStage.AFTER_SUPER_BATCH) { + throw new java.io.IOException("injected failure after super batch"); + } + })); + } + try (PathStatePhysicalStoreSet reopened = PathStatePhysicalStoreSet.openExisting(root, scope, + Engine.ROCKSDB)) { + assertEquals(PathStatePhysicalStoreSet.PublicationRecovery.COMPLETED_INTENT, + reopened.recoverPublication()); + assertEquals(1, reopened.currentMetadata().getBlockNumber()); + } + } + + @Test + public void physicalBlockFinalCrashAfterParticipantBatchFailsClosed() throws Exception { + PathStateParticipantScope scope = new PathStateCanonicalizer().participantScope(); + Path root = temporaryFolder.newFolder("physical-block-final-participant-crash").toPath(); + preparePublishedPhysicalTarget(root, scope); + PathStateBlockTransition transition = new PathStateBlockTransition(1, bytes(51), + new byte[32], 3, P66Phase.P66_ON, + Collections.singletonList(PathStateMutation.put("code", new byte[]{1}, new byte[]{2}))); + try (PathStatePhysicalStoreSet stores = PathStatePhysicalStoreSet.open(root, scope, + Engine.ROCKSDB)) { + assertThrows(java.io.IOException.class, () -> stores.applyAndPublish(transition, stage -> { + if (stage == PathStatePhysicalStoreSet.TransitionStage.AFTER_PARTICIPANT_BATCH) { + throw new java.io.IOException("injected failure after participant batch"); + } + })); + } + try (PathStatePhysicalStoreSet reopened = PathStatePhysicalStoreSet.openExisting(root, scope, + Engine.ROCKSDB)) { + assertThrows(java.io.IOException.class, reopened::recoverPublication); + } + assertTrue(Files.isRegularFile(root.resolve(PathStatePhysicalStoreSet.INTENT_FILE))); + assertTrue(Files.isRegularFile(root.resolve(PathStatePhysicalStoreSet.CURRENT_FILE))); + } + + @Test + public void physicalShortReorgRestoresAncestorAndAdvancesSiblingAcrossRestart() + throws Exception { + PathStateParticipantScope scope = new PathStateCanonicalizer().participantScope(); + Path root = temporaryFolder.newFolder("physical-short-reorg").toPath(); + byte[] baseRoot = preparePublishedPhysicalTarget(root, scope); + byte[] key = new byte[]{1, 2}; + PathStateLayerLimits limits = new PathStateLayerLimits(4, 1L << 20); + + try (PathStatePhysicalSnapshotHead head = PathStatePhysicalSnapshotHead.open(root, + Engine.ROCKSDB, limits)) { + head.advance(new PathStateBlockTransition(1, bytes(61), new byte[32], 3, + P66Phase.P66_ON, Collections.singletonList( + PathStateMutation.put("code", key, new byte[]{3})))); + head.advance(new PathStateBlockTransition(2, bytes(62), bytes(61), 6, + P66Phase.P66_ON, Arrays.asList( + PathStateMutation.put("code", key, new byte[]{4}), + PathStateMutation.put("proposal", new byte[]{5}, new byte[]{6})))); + PathStateRootMetadata rewound = head.rewindTo(0, new byte[32]); + assertEquals(0, rewound.getBlockNumber()); + assertArrayEquals(baseRoot, rewound.getStateRoot()); + PathStateRootMetadata sibling = head.advance(new PathStateBlockTransition(1, bytes(63), + new byte[32], 9, P66Phase.P66_ON, Collections.singletonList( + PathStateMutation.put("code", key, new byte[]{9})))); + assertEquals(1, sibling.getBlockNumber()); + assertArrayEquals(bytes(63), sibling.getBlockHash()); + } + + try (PathStatePhysicalStoreSet reopened = PathStatePhysicalStoreSet.openExisting(root, scope, + Engine.ROCKSDB)) { + assertEquals(1, reopened.currentMetadata().getBlockNumber()); + assertArrayEquals(PathStateCommitmentCodec.presentLeafValue(new byte[]{9}), + reopened.participant("code").getFlat( + PathStateCommitmentCodec.storeLeafKey(scope.require("code").getStoreId(), key))); + assertNull(reopened.participant("proposal").getFlat( + PathStateCommitmentCodec.storeLeafKey(scope.require("proposal").getStoreId(), + new byte[]{5}))); + } + } + + @Test + public void physicalShortReorgIsBoundedAndFailsBeforeAuthorityMoves() throws Exception { + PathStateParticipantScope scope = new PathStateCanonicalizer().participantScope(); + Path root = temporaryFolder.newFolder("physical-short-reorg-bounded").toPath(); + preparePublishedPhysicalTarget(root, scope); + PathStateLayerLimits limits = new PathStateLayerLimits(2, 1L << 20); + try (PathStatePhysicalStoreSet stores = PathStatePhysicalStoreSet.open(root, scope, + Engine.ROCKSDB)) { + stores.applyAndPublish(new PathStateBlockTransition(1, bytes(71), new byte[32], 3, + P66Phase.P66_ON, Collections.emptyList()), limits, stage -> { }); + stores.applyAndPublish(new PathStateBlockTransition(2, bytes(72), bytes(71), 6, + P66Phase.P66_ON, Collections.emptyList()), limits, stage -> { }); + stores.applyAndPublish(new PathStateBlockTransition(3, bytes(73), bytes(72), 9, + P66Phase.P66_ON, Collections.emptyList()), limits, stage -> { }); + byte[] current = stores.currentMetadata().encode(); + assertThrows(java.io.IOException.class, + () -> stores.rewindTo(0, new byte[32], limits)); + assertArrayEquals(current, stores.currentMetadata().encode()); + assertEquals(1, stores.rewindTo(1, bytes(71), limits).getBlockNumber()); + } + } + + @Test + public void physicalShortReorgCrashWindowsCompleteOrFailClosed() throws Exception { + PathStateParticipantScope scope = new PathStateCanonicalizer().participantScope(); + Path completed = temporaryFolder.newFolder("physical-rewind-super-crash").toPath(); + preparePublishedPhysicalTarget(completed, scope); + PathStateLayerLimits limits = new PathStateLayerLimits(4, 1L << 20); + try (PathStatePhysicalStoreSet stores = PathStatePhysicalStoreSet.open(completed, scope, + Engine.ROCKSDB)) { + stores.applyAndPublish(new PathStateBlockTransition(1, bytes(81), new byte[32], 3, + P66Phase.P66_ON, Collections.singletonList( + PathStateMutation.put("code", new byte[]{1}, new byte[]{2})))); + assertThrows(java.io.IOException.class, () -> stores.rewindTo(0, new byte[32], limits, + stage -> { + if (stage == PathStatePhysicalStoreSet.RewindStage.AFTER_SUPER_BATCH) { + throw new java.io.IOException("injected rewind failure after super"); + } + })); + } + try (PathStatePhysicalStoreSet stores = PathStatePhysicalStoreSet.openExisting(completed, + scope, Engine.ROCKSDB)) { + assertEquals(PathStatePhysicalStoreSet.PublicationRecovery.COMPLETED_INTENT, + stores.recoverPublication()); + assertEquals(0, stores.currentMetadata().getBlockNumber()); + } + + Path partial = temporaryFolder.newFolder("physical-rewind-participant-crash").toPath(); + preparePublishedPhysicalTarget(partial, scope); + try (PathStatePhysicalStoreSet stores = PathStatePhysicalStoreSet.open(partial, scope, + Engine.ROCKSDB)) { + stores.applyAndPublish(new PathStateBlockTransition(1, bytes(82), new byte[32], 3, + P66Phase.P66_ON, Collections.singletonList( + PathStateMutation.put("code", new byte[]{1}, new byte[]{2})))); + assertThrows(java.io.IOException.class, () -> stores.rewindTo(0, new byte[32], limits, + stage -> { + if (stage == PathStatePhysicalStoreSet.RewindStage.AFTER_PARTICIPANT_BATCH) { + throw new java.io.IOException("injected rewind failure after participant"); + } + })); + } + try (PathStatePhysicalStoreSet stores = PathStatePhysicalStoreSet.openExisting(partial, scope, + Engine.ROCKSDB)) { + assertThrows(java.io.IOException.class, stores::recoverPublication); + } + } + + @Test + public void physicalStartupRejectsCorruptReverseJournal() throws Exception { + PathStateParticipantScope scope = new PathStateCanonicalizer().participantScope(); + Path root = temporaryFolder.newFolder("physical-reverse-corrupt").toPath(); + preparePublishedPhysicalTarget(root, scope); + try (PathStatePhysicalStoreSet stores = PathStatePhysicalStoreSet.open(root, scope, + Engine.ROCKSDB)) { + stores.applyAndPublish(new PathStateBlockTransition(1, bytes(91), new byte[32], 3, + P66Phase.P66_ON, Collections.singletonList( + PathStateMutation.put("code", new byte[]{1}, new byte[]{2})))); + } + Path reverse; + try (Stream files = Files.list(root.resolve("reverse"))) { + reverse = files.findFirst().get(); + } + byte[] corrupt = Files.readAllBytes(reverse); + corrupt[corrupt.length - 1] ^= 1; + Files.write(reverse, corrupt); + assertThrows(java.io.IOException.class, + () -> PathStatePhysicalSnapshotHead.open(root, Engine.ROCKSDB)); + } + + @Test + public void physicalSteadyTransitionDoesNotRereadIndexedReverseJournal() throws Exception { + PathStateParticipantScope scope = new PathStateCanonicalizer().participantScope(); + Path root = temporaryFolder.newFolder("physical-reverse-runtime-index").toPath(); + preparePublishedPhysicalTarget(root, scope); + try (PathStatePhysicalStoreSet stores = PathStatePhysicalStoreSet.open(root, scope, + Engine.ROCKSDB)) { + stores.applyAndPublish(new PathStateBlockTransition(1, bytes(92), new byte[32], 3, + P66Phase.P66_ON, Collections.singletonList( + PathStateMutation.put("code", new byte[]{1}, new byte[]{2})))); + Path reverse; + try (Stream files = Files.list(root.resolve("reverse"))) { + reverse = files.findFirst().get(); + } + byte[] corrupt = Files.readAllBytes(reverse); + corrupt[corrupt.length - 1] ^= 1; + Files.write(reverse, corrupt); + + assertEquals(2, stores.applyAndPublish(new PathStateBlockTransition(2, bytes(93), bytes(92), + 6, P66Phase.P66_ON, Collections.emptyList())).getBlockNumber()); + } + assertThrows(java.io.IOException.class, + () -> PathStatePhysicalSnapshotHead.open(root, Engine.ROCKSDB)); + } + + @Test + public void physicalOracleWindowLoadsOneExactReadOnlyAncestorChain() throws Exception { + PathStateParticipantScope scope = new PathStateCanonicalizer().participantScope(); + Path root = temporaryFolder.newFolder("physical-oracle-window").toPath(); + preparePublishedPhysicalTarget(root, scope); + PathStateLayerLimits limits = new PathStateLayerLimits(4, 1L << 20); + byte[] key = new byte[]{1, 2}; + + try (PathStatePhysicalStoreSet stores = PathStatePhysicalStoreSet.open(root, scope, + Engine.ROCKSDB)) { + stores.applyAndPublish(new PathStateBlockTransition(1, bytes(94), new byte[32], 3, + P66Phase.P66_ON, Collections.singletonList( + PathStateMutation.put("code", key, new byte[]{3}))), limits); + stores.applyAndPublish(new PathStateBlockTransition(2, bytes(95), bytes(94), 6, + P66Phase.P66_ON, Arrays.asList( + PathStateMutation.put("code", key, new byte[]{4}), + PathStateMutation.put("proposal", new byte[]{5}, new byte[]{6}))), limits); + stores.applyAndPublish(new PathStateBlockTransition(3, bytes(96), bytes(95), 9, + P66Phase.P66_ON, Collections.singletonList( + PathStateMutation.delete("code", key))), limits); + + byte[] currentBefore = Files.readAllBytes(root.resolve( + PathStatePhysicalStoreSet.CURRENT_FILE)); + PathStatePhysicalOracleWindow window = stores.loadOracleWindow(3, limits); + assertEquals(3, window.getBlockCount()); + assertEquals(3, window.getCurrentMetadata().getBlockNumber()); + assertEquals(0, window.getOldestMetadata().getBlockNumber()); + assertArrayEquals(currentBefore, Files.readAllBytes(root.resolve( + PathStatePhysicalStoreSet.CURRENT_FILE))); + assertNull(stores.participant("code").getFlat( + PathStateCommitmentCodec.storeLeafKey(scope.require("code").getStoreId(), key))); + + List journals = window.journals(); + assertEquals(3, journals.size()); + for (int index = 0; index < journals.size(); index++) { + PathStatePhysicalGlobalIntent child = PathStatePhysicalGlobalIntent.decode( + journals.get(index).getChildTarget()); + PathStatePhysicalGlobalIntent parent = PathStatePhysicalGlobalIntent.decode( + journals.get(index).getParentTarget()); + assertEquals(3 - index, child.getMetadata().getBlockNumber()); + assertEquals(2 - index, parent.getMetadata().getBlockNumber()); + } + + } + + Path scratch = new File(temporaryFolder.getRoot(), "physical-oracle-scratch").toPath(); + PathStatePhysicalOracle.Result result = PathStatePhysicalOracleTool.run(new String[]{ + "--root", root.toString(), "--scratch", scratch.toString(), "--blocks", "3", + "--rows-per-flush", "2", "--engine", "ROCKSDB"}); + assertEquals(3, result.getBlockCount()); + assertEquals(3, result.getRowCount()); + assertEquals(3, result.getCurrent().getBlockNumber()); + assertEquals(0, result.getOldest().getBlockNumber()); + assertFalse(Files.exists(scratch)); + } + + @Test + public void physicalOracleToolRejectsUnknownDuplicateAndInvalidOptions() { + assertThrows(IllegalArgumentException.class, + () -> PathStatePhysicalOracleTool.run(new String[]{"--unknown", "value"})); + assertThrows(IllegalArgumentException.class, + () -> PathStatePhysicalOracleTool.run(new String[]{ + "--root", "one", "--root", "two", "--scratch", "scratch", "--blocks", "1"})); + assertThrows(IllegalArgumentException.class, + () -> PathStatePhysicalOracleTool.run(new String[]{ + "--root", "root", "--scratch", "scratch", "--blocks", "0"})); + } + + @Test + public void physicalOracleDetectsFlatValueDriftAndPreservesFailureScratch() throws Exception { + PathStateParticipantScope scope = new PathStateCanonicalizer().participantScope(); + Path root = temporaryFolder.newFolder("physical-oracle-flat-drift").toPath(); + preparePublishedPhysicalTarget(root, scope); + PathStateLayerLimits limits = new PathStateLayerLimits(2, 1L << 20); + + try (PathStatePhysicalStoreSet stores = PathStatePhysicalStoreSet.open(root, scope, + Engine.ROCKSDB)) { + stores.applyAndPublish(new PathStateBlockTransition(1, bytes(99), new byte[32], 3, + P66Phase.P66_ON, Collections.emptyList()), limits); + PathStatePhysicalOracleWindow window = stores.loadOracleWindow(1, limits); + byte[] accountKey = PathStateCommitmentCodec.storeLeafKey( + scope.require("account").getStoreId(), new byte[]{1, 2}); + stores.participant("account").putFlat(accountKey, + PathStateCommitmentCodec.presentLeafValue(new byte[]{9, 9})); + + Path scratch = new File(temporaryFolder.getRoot(), + "physical-oracle-failure-scratch").toPath(); + java.io.IOException failure = assertThrows(java.io.IOException.class, + () -> PathStatePhysicalOracle.verify(stores, window, scratch, 2)); + assertTrue(failure.getMessage().contains("physical oracle root differs")); + assertTrue(Files.isDirectory(scratch)); + } + } + + @Test + public void physicalOracleWindowRejectsPartialUnsettledOrOverLimitInput() throws Exception { + PathStateParticipantScope scope = new PathStateCanonicalizer().participantScope(); + Path root = temporaryFolder.newFolder("physical-oracle-window-invalid").toPath(); + preparePublishedPhysicalTarget(root, scope); + PathStateLayerLimits limits = new PathStateLayerLimits(4, 1L << 20); + + try (PathStatePhysicalStoreSet stores = PathStatePhysicalStoreSet.open(root, scope, + Engine.ROCKSDB)) { + stores.applyAndPublish(new PathStateBlockTransition(1, bytes(97), new byte[32], 3, + P66Phase.P66_ON, Collections.emptyList()), limits); + stores.applyAndPublish(new PathStateBlockTransition(2, bytes(98), bytes(97), 6, + P66Phase.P66_ON, Collections.emptyList()), limits); + + assertThrows(java.io.IOException.class, () -> stores.loadOracleWindow(3, limits)); + assertThrows(java.io.IOException.class, + () -> stores.loadOracleWindow(2, new PathStateLayerLimits(1, 1L << 20))); + assertThrows(IllegalArgumentException.class, + () -> stores.loadOracleWindow(0, limits)); + + Files.write(root.resolve(PathStatePhysicalStoreSet.INTENT_FILE), + Files.readAllBytes(root.resolve(PathStatePhysicalStoreSet.CURRENT_FILE))); + assertThrows(java.io.IOException.class, () -> stores.loadOracleWindow(1, limits)); + } + } + + @Test + public void parallelParticipantWritesStartTogetherAndWaitForEveryCompletion() throws Exception { + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + CountDownLatch started = new CountDownLatch(2); + CountDownLatch release = new CountDownLatch(1); + AtomicBoolean bothStarted = new AtomicBoolean(); + List concurrentWrites = Arrays.asList( + () -> awaitTestLatch(started, release), + () -> awaitTestLatch(started, release)); + Thread releaser = new Thread(() -> { + try { + bothStarted.set(started.await(5, TimeUnit.SECONDS)); + } catch (InterruptedException failure) { + Thread.currentThread().interrupt(); + } finally { + release.countDown(); + } + }); + releaser.start(); + PathStatePhysicalStoreSet.awaitParallelWrites(executor, concurrentWrites); + releaser.join(); + assertTrue(bothStarted.get()); + assertEquals(0, started.getCount()); + + AtomicBoolean secondCompleted = new AtomicBoolean(); + assertThrows(java.io.IOException.class, + () -> PathStatePhysicalStoreSet.awaitParallelWrites(executor, Arrays.asList( + () -> { + throw new IllegalStateException("injected participant failure"); + }, + () -> secondCompleted.set(true)))); + assertTrue(secondCompleted.get()); + } finally { + executor.shutdownNow(); + } + } + + @Test + public void physicalGlobalPublicationAcceptsOnlyOldCurrentOrExactIntentTarget() + throws Exception { + PathStateParticipantScope scope = new PathStateCanonicalizer().participantScope(); + Path uncommitted = temporaryFolder.newFolder("physical-publish-before-intent").toPath(); + preparePhysicalTarget(uncommitted, scope); + try (PathStatePhysicalStoreSet stores = PathStatePhysicalStoreSet.open(uncommitted, scope, + Engine.ROCKSDB)) { + assertEquals(PathStatePhysicalStoreSet.PublicationRecovery.NONE, + stores.recoverPublication()); + } + assertFalse(Files.exists(uncommitted.resolve(PathStatePhysicalStoreSet.CURRENT_FILE))); + + Path tampered = temporaryFolder.newFolder("physical-publish-tampered-target").toPath(); + preparePhysicalTarget(tampered, scope); + try (PathStatePhysicalStoreSet stores = PathStatePhysicalStoreSet.open(tampered, scope, + Engine.ROCKSDB)) { + assertThrows(java.io.IOException.class, () -> stores.publishCurrent(stage -> { + if (stage == PathStatePhysicalStoreSet.PublicationStage.AFTER_INTENT) { + throw new java.io.IOException("injected failure after INTENT"); + } + })); + } + try (PathStatePhysicalStoreSet stores = PathStatePhysicalStoreSet.open(tampered, scope, + Engine.ROCKSDB)) { + stores.participant("proposal").putMetadata( + "store-generation".getBytes(java.nio.charset.StandardCharsets.US_ASCII), bytes(77)); + assertThrows(java.io.IOException.class, stores::recoverPublication); + } + assertFalse(Files.exists(tampered.resolve(PathStatePhysicalStoreSet.CURRENT_FILE))); + assertTrue(Files.isRegularFile(tampered.resolve(PathStatePhysicalStoreSet.INTENT_FILE))); + + Path oldCurrent = temporaryFolder.newFolder("physical-publish-old-current").toPath(); + byte[] expectedRoot = preparePhysicalTarget(oldCurrent, scope); + try (PathStatePhysicalStoreSet stores = PathStatePhysicalStoreSet.open(oldCurrent, scope, + Engine.ROCKSDB)) { + assertArrayEquals(expectedRoot, stores.publishCurrent()); + } + PathStateMetadataFile.publishImmutableBytes( + oldCurrent.resolve(PathStatePhysicalStoreSet.INTENT_FILE), new byte[]{1}); + try (PathStatePhysicalStoreSet stores = PathStatePhysicalStoreSet.open(oldCurrent, scope, + Engine.ROCKSDB)) { + assertEquals(PathStatePhysicalStoreSet.PublicationRecovery.RETAINED_CURRENT, + stores.recoverPublication()); + } + assertFalse(Files.exists(oldCurrent.resolve(PathStatePhysicalStoreSet.INTENT_FILE))); + assertArrayEquals(expectedRoot, PathStatePhysicalGlobalIntent.decode(Files.readAllBytes( + oldCurrent.resolve(PathStatePhysicalStoreSet.CURRENT_FILE))).getSuperRoot()); + } + + @Test + public void physicalGlobalRecordRejectsTruncationChecksumAndCorruptCurrent() throws Exception { + PathStateParticipantScope scope = new PathStateCanonicalizer().participantScope(); + Path root = temporaryFolder.newFolder("physical-global-record-corruption").toPath(); + preparePublishedPhysicalTarget(root, scope); + Path currentPath = root.resolve(PathStatePhysicalStoreSet.CURRENT_FILE); + byte[] encoded = Files.readAllBytes(currentPath); + + assertThrows(IllegalArgumentException.class, + () -> PathStatePhysicalGlobalIntent.decode(Arrays.copyOf(encoded, encoded.length - 1))); + byte[] corruptBody = Arrays.copyOf(encoded, encoded.length); + corruptBody[20] ^= 1; + assertThrows(IllegalArgumentException.class, + () -> PathStatePhysicalGlobalIntent.decode(corruptBody)); + byte[] corruptChecksum = Arrays.copyOf(encoded, encoded.length); + corruptChecksum[corruptChecksum.length - 1] ^= 1; + assertThrows(IllegalArgumentException.class, + () -> PathStatePhysicalGlobalIntent.decode(corruptChecksum)); + + Files.write(currentPath, corruptChecksum); + try (PathStatePhysicalStoreSet stores = PathStatePhysicalStoreSet.open(root, scope, + Engine.ROCKSDB)) { + assertThrows(java.io.IOException.class, stores::recoverPublication); + } + } + + @Test + public void physicalPublicationRejectsMissingCompletionMetadataAndRootNodes() + throws Exception { + PathStateParticipantScope scope = new PathStateCanonicalizer().participantScope(); + + Path missingRoot = temporaryFolder.newFolder("physical-missing-store-root").toPath(); + preparePublishedPhysicalTarget(missingRoot, scope); + try (PathStatePhysicalStoreSet stores = PathStatePhysicalStoreSet.open(missingRoot, scope, + Engine.ROCKSDB)) { + stores.participant("proposal").deleteMetadata(metadata("flat-complete")); + } + assertPublicationRejected(missingRoot, scope); + + Path missingDigest = temporaryFolder.newFolder("physical-missing-flat-digest").toPath(); + preparePublishedPhysicalTarget(missingDigest, scope); + try (PathStatePhysicalStoreSet stores = PathStatePhysicalStoreSet.open(missingDigest, scope, + Engine.ROCKSDB)) { + stores.participant("proposal").deleteMetadata(metadata("flat-digest")); + } + assertPublicationRejected(missingDigest, scope); + + Path corruptGeneration = temporaryFolder.newFolder( + "physical-corrupt-store-generation").toPath(); + preparePublishedPhysicalTarget(corruptGeneration, scope); + try (PathStatePhysicalStoreSet stores = PathStatePhysicalStoreSet.open(corruptGeneration, + scope, Engine.ROCKSDB)) { + stores.participant("proposal").putMetadata(metadata("store-generation"), bytes(88)); + } + assertPublicationRejected(corruptGeneration, scope); + + Path missingSuperGeneration = temporaryFolder.newFolder( + "physical-missing-super-generation").toPath(); + preparePublishedPhysicalTarget(missingSuperGeneration, scope); + try (PathStatePhysicalStoreSet stores = PathStatePhysicalStoreSet.open( + missingSuperGeneration, scope, Engine.ROCKSDB)) { + stores.superStore().deleteMetadata(metadata("super-generation")); + } + assertPublicationRejected(missingSuperGeneration, scope); + + Path missingNode = temporaryFolder.newFolder("physical-missing-root-node").toPath(); + preparePublishedPhysicalTarget(missingNode, scope); + try (PathStatePhysicalStoreSet stores = PathStatePhysicalStoreSet.open(missingNode, scope, + Engine.ROCKSDB)) { + stores.participant("proposal").nodeStore().delete(new byte[0]); + } + assertPublicationRejected(missingNode, scope); + } + + @Test + public void physicalDeleteRecomputesSecureKeyCommitsChangedPathsAndPublishesCurrent() + throws Exception { + PathStateParticipantScope scope = new PathStateCanonicalizer().participantScope(); + Path root = temporaryFolder.newFolder("physical-delete-publish").toPath(); + byte[] originalRoot = preparePublishedPhysicalTarget(root, scope); + byte[] expectedRoot = rootWithOnlyAccount(scope); + byte[] proposalKey = new byte[]{5, 6}; + byte[] secureKey = PathStateCommitmentCodec.storeLeafKey(21, proposalKey); + + try (PathStatePhysicalStoreSet stores = PathStatePhysicalStoreSet.open(root, scope, + Engine.ROCKSDB)) { + PathStatePhysicalStoreSet.PhysicalDeleteResult result = stores.deleteAndPublish( + "proposal", proposalKey, stage -> { }); + assertFalse(Arrays.equals(originalRoot, result.getStateRoot())); + assertArrayEquals(expectedRoot, result.getStateRoot()); + assertTrue(result.getParticipantNodeDeletes() > 0); + assertTrue(result.getSuperNodePuts() > 0); + assertNull(stores.participant("proposal").getFlat(secureKey)); + } + + try (PathStatePhysicalStoreSet reopened = PathStatePhysicalStoreSet.open(root, scope, + Engine.ROCKSDB)) { + assertEquals(PathStatePhysicalStoreSet.PublicationRecovery.NONE, + reopened.recoverPublication()); + PathStatePhysicalGlobalIntent current = PathStatePhysicalGlobalIntent.decode( + Files.readAllBytes(root.resolve(PathStatePhysicalStoreSet.CURRENT_FILE))); + assertArrayEquals(expectedRoot, current.getSuperRoot()); + PathStateRoot restored = reopened.createRoot(); + restored.restoreStoredRoots(current.getSuperRoot()); + restored.verifyNodeStores(); + assertArrayEquals(expectedRoot, restored.rootHash()); + } + } + + @Test + public void physicalDeleteFailsClosedBetweenParticipantSuperAndCurrent() throws Exception { + PathStateParticipantScope scope = new PathStateCanonicalizer().participantScope(); + Path participantFailure = temporaryFolder.newFolder( + "physical-delete-participant-failure").toPath(); + preparePublishedPhysicalTarget(participantFailure, scope); + try (PathStatePhysicalStoreSet stores = PathStatePhysicalStoreSet.open(participantFailure, + scope, Engine.ROCKSDB)) { + assertThrows(java.io.IOException.class, () -> stores.deleteAndPublish("proposal", + new byte[]{5, 6}, stage -> { + if (stage == PathStatePhysicalStoreSet.DeleteStage.AFTER_PARTICIPANT_BATCH) { + throw new java.io.IOException("injected failure after participant batch"); + } + })); + } + try (PathStatePhysicalStoreSet reopened = PathStatePhysicalStoreSet.open(participantFailure, + scope, Engine.ROCKSDB)) { + assertThrows(java.io.IOException.class, reopened::recoverPublication); + assertThrows(java.io.IOException.class, reopened::publishCurrent); + } + + Path superFailure = temporaryFolder.newFolder("physical-delete-super-failure").toPath(); + preparePublishedPhysicalTarget(superFailure, scope); + byte[] expectedRoot = rootWithOnlyAccount(scope); + try (PathStatePhysicalStoreSet stores = PathStatePhysicalStoreSet.open(superFailure, scope, + Engine.ROCKSDB)) { + assertThrows(java.io.IOException.class, () -> stores.deleteAndPublish("proposal", + new byte[]{5, 6}, stage -> { + if (stage == PathStatePhysicalStoreSet.DeleteStage.AFTER_SUPER_BATCH) { + throw new java.io.IOException("injected failure after super batch"); + } + })); + } + try (PathStatePhysicalStoreSet reopened = PathStatePhysicalStoreSet.open(superFailure, scope, + Engine.ROCKSDB)) { + assertThrows(java.io.IOException.class, reopened::recoverPublication); + assertArrayEquals(expectedRoot, reopened.publishCurrent()); + } + try (PathStatePhysicalStoreSet reopened = PathStatePhysicalStoreSet.open(superFailure, scope, + Engine.ROCKSDB)) { + assertEquals(PathStatePhysicalStoreSet.PublicationRecovery.NONE, + reopened.recoverPublication()); + PathStatePhysicalGlobalIntent current = PathStatePhysicalGlobalIntent.decode( + Files.readAllBytes(superFailure.resolve(PathStatePhysicalStoreSet.CURRENT_FILE))); + assertArrayEquals(expectedRoot, current.getSuperRoot()); + assertThrows(java.io.IOException.class, + () -> reopened.deleteAndPublish("proposal", new byte[]{5, 6})); + } + } + + @Test + public void physicalBootstrapBatchesFlatAndNodeWrites() throws Exception { + Path directory = temporaryFolder.newFolder("physical-batched-bootstrap").toPath(); + PathStateParticipantScope scope = new PathStateCanonicalizer().participantScope(); + List rows = new ArrayList<>(); + for (int index = 0; index < PathStatePhysicalStoreSet.BOOTSTRAP_WRITE_BATCH_ENTRIES * 2 + 1; + index++) { + rows.add(new PhysicalRow(new byte[]{ + (byte) (index >>> 24), (byte) (index >>> 16), (byte) (index >>> 8), (byte) index}, + new byte[]{(byte) (index + 1)})); + } + ResumablePhysicalSource source = new ResumablePhysicalSource( + "proposal", rows, bytes(43), Integer.MAX_VALUE); + + try (PathStatePhysicalStoreSet stores = PathStatePhysicalStoreSet.open(directory, scope, + Engine.ROCKSDB)) { + PathStatePhysicalStoreSet.PhysicalStore proposal = stores.participant("proposal"); + long ingestCalls = proposal.getWriteBatchCalls(); + long ingestMutations = proposal.getWriteBatchMutations(); + stores.ingestFlat("proposal", source, Long.MAX_VALUE, Long.MAX_VALUE); + assertEquals(3, proposal.getWriteBatchCalls() - ingestCalls); + assertEquals(rows.size() + 2, proposal.getWriteBatchMutations() - ingestMutations); + + long buildCalls = proposal.getWriteBatchCalls(); + long buildMutations = proposal.getWriteBatchMutations(); + stores.buildRootFromFlat(); + long nodeCalls = proposal.getWriteBatchCalls() - buildCalls; + long nodeMutations = proposal.getWriteBatchMutations() - buildMutations; + assertTrue(nodeMutations > rows.size()); + assertTrue(nodeCalls < 16); + assertTrue(nodeCalls * 1000 < nodeMutations); + } + } + + @Test + public void physicalFlatIngestFlushesBeforeByteLimit() throws Exception { + Path directory = temporaryFolder.newFolder("physical-byte-batched-bootstrap").toPath(); + PathStateParticipantScope scope = new PathStateCanonicalizer().participantScope(); + int valueBytes = 3 * 1024 * 1024; + List rows = Arrays.asList( + new PhysicalRow(new byte[]{1}, new byte[valueBytes]), + new PhysicalRow(new byte[]{2}, new byte[valueBytes]), + new PhysicalRow(new byte[]{3}, new byte[valueBytes])); + ResumablePhysicalSource source = new ResumablePhysicalSource( + "proposal", rows, bytes(45), Integer.MAX_VALUE); + + try (PathStatePhysicalStoreSet stores = PathStatePhysicalStoreSet.open(directory, scope, + Engine.ROCKSDB)) { + PathStatePhysicalStoreSet.PhysicalStore proposal = stores.participant("proposal"); + long calls = proposal.getWriteBatchCalls(); + long mutations = proposal.getWriteBatchMutations(); + stores.ingestFlat("proposal", source, Long.MAX_VALUE, Long.MAX_VALUE); + assertEquals(2, proposal.getWriteBatchCalls() - calls); + assertEquals(rows.size() + 2, proposal.getWriteBatchMutations() - mutations); + } + } + + @Test + public void physicalBootstrapRunsOneLargeAndOneSmallIngestQueue() throws Exception { + Path directory = temporaryFolder.newFolder("physical-tiered-bootstrap").toPath(); + PathStateParticipantScope scope = new PathStateCanonicalizer().participantScope(); + ConcurrentTierPhysicalSource source = new ConcurrentTierPhysicalSource(); + + try (PathStatePhysicalStoreSet stores = PathStatePhysicalStoreSet.open(directory, scope, + Engine.ROCKSDB)) { + stores.ingestAndBuild(source); + } + + assertEquals(2, source.getMaxActive()); + assertTrue(source.sawThread("path-state-physical-bootstrap-large")); + assertTrue(source.sawThread("path-state-physical-bootstrap-small")); + } + + @Test + public void physicalIngestResumesAfterFailureThenBuildsAndReopensTheSameRoot() + throws Exception { + Path directory = temporaryFolder.newFolder("physical-ingest-e2e").toPath(); + Path referenceDirectory = temporaryFolder.newFolder("physical-ingest-reference").toPath(); + PathStateParticipantScope scope = new PathStateCanonicalizer().participantScope(); + List rows = Arrays.asList( + new PhysicalRow(new byte[]{1}, new byte[]{11}), + new PhysicalRow(new byte[]{1, 0}, new byte[]{12}), + new PhysicalRow(new byte[]{2}, new byte[]{22})); + byte[] sourceIdentity = bytes(42); + ResumablePhysicalSource interrupted = new ResumablePhysicalSource( + "proposal", rows, sourceIdentity, 2); + + try (PathStatePhysicalStoreSet stores = PathStatePhysicalStoreSet.open(directory, scope, + Engine.ROCKSDB)) { + assertThrows(java.io.IOException.class, + () -> stores.ingestAndBuild(interrupted, 1, Long.MAX_VALUE)); + PathStatePhysicalIngestCheckpoint checkpoint = stores.ingestCheckpoint("proposal"); + assertEquals(2, checkpoint.getRows()); + assertArrayEquals(new byte[]{1, 0}, checkpoint.getCursor()); + } + + ResumablePhysicalSource resumed = new ResumablePhysicalSource( + "proposal", rows, sourceIdentity, Integer.MAX_VALUE); + byte[] rebuiltRoot; + try (PathStatePhysicalStoreSet stores = PathStatePhysicalStoreSet.open(directory, scope, + Engine.ROCKSDB)) { + rebuiltRoot = stores.ingestAndBuild(resumed, 1, Long.MAX_VALUE).rootHash(); + assertEquals(1, resumed.getScanCount("proposal")); + assertEquals(0, resumed.getScanCount("abi")); + assertArrayEquals(new byte[]{1, 0}, resumed.getLastCursor("proposal")); + PathStatePhysicalIngestCheckpoint checkpoint = stores.ingestCheckpoint("proposal"); + assertEquals(3, checkpoint.getRows()); + assertArrayEquals(new byte[]{2}, checkpoint.getCursor()); + } + + byte[] referenceRoot; + try (PathStatePhysicalStoreSet reference = PathStatePhysicalStoreSet.open( + referenceDirectory, scope, Engine.ROCKSDB)) { + PathStateRoot root = reference.createRoot(); + for (PhysicalRow row : rows) { + root.put("proposal", row.key, row.value); + } + referenceRoot = root.rootHash(); + } + assertArrayEquals(referenceRoot, rebuiltRoot); + + try (PathStatePhysicalStoreSet reopened = PathStatePhysicalStoreSet.open(directory, scope, + Engine.ROCKSDB)) { + assertArrayEquals(referenceRoot, reopened.buildRootFromFlat().rootHash()); + } + } + + @Test + public void physicalIngestReplaysUncheckpointedBatchAfterFailure() throws Exception { + Path directory = temporaryFolder.newFolder("physical-uncheckpointed-replay").toPath(); + PathStateParticipantScope scope = new PathStateCanonicalizer().participantScope(); + List rows = Arrays.asList( + new PhysicalRow(new byte[]{1}, new byte[]{11}), + new PhysicalRow(new byte[]{2}, new byte[]{12}), + new PhysicalRow(new byte[]{3}, new byte[]{13})); + byte[] sourceIdentity = bytes(46); + ResumablePhysicalSource interrupted = new ResumablePhysicalSource( + "proposal", rows, sourceIdentity, 2); + + try (PathStatePhysicalStoreSet stores = PathStatePhysicalStoreSet.open(directory, scope, + Engine.ROCKSDB)) { + assertThrows(java.io.IOException.class, + () -> stores.ingestFlat("proposal", interrupted, Long.MAX_VALUE, Long.MAX_VALUE)); + assertNull(stores.ingestCheckpoint("proposal")); + assertEquals(0, stores.participant("proposal").getWriteBatchCalls()); + } + + ResumablePhysicalSource resumed = new ResumablePhysicalSource( + "proposal", rows, sourceIdentity, Integer.MAX_VALUE); + try (PathStatePhysicalStoreSet stores = PathStatePhysicalStoreSet.open(directory, scope, + Engine.ROCKSDB)) { + stores.ingestFlat("proposal", resumed, Long.MAX_VALUE, Long.MAX_VALUE); + assertNull(resumed.getLastCursor("proposal")); + PathStatePhysicalIngestCheckpoint checkpoint = stores.ingestCheckpoint("proposal"); + assertEquals(3, checkpoint.getRows()); + assertArrayEquals(new byte[]{3}, checkpoint.getCursor()); + } + } + + private byte[] rootFor(PathStateStoreManifest manifest) throws Exception { + byte[] stateRoot; + PathStateRootMetadata progress; + try (PathStateNodeStoreSet stores = PathStateNodeStoreSet.openBase(manifest)) { + PathStateRoot root = stores.createRoot(); + root.apply(Arrays.asList( + PathStateMutation.put("proposal", new byte[]{1}, new byte[]{2}), + PathStateMutation.put("account", new byte[]{3}, new byte[]{4}))); + stateRoot = root.rootHash(); + progress = PathStateRootMetadata.base(100, bytes(1), bytes(2), 300, + P66Phase.P66_ON, manifest.getIdentityDigest(), stateRoot, bytes(3)); + stores.commit(progress); + } + try (PathStateNodeStoreSet reopened = PathStateNodeStoreSet.openBase(manifest)) { + assertArrayEquals(progress.encode(), reopened.getProgress().encode()); + assertArrayEquals(stateRoot, reopened.createRoot().rootHash()); + } + return stateRoot; + } + + private byte[] preparePhysicalTarget(Path directory, PathStateParticipantScope scope) + throws Exception { + byte[] expectedRoot; + try (PathStatePhysicalStoreSet stores = PathStatePhysicalStoreSet.open(directory, scope, + Engine.ROCKSDB)) { + PathStateRoot stateRoot = stores.createRoot(); + stateRoot.put("account", new byte[]{1, 2}, new byte[]{3, 4}); + stateRoot.put("proposal", new byte[]{5, 6}, new byte[]{7, 8}); + expectedRoot = stateRoot.rootHash(); + stores.persistFlatSnapshot(stateRoot); + } + try (PathStatePhysicalStoreSet stores = PathStatePhysicalStoreSet.open(directory, scope, + Engine.ROCKSDB)) { + assertArrayEquals(expectedRoot, stores.buildRootFromFlat().rootHash()); + } + return expectedRoot; + } + + private byte[] rootWithOnlyAccount(PathStateParticipantScope scope) throws Exception { + Path directory = temporaryFolder.newFolder("physical-delete-reference").toPath(); + try (PathStatePhysicalStoreSet stores = PathStatePhysicalStoreSet.open(directory, scope, + Engine.ROCKSDB)) { + PathStateRoot root = stores.createRoot(); + root.put("account", new byte[]{1, 2}, new byte[]{3, 4}); + return root.rootHash(); + } + } + + private byte[] preparePublishedPhysicalTarget(Path directory, + PathStateParticipantScope scope) throws Exception { + byte[] expectedRoot = preparePhysicalTarget(directory, scope); + try (PathStatePhysicalStoreSet stores = PathStatePhysicalStoreSet.open(directory, scope, + Engine.ROCKSDB)) { + assertArrayEquals(expectedRoot, stores.publishCurrent()); + } + return expectedRoot; + } + + private static CommonCheckpointBaseline commonBaseline(byte[] formatIdentity, + PathStateRootMetadata head) { + return new CommonCheckpointBaseline(formatIdentity, + BlockSnapshotMeta.forBlock(head.getBlockNumber(), head.getBlockHash(), + head.getParentHash(), head.getTimestamp()), head.getStateRoot()); + } + + private static CommonCheckpointPayload commonPayload(byte[] formatIdentity, + List deltas) { + PathStateFlushTarget target = PathStateFlushTarget.coalesce(deltas); + List archive = new ArrayList<>(); + for (PathStateSnapshotDelta delta : deltas) { + archive.add(new BlockReverseDiff(delta.getMeta(), Collections.emptyList())); + } + return CommonCheckpointPayload.create(formatIdentity, target, archive, + Collections.emptyList()); + } + + private void assertPublicationRejected(Path directory, PathStateParticipantScope scope) + throws Exception { + try (PathStatePhysicalStoreSet stores = PathStatePhysicalStoreSet.open(directory, scope, + Engine.ROCKSDB)) { + assertThrows(java.io.IOException.class, stores::recoverPublication); + } + } + + private static byte[] metadata(String name) { + return name.getBytes(java.nio.charset.StandardCharsets.US_ASCII); + } + + private PathStateStoreManifest manifest(String name, Engine engine) throws Exception { + return PathStateStoreManifest.createOrOpen(temporaryFolder.newFolder(name).toPath(), engine); + } + + private static List availableEngines() { + return Arch.isArm64() ? Collections.singletonList(Engine.ROCKSDB) + : Arrays.asList(Engine.LEVELDB, Engine.ROCKSDB); + } + + private static long childDirectoryCount(Path directory) throws Exception { + try (Stream paths = Files.list(directory)) { + return paths.filter(Files::isDirectory).count(); + } + } + + private static byte[] namespaceRootKey(int storeId) { + return java.nio.ByteBuffer.allocate(Integer.BYTES).putInt(storeId).array(); + } + + private static byte[] durableLeafKey(int storeId, byte[] secureKey) { + return java.nio.ByteBuffer.allocate(Integer.BYTES * 2 + secureKey.length) + .putInt(-2) + .putInt(storeId) + .put(secureKey) + .array(); + } + + private static byte[] bytes(int seed) { + byte[] value = new byte[32]; + for (int index = 0; index < value.length; index++) { + value[index] = (byte) (seed + index); + } + return value; + } + + private static final class PhysicalRow { + + private final byte[] key; + private final byte[] value; + + private PhysicalRow(byte[] key, byte[] value) { + this.key = key; + this.value = value; + } + } + + private static final class ResumablePhysicalSource implements SnapshotSource { + + private final String dbName; + private final List rows; + private final byte[] identity; + private final int failAfter; + private final java.util.Map scanCounts = new java.util.LinkedHashMap<>(); + private final java.util.Map lastCursors = new java.util.LinkedHashMap<>(); + + private ResumablePhysicalSource(String dbName, List rows, byte[] identity, + int failAfter) { + this.dbName = dbName; + this.rows = rows; + this.identity = identity; + this.failAfter = failAfter; + } + + @Override + public SnapshotIdentity identity() { + return new SnapshotIdentity(100, bytes(1), bytes(2), 300, P66Phase.P66_ON); + } + + @Override + public Collection databases() { + List names = new ArrayList<>(); + for (PathStateParticipantDescriptor.StoreIdentity store + : PathStateParticipantDescriptor.current().getStores()) { + names.add(store.getDbName()); + } + return names; + } + + @Override + public byte[] sourceIdentityDigest() { + return Arrays.copyOf(identity, identity.length); + } + + @Override + public void scan(String name, EntryConsumer consumer) throws java.io.IOException { + scanAfter(name, null, consumer); + } + + @Override + public synchronized void scanAfter(String name, byte[] cursor, EntryConsumer consumer) + throws java.io.IOException { + scanCounts.put(name, scanCounts.getOrDefault(name, 0) + 1); + lastCursors.put(name, cursor == null ? null : Arrays.copyOf(cursor, cursor.length)); + if (!dbName.equals(name)) { + return; + } + int emitted = 0; + for (PhysicalRow row : rows) { + if (cursor != null && compareUnsigned(row.key, cursor) <= 0) { + continue; + } + consumer.accept(row.key, row.value); + emitted++; + if (emitted >= failAfter) { + throw new java.io.IOException("injected physical ingest interruption"); + } + } + } + + @Override + public void verifyIdentity(SnapshotIdentity expected) { + } + + private synchronized int getScanCount(String name) { + return scanCounts.getOrDefault(name, 0); + } + + private synchronized byte[] getLastCursor(String name) { + byte[] cursor = lastCursors.get(name); + return cursor == null ? null : Arrays.copyOf(cursor, cursor.length); + } + } + + private static final class ConcurrentTierPhysicalSource implements SnapshotSource { + + private final java.util.concurrent.CountDownLatch started = + new java.util.concurrent.CountDownLatch(2); + private final AtomicInteger active = new AtomicInteger(); + private final AtomicInteger maxActive = new AtomicInteger(); + private final java.util.Set threads = java.util.Collections.synchronizedSet( + new java.util.HashSet<>()); + + @Override + public SnapshotIdentity identity() { + return new SnapshotIdentity(100, bytes(1), bytes(2), 300, P66Phase.P66_ON); + } + + @Override + public Collection databases() { + List names = new ArrayList<>(); + for (PathStateParticipantDescriptor.StoreIdentity store + : PathStateParticipantDescriptor.current().getStores()) { + names.add(store.getDbName()); + } + return names; + } + + @Override + public byte[] sourceIdentityDigest() { + return bytes(44); + } + + @Override + public void scan(String name, EntryConsumer consumer) throws java.io.IOException { + scanAfter(name, null, consumer); + } + + @Override + public void scanAfter(String name, byte[] cursor, EntryConsumer consumer) + throws java.io.IOException { + if (!"account".equals(name) && !"proposal".equals(name)) { + return; + } + int current = active.incrementAndGet(); + maxActive.accumulateAndGet(current, Math::max); + threads.add(Thread.currentThread().getName()); + started.countDown(); + try { + if (!started.await(5, java.util.concurrent.TimeUnit.SECONDS)) { + throw new java.io.IOException("physical tier queues did not overlap"); + } + consumer.accept(new byte[]{1}, new byte[]{(byte) ("account".equals(name) ? 1 : 2)}); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new java.io.IOException("physical tier queue test interrupted", interrupted); + } finally { + active.decrementAndGet(); + } + } + + @Override + public void verifyIdentity(SnapshotIdentity expected) { + } + + private int getMaxActive() { + return maxActive.get(); + } + + private boolean sawThread(String name) { + return threads.contains(name); + } + } + + private static int compareUnsigned(byte[] left, byte[] right) { + for (int index = 0; index < Math.min(left.length, right.length); index++) { + int compared = Integer.compare(left[index] & 0xff, right[index] & 0xff); + if (compared != 0) { + return compared; + } + } + return Integer.compare(left.length, right.length); + } + + private static Path latestOptionsFile(Path directory) throws java.io.IOException { + try (Stream files = Files.list(directory)) { + return files.filter(path -> path.getFileName().toString().startsWith("OPTIONS-")) + .max(java.util.Comparator.comparing(path -> path.getFileName().toString())) + .orElseThrow(() -> new java.io.IOException("RocksDB OPTIONS file is missing")); + } + } + + private static void awaitTestLatch(CountDownLatch started, CountDownLatch release) { + started.countDown(); + try { + if (!release.await(10, TimeUnit.SECONDS)) { + throw new IllegalStateException("timed out waiting to release participant write"); + } + } catch (InterruptedException failure) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("participant write test interrupted", failure); + } + } +} diff --git a/framework/src/test/java/org/tron/core/db2/stateroot/PathStateNativeSnapshotSourceTest.java b/framework/src/test/java/org/tron/core/db2/stateroot/PathStateNativeSnapshotSourceTest.java new file mode 100644 index 00000000000..a067da5af67 --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/stateroot/PathStateNativeSnapshotSourceTest.java @@ -0,0 +1,331 @@ +package org.tron.core.db2.stateroot; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.tron.common.TestConstants.TEST_CONF; + +import java.io.IOException; +import java.util.AbstractMap.SimpleImmutableEntry; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; +import org.tron.common.storage.leveldb.LevelDbDataSourceImpl; +import org.tron.common.utils.ByteArray; +import org.tron.core.capsule.utils.MarketUtils; +import org.tron.core.config.args.Args; +import org.tron.core.db.common.iterator.DBIterator; +import org.tron.core.db2.ISession; +import org.tron.core.db2.archive.LatestStateGenerationAdapter.StoreSnapshot; +import org.tron.core.db2.common.LevelDB; +import org.tron.core.db2.core.Chainbase; +import org.tron.core.db2.core.SnapshotManager; +import org.tron.core.db2.core.SnapshotRoot; +import org.tron.core.db2.stateroot.PathStateCanonicalizer.P66Phase; +import org.tron.core.db2.stateroot.PathStateRebuildCoordinator.SnapshotIdentity; + +public class PathStateNativeSnapshotSourceTest { + + static { + org.rocksdb.RocksDB.loadLibrary(); + } + + @BeforeClass + public static void initConfiguration() { + Args.setParam(new String[0], TEST_CONF); + } + + @AfterClass + public static void clearConfiguration() { + Args.clearParam(); + } + + @Test + public void pinsExactStoresPagesLexicallyAndOrdersMarketRows() throws Exception { + Registry registry = registry(); + registry.probes.get("proposal").add(new byte[]{1}, new byte[]{11}); + registry.probes.get("proposal").add(new byte[]{1, 0}, new byte[]{12}); + registry.probes.get("proposal").add(new byte[]{2}, new byte[]{22}); + byte[] lowPrice = marketKey(2, 1); + byte[] highPrice = marketKey(1, 1); + assertTrue(compareUnsigned(lowPrice, highPrice) > 0); + registry.probes.get(PathStateParticipantDescriptor.MARKET_PRICE_DATABASE) + .add(lowPrice, new byte[]{1}); + registry.probes.get(PathStateParticipantDescriptor.MARKET_PRICE_DATABASE) + .add(highPrice, new byte[]{2}); + + List proposalKeys = new ArrayList<>(); + List marketKeys = new ArrayList<>(); + try (PathStateNativeSnapshotSource source = PathStateNativeSnapshotSource.acquire( + registry.manager, Collections.emptyMap(), PathStateNativeSnapshotSourceTest::identity, + 2, 10)) { + assertEquals(27, source.databases().size()); + source.scan("proposal", (key, value) -> proposalKeys.add(key)); + source.scan(PathStateParticipantDescriptor.MARKET_PRICE_DATABASE, + (key, value) -> marketKeys.add(key)); + source.verifyIdentity(identity()); + } + + assertEquals(3, proposalKeys.size()); + assertArrayEquals(new byte[]{1}, proposalKeys.get(0)); + assertArrayEquals(new byte[]{1, 0}, proposalKeys.get(1)); + assertArrayEquals(new byte[]{2}, proposalKeys.get(2)); + assertArrayEquals(lowPrice, marketKeys.get(0)); + assertArrayEquals(highPrice, marketKeys.get(1)); + assertEquals(27, registry.totalPins()); + assertEquals(27, registry.totalCloses()); + } + + @Test + public void lexicalPagingPreservesAKeyThatExtendsThePreviousPageBoundary() throws Exception { + Registry registry = registry(); + registry.probes.get("proposal").add(new byte[]{1}, new byte[]{11}); + registry.probes.get("proposal").add(new byte[]{1, 0}, new byte[]{12}); + List keys = new ArrayList<>(); + try (PathStateNativeSnapshotSource source = PathStateNativeSnapshotSource.acquire( + registry.manager, Collections.emptyMap(), PathStateNativeSnapshotSourceTest::identity, + 1, 10)) { + source.scan("proposal", (key, value) -> keys.add(key)); + } + assertEquals(2, keys.size()); + assertArrayEquals(new byte[]{1}, keys.get(0)); + assertArrayEquals(new byte[]{1, 0}, keys.get(1)); + } + + @Test + public void resumableLexicalScanStartsStrictlyAfterThePhysicalCursor() throws Exception { + Registry registry = registry(); + registry.probes.get("proposal").add(new byte[]{1}, new byte[]{11}); + registry.probes.get("proposal").add(new byte[]{1, 0}, new byte[]{12}); + List keys = new ArrayList<>(); + try (PathStateNativeSnapshotSource source = PathStateNativeSnapshotSource.acquire( + registry.manager, Collections.emptyMap(), PathStateNativeSnapshotSourceTest::identity, + 1, 10)) { + source.scanAfter("proposal", new byte[]{1}, (key, value) -> keys.add(key)); + } + assertEquals(1, keys.size()); + assertArrayEquals(new byte[]{1, 0}, keys.get(0)); + } + + @Test + public void acceptsSupplementalAccountAssetAndRejectsMarketOverflow() throws Exception { + Registry registry = registry(); + Probe accountAsset = registry.probes.get("account-asset"); + registry.manager.getDbs().removeIf(database -> "account-asset".equals(database.getDbName())); + accountAsset.add(marketKey(1, 2), new byte[]{1}); + Probe market = registry.probes.get(PathStateParticipantDescriptor.MARKET_PRICE_DATABASE); + market.add(marketKey(1, 2), new byte[]{1}); + market.add(marketKey(2, 3), new byte[]{2}); + + try (PathStateNativeSnapshotSource source = PathStateNativeSnapshotSource.acquire( + registry.manager, Collections.singletonMap("account-asset", accountAsset.store), + PathStateNativeSnapshotSourceTest::identity, 2, 1)) { + assertEquals(27, source.databases().size()); + assertThrows(IOException.class, + () -> source.scan(PathStateParticipantDescriptor.MARKET_PRICE_DATABASE, + (key, value) -> { })); + } + assertEquals(registry.totalPins(), registry.totalCloses()); + } + + @Test + public void identityDriftDuringAcquireReleasesEveryPinnedStore() { + Registry registry = registry(); + AtomicInteger reads = new AtomicInteger(); + assertThrows(IOException.class, () -> PathStateNativeSnapshotSource.acquire( + registry.manager, Collections.emptyMap(), + () -> reads.incrementAndGet() == 1 ? identity() : identity(2), 2, 10)); + assertEquals(27, registry.totalPins()); + assertEquals(27, registry.totalCloses()); + } + + @Test + public void rejectsUnflushedRevokingLayersBeforePinning() throws Exception { + Registry registry = registry(); + try (ISession ignored = registry.manager.buildSession(true)) { + assertThrows(IOException.class, () -> PathStateNativeSnapshotSource.acquire( + registry.manager, Collections.emptyMap(), PathStateNativeSnapshotSourceTest::identity, + 2, 10)); + } + assertEquals(0, registry.totalPins()); + } + + private static Registry registry() { + SnapshotManager manager = new SnapshotManager(""); + LinkedHashMap probes = new LinkedHashMap<>(); + for (PathStateParticipantDescriptor.StoreIdentity participant + : PathStateParticipantDescriptor.current().getStores()) { + Probe probe = new Probe(participant.getDbName()); + probes.put(participant.getDbName(), probe); + manager.getDbs().add(new Chainbase(new SnapshotRoot(probe.store))); + } + return new Registry(manager, probes); + } + + private static SnapshotIdentity identity() { + return identity(1); + } + + private static SnapshotIdentity identity(int suffix) { + byte[] blockHash = new byte[32]; + blockHash[31] = (byte) suffix; + return new SnapshotIdentity(100 + suffix, blockHash, new byte[32], 1_000L + suffix, + P66Phase.P66_ON); + } + + private static byte[] marketKey(long sell, long buy) { + return MarketUtils.createPairPriceKey(ByteArray.fromString("100"), + ByteArray.fromString("200"), sell, buy); + } + + private static int compareUnsigned(byte[] left, byte[] right) { + for (int index = 0; index < Math.min(left.length, right.length); index++) { + int compared = Integer.compare(left[index] & 0xff, right[index] & 0xff); + if (compared != 0) { + return compared; + } + } + return Integer.compare(left.length, right.length); + } + + private static final class Registry { + private final SnapshotManager manager; + private final Map probes; + + private Registry(SnapshotManager manager, Map probes) { + this.manager = manager; + this.probes = probes; + } + + private int totalPins() { + return probes.values().stream().mapToInt(probe -> probe.pins.get()).sum(); + } + + private int totalCloses() { + return probes.values().stream().mapToInt(probe -> probe.closes.get()).sum(); + } + } + + private static final class Probe { + private final String dbName; + private final String sourceIdentity; + private final AtomicInteger pins = new AtomicInteger(); + private final AtomicInteger closes = new AtomicInteger(); + private final List> rows = new ArrayList<>(); + private final FakeLevelDB store; + + private Probe(String dbName) { + this.dbName = dbName; + sourceIdentity = "test:" + dbName; + store = new FakeLevelDB(this); + } + + private void add(byte[] key, byte[] value) { + rows.add(new SimpleImmutableEntry<>(Arrays.copyOf(key, key.length), + Arrays.copyOf(value, value.length))); + rows.sort((left, right) -> compareUnsigned(left.getKey(), right.getKey())); + } + + private StoreSnapshot pin(long blockNumber, byte[] blockHash) { + pins.incrementAndGet(); + byte[] expectedHash = Arrays.copyOf(blockHash, blockHash.length); + List> pinnedRows = new ArrayList<>(rows); + return new StoreSnapshot() { + private boolean closed; + + @Override + public String getDbName() { + return dbName; + } + + @Override + public String getSourceIdentity() { + return sourceIdentity; + } + + @Override + public long getBlockNumber() { + return blockNumber; + } + + @Override + public byte[] getBlockHash() { + return Arrays.copyOf(expectedHash, expectedHash.length); + } + + @Override + public byte[] get(byte[] physicalRawKey) { + for (Map.Entry row : pinnedRows) { + if (Arrays.equals(row.getKey(), physicalRawKey)) { + return Arrays.copyOf(row.getValue(), row.getValue().length); + } + } + return null; + } + + @Override + public List> range(byte[] lowerInclusive, + byte[] upperExclusive, int maxEntries) { + List> result = new ArrayList<>(); + for (Map.Entry row : pinnedRows) { + if (compareUnsigned(row.getKey(), lowerInclusive) >= 0 + && (upperExclusive == null + || compareUnsigned(row.getKey(), upperExclusive) < 0)) { + result.add(row); + if (result.size() == maxEntries) { + break; + } + } + } + return result; + } + + @Override + public void close() { + if (!closed) { + closed = true; + closes.incrementAndGet(); + } + } + }; + } + } + + private static final class FakeLevelDB extends LevelDB { + private final Probe probe; + + private FakeLevelDB(Probe probe) { + super(mock(LevelDbDataSourceImpl.class)); + this.probe = probe; + } + + @Override + public String getDbName() { + return probe.dbName; + } + + @Override + public String getSourceIdentity() { + return probe.sourceIdentity; + } + + @Override + public StoreSnapshot pin(long blockNumber, byte[] blockHash) { + return probe.pin(blockNumber, blockHash); + } + + @Override + public DBIterator iterator() { + return mock(DBIterator.class); + } + } +} diff --git a/framework/src/test/java/org/tron/core/db2/stateroot/PathStateParticipantDescriptorTest.java b/framework/src/test/java/org/tron/core/db2/stateroot/PathStateParticipantDescriptorTest.java new file mode 100644 index 00000000000..e1a018bf93b --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/stateroot/PathStateParticipantDescriptorTest.java @@ -0,0 +1,81 @@ +package org.tron.core.db2.stateroot; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import org.junit.Test; +import org.tron.core.db2.stateroot.PathStateParticipantDescriptor.StoreIdentity; + +public class PathStateParticipantDescriptorTest { + + private static final List EXACT_27 = Arrays.asList( + "abi", "accountid-index", "account-index", "account", "account-asset", + "asset-issue", "asset-issue-v2", "code", "contract-state", "contract", + "DelegatedResourceAccountIndex", "DelegatedResource", "delegation", "properties", + "exchange", "exchange-v2", "market_account", "market_order", + "market_pair_price_to_order", "market_pair_to_price", "proposal", "storage-row", + "votes", "witness_schedule", "witness", "nullifier", "IncrementalMerkleTree"); + + @Test + public void definesIndependentExact27IdentityGolden() { + PathStateParticipantDescriptor descriptor = PathStateParticipantDescriptor.current(); + assertEquals("path-state-root/exact-27/v1", PathStateParticipantDescriptor.SCOPE_ID); + assertEquals(27, descriptor.getStores().size()); + + for (int index = 0; index < EXACT_27.size(); index++) { + StoreIdentity identity = descriptor.getStores().get(index); + assertEquals(index + 1, identity.getStoreId()); + assertEquals(EXACT_27.get(index), identity.getDbName()); + assertEquals(identity, descriptor.require(EXACT_27.get(index))); + } + } + + @Test + public void assignsOnlyTheMarketPriceComparatorProfile() { + PathStateParticipantDescriptor descriptor = PathStateParticipantDescriptor.current(); + for (StoreIdentity identity : descriptor.getStores()) { + String expected = identity.getDbName().equals( + PathStateParticipantDescriptor.MARKET_PRICE_DATABASE) + ? PathStateParticipantDescriptor.MARKET_PRICE_COMPARATOR + : PathStateParticipantDescriptor.UNSIGNED_RAW_COMPARATOR; + assertEquals(expected, identity.getComparatorId()); + } + } + + @Test + public void requiresExactMembershipIndependentOfInputOrder() { + PathStateParticipantDescriptor descriptor = PathStateParticipantDescriptor.current(); + List reversed = new ArrayList<>(EXACT_27); + Collections.reverse(reversed); + descriptor.requireExactDatabases(reversed); + + List missing = new ArrayList<>(EXACT_27); + missing.remove("abi"); + assertThrows(IllegalArgumentException.class, + () -> descriptor.requireExactDatabases(missing)); + + List unexpected = new ArrayList<>(EXACT_27); + unexpected.set(0, "accountTrie"); + assertThrows(IllegalArgumentException.class, + () -> descriptor.requireExactDatabases(unexpected)); + + List duplicate = new ArrayList<>(EXACT_27); + duplicate.add("account"); + assertThrows(IllegalArgumentException.class, + () -> descriptor.requireExactDatabases(duplicate)); + assertThrows(IllegalArgumentException.class, + () -> descriptor.requireExactDatabases(Arrays.asList("abi", null))); + assertThrows(IllegalArgumentException.class, + () -> descriptor.require("unknown")); + } + + @Test + public void exposesAnImmutableOrderedDescriptor() { + assertThrows(UnsupportedOperationException.class, + () -> PathStateParticipantDescriptor.current().getStores().clear()); + } +} diff --git a/framework/src/test/java/org/tron/core/db2/stateroot/PathStatePersistentFormatTest.java b/framework/src/test/java/org/tron/core/db2/stateroot/PathStatePersistentFormatTest.java new file mode 100644 index 00000000000..c3d4195ab98 --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/stateroot/PathStatePersistentFormatTest.java @@ -0,0 +1,145 @@ +package org.tron.core.db2.stateroot; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import com.google.common.hash.Hashing; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tron.common.utils.ByteArray; +import org.tron.core.db2.stateroot.PathStateCanonicalizer.P66Phase; +import org.tron.core.db2.stateroot.PathStateRootMetadata.Kind; +import org.tron.core.db2.stateroot.PathStateStoreManifest.Engine; + +public class PathStatePersistentFormatTest { + + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void manifestCreatesIndependentCurrentOnlyLayoutAndReopensWithoutRewrite() + throws Exception { + Path root = new File(temporaryFolder.getRoot(), "path-state-root").toPath(); + PathStateStoreManifest created = PathStateStoreManifest.createOrOpen(root, Engine.LEVELDB); + Path manifest = root.resolve(PathStateStoreManifest.MANIFEST_FILE); + byte[] original = Files.readAllBytes(manifest); + + PathStateStoreManifest reopened = PathStateStoreManifest.createOrOpen(root, Engine.LEVELDB); + + assertEquals(root.toAbsolutePath(), created.getDirectory()); + assertEquals(Engine.LEVELDB, reopened.getEngine()); + assertTrue(Files.isDirectory(created.getBaseDirectory())); + assertTrue(Files.isDirectory(created.getLayersDirectory())); + assertArrayEquals(original, Files.readAllBytes(manifest)); + assertEquals( + "d0fc17ad2ea70578b2400c8c3563b05407ff6d7f53f26ea7ad47b513565d404e", + ByteArray.toHexString(Hashing.sha256().hashBytes(original).asBytes())); + assertFalse(Files.exists(root.resolve("history"))); + } + + @Test + public void manifestRejectsEngineDriftAndCorruptionWithoutRepair() throws Exception { + Path root = temporaryFolder.newFolder("manifest-fail-closed").toPath(); + PathStateStoreManifest.createOrOpen(root, Engine.LEVELDB); + Path manifest = root.resolve(PathStateStoreManifest.MANIFEST_FILE); + byte[] expected = Files.readAllBytes(manifest); + + assertThrows(IOException.class, + () -> PathStateStoreManifest.createOrOpen(root, Engine.ROCKSDB)); + assertArrayEquals(expected, Files.readAllBytes(manifest)); + + byte[] corrupt = Arrays.copyOf(expected, expected.length); + corrupt[corrupt.length - 1] ^= 1; + Files.write(manifest, corrupt); + assertThrows(IOException.class, + () -> PathStateStoreManifest.validateExisting(root, Engine.LEVELDB)); + assertArrayEquals(corrupt, Files.readAllBytes(manifest)); + } + + @Test + public void validateExistingHasNoCreationSideEffects() { + Path absent = new File(temporaryFolder.getRoot(), "absent").toPath(); + + assertThrows(IOException.class, + () -> PathStateStoreManifest.validateExisting(absent, Engine.LEVELDB)); + assertFalse(Files.exists(absent)); + } + + @Test + public void baseMetadataRoundTripsWithoutInventingAParentRoot() { + PathStateRootMetadata base = PathStateRootMetadata.base(100, bytes(1), bytes(33), 9000, + P66Phase.P66_ACTIVATION, bytes(17), bytes(65), bytes(97)); + + PathStateRootMetadata decoded = PathStateRootMetadata.decode(base.encode()); + + assertEquals(Kind.BASE, decoded.getKind()); + assertEquals(100, decoded.getBlockNumber()); + assertEquals(P66Phase.P66_ACTIVATION, decoded.getPhase()); + assertNull(decoded.getParentStateRoot()); + assertArrayEquals(bytes(65), decoded.getStateRoot()); + assertEquals( + "ab8833ebb7c43812cd6f041aae7796da3c97c51ae3c8890636d2d6b45a471e0d", + ByteArray.toHexString(Hashing.sha256().hashBytes(base.encode()).asBytes())); + } + + @Test + public void layerMetadataBindsParentRootAndTransitionDigest() { + PathStateRootMetadata layer = PathStateRootMetadata.layer(101, bytes(2), bytes(1), 12000, + P66Phase.P66_ON, bytes(17), bytes(65), bytes(66), bytes(98)); + + PathStateRootMetadata decoded = PathStateRootMetadata.decode(layer.encode()); + + assertEquals(Kind.LAYER, decoded.getKind()); + assertArrayEquals(bytes(65), decoded.getParentStateRoot()); + assertArrayEquals(bytes(66), decoded.getStateRoot()); + assertArrayEquals(bytes(98), decoded.getPayloadDigest()); + assertNotNull(decoded.getBlockHash()); + } + + @Test + public void metadataRejectsKindAmbiguityAndCorruption() { + assertThrows(IllegalArgumentException.class, + () -> PathStateRootMetadata.layer(1, bytes(1), bytes(2), 3, P66Phase.P66_ON, + bytes(17), null, bytes(3), bytes(4))); + byte[] encoded = PathStateRootMetadata.base(1, bytes(1), bytes(2), 3, P66Phase.P66_OFF, + bytes(17), bytes(3), bytes(4)).encode(); + encoded[encoded.length - 1] ^= 1; + assertThrows(IllegalArgumentException.class, () -> PathStateRootMetadata.decode(encoded)); + } + + @Test + public void metadataOwnsAllByteArrays() { + byte[] format = bytes(17); + byte[] root = bytes(65); + PathStateRootMetadata metadata = PathStateRootMetadata.base(1, bytes(1), bytes(2), 3, + P66Phase.P66_ON, format, root, bytes(97)); + format[0] = 0; + root[0] = 0; + byte[] returnedFormat = metadata.getFormatDigest(); + byte[] returned = metadata.getStateRoot(); + returnedFormat[0] = 0; + returned[0] = 0; + + assertArrayEquals(bytes(17), metadata.getFormatDigest()); + assertArrayEquals(bytes(65), metadata.getStateRoot()); + } + + private static byte[] bytes(int seed) { + byte[] value = new byte[32]; + for (int i = 0; i < value.length; i++) { + value[i] = (byte) (seed + i); + } + return value; + } +} diff --git a/framework/src/test/java/org/tron/core/db2/stateroot/PathStateRebuildCoordinatorTest.java b/framework/src/test/java/org/tron/core/db2/stateroot/PathStateRebuildCoordinatorTest.java new file mode 100644 index 00000000000..0218687c472 --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/stateroot/PathStateRebuildCoordinatorTest.java @@ -0,0 +1,719 @@ +package org.tron.core.db2.stateroot; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import com.google.common.hash.Hashing; +import com.google.protobuf.ByteString; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tron.common.arch.Arch; +import org.tron.core.db2.stateroot.PathStateCanonicalizer.P66Phase; +import org.tron.core.db2.stateroot.PathStateParticipantDescriptor.StoreIdentity; +import org.tron.core.db2.stateroot.PathStateRebuildCoordinator.EntryConsumer; +import org.tron.core.db2.stateroot.PathStateRebuildCoordinator.RebuildResult; +import org.tron.core.db2.stateroot.PathStateRebuildCoordinator.SnapshotIdentity; +import org.tron.core.db2.stateroot.PathStateRebuildCoordinator.SnapshotSource; +import org.tron.core.db2.stateroot.PathStateRebuildCoordinator.StoreResult; +import org.tron.core.db2.stateroot.PathStateStoreManifest.Engine; +import org.tron.core.trie.TrieImpl; +import org.tron.protos.Protocol.Account; + +public class PathStateRebuildCoordinatorTest { + + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void rebuildsAndPublishesExactSnapshotAcrossNativeEngines() throws Exception { + byte[] expectedRoot = null; + byte[] expectedSourceDigest = null; + for (Engine engine : availableEngines()) { + PathStateStoreManifest manifest = manifest("rebuild-" + engine, engine); + TestSnapshotSource source = exactSource(identity()); + source.add("proposal", new byte[]{1}, new byte[]{11}); + source.add("proposal", new byte[]{2}, new byte[]{22}); + source.add("abi", address(1), new byte[0]); + source.add("accountid-index", new byte[0], address(2)); + source.add("account-index", new byte[0], address(3)); + + RebuildResult result = new PathStateRebuildCoordinator().rebuild(manifest, source); + OracleResult oracle = independentOracle(source); + + assertEquals(27, result.getStores().size()); + assertEquals(5, result.getTotalEntries()); + assertEquals(2, result.requireStore("proposal").getEntryCount()); + assertEquals(1, result.requireStore("abi").getEntryCount()); + assertEquals(1, result.requireStore("accountid-index").getEntryCount()); + assertEquals(1, result.requireStore("account-index").getEntryCount()); + assertEquals(0, result.requireStore("account").getEntryCount()); + assertArrayEquals(result.getSourceDigest(), result.getMetadata().getPayloadDigest()); + assertTrue(source.getVerificationCount() >= 2); + assertArrayEquals(oracle.stateRoot, result.getMetadata().getStateRoot()); + for (StoreResult store : result.getStores()) { + assertArrayEquals(oracle.storeRoots.get(store.getDbName()), store.getStoreRoot()); + assertFalse(Files.exists(manifest.getBaseDirectory().resolve("rebuild-spool") + .resolve(Integer.toString(store.getStoreId())))); + } + + PathStateStoreManifest reopenedManifest = PathStateStoreManifest.validateExisting( + manifest.getDirectory(), engine); + assertArrayEquals(manifest.getIdentityDigest(), reopenedManifest.getIdentityDigest()); + PathStateRootMetadata current = new PathStateCurrentStore(reopenedManifest).current(); + assertArrayEquals(result.getMetadata().encode(), current.encode()); + assertArrayEquals(result.getSourceDigest(), current.getPayloadDigest()); + try (PathStateNodeStoreSet reopened = PathStateNodeStoreSet.openCurrent(reopenedManifest)) { + PathStateRoot restored = reopened.createRoot(); + assertArrayEquals(oracle.stateRoot, restored.rootHash()); + restored.verifyNodeStores(); + } + + if (expectedRoot == null) { + expectedRoot = result.getMetadata().getStateRoot(); + expectedSourceDigest = result.getSourceDigest(); + } else { + assertArrayEquals(expectedRoot, result.getMetadata().getStateRoot()); + assertArrayEquals(expectedSourceDigest, result.getSourceDigest()); + } + assertThrows(IOException.class, + () -> new PathStateRebuildCoordinator().rebuild(manifest, source)); + } + } + + @Test + public void schedulesLargeAndSmallStoresInParallelAndOrdersAccountAsset() throws Exception { + PathStateStoreManifest manifest = manifest("parallel-tiers", Engine.ROCKSDB); + TestSnapshotSource delegate = exactSource(identity()); + CountDownLatch concurrentScans = new CountDownLatch(2); + Map threads = Collections.synchronizedMap(new LinkedHashMap<>()); + AtomicBoolean accountReturned = new AtomicBoolean(); + SnapshotSource source = new SnapshotSource() { + @Override + public SnapshotIdentity identity() { + return delegate.identity(); + } + + @Override + public Collection databases() { + return delegate.databases(); + } + + @Override + public byte[] sourceIdentityDigest() { + return delegate.sourceIdentityDigest(); + } + + @Override + public void scan(String dbName, EntryConsumer consumer) throws IOException { + threads.put(dbName, Thread.currentThread().getName()); + if ("account-asset".equals(dbName) && !accountReturned.get()) { + throw new IOException("account-asset started before account completed"); + } + if ("account".equals(dbName) || "abi".equals(dbName)) { + concurrentScans.countDown(); + try { + if (!concurrentScans.await(5, TimeUnit.SECONDS)) { + throw new IOException("Store tiers did not scan concurrently"); + } + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IOException("parallel Store test interrupted", interrupted); + } + } + delegate.scan(dbName, consumer); + if ("account".equals(dbName)) { + accountReturned.set(true); + } + } + + @Override + public void verifyIdentity(SnapshotIdentity expected) throws IOException { + delegate.verifyIdentity(expected); + } + }; + + assertEquals(27, new PathStateRebuildCoordinator().rebuild(manifest, source) + .getStores().size()); + assertTrue(threads.get("account").startsWith("path-state-rebuild-large-")); + assertTrue(threads.get("storage-row").startsWith("path-state-rebuild-large-")); + assertTrue(threads.get("abi").startsWith("path-state-rebuild-small-")); + assertTrue(accountReturned.get()); + } + + @Test + public void rejectsScopeMismatchBeforeOpeningBaseNodes() throws Exception { + PathStateStoreManifest manifest = manifest("scope-mismatch", Engine.ROCKSDB); + TestSnapshotSource source = exactSource(identity()); + source.removeDatabase("abi"); + + assertThrows(IllegalArgumentException.class, + () -> new PathStateRebuildCoordinator().rebuild(manifest, source)); + assertFalse(new PathStateCurrentStore(manifest).isInitialized()); + assertFalse(Files.exists( + manifest.getBaseDirectory().resolve(PathStateNodeStoreSet.NODES_DIRECTORY))); + } + + @Test + public void rejectsDuplicateOrOutOfOrderPhysicalKeysWithoutPublication() throws Exception { + for (byte[][] keys : new byte[][][]{ + {new byte[]{2}, new byte[]{1}}, + {new byte[]{1}, new byte[]{1}} + }) { + PathStateStoreManifest manifest = manifest("order-" + keys[0][0] + "-" + keys[1][0], + Engine.ROCKSDB); + TestSnapshotSource source = exactSource(identity()); + source.add("proposal", keys[0], new byte[]{1}); + source.add("proposal", keys[1], new byte[]{2}); + + assertThrows(IllegalArgumentException.class, + () -> new PathStateRebuildCoordinator().rebuild(manifest, source)); + assertFalse(new PathStateCurrentStore(manifest).isInitialized()); + assertFalse(Files.exists(manifest.getBaseDirectory() + .resolve(PathStateCurrentStore.METADATA_FILE))); + } + } + + @Test + public void rejectsSnapshotIdentityDriftBeforePublishingBase() throws Exception { + PathStateStoreManifest manifest = manifest("identity-drift", Engine.ROCKSDB); + TestSnapshotSource source = exactSource(identity()); + source.add("proposal", new byte[]{1}, new byte[]{2}); + source.driftAfterFirstVerification(); + + IOException failure = assertThrows(IOException.class, + () -> new PathStateRebuildCoordinator().rebuild(manifest, source)); + assertTrue(failure.getMessage().contains("identity changed")); + assertFalse(new PathStateCurrentStore(manifest).isInitialized()); + assertFalse(Files.exists(manifest.getBaseDirectory() + .resolve(PathStateCurrentStore.METADATA_FILE))); + } + + @Test + public void admitsOnlyTargetP66AccountAssetPhysicalLayout() throws Exception { + byte[] address = address(7); + String tokenId = "1000001"; + + PathStateStoreManifest offManifest = manifest("p66-off", Engine.ROCKSDB); + TestSnapshotSource off = exactSource(identity(P66Phase.P66_OFF)); + byte[] embedded = account(address).toBuilder().putAssetV2(tokenId, 11L).build().toByteArray(); + off.add("account", address, embedded); + RebuildResult offResult = new PathStateRebuildCoordinator().rebuild(offManifest, off); + assertEquals(1, offResult.requireStore("account").getEntryCount()); + assertEquals(0, offResult.requireStore("account-asset").getEntryCount()); + + PathStateStoreManifest onManifest = manifest("p66-on", Engine.ROCKSDB); + TestSnapshotSource on = exactSource(identity(P66Phase.P66_ON)); + byte[] optimized = account(address).toBuilder().setAssetOptimized(true).build().toByteArray(); + on.add("account", address, optimized); + on.add("account-asset", accountAssetKey(address, tokenId), longBytes(11L)); + RebuildResult onResult = new PathStateRebuildCoordinator().rebuild(onManifest, on); + assertEquals(1, onResult.requireStore("account").getEntryCount()); + assertEquals(1, onResult.requireStore("account-asset").getEntryCount()); + + PathStateStoreManifest lazyManifest = manifest("p66-on-lazy", Engine.ROCKSDB); + TestSnapshotSource lazy = exactSource(identity(P66Phase.P66_ON)); + lazy.add("account", address, + account(address).toBuilder() + .putAssetV2(tokenId, 11L) + .putAssetV2("1000002", 0L) + .build().toByteArray()); + RebuildResult lazyResult = new PathStateRebuildCoordinator().rebuild(lazyManifest, lazy); + assertEquals(1, lazyResult.requireStore("account").getEntryCount()); + assertEquals(0, lazyResult.requireStore("account-asset").getEntryCount()); + assertArrayEquals(onResult.getMetadata().getStateRoot(), + lazyResult.getMetadata().getStateRoot()); + } + + @Test + public void buildsAccountAssetRowsWithoutCrossStoreOwnerValidation() throws Exception { + byte[] address = address(8); + String tokenId = "1000001"; + + PathStateStoreManifest mixedManifest = manifest("p66-mixed", Engine.ROCKSDB); + TestSnapshotSource mixed = exactSource(identity(P66Phase.P66_ON)); + mixed.add("account", address, + account(address).toBuilder().putAssetV2(tokenId, 9L).build().toByteArray()); + mixed.add("account-asset", accountAssetKey(address, tokenId), longBytes(9L)); + RebuildResult mixedResult = new PathStateRebuildCoordinator() + .rebuild(mixedManifest, mixed); + assertEquals(1, mixedResult.requireStore("account-asset").getEntryCount()); + assertTrue(new PathStateCurrentStore(mixedManifest).isInitialized()); + + PathStateStoreManifest orphanManifest = manifest("p66-orphan", Engine.ROCKSDB); + TestSnapshotSource orphan = exactSource(identity(P66Phase.P66_ON)); + orphan.add("account-asset", accountAssetKey(address, tokenId), longBytes(10L)); + RebuildResult orphanResult = new PathStateRebuildCoordinator() + .rebuild(orphanManifest, orphan); + assertEquals(1, orphanResult.requireStore("account-asset").getEntryCount()); + assertTrue(new PathStateCurrentStore(orphanManifest).isInitialized()); + + PathStateStoreManifest offDirectManifest = manifest("p66-off-direct", Engine.ROCKSDB); + TestSnapshotSource offDirect = exactSource(identity(P66Phase.P66_OFF)); + offDirect.add("account", address, + account(address).toBuilder().putAssetV2(tokenId, 10L).build().toByteArray()); + offDirect.add("account-asset", accountAssetKey(address, tokenId), longBytes(10L)); + assertThrows(IllegalArgumentException.class, + () -> new PathStateRebuildCoordinator().rebuild(offDirectManifest, offDirect)); + assertFalse(new PathStateCurrentStore(offDirectManifest).isInitialized()); + } + + @Test + public void resumesCompletedStoresWhileKeepingGenerationInvisible() throws Exception { + PathStateStoreManifest manifest = manifest("resume", Engine.ROCKSDB); + TestSnapshotSource first = exactSource(identity()); + byte[] lazyAddress = address(9); + byte[] lazyAccount = account(lazyAddress).toBuilder() + .putAssetV2("1000001", 17L).build().toByteArray(); + first.add("account", lazyAddress, lazyAccount); + first.add("proposal", new byte[]{1}, new byte[]{2}); + AtomicBoolean failed = new AtomicBoolean(); + PathStateRebuildCoordinator interrupted = new PathStateRebuildCoordinator(store -> { + if ("account".equals(store.getDbName()) && failed.compareAndSet(false, true)) { + throw new IOException("injected rebuild interruption"); + } + }); + + assertThrows(IOException.class, () -> interrupted.rebuild(manifest, first)); + assertFalse(new PathStateCurrentStore(manifest).isInitialized()); + assertFalse(Files.exists(manifest.getBaseDirectory() + .resolve(PathStateCurrentStore.METADATA_FILE))); + assertNull(PathStateNodeStoreSet.loadProgress(manifest.getBaseDirectory(), manifest)); + assertFalse(Files.exists(manifest.getBaseDirectory().resolve("rebuild-spool/4"))); + assertTrue(Files.exists(manifest.getBaseDirectory().resolve("rebuild-spool/5"))); + + TestSnapshotSource resumed = exactSource(identity()); + resumed.add("account", lazyAddress, lazyAccount); + resumed.add("proposal", new byte[]{1}, new byte[]{2}); + RebuildResult result = new PathStateRebuildCoordinator().rebuild(manifest, resumed); + + assertEquals(27, result.getStores().size()); + assertTrue(resumed.getScanCount("abi") <= 1); + assertTrue(resumed.getScanCount("accountid-index") <= 1); + assertTrue(resumed.getScanCount("account-index") <= 1); + assertEquals(0, resumed.getScanCount("account")); + assertEquals(1, resumed.getScanCount("account-asset")); + assertTrue(resumed.getScanCount("proposal") == 0 + || resumed.getScanCount("proposal") == 1); + assertTrue(new PathStateCurrentStore(manifest).isInitialized()); + + PathStateStoreManifest freshManifest = manifest("resume-fresh", Engine.ROCKSDB); + TestSnapshotSource fresh = exactSource(identity()); + fresh.add("account", lazyAddress, lazyAccount); + fresh.add("proposal", new byte[]{1}, new byte[]{2}); + RebuildResult freshResult = new PathStateRebuildCoordinator().rebuild(freshManifest, fresh); + assertArrayEquals(freshResult.getMetadata().getStateRoot(), + result.getMetadata().getStateRoot()); + assertArrayEquals(freshResult.getSourceDigest(), result.getSourceDigest()); + try (PathStateNodeStoreSet reopened = PathStateNodeStoreSet.openCurrent(manifest)) { + assertNull(reopened.getRebuildCheckpoint()); + assertArrayEquals(result.getMetadata().getStateRoot(), reopened.createRoot().rootHash()); + } + } + + @Test + public void resumesCheckpointWithoutPersistingFailedStoreResidue() throws Exception { + PathStateStoreManifest manifest = manifest("resume-non-prefix", Engine.ROCKSDB); + TestSnapshotSource failedSource = exactSource(identity()); + byte[] accountKey = address(7); + byte[] accountValue = account(accountKey).toByteArray(); + failedSource.add("account", accountKey, accountValue); + failedSource.add("account", accountKey, accountValue); + + assertThrows(IllegalArgumentException.class, + () -> new PathStateRebuildCoordinator().rebuild(manifest, failedSource)); + assertFalse(new PathStateCurrentStore(manifest).isInitialized()); + assertEquals(0, failedSource.getScanCount("delegation")); + assertEquals(0, failedSource.getScanCount("storage-row")); + assertEquals(0, failedSource.getScanCount("account-asset")); + try (PathStateNodeStoreSet reopened = PathStateNodeStoreSet.openBase(manifest)) { + PathStateRebuildCheckpoint checkpoint = reopened.getRebuildCheckpoint(); + assertTrue(checkpoint.hasIndependentStores()); + assertTrue(checkpoint.getCompletedStores().stream() + .noneMatch(store -> "account".equals(store.getDbName()))); + } + + RebuildResult resumed = new PathStateRebuildCoordinator().rebuild(manifest, + exactSource(identity())); + PathStateStoreManifest freshManifest = manifest("resume-non-prefix-fresh", Engine.ROCKSDB); + RebuildResult fresh = new PathStateRebuildCoordinator().rebuild(freshManifest, + exactSource(identity())); + assertArrayEquals(fresh.getMetadata().getStateRoot(), resumed.getMetadata().getStateRoot()); + assertArrayEquals(fresh.getSourceDigest(), resumed.getSourceDigest()); + } + + @Test + public void rejectsResumeAgainstAnotherSnapshotIdentity() throws Exception { + PathStateStoreManifest manifest = manifest("resume-identity", Engine.ROCKSDB); + TestSnapshotSource first = exactSource(identity()); + PathStateRebuildCoordinator interrupted = new PathStateRebuildCoordinator(store -> { + throw new IOException("stop after first Store"); + }); + assertThrows(IOException.class, () -> interrupted.rebuild(manifest, first)); + + SnapshotIdentity other = new SnapshotIdentity(101, bytes(3), bytes(4), 301, + P66Phase.P66_ON); + IOException failure = assertThrows(IOException.class, + () -> new PathStateRebuildCoordinator().rebuild(manifest, exactSource(other))); + assertTrue(failure.getMessage().contains("checkpoint snapshot identity mismatch")); + assertFalse(new PathStateCurrentStore(manifest).isInitialized()); + } + + @Test + public void rejectsResumeAgainstReplacedPhysicalSources() throws Exception { + PathStateStoreManifest manifest = manifest("resume-source", Engine.ROCKSDB); + TestSnapshotSource first = exactSource(identity()); + PathStateRebuildCoordinator interrupted = new PathStateRebuildCoordinator(store -> { + throw new IOException("stop after first Store"); + }); + assertThrows(IOException.class, () -> interrupted.rebuild(manifest, first)); + + TestSnapshotSource replacement = exactSource(identity()); + replacement.setSourceIdentityDigest(bytes(43)); + IOException failure = assertThrows(IOException.class, + () -> new PathStateRebuildCoordinator().rebuild(manifest, replacement)); + assertTrue(failure.getMessage().contains("checkpoint source identity mismatch")); + assertFalse(new PathStateCurrentStore(manifest).isInitialized()); + } + + @Test + public void rebuildCheckpointCodecSupportsNonPrefixStoresAndRejectsCorruption() + throws Exception { + StoreResult abi = StoreResult.restore(1, "abi", 2, bytes(5), bytes(6)); + PathStateRebuildCheckpoint checkpoint = new PathStateRebuildCheckpoint(bytes(7), bytes(11), + identity(), Collections.singletonList(abi), bytes(8)); + PathStateRebuildCheckpoint decoded = PathStateRebuildCheckpoint.decode(checkpoint.encode()); + assertArrayEquals(checkpoint.getManifestDigest(), decoded.getManifestDigest()); + assertArrayEquals(checkpoint.getSourceIdentityDigest(), decoded.getSourceIdentityDigest()); + assertTrue(checkpoint.getIdentity().sameAs(decoded.getIdentity())); + assertEquals(1, decoded.getCompletedStores().size()); + assertArrayEquals(checkpoint.getPartialRoot(), decoded.getPartialRoot()); + + byte[] legacy = checkpoint.encode(); + ByteBuffer.wrap(legacy).putShort(Integer.BYTES, (short) 1); + int payloadLength = legacy.length - Integer.BYTES; + ByteBuffer.wrap(legacy).putInt(payloadLength, + Hashing.crc32c().hashBytes(legacy, 0, payloadLength).asInt()); + assertFalse(PathStateRebuildCheckpoint.decode(legacy).hasIndependentStores()); + + byte[] corrupt = checkpoint.encode(); + corrupt[corrupt.length - 1] ^= 1; + assertThrows(IOException.class, () -> PathStateRebuildCheckpoint.decode(corrupt)); + StoreResult nonPrefix = StoreResult.restore(2, "accountid-index", 0, bytes(9), bytes(10)); + PathStateRebuildCheckpoint nonPrefixCheckpoint = new PathStateRebuildCheckpoint(bytes(7), + bytes(11), identity(), Collections.singletonList(nonPrefix), bytes(8)); + assertEquals(2, PathStateRebuildCheckpoint.decode(nonPrefixCheckpoint.encode()) + .getCompletedStores().get(0).getStoreId()); + StoreResult wrongFirst = StoreResult.restore(2, "abi", 0, bytes(9), bytes(10)); + assertThrows(IllegalArgumentException.class, () -> new PathStateRebuildCheckpoint(bytes(7), + bytes(11), identity(), Collections.singletonList(wrongFirst), bytes(8))); + } + + @Test + public void publishesBaseThenDrainsBlockHashContinuousCatchUp() throws Exception { + PathStateStoreManifest manifest = manifest("catch-up", Engine.ROCKSDB); + PathStateCatchUpQueue queue = new PathStateCatchUpQueue(identity(), 4, 8, 1L << 20); + PathStateBlockTransition first = transition(101, bytes(20), bytes(1), + PathStateMutation.put("proposal", new byte[]{1}, new byte[]{2})); + PathStateBlockTransition second = transition(102, bytes(21), bytes(20), + PathStateMutation.put("account", new byte[]{3}, new byte[]{4})); + assertEquals(PathStateCatchUpQueue.CaptureDisposition.QUEUED, queue.capture(first)); + assertEquals(PathStateCatchUpQueue.CaptureDisposition.QUEUED, queue.capture(second)); + + RebuildResult result = new PathStateRebuildCoordinator().rebuild(manifest, + exactSource(identity()), queue, new PathStateLayerLimits(4, 1L << 30)); + + assertEquals(100, result.getMetadata().getBlockNumber()); + PathStateRootMetadata current = new PathStateCurrentStore(manifest).current(); + assertEquals(102, current.getBlockNumber()); + assertArrayEquals(second.getBlockHash(), current.getBlockHash()); + assertArrayEquals(second.getPayloadDigest(), current.getPayloadDigest()); + assertEquals(PathStateCatchUpQueue.State.READY, queue.getState()); + assertEquals(0, queue.getQueuedTransitions()); + assertEquals(0, queue.getQueuedMutations()); + assertEquals(0, queue.getQueuedBytes()); + assertEquals(PathStateCatchUpQueue.CaptureDisposition.DIRECT_APPLY, + queue.capture(transition(103, bytes(22), bytes(21), + PathStateMutation.delete("proposal", new byte[]{1})))); + } + + @Test + public void acceptsNewContinuousCaptureWhileCatchUpIsDraining() throws Exception { + PathStateStoreManifest manifest = manifest("catch-up-race", Engine.ROCKSDB); + AtomicReference queueRef = new AtomicReference<>(); + PathStateBlockTransition second = transition(102, bytes(31), bytes(30), + PathStateMutation.put("account", new byte[]{5}, new byte[]{6})); + PathStateCatchUpQueue queue = new PathStateCatchUpQueue(identity(), 4, 8, 1L << 20, + committed -> { + if (committed.getBlockNumber() == 101) { + queueRef.get().capture(second); + } + }); + queueRef.set(queue); + queue.capture(transition(101, bytes(30), bytes(1), + PathStateMutation.put("proposal", new byte[]{1}, new byte[]{2}))); + + new PathStateRebuildCoordinator().rebuild(manifest, exactSource(identity()), queue, + new PathStateLayerLimits(4, 1L << 30)); + + assertEquals(PathStateCatchUpQueue.State.READY, queue.getState()); + assertEquals(102, queue.getReadyHead().getBlockNumber()); + assertEquals(102, new PathStateCurrentStore(manifest).current().getBlockNumber()); + } + + @Test + public void catchUpGapAndOverflowFailBeforeBasePublication() throws Exception { + PathStateCatchUpQueue gap = new PathStateCatchUpQueue(identity(), 2, 2, 1L << 20); + IOException gapFailure = assertThrows(IOException.class, + () -> gap.capture(transition(102, bytes(41), bytes(40), + PathStateMutation.delete("proposal", new byte[]{1})))); + assertTrue(gapFailure.getMessage().contains("block/hash continuous")); + assertEquals(PathStateCatchUpQueue.State.FAILED, gap.getState()); + + PathStateStoreManifest manifest = manifest("catch-up-overflow", Engine.ROCKSDB); + PathStateCatchUpQueue overflow = new PathStateCatchUpQueue(identity(), 1, 2, 1L << 20); + overflow.capture(transition(101, bytes(40), bytes(1), + PathStateMutation.put("proposal", new byte[]{1}, new byte[]{2}))); + IOException overflowFailure = assertThrows(IOException.class, + () -> overflow.capture(transition(102, bytes(41), bytes(40), + PathStateMutation.put("account", new byte[]{3}, new byte[]{4})))); + assertTrue(overflowFailure.getMessage().contains("limit exceeded")); + assertEquals(PathStateCatchUpQueue.State.FAILED, overflow.getState()); + assertThrows(IOException.class, () -> new PathStateRebuildCoordinator().rebuild(manifest, + exactSource(identity()), overflow, new PathStateLayerLimits(4, 1L << 30))); + assertFalse(new PathStateCurrentStore(manifest).isInitialized()); + assertFalse(Files.exists(manifest.getBaseDirectory() + .resolve(PathStateNodeStoreSet.NODES_DIRECTORY))); + + PathStateStoreManifest mismatchManifest = manifest("catch-up-identity", Engine.ROCKSDB); + SnapshotIdentity other = new SnapshotIdentity(99, bytes(50), bytes(51), 299, + P66Phase.P66_ON); + PathStateCatchUpQueue mismatch = new PathStateCatchUpQueue(other, 2, 2, 1L << 20); + IOException mismatchFailure = assertThrows(IOException.class, + () -> new PathStateRebuildCoordinator().rebuild(mismatchManifest, + exactSource(identity()), mismatch, new PathStateLayerLimits(4, 1L << 30))); + assertTrue(mismatchFailure.getMessage().contains("snapshot identity mismatch")); + assertFalse(new PathStateCurrentStore(mismatchManifest).isInitialized()); + + PathStateCatchUpQueue byteOverflow = new PathStateCatchUpQueue(identity(), 2, 2, 1); + assertThrows(IOException.class, () -> byteOverflow.capture( + transition(101, bytes(60), bytes(1), + PathStateMutation.put("proposal", new byte[]{1}, new byte[]{2})))); + assertEquals(PathStateCatchUpQueue.State.FAILED, byteOverflow.getState()); + } + + private PathStateStoreManifest manifest(String name, Engine engine) throws IOException { + Path directory = temporaryFolder.newFolder(name).toPath(); + return PathStateStoreManifest.createOrOpen(directory, engine); + } + + private static TestSnapshotSource exactSource(SnapshotIdentity identity) { + LinkedHashMap> stores = new LinkedHashMap<>(); + for (PathStateParticipantDescriptor.StoreIdentity store + : PathStateParticipantDescriptor.current().getStores()) { + stores.put(store.getDbName(), new ArrayList<>()); + } + return new TestSnapshotSource(identity, stores); + } + + private static SnapshotIdentity identity() { + return identity(P66Phase.P66_ON); + } + + private static SnapshotIdentity identity(P66Phase phase) { + return new SnapshotIdentity(100, bytes(1), bytes(2), 300, phase); + } + + private static PathStateBlockTransition transition(long blockNumber, byte[] blockHash, + byte[] parentHash, PathStateMutation mutation) { + return new PathStateBlockTransition(blockNumber, blockHash, parentHash, + 300 + blockNumber, P66Phase.P66_ON, Collections.singletonList(mutation)); + } + + private static Engine[] availableEngines() { + return Arch.isArm64() ? new Engine[]{Engine.ROCKSDB} + : new Engine[]{Engine.LEVELDB, Engine.ROCKSDB}; + } + + private static OracleResult independentOracle(TestSnapshotSource source) { + PathStateParticipantDescriptor descriptor = PathStateParticipantDescriptor.current(); + PathStateCanonicalizer canonicalizer = new PathStateCanonicalizer(); + Map tries = new LinkedHashMap<>(); + Map roots = new LinkedHashMap<>(); + for (StoreIdentity store : descriptor.getStores()) { + TrieImpl trie = referenceTrie(); + for (Row row : source.stores.get(store.getDbName())) { + PathStateMutation mutation = canonicalizer.put(source.identity.getPhase(), + store.getDbName(), row.key, row.value); + byte[] secureKey = PathStateCommitmentCodec.storeLeafKey(store.getStoreId(), + mutation.getCanonicalKey()); + if (mutation.isDelete()) { + trie.delete(secureKey); + } else { + trie.put(secureKey, PathStateCommitmentCodec.presentLeafValue( + mutation.getCanonicalValue())); + } + } + tries.put(store.getDbName(), trie); + roots.put(store.getDbName(), trie.getRootHash()); + } + TrieImpl superTrie = referenceTrie(); + PathStateParticipantScope scope = canonicalizer.participantScope(); + for (StoreIdentity store : descriptor.getStores()) { + PathStateParticipant participant = scope.require(store.getDbName()); + superTrie.put(PathStateCommitmentCodec.superLeafKey(store.getStoreId()), + PathStateCommitmentCodec.superLeafValue(store.getStoreId(), store.getDbName(), + participant.getStoreFormatVersion(), tries.get(store.getDbName()).getRootHash())); + } + return new OracleResult(roots, superTrie.getRootHash()); + } + + private static TrieImpl referenceTrie() { + TrieImpl trie = new TrieImpl(); + trie.setAsync(false); + return trie; + } + + private static byte[] address(int suffix) { + byte[] address = new byte[21]; + address[0] = 0x41; + address[20] = (byte) suffix; + return address; + } + + private static byte[] bytes(int seed) { + byte[] value = new byte[32]; + for (int index = 0; index < value.length; index++) { + value[index] = (byte) (seed + index); + } + return value; + } + + private static Account account(byte[] address) { + return Account.newBuilder().setAddress(ByteString.copyFrom(address)).build(); + } + + private static byte[] accountAssetKey(byte[] address, String tokenId) { + byte[] token = tokenId.getBytes(java.nio.charset.StandardCharsets.US_ASCII); + return ByteBuffer.allocate(address.length + token.length).put(address).put(token).array(); + } + + private static byte[] longBytes(long value) { + return ByteBuffer.allocate(Long.BYTES).putLong(value).array(); + } + + private static final class TestSnapshotSource implements SnapshotSource { + + private final SnapshotIdentity identity; + private final Map> stores; + private final Map scanCounts = new LinkedHashMap<>(); + private byte[] sourceIdentityDigest = bytes(42); + private int verificationCount; + private boolean drift; + + private TestSnapshotSource(SnapshotIdentity identity, Map> stores) { + this.identity = identity; + this.stores = stores; + } + + private void add(String dbName, byte[] key, byte[] value) { + stores.get(dbName).add(new Row(key, value)); + } + + private void removeDatabase(String dbName) { + stores.remove(dbName); + } + + private void driftAfterFirstVerification() { + drift = true; + } + + private int getVerificationCount() { + return verificationCount; + } + + private synchronized int getScanCount(String dbName) { + Integer count = scanCounts.get(dbName); + return count == null ? 0 : count; + } + + private void setSourceIdentityDigest(byte[] digest) { + sourceIdentityDigest = java.util.Arrays.copyOf(digest, digest.length); + } + + @Override + public SnapshotIdentity identity() { + return identity; + } + + @Override + public Collection databases() { + List names = new ArrayList<>(stores.keySet()); + Collections.reverse(names); + return names; + } + + @Override + public byte[] sourceIdentityDigest() { + return java.util.Arrays.copyOf(sourceIdentityDigest, sourceIdentityDigest.length); + } + + @Override + public void scan(String dbName, EntryConsumer consumer) throws IOException { + synchronized (this) { + scanCounts.put(dbName, getScanCount(dbName) + 1); + } + for (Row row : stores.get(dbName)) { + consumer.accept(row.key, row.value); + } + } + + @Override + public void verifyIdentity(SnapshotIdentity expected) throws IOException { + verificationCount++; + if (!identity.sameAs(expected) || drift && verificationCount > 1) { + throw new IOException("path-state snapshot identity changed during rebuild"); + } + } + } + + private static final class OracleResult { + + private final Map storeRoots; + private final byte[] stateRoot; + + private OracleResult(Map storeRoots, byte[] stateRoot) { + this.storeRoots = storeRoots; + this.stateRoot = stateRoot; + } + } + + private static final class Row { + + private final byte[] key; + private final byte[] value; + + private Row(byte[] key, byte[] value) { + this.key = key; + this.value = value; + } + } +} diff --git a/framework/src/test/java/org/tron/core/db2/stateroot/PathStateRootTest.java b/framework/src/test/java/org/tron/core/db2/stateroot/PathStateRootTest.java new file mode 100644 index 00000000000..8d3cc7cf7c3 --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/stateroot/PathStateRootTest.java @@ -0,0 +1,406 @@ +package org.tron.core.db2.stateroot; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import org.bouncycastle.util.encoders.Hex; +import org.junit.Test; +import org.tron.common.crypto.Hash; +import org.tron.core.trie.TrieImpl; + +public class PathStateRootTest { + + private static final PathStateParticipant ABI = participant(1, "abi"); + private static final PathStateParticipant ACCOUNT = participant(4, "account"); + private static final PathStateParticipant ASSET_ISSUE = participant(6, "asset-issue"); + private static final PathStateParticipant ASSET_ISSUE_V2 = participant(7, "asset-issue-v2"); + private static final PathStateParticipant STORAGE = participant(22, "storage-row"); + + @Test + public void aggregatesEveryParticipantIntoIndependentOracleSuperRoot() { + List participants = participants(); + PathStateRoot stateRoot = stateRoot(participants); + Mutation[] mutations = { + mutation("abi", "contract", "abi-v1"), + mutation("asset-issue", "asset", "legacy"), + mutation("asset-issue-v2", "asset", "v2"), + mutation("account", "address", "account-value"), + mutation("storage-row", "slot", "storage-value") + }; + for (Mutation mutation : mutations) { + stateRoot.put(mutation.dbName, mutation.key, mutation.value); + } + + byte[] expected = referenceRoot(participants, mutations); + assertArrayEquals( + Hex.decode("f8d0364fdb0432016c12f9a660de2bd34513257014e35d90ac289d9024e6d216"), + expected); + assertArrayEquals(expected, stateRoot.rootHash()); + } + + @Test + public void participantAndMutationOrderDoNotChangeSuperRoot() { + List forwardParticipants = participants(); + List reverseParticipants = new ArrayList<>(forwardParticipants); + java.util.Collections.reverse(reverseParticipants); + Mutation[] mutations = { + mutation("abi", "a", "1"), mutation("asset-issue", "b", "2"), + mutation("asset-issue-v2", "c", "3"), mutation("account", "d", "4"), + mutation("storage-row", "e", "5") + }; + + PathStateRoot forward = stateRoot(forwardParticipants); + for (Mutation mutation : mutations) { + forward.put(mutation.dbName, mutation.key, mutation.value); + } + PathStateRoot reverse = stateRoot(reverseParticipants); + for (int i = mutations.length - 1; i >= 0; i--) { + reverse.put(mutations[i].dbName, mutations[i].key, mutations[i].value); + } + assertArrayEquals(forward.rootHash(), reverse.rootHash()); + } + + @Test + public void deleteUpdatesOnlyNamedParticipantAndSuperRoot() { + PathStateRoot stateRoot = stateRoot(participants()); + byte[] key = bytes("asset"); + stateRoot.put("asset-issue", key, bytes("legacy")); + stateRoot.put("asset-issue-v2", key, bytes("v2")); + byte[] originalRoot = stateRoot.rootHash(); + byte[] v2Root = stateRoot.participantRoot("asset-issue-v2"); + + stateRoot.delete("asset-issue", key); + assertArrayEquals(v2Root, stateRoot.participantRoot("asset-issue-v2")); + org.junit.Assert.assertFalse(Arrays.equals(originalRoot, stateRoot.rootHash())); + } + + @Test + public void emptyParticipantStillChangesCommittedScope() { + PathStateRoot base = stateRoot(participants()); + List extended = new ArrayList<>(participants()); + extended.add(participant(21, "proposal")); + PathStateRoot withEmptyProposal = stateRoot(extended); + + org.junit.Assert.assertFalse(Arrays.equals(base.rootHash(), withEmptyProposal.rootHash())); + } + + @Test + public void scopeRejectsMissingDuplicateAndUnknownParticipants() { + assertThrows(IllegalArgumentException.class, + () -> new PathStateParticipantScope(Arrays.asList(ABI, ASSET_ISSUE))); + assertThrows(IllegalArgumentException.class, + () -> new PathStateParticipantScope(Arrays.asList(ABI, ASSET_ISSUE, ASSET_ISSUE_V2, + participant(1, "other")))); + assertThrows(IllegalArgumentException.class, + () -> new PathStateParticipantScope(Arrays.asList(ABI, ASSET_ISSUE, ASSET_ISSUE_V2, + participant(9, "abi")))); + + PathStateRoot stateRoot = stateRoot(participants()); + assertThrows(IllegalArgumentException.class, + () -> stateRoot.put("unknown", bytes("key"), bytes("value"))); + assertThrows(IllegalArgumentException.class, + () -> stateRoot.delete("unknown", bytes("key"))); + + PathStateParticipantScope scope = new PathStateParticipantScope(participants()); + InMemoryPathNodeStore sharedStore = new InMemoryPathNodeStore(); + assertThrows(IllegalArgumentException.class, + () -> new PathStateRoot(scope, ignored -> sharedStore, new InMemoryPathNodeStore())); + assertThrows(IllegalArgumentException.class, + () -> new PathStateRoot(scope, + participant -> participant.getDbName().equals("abi") + ? sharedStore : new InMemoryPathNodeStore(), sharedStore)); + } + + @Test + public void batchValidationRejectsPartialAndDuplicateMutationSets() { + PathStateRoot stateRoot = stateRoot(participants()); + byte[] originalRoot = stateRoot.rootHash(); + List partial = Arrays.asList( + PathStateMutation.put("account", bytes("valid"), bytes("value")), + PathStateMutation.put("unknown", bytes("invalid"), bytes("value"))); + assertThrows(IllegalArgumentException.class, () -> stateRoot.apply(partial)); + assertArrayEquals(originalRoot, stateRoot.rootHash()); + + List duplicate = Arrays.asList( + PathStateMutation.put("abi", bytes("key"), new byte[0]), + PathStateMutation.delete("abi", bytes("key"))); + assertThrows(IllegalArgumentException.class, () -> stateRoot.apply(duplicate)); + assertArrayEquals(originalRoot, stateRoot.rootHash()); + assertThrows(IllegalArgumentException.class, + () -> stateRoot.apply(java.util.Collections.emptyList())); + } + + @Test + public void emptyAndZeroValuesProduceDifferentRoots() { + PathStateRoot empty = stateRoot(participants()); + empty.put("abi", bytes("key"), new byte[0]); + PathStateRoot zero = stateRoot(participants()); + zero.put("abi", bytes("key"), new byte[]{0}); + org.junit.Assert.assertFalse(Arrays.equals(empty.rootHash(), zero.rootHash())); + } + + @Test + public void retainsOnlyLeafDeltaSinceLastDurableBoundary() { + PathStateRoot stateRoot = stateRoot(participants()); + stateRoot.put("abi", bytes("one"), bytes("first")); + stateRoot.put("account", bytes("two"), bytes("second")); + assertEquals(2, stateRoot.pendingLeafMutations().size()); + + stateRoot.clearPendingLeafMutations(); + assertEquals(0, stateRoot.pendingLeafMutations().size()); + stateRoot.put("abi", bytes("one"), bytes("third")); + stateRoot.put("abi", bytes("one"), bytes("fourth")); + stateRoot.delete("account", bytes("two")); + + assertEquals(2, stateRoot.pendingLeafMutations().size()); + } + + @Test + public void verificationRequiresCurrentMaterializedSuperRoot() { + PathStateRoot stateRoot = stateRoot(participants()); + assertThrows(IllegalStateException.class, stateRoot::verifyNodeStores); + stateRoot.rootHash(); + stateRoot.verifyNodeStores(); + stateRoot.put("abi", bytes("key"), bytes("value")); + assertThrows(IllegalStateException.class, stateRoot::verifyNodeStores); + stateRoot.rootHash(); + stateRoot.verifyNodeStores(); + } + + @Test + public void concurrentUniqueMutationsMatchSequentialRoot() throws Exception { + PathStateRoot concurrent = stateRoot(participants()); + PathStateRoot sequential = stateRoot(participants()); + ExecutorService executor = Executors.newFixedThreadPool(4); + List> futures = new ArrayList<>(); + try { + for (int i = 0; i < 64; i++) { + final byte[] key = bytes("key-" + i); + final byte[] value = bytes("value-" + i); + sequential.put("account", key, value); + futures.add(executor.submit(() -> concurrent.put("account", key, value))); + } + for (Future future : futures) { + future.get(); + } + } finally { + executor.shutdownNow(); + } + assertArrayEquals(sequential.rootHash(), concurrent.rootHash()); + } + + @Test + public void gethStyleParticipantAndRootBranchBatchMatchesSequentialRoot() { + PathStateRoot parallel = stateRoot(participants()); + PathStateRoot sequential = stateRoot(participants()); + List initial = new ArrayList<>(); + for (int i = 0; i < 96; i++) { + initial.add(PathStateMutation.put("account", bytes("initial-" + i), + bytes("value-" + i))); + } + parallel.apply(initial); + sequential.apply(initial); + parallel.rootHash(); + sequential.rootHash(); + + List changes = new ArrayList<>(); + for (int i = 0; i < 32; i++) { + changes.add(PathStateMutation.put("account", bytes("initial-" + i), + bytes("updated-" + i)).withPreviousPhysicalValue(bytes("value-" + i))); + } + for (int i = 32; i < 48; i++) { + changes.add(PathStateMutation.delete("account", bytes("initial-" + i)) + .withPreviousPhysicalValue(bytes("value-" + i))); + } + for (int i = 0; i < 24; i++) { + changes.add(PathStateMutation.put("storage-row", bytes("slot-" + i), + bytes("storage-" + i)).withPreviousPhysicalValue(null)); + changes.add(PathStateMutation.put("abi", bytes("contract-" + i), + bytes("abi-" + i)).withPreviousPhysicalValue(null)); + } + sequential.apply(changes); + ExecutorService participants = Executors.newFixedThreadPool(4); + ExecutorService branches = Executors.newFixedThreadPool(8); + PathStateRoot.ParallelApplyStats stats; + try { + stats = parallel.applyParallel(changes, participants, branches); + } finally { + participants.shutdownNow(); + branches.shutdownNow(); + } + assertEquals(3, stats.participantCount()); + assertEquals(96, stats.mutationCount()); + assertEquals(96, stats.authoritativePreviousValues()); + assertEquals(48, stats.maxParticipantMutations()); + assertTrue(stats.participantWorkMillis() >= stats.maxParticipantMillis()); + assertTrue(stats.wallMillis() >= 0); + assertArrayEquals(sequential.rootHash(), parallel.rootHash()); + assertEquals(sequential.pendingLeafMutations().size(), + parallel.pendingLeafMutations().size()); + } + + @Test + public void deferredNodeEncodingIsInitiallyLimitedToAccountParticipants() { + assertTrue(PathStateRoot.usesDeferredNodeEncoding(participant(4, "account"))); + assertTrue(PathStateRoot.usesDeferredNodeEncoding(participant(5, "account-asset"))); + assertFalse(PathStateRoot.usesDeferredNodeEncoding(participant(1, "abi"))); + assertFalse(PathStateRoot.usesDeferredNodeEncoding(participant(22, "storage-row"))); + } + + @Test + public void restoredTrieAttachesDecodedNodesForRepeatedReads() { + CountingPathNodeStore store = new CountingPathNodeStore(); + PathMerkleTrie built = new PathMerkleTrie(store); + byte[] selectedKey = null; + byte[] selectedValue = null; + for (int i = 0; i < 64; i++) { + byte[] key = Hash.sha3(bytes("key-" + i)); + byte[] value = bytes("value-" + i); + built.put(key, value); + if (i == 31) { + selectedKey = key; + selectedValue = value; + } + } + byte[] root = built.rootHash(); + + PathMerkleTrie restored = new PathMerkleTrie(store); + restored.restoreRoot(root); + int readsAfterRoot = store.reads; + assertArrayEquals(selectedValue, restored.get(selectedKey)); + int readsAfterFirstLookup = store.reads; + assertTrue(readsAfterFirstLookup > readsAfterRoot); + assertTrue(restored.getNodeDecodeCount() > 0); + + assertArrayEquals(selectedValue, restored.get(selectedKey)); + assertEquals(readsAfterFirstLookup, store.reads); + } + + private static byte[] referenceRoot(List participants, + Mutation[] mutations) { + Map stores = new LinkedHashMap<>(); + for (PathStateParticipant participant : participants) { + stores.put(participant.getDbName(), referenceTrie()); + } + for (Mutation mutation : mutations) { + PathStateParticipant participant = find(participants, mutation.dbName); + stores.get(mutation.dbName).put( + PathStateCommitmentCodec.storeLeafKey(participant.getStoreId(), mutation.key), + PathStateCommitmentCodec.presentLeafValue(mutation.value)); + } + TrieImpl superTrie = referenceTrie(); + for (PathStateParticipant participant : participants) { + superTrie.put(PathStateCommitmentCodec.superLeafKey(participant.getStoreId()), + PathStateCommitmentCodec.superLeafValue(participant.getStoreId(), + participant.getDbName(), participant.getStoreFormatVersion(), + stores.get(participant.getDbName()).getRootHash())); + } + return superTrie.getRootHash(); + } + + private static PathStateParticipant find(List participants, + String dbName) { + for (PathStateParticipant participant : participants) { + if (participant.getDbName().equals(dbName)) { + return participant; + } + } + throw new AssertionError("missing test participant " + dbName); + } + + private static PathStateRoot stateRoot(List participants) { + PathStateParticipantScope scope = new PathStateParticipantScope(participants); + return new PathStateRoot(scope, ignored -> new InMemoryPathNodeStore(), + new InMemoryPathNodeStore()); + } + + private static TrieImpl referenceTrie() { + TrieImpl trie = new TrieImpl(); + trie.setAsync(false); + return trie; + } + + private static List participants() { + return Arrays.asList(ABI, ACCOUNT, ASSET_ISSUE, ASSET_ISSUE_V2, STORAGE); + } + + private static PathStateParticipant participant(int storeId, String dbName) { + return new PathStateParticipant(storeId, dbName, 1); + } + + private static Mutation mutation(String dbName, String key, String value) { + return new Mutation(dbName, bytes(key), bytes(value)); + } + + private static byte[] bytes(String value) { + return value.getBytes(java.nio.charset.StandardCharsets.UTF_8); + } + + private static final class Mutation { + + private final String dbName; + private final byte[] key; + private final byte[] value; + + private Mutation(String dbName, byte[] key, byte[] value) { + this.dbName = dbName; + this.key = key; + this.value = value; + } + } + + private static final class InMemoryPathNodeStore implements PathNodeStore { + + private final Map nodes = new LinkedHashMap<>(); + + @Override + public byte[] get(byte[] path) { + byte[] node = nodes.get(Hex.toHexString(path)); + return node == null ? null : Arrays.copyOf(node, node.length); + } + + @Override + public void put(byte[] path, byte[] encodedNode) { + nodes.put(Hex.toHexString(path), Arrays.copyOf(encodedNode, encodedNode.length)); + } + + @Override + public void delete(byte[] path) { + nodes.remove(Hex.toHexString(path)); + } + } + + private static final class CountingPathNodeStore implements PathNodeStore { + + private final Map nodes = new LinkedHashMap<>(); + private int reads; + + @Override + public byte[] get(byte[] path) { + reads++; + byte[] node = nodes.get(Hex.toHexString(path)); + return node == null ? null : Arrays.copyOf(node, node.length); + } + + @Override + public void put(byte[] path, byte[] encodedNode) { + nodes.put(Hex.toHexString(path), Arrays.copyOf(encodedNode, encodedNode.length)); + } + + @Override + public void delete(byte[] path) { + nodes.remove(Hex.toHexString(path)); + } + } +} diff --git a/framework/src/test/java/org/tron/core/db2/stateroot/PathStateRuntimeAdmissionTest.java b/framework/src/test/java/org/tron/core/db2/stateroot/PathStateRuntimeAdmissionTest.java new file mode 100644 index 00000000000..2dc4097a2a4 --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/stateroot/PathStateRuntimeAdmissionTest.java @@ -0,0 +1,102 @@ +package org.tron.core.db2.stateroot; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; + +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tron.core.db2.stateroot.PathStateCanonicalizer.P66Phase; +import org.tron.core.db2.stateroot.PathStateRuntimeAdmission.Status; +import org.tron.core.db2.stateroot.PathStateStoreManifest.Engine; + +public class PathStateRuntimeAdmissionTest { + + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void disabledAndMissingEnabledAdmissionDoNotCreateStorage() throws Exception { + Path disabled = temporaryFolder.getRoot().toPath().resolve("disabled"); + assertSame(Status.DISABLED, + PathStateRuntimeAdmission.inspect(false, null, null).getStatus()); + assertFalse(Files.exists(disabled)); + + Path missing = temporaryFolder.getRoot().toPath().resolve("missing"); + PathStateRuntimeAdmission.Result result = PathStateRuntimeAdmission.inspect( + true, missing, Engine.ROCKSDB); + assertSame(Status.REBUILD_REQUIRED, result.getStatus()); + assertNull(result.getManifest()); + assertFalse(Files.exists(missing)); + } + + @Test + public void enabledAdmissionDistinguishesRebuildFromCurrentReady() throws Exception { + Path root = temporaryFolder.getRoot().toPath().resolve("enabled"); + PathStateStoreManifest manifest = PathStateStoreManifest.createOrOpen(root, Engine.ROCKSDB); + PathStateRuntimeAdmission.Result empty = PathStateRuntimeAdmission.inspect( + true, root, Engine.ROCKSDB); + assertSame(Status.REBUILD_REQUIRED, empty.getStatus()); + assertArrayEquals(manifest.getIdentityDigest(), empty.getManifest().getIdentityDigest()); + + try (PathStateNodeStoreSet stores = PathStateNodeStoreSet.openBase(manifest)) { + PathStateRoot state = stores.createRoot(); + PathStateRootMetadata base = PathStateRootMetadata.base(100, bytes(1), bytes(2), 300, + P66Phase.P66_ON, manifest.getIdentityDigest(), state.rootHash(), bytes(3)); + new PathStateBasePublication(manifest).publish(stores, base); + } + + PathStateRuntimeAdmission.Result ready = PathStateRuntimeAdmission.inspect( + true, root, Engine.ROCKSDB); + assertSame(Status.CURRENT_READY, ready.getStatus()); + assertArrayEquals(manifest.getIdentityDigest(), ready.getManifest().getIdentityDigest()); + } + + @Test + public void physicalAdmissionIsNonCreatingRejectsLegacyAndBindsCurrentMetadata() + throws Exception { + assertSame(PathStatePhysicalRuntimeAdmission.Status.DISABLED, + PathStatePhysicalRuntimeAdmission.inspect(false, null, null).getStatus()); + + Path missing = temporaryFolder.getRoot().toPath().resolve("physical-missing"); + assertSame(PathStatePhysicalRuntimeAdmission.Status.REBUILD_REQUIRED, + PathStatePhysicalRuntimeAdmission.inspect(true, missing, Engine.ROCKSDB).getStatus()); + assertFalse(Files.exists(missing)); + + Path legacy = temporaryFolder.getRoot().toPath().resolve("physical-legacy"); + PathStateStoreManifest.createOrOpen(legacy, Engine.ROCKSDB); + assertThrows(java.io.IOException.class, + () -> PathStatePhysicalRuntimeAdmission.inspect(true, legacy, Engine.ROCKSDB)); + + Path physical = temporaryFolder.getRoot().toPath().resolve("physical-ready"); + PathStateRootMetadata expected; + try (PathStatePhysicalStoreSet stores = PathStatePhysicalStoreSet.open(physical, + new PathStateCanonicalizer().participantScope(), Engine.ROCKSDB)) { + PathStateRoot root = stores.buildRootFromFlat(); + expected = PathStateRootMetadata.base(100, bytes(4), bytes(5), 300, + P66Phase.P66_ON, stores.getFormatDigest(), root.rootHash(), bytes(6)); + stores.publishCurrent(expected); + } + assertSame(PathStatePhysicalRuntimeAdmission.Status.CURRENT_CANDIDATE, + PathStatePhysicalRuntimeAdmission.inspect(true, physical, Engine.ROCKSDB).getStatus()); + try (PathStatePhysicalSnapshotHead head = PathStatePhysicalSnapshotHead.open( + physical, Engine.ROCKSDB)) { + assertArrayEquals(expected.encode(), head.getHead().encode()); + } + assertFalse(Files.exists(physical.resolve("base").resolve("nodes"))); + assertFalse(Files.exists(physical.resolve("rebuild-spool"))); + } + + private static byte[] bytes(int seed) { + byte[] value = new byte[32]; + for (int index = 0; index < value.length; index++) { + value[index] = (byte) (seed + index); + } + return value; + } +} diff --git a/framework/src/test/java/org/tron/core/db2/stateroot/PathStateSnapshotHeadTest.java b/framework/src/test/java/org/tron/core/db2/stateroot/PathStateSnapshotHeadTest.java new file mode 100644 index 00000000000..d4092096bac --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/stateroot/PathStateSnapshotHeadTest.java @@ -0,0 +1,300 @@ +package org.tron.core.db2.stateroot; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.stream.Stream; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tron.common.arch.Arch; +import org.tron.core.db2.archive.BlockReverseDiff; +import org.tron.core.db2.archive.BlockSnapshotMeta; +import org.tron.core.db2.archive.OldValue; +import org.tron.core.db2.core.CommonCheckpointPayload; +import org.tron.core.db2.core.CommonCheckpointPayload.Mutation; +import org.tron.core.db2.core.CommonCheckpointPayload.StoreMutations; +import org.tron.core.db2.core.CommonCheckpointPayloadCodec; +import org.tron.core.db2.stateroot.PathStateCanonicalizer.P66Phase; +import org.tron.core.db2.stateroot.PathStateStoreManifest.Engine; + +public class PathStateSnapshotHeadTest { + + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void publishesOnlyCommittedContinuousSnapshotsAcrossReopen() throws Exception { + for (Engine engine : availableEngines()) { + Fixture fixture = fixture("advance-" + engine, engine); + PathStateSnapshotHead owner = PathStateSnapshotHead.open( + fixture.manifest, PathStateLayerLimits.defaults()); + PreparedPathStateTransition prepared = owner.prepare(transition(101, 11, + fixture.base.getBlockHash(), Collections.singletonList( + PathStateMutation.put("proposal", new byte[]{1}, new byte[]{5})))); + PreparedPathStateTransition stale = owner.prepare(transition(101, 21, + fixture.base.getBlockHash(), Collections.singletonList( + PathStateMutation.put("proposal", new byte[]{1}, new byte[]{6})))); + assertTrue(prepared.getNodeMutationCount() > 0); + try (Stream layers = Files.list( + fixture.manifest.getLayersDirectory())) { + assertFalse(layers.findAny().isPresent()); + } + assertArrayEquals(fixture.base.encode(), + new PathStateCurrentStore(fixture.manifest).current().encode()); + PathStateRootMetadata first = owner.advancePrepared(prepared); + assertArrayEquals(first.getStateRoot(), owner.getSnapshot().getStateRoot()); + assertThrows(IOException.class, () -> owner.advancePrepared(stale)); + assertArrayEquals(first.encode(), owner.getHead().encode()); + + assertThrows(IOException.class, () -> owner.advance(transition(103, 13, + first.getBlockHash(), Collections.emptyList()))); + assertArrayEquals(first.encode(), owner.getHead().encode()); + assertFalse(owner.isFailed()); + + PathStateRootMetadata second = owner.advance(transition(102, 12, + first.getBlockHash(), Collections.emptyList())); + assertArrayEquals(first.getStateRoot(), second.getStateRoot()); + try (PathStateNodeStoreSet stores = PathStateNodeStoreSet.openCurrent(fixture.manifest)) { + assertArrayEquals(second.getStateRoot(), stores.createRoot().rootHash()); + } + } + } + + @Test + public void commitAdmissionFailureKeepsOwnedSnapshotAndCurrent() throws Exception { + Fixture fixture = fixture("admission", Engine.ROCKSDB); + PathStateSnapshotHead owner = PathStateSnapshotHead.open( + fixture.manifest, new PathStateLayerLimits(10, 1)); + + assertThrows(IOException.class, () -> owner.advance(transition(101, 11, + fixture.base.getBlockHash(), Collections.singletonList( + PathStateMutation.put("proposal", new byte[]{1}, new byte[]{5}))))); + + assertArrayEquals(fixture.base.encode(), owner.getHead().encode()); + assertArrayEquals(fixture.base.encode(), + new PathStateCurrentStore(fixture.manifest).current().encode()); + assertFalse(owner.isFailed()); + } + + @Test + public void preparedTransitionFreezesAnImmutableSnapshotForwardDelta() throws Exception { + Fixture fixture = fixture("snapshot-delta", Engine.ROCKSDB); + PathStateSnapshotHead owner = PathStateSnapshotHead.open( + fixture.manifest, PathStateLayerLimits.defaults()); + PathStateBlockTransition transition = transition(101, 11, fixture.base.getBlockHash(), + Collections.singletonList( + PathStateMutation.put("proposal", new byte[]{1}, new byte[]{5}))); + PreparedPathStateTransition prepared = owner.prepare(transition); + BlockSnapshotMeta meta = BlockSnapshotMeta.forBlock(transition.getBlockNumber(), + transition.getBlockHash(), transition.getParentHash(), transition.getTimestamp()); + + PathStateSnapshotDelta delta = prepared.toSnapshotDelta(meta); + + assertEquals(meta, delta.getMeta()); + assertArrayEquals(fixture.base.getStateRoot(), delta.getParentStateRoot()); + assertArrayEquals(prepared.getStateRoot(), delta.getStateRoot()); + assertArrayEquals(transition.getPayloadDigest(), delta.getTransitionPayloadDigest()); + assertEquals(1, delta.getStores().size()); + PathStateSnapshotDelta.StoreDelta store = delta.getStores().get(0); + assertEquals("proposal", store.getDbName()); + assertEquals(1, store.getFlatMutations().size()); + assertTrue(store.getNodeMutations().size() > 0); + assertTrue(delta.getSuperNodeMutations().size() > 0); + + byte[] exposedRoot = delta.getStateRoot(); + exposedRoot[0] ^= 1; + assertArrayEquals(prepared.getStateRoot(), delta.getStateRoot()); + byte[] exposedKey = store.getFlatMutations().get(0).getKey(); + exposedKey[0] ^= 1; + assertArrayEquals(PathStateCommitmentCodec.storeLeafKey(store.getStoreId(), new byte[]{1}), + store.getFlatMutations().get(0).getKey()); + + BlockSnapshotMeta wrongMeta = BlockSnapshotMeta.forBlock(101, bytes(12), + fixture.base.getBlockHash(), transition.getTimestamp()); + assertThrows(IllegalArgumentException.class, () -> prepared.toSnapshotDelta(wrongMeta)); + } + + @Test + public void coalescesConsecutiveSnapshotDeltasAndRetainsEveryBlockBinding() throws Exception { + Fixture fixture = fixture("snapshot-delta-coalesce", Engine.ROCKSDB); + PathStateSnapshotHead owner = PathStateSnapshotHead.open( + fixture.manifest, PathStateLayerLimits.defaults()); + + PathStateBlockTransition firstTransition = transition(101, 11, + fixture.base.getBlockHash(), Collections.singletonList( + PathStateMutation.put("proposal", new byte[]{1}, new byte[]{5}))); + PreparedPathStateTransition firstPrepared = owner.prepare(firstTransition); + PathStateSnapshotDelta first = firstPrepared.toSnapshotDelta(BlockSnapshotMeta.forBlock( + 101, firstTransition.getBlockHash(), firstTransition.getParentHash(), 303)); + owner.advancePrepared(firstPrepared); + + PathStateBlockTransition secondTransition = transition(102, 12, + firstTransition.getBlockHash(), Arrays.asList( + PathStateMutation.put("proposal", new byte[]{1}, new byte[]{6}), + PathStateMutation.delete("account", new byte[]{3}))); + PreparedPathStateTransition secondPrepared = owner.prepare(secondTransition); + PathStateSnapshotDelta second = secondPrepared.toSnapshotDelta(BlockSnapshotMeta.forBlock( + 102, secondTransition.getBlockHash(), secondTransition.getParentHash(), 306)); + owner.advancePrepared(secondPrepared); + + PathStateBlockTransition thirdTransition = transition(103, 13, + secondTransition.getBlockHash(), Collections.emptyList()); + PathStateSnapshotDelta third = owner.prepare(thirdTransition).toSnapshotDelta( + BlockSnapshotMeta.forBlock(103, thirdTransition.getBlockHash(), + thirdTransition.getParentHash(), 309)); + + PathStateFlushTarget target = PathStateFlushTarget.coalesce( + Arrays.asList(first, second, third)); + + assertEquals(3, target.getBlocks().size()); + assertEquals(101, target.getBlocks().get(0).getMeta().getBlockNumber()); + assertEquals(103, target.getBlocks().get(2).getMeta().getBlockNumber()); + assertArrayEquals(first.getParentStateRoot(), target.getParentStateRoot()); + assertArrayEquals(third.getStateRoot(), target.getStateRoot()); + assertEquals(2, target.getStores().size()); + PathStateFlushTarget.StoreTarget proposal = target.getStores().stream() + .filter(store -> "proposal".equals(store.getDbName())).findFirst().get(); + assertEquals(1, proposal.getFlatMutations().size()); + assertArrayEquals(PathStateCommitmentCodec.presentLeafValue(new byte[]{6}), + proposal.getFlatMutations().get(0).getValue()); + PathStateFlushTarget.StoreTarget account = target.getStores().stream() + .filter(store -> "account".equals(store.getDbName())).findFirst().get(); + assertEquals(1, account.getFlatMutations().size()); + assertTrue(account.getFlatMutations().get(0).isDelete()); + assertTrue(target.getMutationBytes() > 0); + + java.util.List archiveBlocks = new ArrayList<>(); + for (PathStateFlushTarget.BlockBinding block : target.getBlocks()) { + BlockReverseDiff.DbGroup group = new BlockReverseDiff.DbGroup("proposal", + Collections.singletonList( + new BlockReverseDiff.Entry(new byte[]{1}, OldValue.present(new byte[]{2})))); + archiveBlocks.add(new BlockReverseDiff(block.getMeta(), Collections.singletonList(group))); + } + CommonCheckpointPayload payload = CommonCheckpointPayload.create(bytes(77), target, + archiveBlocks, Arrays.asList( + new StoreMutations("proposal", Collections.singletonList( + new Mutation(new byte[]{1}, new byte[]{6}))), + new StoreMutations("account", Collections.singletonList( + new Mutation(new byte[]{3}, null))))); + CommonCheckpointPayloadCodec codec = new CommonCheckpointPayloadCodec(); + byte[] encoded = codec.encode(payload); + assertArrayEquals(encoded, codec.encode(payload)); + CommonCheckpointPayload decoded = codec.decode(encoded); + assertArrayEquals(payload.getFormatIdentity(), decoded.getFormatIdentity()); + assertArrayEquals(payload.getParentStateRoot(), decoded.getParentStateRoot()); + assertArrayEquals(payload.getStateRoot(), decoded.getStateRoot()); + assertEquals(3, decoded.getBlocks().size()); + assertEquals(2, decoded.getChainbaseStores().size()); + assertEquals(2, decoded.getPathStores().size()); + assertArrayEquals(codec.digest(payload), codec.digest(decoded)); + byte[] corrupt = Arrays.copyOf(encoded, encoded.length); + corrupt[corrupt.length - 1] ^= 1; + assertThrows(IllegalArgumentException.class, () -> codec.decode(corrupt)); + assertThrows(IllegalArgumentException.class, + () -> codec.decode(Arrays.copyOf(encoded, encoded.length - 1))); + byte[] wrongVersion = Arrays.copyOf(encoded, encoded.length); + wrongVersion[5]++; + assertThrows(IllegalArgumentException.class, () -> codec.decode(wrongVersion)); + assertThrows(IllegalArgumentException.class, + () -> new CommonCheckpointPayloadCodec(encoded.length - 1).decode(encoded)); + java.util.List mismatchedArchive = new ArrayList<>(archiveBlocks); + PathStateFlushTarget.BlockBinding firstBlock = target.getBlocks().get(0); + BlockSnapshotMeta wrongBlock = BlockSnapshotMeta.forBlock( + firstBlock.getMeta().getBlockNumber(), bytes(88), + firstBlock.getMeta().getParentHash(), firstBlock.getMeta().getTimestamp()); + mismatchedArchive.set(0, new BlockReverseDiff(wrongBlock, Collections.emptyList())); + assertThrows(IllegalArgumentException.class, () -> CommonCheckpointPayload.create( + bytes(77), target, mismatchedArchive, Collections.emptyList())); + + assertThrows(IllegalArgumentException.class, + () -> PathStateFlushTarget.coalesce(Collections.emptyList())); + PathStateSnapshotDelta wrongRoot = mock(PathStateSnapshotDelta.class); + when(wrongRoot.getMeta()).thenReturn(third.getMeta()); + when(wrongRoot.getParentStateRoot()).thenReturn(bytes(99)); + assertThrows(IllegalArgumentException.class, + () -> PathStateFlushTarget.coalesce(Arrays.asList(second, wrongRoot))); + } + + @Test + public void rewindsOwnedHeadThenBuildsCanonicalSibling() throws Exception { + for (Engine engine : availableEngines()) { + Fixture fixture = fixture("rewind-" + engine, engine); + PathStateSnapshotHead owner = PathStateSnapshotHead.open( + fixture.manifest, PathStateLayerLimits.defaults()); + PathStateRootMetadata first = owner.advance(transition(101, 11, + fixture.base.getBlockHash(), Collections.singletonList( + PathStateMutation.put("proposal", new byte[]{1}, new byte[]{5})))); + PathStateRootMetadata oldSecond = owner.advance(transition(102, 12, + first.getBlockHash(), Collections.emptyList())); + + assertArrayEquals(first.encode(), owner.rewindTo( + first.getBlockNumber(), first.getBlockHash()).encode()); + assertFalse(Files.exists(fixture.manifest.getLayerDirectory( + oldSecond.getBlockNumber(), oldSecond.getBlockHash()))); + PathStateRootMetadata sibling = owner.advance(transition(102, 22, + first.getBlockHash(), Collections.singletonList( + PathStateMutation.put("proposal", new byte[]{1}, new byte[]{9})))); + + assertArrayEquals(sibling.encode(), PathStateSnapshotHead.open( + fixture.manifest, PathStateLayerLimits.defaults()).getHead().encode()); + assertFalse(owner.isFailed()); + } + } + + private Fixture fixture(String name, Engine engine) throws Exception { + PathStateStoreManifest manifest = PathStateStoreManifest.createOrOpen( + new File(temporaryFolder.getRoot(), name).toPath(), engine); + try (PathStateNodeStoreSet stores = PathStateNodeStoreSet.openBase(manifest)) { + PathStateRoot root = stores.createRoot(); + root.apply(Arrays.asList( + PathStateMutation.put("proposal", new byte[]{1}, new byte[]{2}), + PathStateMutation.put("account", new byte[]{3}, new byte[]{4}))); + PathStateRootMetadata base = PathStateRootMetadata.base(100, bytes(1), bytes(2), 300, + P66Phase.P66_ON, manifest.getIdentityDigest(), root.rootHash(), bytes(3)); + new PathStateBasePublication(manifest).publish(stores, base); + return new Fixture(manifest, base); + } + } + + private static PathStateBlockTransition transition(long blockNumber, int hashSeed, + byte[] parentHash, java.util.List mutations) { + return new PathStateBlockTransition(blockNumber, bytes(hashSeed), parentHash, + blockNumber * 3, P66Phase.P66_ON, mutations); + } + + private static Engine[] availableEngines() { + return Arch.isArm64() ? new Engine[]{Engine.ROCKSDB} + : new Engine[]{Engine.LEVELDB, Engine.ROCKSDB}; + } + + private static byte[] bytes(int seed) { + byte[] value = new byte[32]; + for (int index = 0; index < value.length; index++) { + value[index] = (byte) (seed + index); + } + return value; + } + + private static final class Fixture { + + private final PathStateStoreManifest manifest; + private final PathStateRootMetadata base; + + private Fixture(PathStateStoreManifest manifest, PathStateRootMetadata base) { + this.manifest = manifest; + this.base = base; + } + } +} diff --git a/framework/src/test/java/org/tron/core/db2/stateroot/PathStateStackTrieTest.java b/framework/src/test/java/org/tron/core/db2/stateroot/PathStateStackTrieTest.java new file mode 100644 index 00000000000..5aaafc853dc --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/stateroot/PathStateStackTrieTest.java @@ -0,0 +1,139 @@ +package org.tron.core.db2.stateroot; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Random; +import org.junit.Test; +import org.tron.common.crypto.Hash; + +public class PathStateStackTrieTest { + + private static final Comparator UNSIGNED = (left, right) -> { + for (int index = 0; index < left.key.length; index++) { + int compared = Integer.compare(left.key[index] & 0xff, right.key[index] & 0xff); + if (compared != 0) { + return compared; + } + } + return 0; + }; + + @Test + public void matchesReferenceRootAndEmitsDurableNodes() { + List rows = rows(10_000, 19L); + Map emitted = new LinkedHashMap<>(); + PathStateStackTrie stack = new PathStateStackTrie( + (path, encoded) -> emitted.put(hex(path), encoded)); + PathMerkleTrie reference = new PathMerkleTrie(new MemoryNodeStore()); + + for (Row row : rows) { + stack.update(row.key, row.value); + reference.put(row.key, row.value); + } + + byte[] root = stack.rootHash(); + assertArrayEquals(reference.rootHash(), root); + assertArrayEquals(root, Hash.sha3(emitted.get(""))); + assertTrue(stack.getEmittedNodes() > 1); + } + + @Test + public void retainsOnlyAscendingFrontierForLargeInput() { + PathStateStackTrie stack = new PathStateStackTrie((path, encoded) -> { }); + for (Row row : rows(100_000, 41L)) { + stack.update(row.key, row.value); + assertTrue(stack.retainedNodes() <= 1024); + } + stack.rootHash(); + assertTrue(stack.retainedNodes() <= 1); + } + + @Test + public void rejectsDuplicateAndDescendingKeys() { + byte[] low = key(1); + byte[] high = key(2); + PathStateStackTrie duplicate = new PathStateStackTrie((path, encoded) -> { }); + duplicate.update(low, new byte[]{1}); + assertThrows(IllegalArgumentException.class, + () -> duplicate.update(low, new byte[]{2})); + + PathStateStackTrie descending = new PathStateStackTrie((path, encoded) -> { }); + descending.update(high, new byte[]{1}); + assertThrows(IllegalArgumentException.class, + () -> descending.update(low, new byte[]{2})); + } + + @Test + public void returnsCanonicalEmptyRoot() { + assertArrayEquals(Hash.EMPTY_TRIE_HASH, + new PathStateStackTrie((path, encoded) -> { }).rootHash()); + } + + private static List rows(int count, long seed) { + Random random = new Random(seed); + List rows = new ArrayList<>(count); + for (int index = 0; index < count; index++) { + byte[] key = new byte[PathMerkleTrie.SECURE_KEY_LENGTH]; + random.nextBytes(key); + ByteBuffer.wrap(key, key.length - Integer.BYTES, Integer.BYTES).putInt(index); + byte[] value = new byte[8 + random.nextInt(96)]; + random.nextBytes(value); + rows.add(new Row(key, value)); + } + rows.sort(UNSIGNED); + return rows; + } + + private static byte[] key(int suffix) { + byte[] key = new byte[PathMerkleTrie.SECURE_KEY_LENGTH]; + key[key.length - 1] = (byte) suffix; + return key; + } + + private static String hex(byte[] value) { + StringBuilder result = new StringBuilder(value.length * 2); + for (byte present : value) { + result.append(String.format("%02x", present & 0xff)); + } + return result.toString(); + } + + private static final class Row { + + private final byte[] key; + private final byte[] value; + + private Row(byte[] key, byte[] value) { + this.key = key; + this.value = value; + } + } + + private static final class MemoryNodeStore implements PathNodeStore { + + private final Map nodes = new LinkedHashMap<>(); + + @Override + public byte[] get(byte[] path) { + return nodes.get(hex(path)); + } + + @Override + public void put(byte[] path, byte[] encodedNode) { + nodes.put(hex(path), encodedNode); + } + + @Override + public void delete(byte[] path) { + nodes.remove(hex(path)); + } + } +} diff --git a/framework/src/test/java/org/tron/core/db2/stateroot/PathStateStoreTrieBuilderTest.java b/framework/src/test/java/org/tron/core/db2/stateroot/PathStateStoreTrieBuilderTest.java new file mode 100644 index 00000000000..e8043b92fe2 --- /dev/null +++ b/framework/src/test/java/org/tron/core/db2/stateroot/PathStateStoreTrieBuilderTest.java @@ -0,0 +1,65 @@ +package org.tron.core.db2.stateroot; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import java.nio.ByteBuffer; +import java.nio.file.Path; +import java.util.Random; +import java.util.concurrent.atomic.AtomicLong; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tron.core.db2.stateroot.PathStateStoreManifest.Engine; + +public class PathStateStoreTrieBuilderTest { + + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void diskSortsUnorderedRowsAndBoundsPendingValues() throws Exception { + Path spool = temporaryFolder.newFolder("store-spool").toPath(); + PathMerkleTrie reference = new PathMerkleTrie(new MemoryNodeStore()); + Random random = new Random(71L); + int count = PathStateStoreTrieBuilder.DEFAULT_WRITE_BATCH_ROWS * 3 + 17; + AtomicLong reportedRows = new AtomicLong(); + + try (PathStateStoreTrieBuilder builder = new PathStateStoreTrieBuilder(spool, + Engine.ROCKSDB, new byte[0], (path, encoded) -> { }, + (key, value) -> { }, (sortedRows, elapsedMillis) -> reportedRows.set(sortedRows))) { + for (int index = count - 1; index >= 0; index--) { + byte[] key = new byte[PathMerkleTrie.SECURE_KEY_LENGTH]; + ByteBuffer.wrap(key, key.length - Integer.BYTES, Integer.BYTES).putInt(index); + byte[] value = new byte[64 + random.nextInt(128)]; + random.nextBytes(value); + builder.put(key, value); + reference.put(key, value); + assertTrue(builder.getPendingRows() < PathStateStoreTrieBuilder.DEFAULT_WRITE_BATCH_ROWS); + } + + assertArrayEquals(reference.rootHash(), builder.build()); + assertEquals(count, builder.getInputRows()); + assertEquals(count, builder.getSortedRows()); + assertEquals(count, reportedRows.get()); + assertEquals(0, builder.getPendingRows()); + } + } + + private static final class MemoryNodeStore implements PathNodeStore { + + @Override + public byte[] get(byte[] path) { + return null; + } + + @Override + public void put(byte[] path, byte[] encodedNode) { + } + + @Override + public void delete(byte[] path) { + } + } +} diff --git a/framework/src/test/java/org/tron/core/jsonrpc/JsonrpcServiceTest.java b/framework/src/test/java/org/tron/core/jsonrpc/JsonrpcServiceTest.java index e8d14ace060..f83d5c308da 100644 --- a/framework/src/test/java/org/tron/core/jsonrpc/JsonrpcServiceTest.java +++ b/framework/src/test/java/org/tron/core/jsonrpc/JsonrpcServiceTest.java @@ -541,6 +541,11 @@ public void testGetTrxBalance() { () -> tronJsonRpc.getTrxBalance("", "safe")); Assert.assertEquals(TAG_NOT_SUPPORT_ERROR, e4.getMessage()); + Exception e5 = Assert.assertThrows(Exception.class, + () -> tronJsonRpc.getTrxBalance("", "0x1")); + Assert.assertEquals( + "QUANTITY not supported, just support TAG as latest", e5.getMessage()); + try { balance = tronJsonRpc.getTrxBalance("0xabd4b9367799eaa3197fecb144eb71de1e049abc", "latest"); @@ -811,6 +816,13 @@ public void testGetCallWithBlockObject() { Exception missingHashEx = Assert.assertThrows(Exception.class, () -> tronJsonRpc.getCall(null, missingHashParams)); Assert.assertEquals("header for hash not found", missingHashEx.getMessage()); + + HashMap historicalParams = new HashMap<>(); + historicalParams.put("blockNumber", ByteArray.toJsonHex(blockCapsule1.getNum())); + Exception historicalEx = Assert.assertThrows(Exception.class, + () -> tronJsonRpc.getCall(null, historicalParams)); + Assert.assertEquals( + "QUANTITY not supported, just support TAG as latest", historicalEx.getMessage()); } /** diff --git a/framework/src/test/java/org/tron/core/net/message/adv/SanitizeUnknownFieldsTest.java b/framework/src/test/java/org/tron/core/net/message/adv/SanitizeUnknownFieldsTest.java index 7d883b7207d..8511b99adfd 100644 --- a/framework/src/test/java/org/tron/core/net/message/adv/SanitizeUnknownFieldsTest.java +++ b/framework/src/test/java/org/tron/core/net/message/adv/SanitizeUnknownFieldsTest.java @@ -186,6 +186,24 @@ public void blockMessageSanitizeUpdatesBothCapsuleAndWireBytes() throws Exceptio paddedBytes.length, msg.getData().length); } + @Test + public void blockMessageSanitizePreservesKnownStateRoot() throws Exception { + ByteString stateRoot = ByteString.copyFrom(new byte[32]); + BlockHeader header = sampleBlock().getBlockHeader().toBuilder() + .setStateRoot(stateRoot) + .setUnknownFields(PADDING) + .build(); + Block padded = sampleBlock().toBuilder().setBlockHeader(header).build(); + BlockMessage message = new BlockMessage(padded.toByteArray()); + + message.sanitize(); + + assertEquals(stateRoot, + message.getBlockCapsule().getInstance().getBlockHeader().getStateRoot()); + assertTrue(message.getBlockCapsule().getInstance().getBlockHeader() + .getUnknownFields().asMap().isEmpty()); + } + @Test public void blockMessageSanitizeSkipsDataRewriteOnCleanBlock() throws Exception { byte[] cleanBytes = sampleBlock().toByteArray(); diff --git a/framework/src/test/java/org/tron/core/service/RewardViCalServiceLifecycleTest.java b/framework/src/test/java/org/tron/core/service/RewardViCalServiceLifecycleTest.java new file mode 100644 index 00000000000..3cfdbec225e --- /dev/null +++ b/framework/src/test/java/org/tron/core/service/RewardViCalServiceLifecycleTest.java @@ -0,0 +1,65 @@ +package org.tron.core.service; + +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import org.junit.Test; +import org.tron.common.TestConstants; +import org.tron.common.utils.ReflectUtils; +import org.tron.core.config.args.Args; +import org.tron.core.db2.common.DB; +import org.tron.core.store.DelegationStore; +import org.tron.core.store.DynamicPropertiesStore; +import org.tron.core.store.WitnessStore; + +public class RewardViCalServiceLifecycleTest { + + @Test + public void shouldInterruptAndAwaitWorkerBeforeReturningFromStop() throws Exception { + Args.setParam(new String[0], TestConstants.TEST_CONF); + RewardViCalService service = null; + try { + DynamicPropertiesStore propertiesStore = mock(DynamicPropertiesStore.class); + DelegationStore delegationStore = mock(DelegationStore.class); + WitnessStore witnessStore = mock(WitnessStore.class); + DB db = mockDb(); + when(propertiesStore.getDb()).thenReturn(db); + when(delegationStore.getDb()).thenReturn(db); + when(witnessStore.getDb()).thenReturn(db); + service = new RewardViCalService(propertiesStore, delegationStore, witnessStore); + ScheduledExecutorService executor = ReflectUtils.getFieldValue(service, "es"); + CountDownLatch started = new CountDownLatch(1); + CountDownLatch interrupted = new CountDownLatch(1); + executor.execute(() -> { + started.countDown(); + try { + new CountDownLatch(1).await(); + } catch (InterruptedException e) { + interrupted.countDown(); + Thread.currentThread().interrupt(); + } + }); + assertTrue(started.await(5, TimeUnit.SECONDS)); + + service.stop(); + service.stop(); + + assertTrue(interrupted.await(5, TimeUnit.SECONDS)); + assertTrue(executor.isTerminated()); + } finally { + if (service != null) { + service.stop(); + } + Args.clearParam(); + } + } + + @SuppressWarnings("unchecked") + private static DB mockDb() { + return mock(DB.class); + } +} diff --git a/framework/src/test/java/org/tron/program/ArchiveStateDiagnosticTest.java b/framework/src/test/java/org/tron/program/ArchiveStateDiagnosticTest.java new file mode 100644 index 00000000000..19aaeae0474 --- /dev/null +++ b/framework/src/test/java/org/tron/program/ArchiveStateDiagnosticTest.java @@ -0,0 +1,180 @@ +package org.tron.program; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import com.google.common.primitives.Longs; +import java.nio.charset.StandardCharsets; +import java.util.AbstractMap.SimpleImmutableEntry; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import org.junit.Test; +import org.tron.common.application.TronApplicationContext; +import org.tron.core.db.Manager; +import org.tron.core.db.common.DbSourceInter; +import org.tron.core.db2.archive.ArchivePersistenceException; +import org.tron.core.db2.archive.ArchiveStoreScope; +import org.tron.core.db2.archive.HistoricalAccountAssetBalanceResolver; +import org.tron.core.db2.archive.OldValue; +import org.tron.core.db2.archive.P66AccountAssetCodec.Phase; +import org.tron.core.db2.core.Chainbase; +import org.tron.core.db2.core.SnapshotManager; +import org.tron.core.exception.TronError; +import org.tron.core.store.AccountAssetStore; +import org.tron.core.store.DynamicPropertiesStore; + +public class ArchiveStateDiagnosticTest { + + private static final long BLOCK_NUMBER = 100L; + + @Test + public void shouldConvertDiagnosticFailureToFatalStartupError() { + ArchivePersistenceException failure = new ArchivePersistenceException("injected failure"); + + TronError fatal = assertThrows(TronError.class, + () -> FullNode.runArchiveStateDiagnostic(() -> { + throw failure; + })); + + assertEquals(TronError.ErrCode.STATE_ARCHIVE_INIT, fatal.getErrCode()); + assertEquals(failure, fatal.getCause()); + } + + @Test + public void shouldRemainDisabledWithoutExplicitSystemProperty() { + String previous = System.getProperty(ArchiveStateDiagnostic.ENABLE_PROPERTY); + System.clearProperty(ArchiveStateDiagnostic.ENABLE_PROPERTY); + try { + TronApplicationContext context = mock(TronApplicationContext.class); + + ArchiveStateDiagnostic.runIfEnabled(context); + + verifyNoInteractions(context); + } finally { + if (previous == null) { + System.clearProperty(ArchiveStateDiagnostic.ENABLE_PROPERTY); + } else { + System.setProperty(ArchiveStateDiagnostic.ENABLE_PROPERTY, previous); + } + } + } + + @Test + public void shouldCompareExact27AndP66PointWithoutDeferredPrefix() { + Manager manager = mock(Manager.class); + SnapshotManager snapshotManager = mock(SnapshotManager.class); + DynamicPropertiesStore properties = mock(DynamicPropertiesStore.class); + AccountAssetStore accountAssetStore = mock(AccountAssetStore.class); + @SuppressWarnings("unchecked") + DbSourceInter accountAssetSource = mock(DbSourceInter.class); + when(manager.getDynamicPropertiesStore()).thenReturn(properties); + when(properties.getLatestBlockHeaderNumber()).thenReturn(BLOCK_NUMBER); + when(manager.getAccountAssetStore()).thenReturn(accountAssetStore); + when(accountAssetStore.getDbSource()).thenReturn(accountAssetSource); + + byte[] address = new byte[21]; + address[0] = 0x41; + Arrays.fill(address, 1, address.length, (byte) 7); + String tokenId = "1000001"; + byte[] assetKey = concat(address, tokenId.getBytes(StandardCharsets.US_ASCII)); + byte[] assetValue = Longs.toByteArray(9L); + when(accountAssetSource.iterator()).thenAnswer(invocation -> Collections.singletonList( + entry(assetKey, assetValue)).iterator()); + when(accountAssetSource.getData(any(byte[].class))).thenAnswer(invocation -> + Arrays.equals(assetKey, invocation.getArgument(0)) ? assetValue : null); + + List databases = new ArrayList<>(); + for (String dbName : ArchiveStoreScope.getStateDatabases()) { + if ("account-asset".equals(dbName)) { + continue; + } + Chainbase database = mock(Chainbase.class); + when(database.getDbName()).thenReturn(dbName); + if ("nullifier".equals(dbName)) { + when(database.iterator()).thenAnswer(invocation -> Collections.emptyIterator()); + when(database.getUnchecked(any(byte[].class))).thenReturn(null); + } else { + byte[] key = bytes("key-" + dbName); + byte[] value = bytes("value-" + dbName); + when(database.iterator()).thenAnswer(invocation -> Collections.singletonList( + entry(key, value)).iterator()); + when(database.getUnchecked(any(byte[].class))).thenAnswer(invocation -> + Arrays.equals(key, invocation.getArgument(0)) ? value : null); + } + databases.add(database); + } + when(snapshotManager.getDbs()).thenReturn(databases); + when(manager.getArchiveStateValue(anyLong(), anyString(), any(byte[].class))) + .thenAnswer(invocation -> { + String dbName = invocation.getArgument(1); + if ("nullifier".equals(dbName)) { + return OldValue.absent(); + } + if ("account-asset".equals(dbName)) { + return OldValue.present(assetValue); + } + return OldValue.present(bytes("value-" + dbName)); + }); + + HistoricalAccountAssetBalanceResolver.Result logical = + mock(HistoricalAccountAssetBalanceResolver.Result.class); + when(logical.isAccountPresent()).thenReturn(true); + when(logical.getPhase()).thenReturn(Phase.P66_ON); + when(logical.getBalance()).thenReturn(9L); + when(manager.getArchiveAccountAssetBalance(eq(BLOCK_NUMBER), any(byte[].class), eq(tokenId))) + .thenReturn(logical); + ArchiveStateDiagnostic.Report report = ArchiveStateDiagnostic.run(manager, snapshotManager); + + assertEquals(BLOCK_NUMBER, report.getBlockNumber()); + assertEquals(27, report.getStoreCount()); + assertEquals(26, report.getPresentCount()); + assertEquals(1, report.getAbsentCount()); + assertEquals(Phase.P66_ON, report.getP66Phase()); + assertEquals(9L, report.getP66Balance()); + verify(manager, never()).getArchiveAccountAssets(anyLong(), any(byte[].class), any()); + } + + @Test + public void shouldRejectIncompleteRuntimeStoreScope() { + Manager manager = mock(Manager.class); + SnapshotManager snapshotManager = mock(SnapshotManager.class); + DynamicPropertiesStore properties = mock(DynamicPropertiesStore.class); + AccountAssetStore accountAssetStore = mock(AccountAssetStore.class); + @SuppressWarnings("unchecked") + DbSourceInter accountAssetSource = mock(DbSourceInter.class); + when(manager.getDynamicPropertiesStore()).thenReturn(properties); + when(properties.getLatestBlockHeaderNumber()).thenReturn(BLOCK_NUMBER); + when(manager.getAccountAssetStore()).thenReturn(accountAssetStore); + when(accountAssetStore.getDbSource()).thenReturn(accountAssetSource); + when(snapshotManager.getDbs()).thenReturn(Collections.emptyList()); + + assertThrows(ArchivePersistenceException.class, + () -> ArchiveStateDiagnostic.run(manager, snapshotManager)); + } + + private static Map.Entry entry(byte[] key, byte[] value) { + return new SimpleImmutableEntry<>(key, value); + } + + private static byte[] bytes(String value) { + return value.getBytes(StandardCharsets.UTF_8); + } + + private static byte[] concat(byte[] first, byte[] second) { + byte[] result = Arrays.copyOf(first, first.length + second.length); + System.arraycopy(second, 0, result, first.length, second.length); + return result; + } +} diff --git a/framework/src/test/java/org/tron/program/BlockReplayTest.java b/framework/src/test/java/org/tron/program/BlockReplayTest.java new file mode 100644 index 00000000000..1d1f4e2713a --- /dev/null +++ b/framework/src/test/java/org/tron/program/BlockReplayTest.java @@ -0,0 +1,179 @@ +package org.tron.program; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.google.protobuf.ByteString; +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.nio.file.Path; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.mockito.ArgumentCaptor; +import org.tron.common.application.TronApplicationContext; +import org.tron.common.utils.BlockFile; +import org.tron.common.utils.Sha256Hash; +import org.tron.core.ChainBaseManager; +import org.tron.core.capsule.BlockCapsule; +import org.tron.core.capsule.BlockCapsule.BlockId; +import org.tron.core.consensus.ConsensusService; +import org.tron.core.db.RevokingDatabase; +import org.tron.core.net.TronNetDelegate; + +public class BlockReplayTest { + + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void shouldVerifyACompleteFileWithoutApplying() throws Exception { + BlockId parent = new BlockId(Sha256Hash.ZERO_HASH, 9); + BlockCapsule[] blocks = blocks(parent, 10, 2); + Path input = write(blocks); + + BlockReplay.ReplayResult result = BlockReplay.replay(input, null, null, 1, + Long.MAX_VALUE, false); + + assertTrue(result.format().contains("mode=verify")); + assertTrue(result.format().contains("processed=2")); + assertTrue(result.format().contains("measured=1")); + } + + @Test + public void shouldVerifyThroughCommandLine() throws Exception { + BlockId parent = new BlockId(Sha256Hash.ZERO_HASH, 9); + Path input = write(blocks(parent, 10, 2)); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + ByteArrayOutputStream error = new ByteArrayOutputStream(); + + int exitCode = BlockReplay.execute(new String[] {"--input", input.toString()}, + new PrintStream(output), new PrintStream(error)); + + assertEquals(error.toString(), 0, exitCode); + assertTrue(output.toString().contains("mode=verify")); + assertTrue(output.toString().contains("processed=2")); + } + + @Test + public void shouldVerifyConsecutiveDirectoryWithGlobalLimitAndWarmup() throws Exception { + BlockId parent = new BlockId(Sha256Hash.ZERO_HASH, 9); + BlockCapsule[] blocks = blocks(parent, 10, 4); + Path directory = temporaryFolder.newFolder("chunks").toPath(); + write(directory.resolve("second.dat"), new BlockCapsule[] {blocks[2], blocks[3]}); + write(directory.resolve("first.dat"), new BlockCapsule[] {blocks[0], blocks[1]}); + + BlockReplay.ReplayResult result = BlockReplay.replayInput(directory, null, null, 1, 3, false); + + assertTrue(result.format().contains("range=[10,12]")); + assertTrue(result.format().contains("processed=3")); + assertTrue(result.format().contains("warmup=1")); + assertTrue(result.format().contains("measured=2")); + } + + @Test + public void shouldRejectParentMismatchBetweenChunks() throws Exception { + BlockId parent = new BlockId(Sha256Hash.ZERO_HASH, 9); + BlockCapsule first = blocks(parent, 10, 1)[0]; + BlockCapsule second = blocks(new BlockId(Sha256Hash.ZERO_HASH, 10), 11, 1)[0]; + Path directory = temporaryFolder.newFolder("parent-mismatch").toPath(); + write(directory.resolve("10.dat"), new BlockCapsule[] {first}); + write(directory.resolve("11.dat"), new BlockCapsule[] {second}); + + assertThrows(IllegalArgumentException.class, + () -> BlockReplay.replayInput(directory, null, null, 0, Long.MAX_VALUE, false)); + } + + @Test + public void shouldApplyBlocksThroughSyncPath() throws Exception { + BlockId parent = new BlockId(Sha256Hash.ZERO_HASH, 9); + BlockCapsule[] blocks = blocks(parent, 10, 2); + Path input = write(blocks); + TronNetDelegate tronNetDelegate = mock(TronNetDelegate.class); + ChainBaseManager chainBaseManager = mock(ChainBaseManager.class); + when(chainBaseManager.getHeadBlockNum()).thenReturn(9L, 10L, 11L); + when(chainBaseManager.getHeadBlockId()).thenReturn(parent, blocks[0].getBlockId(), + blocks[1].getBlockId()); + + BlockReplay.ReplayResult result = BlockReplay.replay(input, tronNetDelegate, chainBaseManager, + 0, Long.MAX_VALUE, true); + + ArgumentCaptor captor = ArgumentCaptor.forClass(BlockCapsule.class); + verify(tronNetDelegate, times(2)).processBlock(captor.capture(), eq(true)); + assertEquals(blocks[0].getBlockId(), captor.getAllValues().get(0).getBlockId()); + assertEquals(blocks[1].getBlockId(), captor.getAllValues().get(1).getBlockId()); + assertTrue(result.format().contains("mode=apply")); + assertTrue(result.format().contains("processed=2")); + } + + @Test + public void shouldStartConsensusBeforeApplyingBlocks() { + TronApplicationContext context = mock(TronApplicationContext.class); + ConsensusService consensusService = mock(ConsensusService.class); + when(context.getBean(ConsensusService.class)).thenReturn(consensusService); + + BlockReplay.startConsensus(context); + + verify(consensusService).start(); + } + + @Test + public void shouldFlushPendingSnapshotsAfterSuccessfulReplay() { + TronApplicationContext context = mock(TronApplicationContext.class); + RevokingDatabase revokingDatabase = mock(RevokingDatabase.class); + when(context.getBean(RevokingDatabase.class)).thenReturn(revokingDatabase); + + BlockReplay.flushPending(context); + + verify(revokingDatabase).flushPending(); + } + + @Test + public void shouldRejectD0WithDifferentHead() throws Exception { + BlockId fileParent = new BlockId(Sha256Hash.ZERO_HASH, 9); + BlockCapsule[] blocks = blocks(fileParent, 10, 1); + Path input = write(blocks); + TronNetDelegate tronNetDelegate = mock(TronNetDelegate.class); + ChainBaseManager chainBaseManager = mock(ChainBaseManager.class); + when(chainBaseManager.getHeadBlockNum()).thenReturn(9L); + when(chainBaseManager.getHeadBlockId()) + .thenReturn(new BlockId(Sha256Hash.of(true, new byte[] {1}), 9)); + + assertThrows(IllegalArgumentException.class, + () -> BlockReplay.replay(input, tronNetDelegate, chainBaseManager, + 0, Long.MAX_VALUE, true)); + verify(tronNetDelegate, times(0)).processBlock(any(BlockCapsule.class), eq(true)); + } + + private Path write(BlockCapsule[] blocks) throws Exception { + Path input = temporaryFolder.getRoot().toPath().resolve("blocks.dat"); + return write(input, blocks); + } + + private Path write(Path input, BlockCapsule[] blocks) throws Exception { + BlockFile.write(input, blocks[0].getNum(), blocks[blocks.length - 1].getNum(), false, + height -> { + BlockCapsule block = blocks[(int) (height - blocks[0].getNum())]; + return new BlockFile.Record(height, block.getBlockId().getBytes(), block.getData()); + }); + return input; + } + + private static BlockCapsule[] blocks(BlockId parent, long start, int count) { + BlockCapsule[] blocks = new BlockCapsule[count]; + BlockId previous = parent; + for (int i = 0; i < count; i++) { + long height = start + i; + blocks[i] = new BlockCapsule(height, previous, height * 3000, ByteString.EMPTY); + previous = blocks[i].getBlockId(); + } + return blocks; + } +} diff --git a/framework/src/test/java/org/tron/program/HttpBlockExportTest.java b/framework/src/test/java/org/tron/program/HttpBlockExportTest.java new file mode 100644 index 00000000000..8d995c12141 --- /dev/null +++ b/framework/src/test/java/org/tron/program/HttpBlockExportTest.java @@ -0,0 +1,89 @@ +package org.tron.program; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import com.google.protobuf.Any; +import com.google.protobuf.ByteString; +import java.nio.file.Path; +import java.util.List; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tron.common.utils.BlockFile; +import org.tron.core.capsule.BlockCapsule; +import org.tron.core.services.http.Util; +import org.tron.protos.Protocol.Block; +import org.tron.protos.Protocol.BlockHeader; +import org.tron.protos.Protocol.Transaction; +import org.tron.protos.Protocol.Transaction.Contract.ContractType; +import org.tron.protos.contract.BalanceContract.TransferContract; + +public class HttpBlockExportTest { + + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void parsePrintedBlockResponseAndVerifyChunk() throws Exception { + Block block = block(100, new byte[32], transferTransaction()); + String response = "{\"block\":[" + Util.printBlock(block, false) + "]}"; + + List records = HttpBlockExport.parseResponse(response); + + assertEquals(1, records.size()); + assertEquals(100, records.get(0).getHeight()); + assertEquals(block, records.get(0).getBlock()); + + Path output = temporaryFolder.newFile("100-100.dat").toPath(); + BlockFile.write(output, 100, 100, true, height -> records.get(0)); + HttpBlockExport.verifyChunk(output, 100, 100); + } + + @Test + public void rejectMismatchedBlockId() { + Block block = block(100, new byte[32], transferTransaction()); + String response = "{\"block\":[" + Util.printBlock(block, false) + .replaceFirst("\\\"blockID\\\":\\\".", "\\\"blockID\\\":\\\"f") + "]}"; + + try { + HttpBlockExport.parseResponse(response); + } catch (Exception e) { + assertTrue(e.getMessage().contains("Computed block ID mismatch")); + return; + } + throw new AssertionError("Expected mismatched block ID to fail"); + } + + private static Block block(long height, byte[] parentHash, Transaction transaction) { + BlockHeader.raw raw = BlockHeader.raw.newBuilder() + .setNumber(height) + .setParentHash(com.google.protobuf.ByteString.copyFrom(parentHash)) + .setTimestamp(1_700_000_000_000L + height) + .build(); + BlockHeader header = BlockHeader.newBuilder().setRawData(raw).build(); + Block block = Block.newBuilder().setBlockHeader(header).addTransactions(transaction).build(); + assertEquals(height, new BlockCapsule(block).getNum()); + return block; + } + + private static Transaction transferTransaction() { + TransferContract transfer = TransferContract.newBuilder() + .setOwnerAddress(ByteString.copyFrom(new byte[] {0x41, 1})) + .setToAddress(ByteString.copyFrom(new byte[] {0x41, 2})) + .setAmount(10) + .build(); + Transaction.Contract contract = Transaction.Contract.newBuilder() + .setType(ContractType.TransferContract) + .setParameter(Any.pack(transfer)) + .build(); + Transaction.raw raw = Transaction.raw.newBuilder() + .addContract(contract) + .setTimestamp(1_700_000_000_000L) + .setExpiration(1_700_000_060_000L) + .build(); + return Transaction.newBuilder().setRawData(raw) + .addSignature(ByteString.copyFrom(new byte[] {1, 2, 3})) + .build(); + } +} diff --git a/platform/src/main/java/common/org/tron/common/arch/Arch.java b/platform/src/main/java/common/org/tron/common/arch/Arch.java index 999bb631bea..1409d99fea2 100644 --- a/platform/src/main/java/common/org/tron/common/arch/Arch.java +++ b/platform/src/main/java/common/org/tron/common/arch/Arch.java @@ -6,6 +6,9 @@ @Slf4j(topic = "arch") public final class Arch { + /** Explicit opt-in for the x86 Java 17 validation candidate. */ + public static final String JAVA17_X86_CANDIDATE_PROPERTY = "tron.java17.x86.candidate"; + private Arch() { } @@ -74,8 +77,12 @@ public static boolean isJava17() { return javaSpecificationVersion().equals("17"); } + public static boolean isJava17X86Candidate() { + return Boolean.parseBoolean(System.getProperty(JAVA17_X86_CANDIDATE_PROPERTY, "false")); + } + public static void throwIfUnsupportedJavaVersion() { - if ((isX86() && !isJava8()) || (isArm64() && !isJava17())) { + if ((isX86() && !isJava8() && !isJava17X86Candidate()) || (isArm64() && !isJava17())) { logger.info(withAll()); throw new UnsupportedOperationException(String.format( "Java %s is required for %s architecture. Detected version %s", isX86() ? "1.8" : "17", diff --git a/platform/src/main/java/common/org/tron/common/utils/MarketComparator.java b/platform/src/main/java/common/org/tron/common/utils/MarketComparator.java index c2742d4d10b..7c87bb57db8 100644 --- a/platform/src/main/java/common/org/tron/common/utils/MarketComparator.java +++ b/platform/src/main/java/common/org/tron/common/utils/MarketComparator.java @@ -5,9 +5,14 @@ public class MarketComparator { public static final int TOKEN_ID_LENGTH = Long.toString(Long.MAX_VALUE).getBytes().length; // 19 + private static final int PRICE_KEY_LENGTH = TOKEN_ID_LENGTH * 2 + Long.BYTES * 2; public static int comparePriceKey(byte[] o1, byte[] o2) { + if (o1 == null || o2 == null + || o1.length < PRICE_KEY_LENGTH || o2.length < PRICE_KEY_LENGTH) { + return compareUnsigned(o1, o2); + } //compare pair byte[] pair1 = new byte[TOKEN_ID_LENGTH * 2]; byte[] pair2 = new byte[TOKEN_ID_LENGTH * 2]; diff --git a/platform/src/main/java/x86-high/org/tron/common/math/MathWrapper.java b/platform/src/main/java/x86-high/org/tron/common/math/MathWrapper.java new file mode 100644 index 00000000000..7bfb87ae1da --- /dev/null +++ b/platform/src/main/java/x86-high/org/tron/common/math/MathWrapper.java @@ -0,0 +1,24 @@ +package org.tron.common.math; + +/** Java 8 math compatibility implementation for the high-version x86 build. */ +@Deprecated +public class MathWrapper { + + public static double pow(double a, double b) { return Math.pow(a, b); } + public static long addExact(long x, long y) { return Math.addExact(x, y); } + public static int addExact(int x, int y) { return Math.addExact(x, y); } + public static long floorDiv(long x, long y) { return Math.floorDiv(x, y); } + public static int multiplyExact(int x, int y) { return Math.multiplyExact(x, y); } + public static long multiplyExact(long x, long y) { return Math.multiplyExact(x, y); } + public static long subtractExact(long x, long y) { return Math.subtractExact(x, y); } + public static int min(int a, int b) { return Math.min(a, b); } + public static long min(long a, long b) { return Math.min(a, b); } + public static int max(int a, int b) { return Math.max(a, b); } + public static long max(long a, long b) { return Math.max(a, b); } + public static int round(float a) { return Math.round(a); } + public static long round(double a) { return Math.round(a); } + public static double ceil(double a) { return Math.ceil(a); } + public static double signum(double a) { return Math.signum(a); } + public static double random() { return Math.random(); } + public static long abs(long a) { return Math.abs(a); } +} diff --git a/platform/src/main/java/x86-high/org/tron/common/utils/MarketOrderPriceComparatorForRocksDB.java b/platform/src/main/java/x86-high/org/tron/common/utils/MarketOrderPriceComparatorForRocksDB.java new file mode 100644 index 00000000000..6f558594ebc --- /dev/null +++ b/platform/src/main/java/x86-high/org/tron/common/utils/MarketOrderPriceComparatorForRocksDB.java @@ -0,0 +1,28 @@ +package org.tron.common.utils; + +import java.nio.ByteBuffer; +import org.rocksdb.AbstractComparator; +import org.rocksdb.ComparatorOptions; + +public class MarketOrderPriceComparatorForRocksDB extends AbstractComparator { + + public MarketOrderPriceComparatorForRocksDB(final ComparatorOptions copt) { + super(copt); + } + + @Override + public String name() { + return "MarketOrderPriceComparator"; + } + + @Override + public int compare(final ByteBuffer a, final ByteBuffer b) { + return MarketComparator.comparePriceKey(convertDataToBytes(a), convertDataToBytes(b)); + } + + public byte[] convertDataToBytes(ByteBuffer buf) { + byte[] bytes = new byte[buf.remaining()]; + buf.get(bytes); + return bytes; + } +} diff --git a/platform/src/main/java/x86/org/tron/common/utils/MarketOrderPriceComparatorForRocksDB.java b/platform/src/main/java/x86/org/tron/common/utils/MarketOrderPriceComparatorForRocksDB.java index be406ff658d..ffb39c5f638 100644 --- a/platform/src/main/java/x86/org/tron/common/utils/MarketOrderPriceComparatorForRocksDB.java +++ b/platform/src/main/java/x86/org/tron/common/utils/MarketOrderPriceComparatorForRocksDB.java @@ -26,11 +26,9 @@ public int compare(final DirectSlice a, final DirectSlice b) { public byte[] convertDataToBytes(DirectSlice directSlice) { int capacity = directSlice.data().capacity(); byte[] bytes = new byte[capacity]; - for (int i = 0; i < capacity; i++) { bytes[i] = directSlice.get(i); } - return bytes; } diff --git a/plugins/src/main/java/common/org/tron/plugins/Db.java b/plugins/src/main/java/common/org/tron/plugins/Db.java index 84654dca934..daf153ee8b1 100644 --- a/plugins/src/main/java/common/org/tron/plugins/Db.java +++ b/plugins/src/main/java/common/org/tron/plugins/Db.java @@ -12,7 +12,8 @@ DbConvert.class, DbLite.class, DbCopy.class, - DbRoot.class + DbRoot.class, + DbBlock.class }, commandListHeading = "%nCommands:%n%nThe most commonly used db commands are:%n" ) diff --git a/plugins/src/main/java/common/org/tron/plugins/DbBlock.java b/plugins/src/main/java/common/org/tron/plugins/DbBlock.java new file mode 100644 index 00000000000..99a08706fb9 --- /dev/null +++ b/plugins/src/main/java/common/org/tron/plugins/DbBlock.java @@ -0,0 +1,10 @@ +package org.tron.plugins; + +import picocli.CommandLine; + +@CommandLine.Command(name = "block", + mixinStandardHelpOptions = true, + description = "Export block data for offline replay.", + subcommands = {CommandLine.HelpCommand.class, DbBlockExport.class}) +public class DbBlock { +} diff --git a/plugins/src/main/java/common/org/tron/plugins/DbBlockExport.java b/plugins/src/main/java/common/org/tron/plugins/DbBlockExport.java new file mode 100644 index 00000000000..aa6e7a26852 --- /dev/null +++ b/plugins/src/main/java/common/org/tron/plugins/DbBlockExport.java @@ -0,0 +1,94 @@ +package org.tron.plugins; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.concurrent.Callable; +import org.tron.common.utils.BlockFile; +import org.tron.plugins.utils.ByteArray; +import org.tron.plugins.utils.db.DBInterface; +import org.tron.plugins.utils.db.DbTool; +import picocli.CommandLine; + +@CommandLine.Command(name = "export", + mixinStandardHelpOptions = true, + description = "Export an inclusive range of consecutive blocks from a stopped node.") +public class DbBlockExport implements Callable { + + private static final String BLOCK_DB_NAME = "block"; + private static final String BLOCK_INDEX_DB_NAME = "block-index"; + + @CommandLine.Spec + private CommandLine.Model.CommandSpec spec; + + @CommandLine.Option(names = {"-d", "--database-directory"}, required = true, + description = "Node database directory containing block and block-index.") + private Path databaseDirectory; + + @CommandLine.Option(names = "--start", required = true, + description = "First block height, inclusive.") + private long start; + + @CommandLine.Option(names = "--end", required = true, + description = "Last block height, inclusive.") + private long end; + + @CommandLine.Option(names = {"-o", "--output"}, required = true, + description = "Destination block file.") + private Path output; + + @CommandLine.Option(names = "--overwrite", + description = "Replace an existing destination file.") + private boolean overwrite; + + @Override + public Integer call() throws Exception { + Path database = databaseDirectory.toAbsolutePath().normalize(); + requireExistingDatabase(database, BLOCK_DB_NAME); + requireExistingDatabase(database, BLOCK_INDEX_DB_NAME); + + String databasePath = database.toString(); + DBInterface blockIndex = null; + DBInterface block = null; + try { + blockIndex = DbTool.getDB(databasePath, BLOCK_INDEX_DB_NAME); + block = DbTool.getDB(databasePath, BLOCK_DB_NAME); + DBInterface sourceBlockIndex = blockIndex; + DBInterface sourceBlock = block; + BlockFile.Header header = BlockFile.write(output, start, end, overwrite, height -> { + byte[] blockId = sourceBlockIndex.get(ByteArray.fromLong(height)); + if (blockId == null) { + throw new IOException("Block index is missing height " + height); + } + byte[] blockData = sourceBlock.get(blockId); + if (blockData == null) { + throw new IOException("Block data is missing height " + height); + } + return new BlockFile.Record(height, blockId, blockData); + }); + spec.commandLine().getOut().printf( + "Exported %d blocks [%d, %d] to %s%n", + header.getCount(), header.getStart(), header.getEnd(), + output.toAbsolutePath().normalize()); + return 0; + } finally { + close(databasePath, BLOCK_DB_NAME, block); + close(databasePath, BLOCK_INDEX_DB_NAME, blockIndex); + } + } + + private void requireExistingDatabase(Path database, String name) { + Path path = database.resolve(name); + if (!Files.isDirectory(path)) { + throw new CommandLine.ParameterException(spec.commandLine(), + "Database does not exist: " + path); + } + } + + private static void close(String database, String name, DBInterface db) throws IOException { + if (db != null) { + DbTool.closeDB(database, name); + } + } +} diff --git a/plugins/src/test/java/org/tron/plugins/DbBlockExportTest.java b/plugins/src/test/java/org/tron/plugins/DbBlockExportTest.java new file mode 100644 index 00000000000..3d28f4debdf --- /dev/null +++ b/plugins/src/test/java/org/tron/plugins/DbBlockExportTest.java @@ -0,0 +1,78 @@ +package org.tron.plugins; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; + +import com.google.protobuf.ByteString; +import java.nio.file.Path; +import org.junit.After; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.tron.common.utils.BlockFile; +import org.tron.plugins.utils.ByteArray; +import org.tron.plugins.utils.db.DBInterface; +import org.tron.plugins.utils.db.DbTool; +import org.tron.protos.Protocol.Block; +import org.tron.protos.Protocol.BlockHeader; +import picocli.CommandLine; + +public class DbBlockExportTest { + + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @After + public void tearDown() { + DbTool.close(); + } + + @Test + public void shouldExportBlocksFromDatabase() throws Exception { + Path database = temporaryFolder.newFolder("database").toPath(); + DBInterface blockIndex = DbTool.getDB(database.toString(), "block-index", + DbTool.DbType.RocksDB); + DBInterface blockDb = DbTool.getDB(database.toString(), "block", DbTool.DbType.RocksDB); + byte[] previousId = new byte[32]; + for (long height = 20; height <= 21; height++) { + byte[] id = blockId(height); + Block block = block(height, previousId); + blockIndex.put(ByteArray.fromLong(height), id); + blockDb.put(id, block.toByteArray()); + previousId = id; + } + DbTool.closeDB(database.toString(), "block-index"); + DbTool.closeDB(database.toString(), "block"); + + Path output = temporaryFolder.getRoot().toPath().resolve("blocks.dat"); + CommandLine cli = new CommandLine(new Toolkit()); + assertEquals(0, cli.execute("db", "block", "export", + "-d", database.toString(), "--start", "20", "--end", "21", + "-o", output.toString())); + + try (BlockFile.Reader reader = BlockFile.open(output)) { + assertEquals(2, reader.getHeader().getCount()); + assertEquals(20, reader.next().getHeight()); + assertEquals(21, reader.next().getHeight()); + assertFalse(reader.hasNext()); + } + } + + private static Block block(long height, byte[] parent) { + return Block.newBuilder() + .setBlockHeader(BlockHeader.newBuilder() + .setRawData(BlockHeader.raw.newBuilder() + .setNumber(height) + .setTimestamp(height * 3000) + .setParentHash(ByteString.copyFrom(parent)))) + .build(); + } + + private static byte[] blockId(long height) { + byte[] value = new byte[32]; + byte[] heightBytes = ByteArray.fromLong(height); + System.arraycopy(heightBytes, 0, value, 0, heightBytes.length); + value[31] = (byte) height; + return value; + } +} diff --git a/protocol/src/main/protos/core/Tron.proto b/protocol/src/main/protos/core/Tron.proto index a68e841bb60..1ffcfe2adf8 100644 --- a/protocol/src/main/protos/core/Tron.proto +++ b/protocol/src/main/protos/core/Tron.proto @@ -514,6 +514,7 @@ message BlockHeader { } raw raw_data = 1; bytes witness_signature = 2; + bytes state_root = 3; } // block