Lotus 源码研究 06 - CC 扇区恢复功能的设计与实现
张爱玲
人总是在接近幸福时倍感幸福,在幸福进行时却患得患失。
转载声明
本文转载自原语云公众号 Lotus CC 扇区恢复功能的设计与实现 (opens new window)。欢迎订阅,第一时间获取技术干货。
目前 Lotus 主网大部分数据都是 CC 扇区,这些扇区里面存储的都是 Junk Data,其实呢都是 0x00。熟悉扇区计算过程的小伙伴都应该明白这些扇区即使丢了或者损坏了, 只要你还有封装机器就可以重新再计算回来,当然功能这存在的意义肯定是不再需要额外的抵押。原语云在最近的 1.14.1 版本中正式的增加了这个功能,接下来讲解下原语的“CC 扇区恢复”功能的设计和实现。
# 1. 使用方式设计
对于这个功能,个人觉得产品体验方式比编码实现更值得思考,因为扇区计算过程中的扇区流转状态比较多,而且完了之后还需要被下载到最终存储,如果产品体验方式还需要额外的配置或者服务器, 这无疑会增加运维的工作量,经过一番思考,原语云最终是通过如下的一个命令来实现一个扇区的恢复计算:
lotus git:(yy_master) ./lotus-miner sectors recover --help
NAME:
lotus-miner sectors recover - recover the specified sector
USAGE:
lotus-miner sectors recover [command options] <sectorNum>
DESCRIPTION:
recover the specified sector. @Note:
1, make sure the specified sector it really lost or unrecoverable before call this command.
2, this ONLY workes for CC sectors for NOW (sectors without deals).
3, remove the original sector data by calling sectors remove <sector> before invoke this command.
OPTIONS:
--ticket-epoch value ticket epoch in the PreCommit submit (default: -1)
--precommit-epoch value sector precommit epoch for seed epoch and value define (default: -1)
--debug pass this flag to print the ignore analysis log (will NOT start the recovery) (default: false)
--really-do-it pass this flag if you know what you are doing (default: false)
--help, -h show help (default: false)
例如,如果扇区ID为2的 Proving 状态的扇区丢失或者损坏了,就可以通过如下命令来开始修复:
lotus git:(yy_master) ./lotus-miner sectors recover --really-do-it=true 2
State: Proving
Ignore: GetTicket/PreCommitting/WaitSeed/C2/Committing
Ticket: {Value: 2UYaQlVzItyHuac4s4xhwxEr2K7IAwiw5tXLm60PX8M=, Epoch: -713}
Seed: {Value: 7tBu5ZPM5RuD1tVdjOssepUjpytNJUwC0rFFbPuyi6g=, Epoch: 202}
这条命令运行以后,2 号扇区就会开始进入恢复计算,具体的计算过程会依据这个扇区本身的状态,例如这个 Proving 状态的2号扇区只要完成 AP/PC1/PC2 计算就可以了, 这个功能的核心是想做到整个恢复计算过程和普通的封装过程一样,现有的计算服务器架构,一样的资源分配和调度逻辑,无需任何额外的配置和管理工作。
# 2. 扇区的计算过程
恢复过程本质上就是一个封装过程,只是恢复过程中一些计算是可以省略的,例如对于一个 Proving 的扇区,恢复过程就没有必要再进行 C1/C2 计算, 也不再需要 PreCommit 和 Commit 两次上链了,要清楚的知道整个实现过程,我们得了解一个扇区的主要的计算过程,具体如下:
- AddPiece: 通过文件指针移位的方式得到一个扇区大小的空文件,然后再往里面写满 0x00,一般1~2分钟就完成了,这个过程也可以缓存加速。
- GetTicket: 获取随机数,以当前 actorID 和区块高度为主要参数去生成一个随机数,代码里面叫做 TicketValue。
- PreCommit1: 在 AP 生成的空扇区基础上结合得到的 Ticket 随机数就开始进行PC1计算了,一个32G的扇区需要2.5小时左右。
- PreCommit2: 在PC1的数据上,继续计算生成扇区的 r 和 c 层,一个32G的扇区使用 3080 GPU 计算通常 10 分钟左右完成。
- PreCommitSector: 前置上链,将 AP 后以及 PC2 后的 cid上链 (此处的 CID 就是 IPFS 的内容寻址 ID ),这个过程需要抵押。
- WaitSeed: 等待Seed随机数,这个随机数需要用来参与后续的 C1 计算,这个地方要强制等待指定的时间。
- Commit1: C1计算,生成扇区的 vanilla 证明。
- Commit2: C2计算,在 C1 的 vanilla 证明的基础上生成 zk-snark 证明。
- CommitSector: 证明上链,将 C2 计算得到的 zk-snark 结果提交上链,这个过程同样需要抵押。
- FinalizeSector: 将 PC2 后的数据做一些 triming 清理,然后调度存储worker下载数据写到最终(canStore = true)存储,用于后续的时空/爆块证明挑战读取。 封装计算过程,每个扇区都会经历上面的十个核心过程的转变,而且这个顺序是固定的,不能打乱,后面我们就会以前面的序号来代替这些计算过程。
# 3. 扇区状态和事件处理
恢复过程中还需要了解的一个核心点就是扇区的状态管理机制,Lotus 内部是通过一个 FSM 的状态机来管理一个扇区的状态以及该状态下的可处理事件,
具体的映射关系定义在:lotus/extern/storage-sealing/fsm.go
中的一个 fsmPlanners 的 map 中,例如我们截取其中的一小部分如下:
var fsmPlanners = map[SectorState]func(events []statemachine.Event, state *SectorInfo) (uint64, error){
...
PreCommit1: planOne(
on(SectorPreCommit1{}, PreCommit2),
on(SectorSealPreCommit1Failed{}, SealPreCommit1Failed),
on(SectorDealsExpired{}, DealsExpired),
on(SectorInvalidDealIDs{}, RecoverDealIDs),
on(SectorOldTicket{}, GetTicket),
apply(SectorRecover{}),
),
...
}
上述代码的第 3 行定义了 PreCommit1 状态的下的事件处理关系,这个表示在 PC1 状态下,状态状态机只能处理这 6 个事件,不然就会出现错误导致状态机协程退出,
然后就会出现大家都比较熟悉的 normal shutdown of statemachine
错误:
- SectorPreCommit1: 表示在 PC1 状态下 扇区已经完成了 PC1 计算,第二个参数的
PreCommit2
表述将该扇区的状态设置为PreCommit2
。 - SectorSealPreCommit1Failed: 表示 PC1 计算出错了,将扇区状态设置为
SealPreCommit1Failed
,状态机会在 60s 后继续设置状态为 PC1 。 - SectorDealsExpired: 扇区里面包含的订单已经过期,扇区状态会被设置为
DealsExpired
。 - SectorInvalidDealIDs: 这个表示扇区信息里面的订单信息错误,将扇区状态设置为
RecoverDealIDs
。 - SectorOldTicket: 这个表示最近一次获取的 Ticket 随机数过期了,将扇区状态设置为
GetTicket
去重新获取 Ticket 随机数 。 - SectorRecover: 这个状态就是原语云增加的用于将扇区转变到恢复状态以便开始进行扇区恢复计算,这里的 apply 没有第二个状态参数,所以也不会发生状态的改变,具体在状态会依据需要设置。
整个事件的响应逻辑就是我们给指定扇区的状态机发送一个事件,然后状态机通过上述 fsmPlanners
定义的关系映射中找到扇区当前状态的事件集合,然后再通过事件名称找到具体的事件定义,再执行预先设定的事件处理函数 (apply) 并且将状态设置为第二个参数定义的状态 (如果有第二个参数定义)。
# 4. 扇区恢复的实现
了解上面的扇区的10个计算过程和扇区的状态管理后就可以开始本篇最核心的部分了 - 扇区恢复的设计和实现。 上面也提到过扇区恢复本质上一个选择性的重新计算的过程,具体的计算步骤还是上面描述的10个步骤,只是依据扇区的状态需要选择性的忽略某些计算,具体可以分成下面三种情况:
PreCommit
上链前:这个状态前的扇区没有任何上链,所以恢复可以选择直接全部重新计算,包括 Ticket 也重新获取。PreCommit
上链后到 Commit 上链前:这个区间的状态的扇区已经完成了PreCommitSector
上链,也就是如果要恢复需要保障前面的计算的出来的扇区的 sealed cid 是一样的, 这个时候你可以再翻看下前面的AP/GetTicket/PC1/PC2
这 4 个计算过程,发现只要 Ticket 保持一致那重新计算的结果就是一样的, 而且因为已经完成了PreCommitSector
上链,所以这里还要忽略PreCommitSector
上链,这之后的WaitSeed/C1/C2/Commit 上链/Finalize
计算过程和普通密封过程一样。CommitSector
上链后:这个状态的扇区已经完成了绝大部分的计算过程,最常见的就是Proving
,所以这里肯定不能再PreCommitSector
和CommitSector
上链了, 也不需要后面的证明过程,简单的说只要完成AP/PC1/PC2/Finalize
计算就可以了。
明白上面这个逻辑后,接下来编码上只需要做好下面三步工作就可以了:
SectorInfo 里面定义一个字段用于存储需要忽略计算的任务,我在
lotus/extern/storage-sealing/types.go
定义的 SectorInfo 里面增加一个SealTaskIgnore
字段来存储该扇区需要忽略的任务:type SealTask int64 const ( TaskNone SealTask = 0x01 << 0 TaskPacking SealTask = 0x01 << 1 TaskGetTicket SealTask = 0x01 << 2 TaskPreCommit1 SealTask = 0x01 << 3 TaskPreCommit2 SealTask = 0x01 << 4 TaskPreCommitSubmit SealTask = 0x01 << 5 TaskWaitSeed SealTask = 0x01 << 6 TaskCommit SealTask = 0x01 << 7 TaskCommitSubmit SealTask = 0x01 << 8 TaskFinalize SealTask = 0x01 << 9 ) type SectorInfo struct { // 密封中需要忽略的任务 SealTaskIgnore SealTask ... }
SealTaskIgnore 为一个 SealTask 类型,实际上是一个 int64 类型,这样我只要通过位运算就可以知道该扇区的密封计算是需要忽略哪些具体计算了。
增加一个扇区恢复事件:
正如上面的 FSM 状态定义里面描述,我增加了一个 SectorRecover 事件用于告诉状态机让扇区进入恢复状态,恢复状态的初始化过程主要就是定义 SealTaskIgnore 和初始化 TicketValue/SeedValue 的过程, 扇区恢复初始化成功后就会进入正常的密封计算过程。SectorRecover 的定义以及他的 apply 事件处理实现如下:
type SectorRecover struct { State SectorState // starting state of recovery TaskIgnore SealTask TicketEpoch abi.ChainEpoch TicketValue abi.SealRandomness SeedEpoch abi.ChainEpoch SeedValue abi.InteractiveSealRandomness } func (evt SectorRecover) apply(state *SectorInfo) { state.State = evt.State state.SealTaskIgnore = evt.TaskIgnore state.TicketValue = evt.TicketValue state.TicketEpoch = evt.TicketEpoch state.SeedValue = evt.SeedValue state.SeedEpoch = evt.SeedEpoch }
核心字段就是
SealTaskIgnore
以及 PC1 和 C1 需要的Ticket/Seed
随机数。从 SectorRecover 的 apply 函数可以看出给扇区推送了 SectorRecover 事件后, 状态会变成其 State 指定的值,通常这个状态通常是 Packing,也就是会从 AddPiece 重新开始,也就是我们通过SectorRecover
事件让扇区的恢复变成了一个普通的封装计算,之后的过程和前面描述的一致。响应 SealTaskIgnore 的设置:
前面两步提到过,我们会依据扇区所在的状态来初始化
SealTaskIgnore
并且重置扇区到 Packing 开始重新计算,那接下来肯定就是需要响应SealTaskIgnore
的设置了,也就是如果设置需要忽略 GetTicket, 就状态流转过程不一定不能再去执行 GetTicket 操作,这些需要修改lotus/extern/storage-sealing/states_sealing.go
中定义的各种 handleXXX 的实现,例如handleGetTicket
是 GetTicket 操作的具体的执行代码, 我们在函数最前面增加如下拦截判断 (注意看代码注释):func (m *Sealing) handleGetTicket(ctx statemachine.Context, sector SectorInfo) error { // 如果 SealTaskIgnore 设置需要忽略 TaskGetTicket 计算 // 那就直接返回旧的 TicketValue 和 TicketEpoch if (sector.SealTaskIgnore & TaskGetTicket) != 0 { log.Infof("Ignore handle %d.GetTicket", sector.SectorNumber) return ctx.Send(SectorTicket{ TicketValue: sector.TicketValue, TicketEpoch: sector.TicketEpoch, }) // 中间其他代码 ... return ctx.Send(SectorTicket{ TicketValue: ticketValue, TicketEpoch: ticketEpoch, }) }
将其他任务的 handleXXX 函数都增加类似的拦截和判断后,整个内部的恢复的计算流程就完整了,剩下的就是下面要描述的细节工作了。
# 5. 扇区恢复命令
内部的逻辑实现好了之后,剩下的就是提供一个 api 用于触发这个操作也就是 “向需要进行恢复的扇区推送一个 SectorRecover 事件”。 至于如何给添加一个 lotus api 和命令,我在之前的文章 Lotus 源码研究 04 - 小试牛刀 已经详细说明了,这里不再赘述。 这里我们重点描述 SealTaskIgnore 的定义以及 Ticket/Seed 的初始化。以下代码都是在 sectors recover 的命令行 api 里面判断的。
SealTaskIgnore
的初始化:上面提到过我们需要依据扇区的状态来判断需要忽略哪些计算,具体三种情况也在上面仔细的描述了,具体代码实现如下 (注意看注释):
var sectorsRecoverCmd = &cli.Command{ Name: "recover", Usage: "recover the specified sector", Description: `...`, ArgsUsage: "<sectorNum>", Flags: []cli.Flag{ ... }, Action: func(cctx *cli.Context) error { ... // 查询扇区的信息 sectorInfo, err = nodeApi.SectorsStatus(ctx, abi.SectorNumber(id), false) if err != nil { return err } // 获取扇区的分配状态 allocated, err := fullApi.StateMinerSectorAllocated(ctx, maddr, abi.SectorNumber(id), types.EmptyTSK) if err != nil { return err } // 第一种情况:扇区在 PreCommitSector 上链前 // 直接设置 SealTaskIgnore 为 SectorTaskNone,也就是不忽略任何任务 if !allocated { sealTaskIgnore = api.SectorTaskNone goto RequestRecover } // 第三种情况:已经完成了 CommitSector 上链 // 只需要完成 Packing/PreCommit1/PreCommit2/Finalize 计算 // 忽略如下指定的其他任务 if onChainInfo != nil { sealTaskIgnore |= api.SectorTaskGetTicket // ignore the getTicket sealTaskIgnore |= api.SectorTaskPreCommitSubmit // Ignore the PreCommitSubmit sealTaskIgnore |= api.SectorTaskWaitSeed // Ignore the WaitSeed sealTaskIgnore |= api.SectorTaskCommit // Ignore the Commit sealTaskIgnore |= api.SectorTaskCommitSubmit // Ignore the CommitSubmit goto RequestRecover } // 第二种情况:PreCommitSector 上链到 CommitSector 上链之间。 // 这里需要忽略 GetTicket/PreCommitSubmit 任务 if hasPreCommitInfo { sealTaskIgnore |= api.SectorTaskGetTicket sealTaskIgnore |= api.SectorTaskPreCommitSubmit goto RequestRecover } } }
Ticket/Seed
的初始化:如果 Miner 的元数据没有损坏,
Ticket/Seed
这两个值是可以直接拿到的,这个时候我们只需要简单的调用sectors recover <sectorId>
就好,上面的nodeApi.SectorsStatus
返回的SectorInfo
就包含了之前的Ticket/Seed
的值,我们直接使用即可,如果 Miner 的元数据损坏了,那就只能通过ticket-epoch
和precommit2-epoch
来分别来重新计算 Ticket 和 Seed 值了, 这两个值只能遍历区块数据才能得到 (区块浏览器可以看到),所以最好定期备份 Miner 的元数据,具体的获取代码如下:// 通过 ticketEpoch 重新计算 Ticket 随机数 rand, err := fullApi.StateGetRandomnessFromTickets(ctx, crypto.DomainSeparationTag_SealRandomness, ticketEpoch, buf.Bytes(), tipSet.Key()) if err != nil { return xerrors.Errorf("failed to get randomness from tickets: %w", err) } // 通过 Precommit2 上链的 epoch 来重新计算 Seed 随机数 rand, err := fullApi.StateGetRandomnessFromBeacon(ctx, crypto.DomainSeparationTag_InteractiveSealChallengeSeed, seedEpoch, buf.Bytes(), tipSet.Key()) if err != nil { return xerrors.Errorf("failed to get randomness from beacon: %w", err) }
整个扇区恢复的原理,设计以及实现就到此结束,有问题可以到我们的电报群 原语云 lotus 交流群 (opens new window) 交流探讨。
本站博文如非注明转载则均属作者原创文章,引用或转载无需申请版权或者注明出处,如需联系作者请加微信: geekmaster01