BaseMigration.ts 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185
  1. import { SubmittableResult } from '@polkadot/api'
  2. import { KeyringPair } from '@polkadot/keyring/types'
  3. import { QueryNodeApi } from './sumer-query-node/api'
  4. import { RuntimeApi } from '../RuntimeApi'
  5. import { Keyring } from '@polkadot/keyring'
  6. import { Logger } from 'winston'
  7. import path from 'path'
  8. import nodeCleanup from 'node-cleanup'
  9. import _ from 'lodash'
  10. import fs from 'fs'
  11. export type MigrationResult = {
  12. idsMap: Map<number, number>
  13. failedMigrations: number[]
  14. }
  15. export type MigrationStateJson = {
  16. idsMapEntries: [number, number][]
  17. failedMigrations: number[]
  18. }
  19. export type BaseMigrationConfig = {
  20. migrationStatePath: string
  21. sudoUri: string
  22. }
  23. export type BaseMigrationParams = {
  24. api: RuntimeApi
  25. queryNodeApi: QueryNodeApi
  26. config: BaseMigrationConfig
  27. }
  28. export abstract class BaseMigration {
  29. abstract readonly name: string
  30. protected api: RuntimeApi
  31. protected queryNodeApi: QueryNodeApi
  32. protected sudo!: KeyringPair
  33. protected config: BaseMigrationConfig
  34. protected failedMigrations: Set<number>
  35. protected idsMap: Map<number, number>
  36. protected pendingMigrationIteration: Promise<void> | undefined
  37. protected abstract logger: Logger
  38. public constructor({ api, queryNodeApi, config }: BaseMigrationParams) {
  39. this.api = api
  40. this.queryNodeApi = queryNodeApi
  41. this.config = config
  42. this.failedMigrations = new Set()
  43. this.idsMap = new Map()
  44. fs.mkdirSync(config.migrationStatePath, { recursive: true })
  45. }
  46. protected getMigrationStateFilePath(): string {
  47. const { migrationStatePath } = this.config
  48. return path.join(migrationStatePath, `${_.camelCase(this.name)}.json`)
  49. }
  50. public async init(): Promise<void> {
  51. this.loadMigrationState()
  52. nodeCleanup(this.onExit.bind(this))
  53. await this.loadSudoKey()
  54. }
  55. public abstract run(): Promise<MigrationResult>
  56. protected abstract migrateBatch(batch: { id: string }[]): Promise<void>
  57. protected getMigrationStateJson(): MigrationStateJson {
  58. return {
  59. idsMapEntries: Array.from(this.idsMap.entries()),
  60. failedMigrations: Array.from(this.failedMigrations),
  61. }
  62. }
  63. protected loadMigrationState(): void {
  64. const stateFilePath = this.getMigrationStateFilePath()
  65. if (fs.existsSync(stateFilePath)) {
  66. const migrationStateJson = fs.readFileSync(stateFilePath).toString()
  67. const migrationState: MigrationStateJson = JSON.parse(migrationStateJson)
  68. this.idsMap = new Map(migrationState.idsMapEntries)
  69. }
  70. }
  71. protected onExit(exitCode: number | null, signal: string | null): void | false {
  72. nodeCleanup.uninstall() // don't call cleanup handler again
  73. this.logger.info('Exitting...')
  74. if (signal && this.pendingMigrationIteration) {
  75. this.logger.info('Waiting for currently pending migration iteration to finalize...')
  76. this.pendingMigrationIteration.then(() => {
  77. this.saveMigrationState()
  78. this.logger.info('Done.')
  79. process.kill(process.pid, signal)
  80. })
  81. return false
  82. } else {
  83. this.saveMigrationState()
  84. this.logger.info('Done.')
  85. }
  86. }
  87. protected saveMigrationState(): void {
  88. this.logger.info('Saving migration state...')
  89. const stateFilePath = this.getMigrationStateFilePath()
  90. const migrationState = this.getMigrationStateJson()
  91. fs.writeFileSync(stateFilePath, JSON.stringify(migrationState, undefined, 2))
  92. }
  93. private async loadSudoKey() {
  94. const { sudoUri } = this.config
  95. const keyring = new Keyring({ type: 'sr25519' })
  96. this.sudo = keyring.createFromUri(sudoUri)
  97. const sudoKey = await this.api.query.sudo.key()
  98. if (sudoKey.toString() !== this.sudo.address) {
  99. throw new Error(`Invalid sudo key! Expected: ${sudoKey.toString()}, Got: ${this.sudo.address}`)
  100. }
  101. }
  102. protected async executeBatchMigration<T extends { id: string }>(batch: T[]): Promise<void> {
  103. this.pendingMigrationIteration = this.migrateBatch(batch)
  104. await this.pendingMigrationIteration
  105. this.pendingMigrationIteration = undefined
  106. }
  107. /**
  108. * Extract failed migrations (entity ids) from batch transaction result.
  109. * Assumptions:
  110. * - Each entity is migrated with a constant number of calls (2 by default: balnces.transferKeepAlive and sudo.sudoAs)
  111. * - Ordering of the entities in the `batch` array matches the ordering of the batched calls through which they are migrated
  112. * - Last call for each entity is always sudo.sudoAs
  113. * - There is only one sudo.sudoAs call per entity
  114. *
  115. * Entity migration is considered failed if sudo.sudoAs call failed or was not executed at all, regardless of
  116. * the result of any of the previous calls associated with that entity migration.
  117. * (This means that regardless of whether balnces.transferKeepAlive failed and interrupted the batch or balnces.transferKeepAlive
  118. * succeeded, but sudo.sudoAs failed - in both cases the migration is considered failed and should be fully re-executed on
  119. * the next script run)
  120. */
  121. protected extractFailedMigrations<T extends { id: string }>(
  122. result: SubmittableResult,
  123. batch: T[],
  124. callsPerEntity = 2
  125. ): void {
  126. const { api } = this
  127. const batchInterruptedEvent = api.findEvent(result, 'utility', 'BatchInterrupted')
  128. const sudoAsDoneEvents = api.findEvents(result, 'sudo', 'SudoAsDone')
  129. const numberOfSuccesfulCalls = batchInterruptedEvent
  130. ? batchInterruptedEvent.data[0].toNumber()
  131. : callsPerEntity * batch.length
  132. const numberOfMigratedEntites = Math.floor(numberOfSuccesfulCalls / callsPerEntity)
  133. if (sudoAsDoneEvents.length !== numberOfMigratedEntites) {
  134. throw new Error(
  135. `Unexpected number of SudoAsDone events (expected: ${numberOfMigratedEntites}, got: ${sudoAsDoneEvents.length})! ` +
  136. `Could not extract failed migrations from: ${JSON.stringify(result.toHuman())}`
  137. )
  138. }
  139. const failedIds: number[] = []
  140. batch.forEach((entity, i) => {
  141. const entityId = parseInt(entity.id)
  142. if (i >= numberOfMigratedEntites || sudoAsDoneEvents[i].data[0].isFalse) {
  143. failedIds.push(entityId)
  144. this.failedMigrations.add(entityId)
  145. }
  146. })
  147. if (batchInterruptedEvent) {
  148. this.logger.error(
  149. `Batch interrupted at call ${numberOfSuccesfulCalls}: ${this.api.formatDispatchError(
  150. batchInterruptedEvent.data[1]
  151. )}`
  152. )
  153. }
  154. if (failedIds.length) {
  155. this.logger.error(`Failed to migrate:`, { failedIds })
  156. }
  157. }
  158. public getResult(): MigrationResult {
  159. const { idsMap, failedMigrations } = this
  160. return {
  161. idsMap: new Map(idsMap.entries()),
  162. failedMigrations: Array.from(failedMigrations),
  163. }
  164. }
  165. }