Оновлення програми на Solana не змінює дані в існуючих рахунках автоматично. Код оновлюється, а стан — ні. Тому будь-яка зміна структури рахунку (додавання полів, зміна типів, переупорядкування байтів) вимагає свідомої міграції. Нижче — перевірені стратегії, патерни сумісності, порядок тестування на клоні mainnet та процедури відкату.
Стратегії міграції: on-chain та off-chain
Обидва підходи вирішують одне завдання — перетворити старий формат даних на новий. Різниця в тому, де виконується логіка перетворення і хто несе витрати.
On-chain міграція
Нова версія програми містить спеціальну інструкцію (наприклад, migrate_account). Ця інструкція читає стару структуру, валідує її, перетворює на нову і записує назад у той самий рахунок. Якщо новий формат більший за старий — викликається realloc для збільшення розміру рахунку, а викликавець сплачує різницю.
Коли обрати:
- Кількість рахунків обмежена (сотні або низка тисяч).
- Міграція має бути атомарною — або рахунок оновлено повністю, або взагалі.
- Кожен користувач може ініціювати міграцію власного рахунку (self-service).
- Потрібна повна перевіреність на ланцюжку — аудитор бачить логіку в байткоді.
Обмеження: інструкція міграції збільшує розмір бінарника програми. Якщо міграційна логіка складна, це може наблизити вас до ліміту розміру програми. Також кожен виклик споживає compute units і, за потреби, SOL на realloc.
Off-chain міграція
Скрипт (TypeScript, Python, Rust) підключається до RPC-вузла, зчитує рахунки через getProgramAccounts або getAccountsByOwner, формує транзакції з інструкцією міграції (або прямим записом, якщо є повноваження) і відправляє їх. Процес можна пакетувати, обмежувати за швидкістю, зупиняти й відновлювати.
Коли обрати:
- Тисячі або десятки тисяч рахунків — on-chain міграція кожного окремо непрактична.
- Авторитет над рахунками належить одному ключу або обмеженому набору ключів.
- Потрібен тонкий контроль над швидкістю відправки транзакцій, щоб не перевантажувати RPC і не створювати гонитву за слотами.
- Міграцію треба виконати у вікно низької активності мережі.
Обмеження: процес неатомарний. Між початком і завершенням міграції частина рахунків буде в старому форматі, а частина — в новому. Програма повинна вміти працювати з обома форматами (про це — у наступному розділі). Також існує ризик гонитви: якщо користувач взаємодіє зі старим рахунком під час міграції, транзакція може провалитися або спричинити неконсистентний стан.
Критерій вибору
| Фактор | On-chain | Off-chain |
|---|---|---|
| Кількість рахунків | До кількох тисяч | Тисячі й більше |
| Атомарність | Повна | Відсутня |
| Хто платить за realloc | Викликавець інструкції | Власник міграційного ключа |
| Залежність від RPC | Немає | Критична |
| Складність відладки | Вища (логи транзакцій) | Нижча (локальні логи скрипта) |
Збереження зворотної сумісності
Якщо міграція не миттєва (а off-chain міграція ніколи не миттєва), нова програма зустрінеться з рахунками обох форматів. Зворотна сумісність — це не бажана властивість, а інженерна вимога.
Поле версії в рахунку
Найнадійніший патерн: перший байт або окреме поле version: u8 вказує на формат решти даних. Програма читає версію і диспетчеризує десеріалізацію відповідно. Це дозволяє одному бінарнику обробляти будь-яку кількість форматів.
Важливо: якщо старий формат не мав поля версії, ви не зможете відрізнити його від нового за вмістом. У такому разі доведеться спиратися на зовнішні ознаки (наприклад, розмір рахунку) або мігрувати всі рахунки до оновлення програми — що зводить нанівець ідею поступової міграції.
Десеріалізація з запасом
Якщо зміна полягає в додаванні полів у кінець структури, а старі дані просто не містять цих байтів, можна реалізувати десеріалізацію, яка заповнює нові поля значеннями за замовчуванням, коли байтів не вистачає. Це працює для Borsh-серіалізації, але не для zero-copy (Pod/bytemuck), де відступ від початку буфера жорстко прив'язаний до поля.
Обгортка (wrapper)
Новий формат містить старий як вкладену структуру плюс додаткові поля. Це зручно, коли стару логіку треба зберегти для зворотної сумісності, а нову — додати паралельно.
Жорсткі обмеження Solana
- Насіння PDA не можна змінювати. Адреса рахунка визначається насінням. Якщо ви зміните насіння, отримаєте іншу адресу — це не міграція, а створення нового рахунка з копією даних.
- Розмір рахунка можна лише збільшувати. Runtime Solana не дозволяє зменшити розмір через realloc. Якщо новий формат менший за старий, розмір залишиться старим — це нормально, але варто враховувати при розрахунку rent-exempt.
- Власник рахунка — це адреса програми. Після оновлення програми власник не змінюється (адреса програми стала), тому новий код має повний доступ до старих даних.
Коли можна не зберігати зворотну сумісність
Тільки за одночасної виконання трьох умов: ви контролюєте всі рахунки, міграція виконується до розгортання нової програми (off-chain скрипт мігрує дані, потім ви оновлюєте програму), і ви можете гарантувати відсутність транзакцій під час вікна міграції. На практиці це можливо лише для внутрішніх інструментів або тестових мереж.
Тестування міграції на клоні mainnet
Міграцію в production не можна тестувати вперше на production. Єдиний надійний спосіб — клонувати стан mainnet і програти міграцію локально.
Підготовка середовища
- Отримайте знімок mainnet. Використовуйте solana-test-validator з прапорцем --clone для конкретних рахунків або завантажте повний знімок ledger (перевірте актуальність інструментів знімків у офіційній документації Solana, оскільки формат і процедури змінюються між релізами).
- Запустіть локальний валідатор. Вкажіть знімок і, за потреби, обмежте набір рахунків, щоб зменшити час завантаження.
- Збережіть поточний бінарник програми. Перед оновленням обов'язково експортуйте поточну версію — вона знадобиться для відкату.
- Зберіть нову версію програми. Режим release, без логів відладки. Точно та сама збірка, яку плануєте деплоїти.
Виконання міграції
- Оновіть програму на локальному валідаторі (використовуйте тестовий upgrade authority).
- Запустіть міграційний скрипт або викличте on-chain міграцію для кожного цільового рахунка.
- Фіксуйте результат кожної транзакції: успіх, помилка, кількість спожитих compute units, зміна розміру рахунка.
Що перевіряти
- Повнота: чи знайдено всі цільові рахунки. Рахунки, які не відповідають фільтру (закриті, неправильний власник, нульовий розмір даних), мають бути логовані окремо.
- Цілісність даних: порівняйте значення полів до і після міграції. Для критичних полів — байт-за-байтом.
- Розмір рахунка: перевірте, що realloc відбувся коректно і рахунок залишається rent-exempt.
- Функціональність: після міграції викличте основні інструкції програми з мігрованими рахунками і переконайтеся, що вони працюють.
- Edge cases: порожні рахунки (дані — нульові байти), рахунки максимального розміру (де realloc може перевищити ліміт), рахунки з нестандартним станом, який міг виникнути через попередні баги.
- Compute budget: чи вистачає стандартного ліміту compute units для міграційної інструкції. Якщо ні — розрахуйте точне значення для compute_budget у production.
Роллбек міграції при помилках
Роллбек на Solana має фундаментальне обмеження: оновлення програми є одностороннім. Ви не можете «відкотити» до попереднього бінарника через спеціальну інструкцію. Але ви можете повторно оновити програму, завантаживши старий бінарник — за умови, що ви його зберегли.
Сценарій 1: програма оновлена, міграція не розпочата
Найпростіший випадок. Якщо нова програма зберігає зворотну сумісність і коректно читає старий формат — нічого робити не треба, система працює. Якщо нова програма не читає старий формат (помилка дизайну), повторно оновіть програму збереженим старим бінарником. Дані не пошкоджені.
Сценарій 2: міграція частково виконана
Частина рахунків мігрована, частина — ні. Тут критично, чи є в рахунках поле версії.
- Поле версії є: повторно оновіть програму на версію, яка розуміє обидва формати (це може бути як старий бінарник із доданою підтримкою читання нового формату, так і новий бінарник із фіксованою міграційною логікою). Система продовжує працювати, міграцію можна відновити пізніше.
- Поля версії немає: старий бінарник не зможе десеріалізувати мігровані рахунки. Це критична ситуація. Вихід — розгорнути проміжну версію програми, яка розпізнає обидва формати за вторинними ознаками (розмір, магічні байти) і може перетворити новий формат назад на старий. Якщо такої версії не існує — дані мігрованих рахунків втрачено для старого коду.
Двофазна міграція як засіб захисту
Найбезпечніший патерн, який усуває більшість ризиків відкату:
- Фаза 1 — підготовка. Оновіть програму на версію, яка: читає старий формат, читає новий формат (за полем версії), містить інструкцію міграції, але не вимагає нового формату для основної логіки. розгортання цієї версії безпечний: нічого не ламається.
- Фаза 2 — міграція. Виконайте міграцію рахунків (on-chain або off-chain). Якщо щто пішло не так — зупиніться. Програма з фази 1 продовжує працювати з обома форматами.
- Фаза 3 — активація (опціонально). Коли всі рахунки мігровані, оновіть програму на версію, яка працює лише з новим форматом. Цей крок можна відкласти на невизначений термін.
Роллбек у цій схемі тривіальний: на будь-якому етапі ви просто зупиняєте міграцію і, за потреби, повертаєте програму до попередньої фази.
Практичні кроки при відкаті
- Негайно зупиніть міграційний скрипт або заблокуйте виклик міграційної інструкції (наприклад, через перемикач в рахунку конфігурації програми).
- Зафіксуйте стан: експортуйте список мігрованих і немігрованих рахунків. Це потрібно для аналізу та можливого ручного відновлення.
- Оновіть програму на безпечну версію (збережений бінарник попередньої фази).
- Перевірте: викличте основні інструкції з мігрованими та немігрованими рахунками, переконайтеся, що програма обробляє обидва типи.
- Проаналізуйте причину і виправте логіку перед повторною спробою. Не намагайтеся «додоправити» міграцію на льоту — це призводить до каскадних помилок.
Коли роллбек неможливий
- Старий бінарник не збережено перед оновленням.
- У рахунках немає поля версії, і міграція змінила структуру даних необоротно.
- Розмір рахунка був зменшений (що неможливо через обмеження runtime, але якщо хтось спробував обійти це через закриття й пересоздання — старі адреси втрачено).
Ці ситуації не є технічними обмеженнями платформи — вони є наслідком пропусків у процесі підготовки. Дотримання двофазного патерна та обов'язкове збереження бінарника перед кожним оновленням усувають їх повністю.