run.go 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495
  1. package migrate
  2. import (
  3. "git.ycl.pub/yl32940/TsDB2/DB/core"
  4. "git.ycl.pub/yl32940/TsDB2/DB/core/page"
  5. "git.ycl.pub/yl32940/TsDB2/DB/core/types"
  6. "errors"
  7. "fmt"
  8. "strings"
  9. "time"
  10. )
  11. // errStop 内部哨兵: 回调拿它请求"提前结束扫描"(Limit 生效), 由 Run 吞掉,
  12. // 不会当成失败往外抛。
  13. var errStop = errors.New("migrate: 提前结束扫描")
  14. // Committer 目标端可选实现: 每张表写完调用一次, 让引擎把这一批落盘。
  15. // VRTX 实现了它 (千万行的库不能等最后 Close 才提交)。
  16. type Committer interface {
  17. Commit() error
  18. }
  19. // Run 把 src 的表结构 + 数据 + 索引 + 主键搬到 dst。
  20. //
  21. // VRTX → SQLite / MySQL 导出
  22. // SQLite / MySQL → VRTX 导入
  23. // VRTX → VRTX 整库拷贝 / 压实
  24. //
  25. // 任意方向共用这一条路径: src.Schema 给出中间表示, src.Scan 推流, dst.CreateTable
  26. // 建结构 (含索引/主键), dst.Open 拿批量写入器。opts 传 nil 等价于 DefaultOptions()。
  27. //
  28. // 出错时返回的部分报告仍可用 (已完成的表照常记着), 方便断点续迁 —— 重跑时带
  29. // 同样的 opts 但 Recreate=false, 已迁完的表会走"复用已有表 + 追加", 会重复插数据,
  30. // 所以务实用 Tables 只点名没迁完的表。
  31. //
  32. // 开关 ContinueOnError 决定"一张表出问题要不要连坐整趟": 打开时失败的表记进
  33. // 报告的 TableReport.Error 并继续迁后面的表, 最后 Run 用聚合错误返回失败清单;
  34. // 关闭时(默认)第一个错误就中止 —— 中止的那张表同样带着 Error, 只是不再往下走。
  35. func Run(src Source, dst Sink, opts *Options) (*Report, error) {
  36. if src == nil || dst == nil {
  37. return nil, errors.New("migrate: 源与目标都需要提供")
  38. }
  39. o := opts.normalized()
  40. if src.Identity() == dst.Identity() {
  41. return nil, fmt.Errorf("migrate: 源与目标是同一个库 (%s), 拒绝自我迁移", src.Identity())
  42. }
  43. started := time.Now()
  44. rep := &Report{From: src.Kind(), To: dst.Kind()}
  45. defer func() { rep.Elapsed = time.Since(started) }()
  46. plan, skipped, err := planTables(src, o)
  47. if err != nil {
  48. return rep, err
  49. }
  50. rep.Tables = append(rep.Tables, skipped...)
  51. if len(plan) == 0 && len(skipped) == 0 {
  52. return rep, nil
  53. }
  54. var failed []error
  55. for i, name := range plan {
  56. // 先报"开始": 表一多、或头一张就是大表时, 只有"完成"行的日志会让人
  57. // 以为卡住了, 分不清是在跑还是死锁。
  58. o.vlogf("[migrate] 表 %s (%d/%d): 开始", name, i+1, len(plan))
  59. tr, err := runTable(src, dst, name, o)
  60. if err != nil {
  61. // 原因先落进报告再说 —— 跳过还是中止都是后话, 但"为什么失败"丢了就
  62. // 只剩一句"失败", 界面和报告都无从解释。
  63. tr.Error = err.Error()
  64. }
  65. if err != nil && o.ContinueOnError {
  66. // 单表失败不拖垮整趟: 记进报告接着迁后面的表, 全部跑完再统一报错。
  67. failed = append(failed, err)
  68. o.logf("[migrate] 表 %s 失败, 跳过继续: %v", name, err)
  69. }
  70. rep.Tables = append(rep.Tables, tr)
  71. rep.Rows += tr.Rows
  72. if err != nil && !o.ContinueOnError {
  73. return rep, err
  74. }
  75. }
  76. if len(failed) > 0 {
  77. return rep, fmt.Errorf("有 %d 张表没能迁完 (其余已照常迁完): %w", len(failed), errors.Join(failed...))
  78. }
  79. return rep, nil
  80. }
  81. // planTables 决定迁哪些表。返回 (待迁清单, 直接标记为跳过的报告)。
  82. func planTables(src Source, o *Options) ([]string, []TableReport, error) {
  83. names, err := src.Tables()
  84. if err != nil {
  85. return nil, nil, fmt.Errorf("列出源库的表: %w", err)
  86. }
  87. avail := make(map[string]bool, len(names))
  88. for _, n := range names {
  89. avail[n] = true
  90. }
  91. var (
  92. plan []string
  93. skipped []TableReport
  94. )
  95. if len(o.Tables) == 0 {
  96. for _, n := range names {
  97. // 保真表是迁移的"随身行李", 不是业务表: 不带 __vrtx_meta 去 VRTX,
  98. // 除非调用方点名要它。
  99. if n == MetaTable {
  100. continue
  101. }
  102. plan = append(plan, n)
  103. }
  104. return plan, nil, nil
  105. }
  106. for _, want := range o.Tables {
  107. if !avail[want] {
  108. skipped = append(skipped, TableReport{Table: want, Skipped: true, Note: "源库没有这张表"})
  109. continue
  110. }
  111. plan = append(plan, want)
  112. }
  113. return plan, skipped, nil
  114. }
  115. // progressEvery 进行中进度的上报间隔: 太密会把日志刷没, 太疏又看不出"在动"。
  116. const progressEvery = 2 * time.Second
  117. // abortWriter 出错时收掉写入器。
  118. //
  119. // 走 Discarder 而不是 Close: Close 是【成功语义】(SQL 侧 flush 剩余缓冲 + Commit),
  120. // 用它收尾等于把缓冲区里那批行当成功提交。扫描失败 / flush 失败的调用方都带着错误
  121. // 返回, 库里却躺了半张表 —— 这种"报了错但还是写进去了"最难查。
  122. // 没实现 Discarder 的写入器 (VRTX) 退回 Close: 它的 Close 不提交, 提交由 Committer
  123. // 在整表成功后单独调用, 因此没有这个问题。
  124. func abortWriter(w RowWriter) {
  125. if d, ok := w.(Discarder); ok {
  126. _ = d.Discard()
  127. return
  128. }
  129. _ = w.Close()
  130. }
  131. // runTable 迁一张表: 读结构 → (必要时) 建表 → 推流写入 → 提交。
  132. // 第二个返回值是这张表的报告 (即使出错也带着已写行数, 便于定位)。
  133. func runTable(src Source, dst Sink, name string, o *Options) (TableReport, error) {
  134. begin := time.Now()
  135. tr := TableReport{Table: name}
  136. sc, err := src.Schema(name)
  137. if err != nil {
  138. return tr, fmt.Errorf("读表 %s 的结构: %w", name, err)
  139. }
  140. sc = sc.Clone() // 迁移会往 schema 里补列, 不能污染源端那份
  141. // TEXT 采样定宽 (见 textwidth.go): 建目标表之前把"源声明 TEXT 家族"的动态列按
  142. // 源端采样的实际最大长度改落成定长档。先于 prepareForVRTX / fitRowWidthForVRTX:
  143. // 退化主键列要不要补索引、行宽闸门要不要把采样得过宽的列降回动态列, 都该看到
  144. // 采样后的最终列宽。DryRun 不碰数据, 连采样也不做。
  145. textWidthNote := ""
  146. if dst.Kind() == KindVRTX && o.TextWidthScan && !o.DryRun {
  147. textWidthNote = scanTextWidths(src, sc, o)
  148. }
  149. if dst.Kind() == KindVRTX {
  150. if err := prepareForVRTX(sc, o); err != nil {
  151. return tr, err
  152. }
  153. // 结构闸门必须在建表【之前】: 源库的结构能力比 VRTX 宽 (MySQL 1017 列 / 表名 64 字节 /
  154. // 复合主键…), 不先查一遍就会在建表时才炸, 报错还看不出是"源库比目标库宽"
  155. // (见 validateForVRTX)。放在 prepareForVRTX 之后是因为补出的 _id 列也算列数。
  156. if err := validateForVRTX(sc); err != nil {
  157. return tr, err
  158. }
  159. // 行宽闸门 (同样必须在建表之前, 且要在列清单定形之后): 源库那张表可能宽到 VRTX
  160. // 一页装不下两行 —— 那是建出来之后【每一行】都写不进去的死局, 不能等写数据才发现。
  161. // 能自动救的就地救 (宽度 > 256 字节的定长字符串列 → 动态列), 救不了才拒绝,
  162. // 见 fitRowWidthForVRTX 的文件头。
  163. demoted, err := fitRowWidthForVRTX(sc)
  164. if err != nil {
  165. return tr, err
  166. }
  167. if demoted > 0 {
  168. o.vlogf("[migrate] 表 %s 行宽超限: 已把 %d 个宽列降为动态列", sc.Name, demoted)
  169. }
  170. }
  171. tr.Columns = len(sc.Columns)
  172. tr.Indexes = len(sc.Indexes)
  173. if sc.PKColumn != "" {
  174. tr.Indexes++ // 主键索引也是一条索引
  175. }
  176. if o.DryRun {
  177. tr.Note = "试跑"
  178. o.vlogf("[migrate] %s → %s 表 %s: %d 列 %d 索引",
  179. src.Kind(), dst.Kind(), name, tr.Columns, tr.Indexes)
  180. tr.Elapsed = time.Since(begin)
  181. return tr, nil
  182. }
  183. exists, err := dst.Exists(name)
  184. if err != nil {
  185. return tr, fmt.Errorf("检查目标表 %s: %w", name, err)
  186. }
  187. if !exists && !o.Create {
  188. return tr, fmt.Errorf("目标库没有表 %s, 且未开启建表 (Options.Create)", name)
  189. }
  190. if exists && o.Recreate {
  191. o.vlogf("[migrate] 目标表 %s 已存在, 按 Recreate 重建", name)
  192. }
  193. if err := dst.CreateTable(sc, o.Recreate); err != nil {
  194. return tr, err
  195. }
  196. if len(sc.SkippedIndexes) > 0 {
  197. // 跳过的索引必须报出来: 静默丢索引等于悄悄改了结构。
  198. tr.Note = appendNote(tr.Note, "跳过索引 "+strings.Join(sc.SkippedIndexes, "; "))
  199. }
  200. if sc.PKNote != "" {
  201. // 源表的主键没能原样承载 (复合主键)。这是"结构变了", 比丢一条索引更要紧,
  202. // 单独报一句而不是塞进别的列表里。
  203. tr.Note = appendNote(tr.Note, sc.PKNote)
  204. }
  205. if len(sc.DegradedColumns) > 0 {
  206. // 类型从定点退成浮点、主键降级成普通列这类: 类型都换了, 存进去的东西语义不同。
  207. // 与容量封顶分开报 —— 那个是"上限变小, 类型没变"。
  208. tr.Note = appendNote(tr.Note, "降级 "+strings.Join(sc.DegradedColumns, "; "))
  209. }
  210. if len(sc.CappedColumns) > 0 {
  211. // 同理: 源库声明了 60000 字节的列到这里只能装 types.MaxStringSize, 不报等于
  212. // 悄悄把容量改小。超限只影响"这一列的上限比声明的小", 数据照迁 (真有大值会在
  213. // 写入时报出列名), 所以是报告而不是拒绝整张表。
  214. tr.Note = appendNote(tr.Note, "容量封顶 "+strings.Join(sc.CappedColumns, "; "))
  215. }
  216. if textWidthNote != "" {
  217. // TEXT 采样定宽的结果 (哪些列定到了哪一档 / 哪些列超宽保持动态): 定宽会约束
  218. // 未来写入, 必须让用户在报告里看得见。
  219. tr.Note = appendNote(tr.Note, textWidthNote)
  220. }
  221. if o.SchemaOnly {
  222. // 两阶段迁移的第一步: 到这儿结构就算立好了, 数据一行不碰。
  223. tr.Note = appendNote(tr.Note, "仅结构")
  224. tr.Elapsed = time.Since(begin)
  225. o.vlogf("[migrate] %s 表 %s: 结构就绪 (%d 列 %d 索引)",
  226. dst.Kind(), name, tr.Columns, tr.Indexes)
  227. return tr, nil
  228. }
  229. writer, err := dst.Open(name, sc)
  230. if err != nil {
  231. return tr, err
  232. }
  233. var (
  234. written uint64
  235. batch = make([]Row, 0, o.Batch)
  236. lastRep = time.Now()
  237. )
  238. flush := func() error {
  239. if len(batch) == 0 {
  240. return nil
  241. }
  242. if err := writer.Write(batch); err != nil {
  243. return fmt.Errorf("写入表 %s: %w", name, err)
  244. }
  245. written += uint64(len(batch))
  246. batch = batch[:0]
  247. if time.Since(lastRep) >= progressEvery {
  248. lastRep = time.Now()
  249. o.progress(name, written, time.Since(begin))
  250. }
  251. return nil
  252. }
  253. scanErr := src.Scan(name, sc, func(r Row) error {
  254. if o.Limit > 0 && written+uint64(len(batch)) >= o.Limit {
  255. return errStop
  256. }
  257. batch = append(batch, r)
  258. if o.EachRow != nil {
  259. o.EachRow(name, written+uint64(len(batch)), r)
  260. }
  261. if len(batch) >= o.Batch {
  262. return flush()
  263. }
  264. return nil
  265. })
  266. if scanErr != nil && !errors.Is(scanErr, errStop) {
  267. abortWriter(writer)
  268. tr.Rows = written
  269. return tr, fmt.Errorf("扫描表 %s: %w", name, scanErr)
  270. }
  271. if err := flush(); err != nil {
  272. abortWriter(writer)
  273. tr.Rows = written
  274. return tr, err
  275. }
  276. if err := writer.Close(); err != nil {
  277. tr.Rows = written
  278. return tr, fmt.Errorf("收尾表 %s: %w", name, err)
  279. }
  280. // 每张表落一次盘: 中途出错前面迁完的不会白费。
  281. if c, ok := dst.(Committer); ok {
  282. if err := c.Commit(); err != nil {
  283. tr.Rows = written
  284. return tr, fmt.Errorf("提交表 %s: %w", name, err)
  285. }
  286. }
  287. tr.Rows = written
  288. tr.Elapsed = time.Since(begin)
  289. if o.Limit > 0 && written >= o.Limit {
  290. tr.Note = appendNote(tr.Note, fmt.Sprintf("截断到 %d 行", o.Limit))
  291. }
  292. o.vlogf("[migrate] %s → %s 表 %s: %d 行 %d 列 %d 索引 (%s)",
  293. src.Kind(), dst.Kind(), name, tr.Rows, tr.Columns, tr.Indexes, tr.Elapsed.Round(time.Millisecond))
  294. return tr, nil
  295. }
  296. // appendNote 往报告备注里续一条 (分号分隔), 已有内容时追加而不是覆盖。
  297. func appendNote(note, add string) string {
  298. if note == "" {
  299. return add
  300. }
  301. return note + "; " + add
  302. }
  303. // prepareForVRTX 把一张(可能来自 SQL 的) schema 收拾成 VRTX 能建表的样子。
  304. //
  305. // VRTX 的行数据寄存在"行存储索引"(主键/时序)的条目里, 而这个索引的键必须能承载
  306. // 行号 —— 落到 SQL 上就是"主键只能是 8 字节整数列"。所以:
  307. // - 行存储索引的键列已由 __vrtx_meta 指明 (时序表) → 不用动;
  308. // - 主键已经是 uint64 列 → 什么都不用做;
  309. // - 主键是字符串/其它宽度 → 补一个 _id 列承载行号, 原主键列退化成普通列
  310. // (顺手给它建个 B+ 树索引, 找回"按原主键查"的能力);
  311. // - 压根没有主键 → 只有 AutoPK 打开才补 _id, 否则报错 (VRTX 不接受没有行主键的表)。
  312. //
  313. // 后两条都是【改结构】, 所以会往 sc.DegradedColumns 记一笔由 runTable 报给用户;
  314. // 复合主键 (源表有主键但承载不了) 则记在 sc.PKNote 上, 报错时也用它说准话。
  315. func prepareForVRTX(sc *TableSchema, o *Options) error {
  316. if len(sc.RowStoreColumns) > 0 {
  317. // 时序表: 行存储索引建在时间列上 (RowStore), 行号由引擎分配, 不占列。
  318. return nil
  319. }
  320. if sc.PKColumn != "" {
  321. // 已有的主键列若是 8 字节整数, 钉到 uint64 档即可直接承载行号 (SQL 的整数会先被
  322. // 收编到有符号的 Int64 档, 见 rowStorePKType); 窄列/非整数列才需要补 _id。
  323. if pinRowStorePK(sc, sc.PKColumn) {
  324. return nil
  325. }
  326. } else if !o.AutoPK {
  327. if sc.PKNote != "" {
  328. // 源表【有】主键, 只是多列 —— 报"没有主键"是假消息, 用户会去源库找不存在的问题。
  329. return fmt.Errorf("表 %s: %s —— 请改用单列 8 字节整数主键, "+
  330. "或开 AutoPK 让它自动补 %s 列", sc.Name, sc.PKNote, "_id")
  331. }
  332. return fmt.Errorf("表 %s 没有主键, 目标 VRTX 的表必须有行主键列 —— "+
  333. "请给源表加 8 字节整数主键, 或开 AutoPK 让它自动补 %s 列", sc.Name, "_id")
  334. }
  335. origPK := sc.PKColumn
  336. idName := freeColumnName(sc, "_id")
  337. sc.Columns = append(sc.Columns, Column{Name: idName, Type: types.FieldType_Uint64})
  338. sc.MarkSynthetic(idName)
  339. sc.PKColumn = idName
  340. sc.PKIndexType = page.IndexTypePrimary
  341. sc.PKIndexName = "" // 让 VRTX 侧按默认 pk_<表名> 命名
  342. if origPK == "" {
  343. return nil
  344. }
  345. // 原主键列不再是行主键, 但"按它查"是源库的既有用法, 用二级索引续上。
  346. ensureColumnIndex(sc, origPK)
  347. // 记下源库的主键列名: 行号已经换成引擎分配的 _id, 只有这个列名能把写失败的行报回源库
  348. // 认得的身份 (见 vrtxWriter.rowTag 与 TableSchema.SourcePKColumn)。
  349. sc.SourcePKColumn = origPK
  350. // 这一步是【约束降级】: 源库的主键唯一性由主键约束强制, 而补出来的二级索引不是唯一
  351. // 索引。静默丢掉一个约束比报错更糟 —— 用户会以为主键还在, 继续依赖它。
  352. sc.DegradedColumns = append(sc.DegradedColumns, fmt.Sprintf(
  353. "主键 %s 承载不了行号 (不是 8 字节整数列): 已改用引擎分配的 %s 列, "+
  354. "原主键列降为普通列 + 普通索引 (源库的主键唯一性不再强制)", origPK, idName))
  355. return nil
  356. }
  357. // ensureColumnIndex 给某一列补一个 B+ 树二级索引 (已有同列索引则跳过)。
  358. //
  359. // 主键列退化成普通列时用它兜底: 少了主键的"按列查"能力, 至少让查询还能走索引。
  360. //
  361. // 动态列 / blob 列不补: 它们的行内只有引用 (堆页号 / 链引用), 按值查不到, 建了也会被
  362. // core.CreateIndex 拒 (ErrIndexDynamicField / ErrIndexBlobField) —— 与其让整张表的迁移
  363. // 因此失败, 不如干脆不补这一个索引。
  364. func ensureColumnIndex(s *TableSchema, col string) {
  365. if i, ok := s.ColumnIdx(col); ok {
  366. if c := s.Columns[i]; c.IsDynamic() || c.Type == types.FieldType_Blob {
  367. return
  368. }
  369. }
  370. for _, idx := range s.Indexes {
  371. if idx.SameColumns([]string{col}) {
  372. return
  373. }
  374. }
  375. s.Indexes = append(s.Indexes, IndexSchema{
  376. Name: indexNameFor(s.Name, col),
  377. Type: page.IndexTypeBTree,
  378. Columns: []string{col},
  379. })
  380. }
  381. // indexNameFor 给"自动补出来的索引"起个不会超长的名字。
  382. // VRTX 索引名有长度上限, 表名本身就可能占掉大半, 因此超长时退化成短名。
  383. func indexNameFor(table, col string) string {
  384. name := "idx_" + table + "_" + col
  385. if len(name) <= core.IndexNameSize {
  386. return name
  387. }
  388. // 超长时退化成短名: 保住"能建出来"比名字好看重要。
  389. sum := uint64(1469598103934665603) // FNV-1a 偏移
  390. for i := 0; i < len(name); i++ {
  391. sum ^= uint64(name[i])
  392. sum *= 1099511628211
  393. }
  394. short := fmt.Sprintf("idx_%06x", sum&0xFFFFFF)
  395. if len(short) <= core.IndexNameSize {
  396. return short
  397. }
  398. return short[:core.IndexNameSize]
  399. }
  400. // ============================ 便捷入口 ============================
  401. // ParseDSN 把命令行参数翻译成一个 Endpoint (既能当源也能当目标)。
  402. //
  403. // 前缀决定库的种类, 后面整段就是它的标识:
  404. //
  405. // vrtx:/data/app.db
  406. // sqlite:/data/app.db 也可以是 file:... URI
  407. // mysql:root:pw@tcp(127.0.0.1:3306)/db?charset=utf8mb4
  408. //
  409. // 返回的对象已经打开, 调用方负责 Close。
  410. func ParseDSN(dsn string, opts *Options) (*Endpoint, error) {
  411. kind, rest, ok := strings.Cut(dsn, ":")
  412. if !ok {
  413. return nil, fmt.Errorf("DSN %q 缺少前缀, 应形如 vrtx:/path 或 sqlite:/path 或 mysql:user:pw@tcp(host)/db", dsn)
  414. }
  415. switch Kind(strings.ToLower(kind)) {
  416. case KindVRTX:
  417. v, err := OpenVRTX(rest, opts)
  418. if err != nil {
  419. return nil, err
  420. }
  421. return &Endpoint{Kind: KindVRTX, Src: v, Dst: v}, nil
  422. case KindSQLite:
  423. s, err := OpenSQLite(rest, opts)
  424. if err != nil {
  425. return nil, err
  426. }
  427. return &Endpoint{Kind: KindSQLite, Src: s, Dst: s}, nil
  428. case KindMySQL:
  429. s, err := OpenMySQL(rest, opts)
  430. if err != nil {
  431. return nil, err
  432. }
  433. return &Endpoint{Kind: KindMySQL, Src: s, Dst: s}, nil
  434. }
  435. return nil, fmt.Errorf("DSN %q 的前缀 %q 不是 vrtx/sqlite/mysql", dsn, kind)
  436. }
  437. // Endpoint 解析出来的一个库: 同时能当源和目标用。
  438. // 两个字段指向同一个底层对象, 只是按接口名区分, 让调用点读起来清楚。
  439. type Endpoint struct {
  440. Kind Kind
  441. Src Source
  442. Dst Sink
  443. }
  444. // Identity 该库的唯一标识 (路径 / DSN)。
  445. func (e *Endpoint) Identity() string {
  446. if e.Src != nil {
  447. return e.Src.Identity()
  448. }
  449. if e.Dst != nil {
  450. return e.Dst.Identity()
  451. }
  452. return ""
  453. }
  454. // Close 关掉底层连接。
  455. func (e *Endpoint) Close() error {
  456. if e.Dst != nil {
  457. return e.Dst.Close()
  458. }
  459. if e.Src != nil {
  460. return e.Src.Close()
  461. }
  462. return nil
  463. }