Изменение схемы данных с помощью space:format() | Tdb

Версия:

latest
Руководство пользователя Миграция схемы данных Изменение схемы данных с помощью space:format()

Изменение схемы данных с помощью space:format()

В этом руководстве рассказано, как разработать типовое приложение в Tarantool DB и изменить в нем схему данных, используя метод space:format(). В качестве примера используется база данных для системы управления проектами. Для работы используются модули migrations, CRUD и vshard.

Руководство включает следующие шаги:

Пререквизиты

Для выполнения примера требуются:

  • установленный Docker-образ Tarantool DB;

  • приложение Docker compose;

  • утилита TT CLI;

  • исходные файлы примера migrations.

    Примечание

    Есть два способа получить исходные файлы примера:

    • Архив с полной документацией Tarantool DB, полученный по почте или скачанный в личном кабинете tarantool.io. Пример архива: tarantooldb-documentation-0.8.0.tar.gz. Пример migrations расположен в таком архиве в директории ./doc/examples/migrations/.

    • Отдельный архив migrations.tar.gz, скачанный c сайта Tarantool.

Схема данных

В качестве примера приведена база данных для системы управления проектами, которая состоит из трех спейсов: projects (проекты), tasks (задачи), users (пользователи). Схема этой базы данных выглядит так:

Cхема данных

Здесь:

  • Спейсы projects и tasks имеют одинаковый ключ шардирования project_id и находятся на одном экземпляре.

  • Спейс users имеет ключ шардирования user_id.

Особенности базы данных:

  • Задач на проекте больше, чем пользователей.

  • При удалении проекта нужно удалить и связанные с ним задачи. Это удобно сделать, если все записи находятся на одном экземпляре.

Примечание

При выборе ключа шардирования учитывайте предметную область и предполагаемое API.

Для работы с данными в примере используются методы модуля CRUD. Дополнительно будет реализовано следующее API:

  • app.delete_user(user_id) – удалить пользователя. У всех задач, связанных с этим пользователем, в поле assigned_user_id должен быть выставлен box.NULL;

  • app.delete_project(project_id) – удалить проект и все связанные с ним задачи;

  • app.get_project_data(project_id) – получить проект и все связанные с ним задачи и пользователей.

Запуск стенда

Для запуска и настройки кластера используются файлы из папки migrations:

  • docker-compose.yml – описание узлов кластера;

  • bootstrap/topology.json – топология кластера.

Для успешного старта должны быть свободны следующие порты:

  • 3300 .. 3304

  • 8080 .. 8084

Перейдите в директорию примера migrations:

cd ./doc/examples/migrations/

Запустите стенд:

docker compose up -d

В запущенном кластере созданы спейсы projects, tasks и users, а также функции app.delete_user(user_id) и app.get_project_data(project_id).

Загрузка и проверка данных

Подключитесь к роутеру с помощью команды tt connect. Команда открывает интерактивную консоль Tarantool, позволяющую работать с базой данных:

tt connect admin:secret-cluster-cookie@localhost:3300

Исходный код миграции приведен в файле 001_test.lua в директории ./bootstrap/migrations/source/ примера migrations.

Загрузить тестовые данные можно с помощью функции __create_example_data. Функция очищает кластер и заполняет его данными из примера:

localhost:3300> box.schema.func.call('__create_example_data')

Чтобы проверить загруженные данные, выполните несколько базовых операций в спейсе users, используя модуль CRUD:

localhost:3300> crud.select('users')
---
- metadata: [{'name': 'user_id', 'type': 'uuid'}, {'name': 'bucket_id', 'type': 'unsigned'},
    {'name': 'name', 'type': 'string'}, {'type': 'string', 'name': 'email', 'is_nullable': true}]
  rows:
  - [04e7f6a2-2979-46e4-8d71-e80217e3aac3, 23464, 'john_doe', 'john.doe@example.com']
  - [1e63739a-dad0-4c5d-80e4-cd39594fe302, 1985, 'jane_smith', 'jane.smith@example.com']
- null
...

localhost:3300> crud.select('users', {{"==", "name", "john_doe"}})
---
- metadata: [{'name': 'user_id', 'type': 'uuid'}, {'name': 'bucket_id', 'type': 'unsigned'},
    {'name': 'name', 'type': 'string'}, {'type': 'string', 'name': 'email', 'is_nullable': true}]
  rows:
  - [04e7f6a2-2979-46e4-8d71-e80217e3aac3, 23464, 'john_doe', 'john.doe@example.com']
- null

localhost:3300> crud.update('users', require('uuid').fromstr('04e7f6a2-2979-46e4-8d71-e80217e3aac3'), {{'=', 'name', "John Doe"}})
---
- rows:
  - [04e7f6a2-2979-46e4-8d71-e80217e3aac3, 23464, 'John Doe', 'john.doe@example.com']
  metadata: [{'name': 'user_id', 'type': 'uuid'}, {'name': 'bucket_id', 'type': 'unsigned'},
    {'name': 'name', 'type': 'string'}, {'type': 'string', 'name': 'email', 'is_nullable': true}]
- null

localhost:3300> crud.get('users', require('uuid').fromstr('04e7f6a2-2979-46e4-8d71-e80217e3aac3'))
---
- rows:
  - [04e7f6a2-2979-46e4-8d71-e80217e3aac3, 23464, 'John Doe', 'john.doe@example.com']
  metadata: [{'name': 'user_id', 'type': 'uuid'}, {'name': 'bucket_id', 'type': 'unsigned'},
    {'name': 'name', 'type': 'string'}, {'type': 'string', 'name': 'email', 'is_nullable': true}]
- null

localhost:3300> crud.delete('users', require('uuid').fromstr('04e7f6a2-2979-46e4-8d71-e80217e3aac3'))
---
- rows:
  - [04e7f6a2-2979-46e4-8d71-e80217e3aac3, 23464, 'John Doe', 'john.doe@example.com']
  metadata: [{'name': 'user_id', 'type': 'uuid'}, {'name': 'bucket_id', 'type': 'unsigned'},
    {'name': 'name', 'type': 'string'}, {'type': 'string', 'name': 'email', 'is_nullable': true}]
- null
...

Чтобы вернуть данные в прежнее состояние, вызовите функцию __create_example_data еще раз:

localhost:3300> box.schema.func.call('__create_example_data')

Проверка пользовательских функций базы данных

Модуль CRUD упрощает работу с шардированными данными – выполнение простых операций чтения и записи таких данных прозрачно для пользователя. Тем не менее, для задач, реализующих функции базы данных (app.get_project_data(id), app.delete_user(id) и app.delete_project(id)), модуля CRUD недостаточно. Это связано с тем, что эти функции работают с несколькими спейсами и нестандартными операциями чтения и записи.

Получение данных проекта

Для проверки функции app.get_project_data(project_id) верните данные в первоначальное состояние:

localhost:3300> box.schema.func.call('__create_example_data')

Вызовите функцию и оцените результат:

localhost:3300> box.schema.func.call('app.get_project_data', require('uuid').fromstr('46f8e628-d2c2-42ba-984f-29a459a3d0fc'))
---
- res:
    tasks:
    - status: In Progress
      user:
        name: john_doe
        email: john.doe@example.com
      name: Optimize Database
      description: Optimize the database to improve performance.
    name: Task Management
    description: Development of a task management system
  err: null

Функция app.get_project_data(project_id) выполняет join из всех спейсов, используются crud.get, crud.pairs.

Удаление пользователя

Для проверки функции app.delete_user(user_id) верните данные в первоначальное состояние:

localhost:3300> box.schema.func.call('__create_example_data')

Удалите пользователя:

localhost:3300> box.schema.func.call('app.delete_user', require('uuid').fromstr('04e7f6a2-2979-46e4-8d71-e80217e3aac3'))
---
- res: true
  err: null
...

localhost:3300> box.schema.func.call('app.get_project_data', require('uuid').fromstr('46f8e628-d2c2-42ba-984f-29a459a3d0fc'))
---
- res:
    tasks:
    - status: In Progress
      name: Optimize Database
      description: Optimize the database to improve performance.
    name: Task Management
    description: Development of a task management system
  err: null

localhost:3300> crud.get('users', require('uuid').fromstr('04e7f6a2-2979-46e4-8d71-e80217e3aac3'))
---
- rows: []
  metadata: [{'name': 'user_id', 'type': 'uuid'}, {'name': 'bucket_id', 'type': 'unsigned'},
    {'name': 'name', 'type': 'string'}, {'type': 'string', 'name': 'email', 'is_nullable': true}]
- null
...

В выводе функции видно, что информации о пользователе нет – пользователь успешно удален.

Теперь во всех связанных с этим пользователем задачах нужно присвоить полю assigned_user_id значение box.NULL. Для этого на всех хранилищах была объявлена функция tasks.set_box_NULL_for_user_id. Функция задает box.NULL в поле assigned_user_id для всех задач, у которых assigned_user_id == user_id, где user_id – аргумент функции.

Код функции:

function(user_id)
  local fiber = require('fiber')
  local every_100 = 0
  for _, t in box.space.tasks.index.assigned_user_id:pairs({user_id}, 'EQ') do
      box.space.tasks:update(t.id, {{'=', 'assigned_user_id', box.NULL}})
      every_100 = every_100 + 1
      if every_100 == 100 then
          every_100 = 0
          fiber.yield() -- выполняем fiber.yield(), чтобы не занимать полностью TX тред в случае, когда задач у пользователя очень много
      end
  end
  return true
end

Особенность app.delete_user(id) в том, спейсы tasks и users шардируются по разным ключам. В общем случае связанные задачи и пользователи будут находиться на разных шардах. Это значит, что нет узла, на котором бы было известно, на каких шардах будут задачи, связанные с удаляемым пользователем. Вызывать функцию tasks.set_box_NULL_for_user_id нужно на каждом мастере шарда., потому что такие задачи будут на всех шардах. Для вызова функции на всех шардах используется модуль для горизонтального масштабирования vshard.

Вызов функции tasks.set_box_NULL_for_user_id выглядит так:

local _, err, uuid = vshard_router.map_callrw('tasks.set_box_NULL_for_user_id', {user_id})

Полный исходный код приведен в файле миграции ./bootstrap/migrations/source/001_test.lua примера migrations.

Удаление проекта

Для проверки функции app.delete_project(project_id) верните данные в первоначальное состояние:

localhost:3300> box.schema.func.call('__create_example_data')

Просмотрите содержимое спейса projects. Видно, что проект Website Update был удален вместе со всеми задачами:

localhost:3300> crud.select('projects')
---
- metadata: [{'name': 'id', 'type': 'uuid'}, {'name': 'bucket_id', 'type': 'unsigned'},
    {'name': 'name', 'type': 'string'}, {'type': 'string', 'name': 'description',
      'is_nullable': true}]
  rows:
  - [46f8e628-d2c2-42ba-984f-29a459a3d0fc, 1033, 'Task Management', 'Development of
      a task management system']
  - [f53392af-30e3-4bfc-bde8-37043951159a, 21589, 'Website Update', 'Making changes
      to the website design and functionality']
- null

localhost:3300> crud.select('tasks')
---
- metadata: [{'name': 'task_id', 'type': 'uuid'}, {'name': 'bucket_id', 'type': 'unsigned'},
    {'name': 'name', 'type': 'string'}, {'type': 'string', 'name': 'description',
      'is_nullable': true}, {'name': 'status', 'type': 'string'}, {'name': 'project_id',
      'type': 'uuid'}, {'type': 'uuid', 'name': 'assigned_user_id', 'is_nullable': true}]
  rows:
  - [5043a3f6-6ffa-4d90-8b66-4fb623878f8e, 1033, 'Optimize Database', 'Optimize the
      database to improve performance.', 'In Progress', 46f8e628-d2c2-42ba-984f-29a459a3d0fc,
    04e7f6a2-2979-46e4-8d71-e80217e3aac3]
  - [c57d56ef-33fc-453b-880f-5d3ba4dc9d10, 21589, 'Create New Logo', 'Design a new
      logo for the website.', 'Not Started', f53392af-30e3-4bfc-bde8-37043951159a,
    1e63739a-dad0-4c5d-80e4-cd39594fe302]
- null

localhost:3300> box.schema.func.call('app.delete_project', require('uuid').fromstr('f53392af-30e3-4bfc-bde8-37043951159a'))
---
- res: true
  err: null
...

localhost:3300> crud.select('projects')
---
- metadata: [{'name': 'project_id', 'type': 'uuid'}, {'name': 'bucket_id', 'type': 'unsigned'},
    {'name': 'name', 'type': 'string'}, {'type': 'string', 'name': 'description',
      'is_nullable': true}]
  rows:
  - [46f8e628-d2c2-42ba-984f-29a459a3d0fc, 1033, 'Task Management', 'Development of
      a task management system']
- null

localhost:3300> crud.select('tasks')
---
- metadata: [{'name': 'task_id', 'type': 'uuid'}, {'name': 'bucket_id', 'type': 'unsigned'},
    {'name': 'name', 'type': 'string'}, {'type': 'string', 'name': 'description',
      'is_nullable': true}, {'name': 'status', 'type': 'string'}, {'name': 'project_id',
      'type': 'uuid'}, {'type': 'uuid', 'name': 'assigned_user_id', 'is_nullable': true}]
  rows:
  - [5043a3f6-6ffa-4d90-8b66-4fb623878f8e, 1033, 'Optimize Database', 'Optimize the
      database to improve performance.', 'In Progress', 46f8e628-d2c2-42ba-984f-29a459a3d0fc,
    04e7f6a2-2979-46e4-8d71-e80217e3aac3]
- null
...

Спейсы projects и tasks шардируются по одинаковым значениям. Это означает, что связанные между собой проект и задача находятся на одном экземпляре. Такой подход позволяет транзакционно удалить данные из projects и tasks. Для этого на хранилищах реализована API-функция 'projects.delete_project.

Код функции:

function(project_id)
    local proj = box.space.projects:get(project_id)
    if proj == nil then
        return false
    end

    box.atomic(function() -- атомарно удаляем и из projects и из tasks
        box.space.projects:delete(project_id)

        for _, t in box.space.tasks.index.project_id:pairs({project_id}, 'EQ') do
            box.space.tasks:delete(t.id)
        end
    end)
    return true
end

Для вызова функции на конкретном мастере используется модуль vshard. Вызов функции projects.delete_project выглядит так:

local vshard_router = require('vshard.router')
local bucket_id = vshard_router.bucket_id_strcrc32(id)
local _, err = vshard_router.callrw(bucket_id, 'projects.delete_project', {id})

Полный исходный код приведен в файле миграции ./bootstrap/migrations/source/001_test.lua примера migrations.

Изменение схемы данных

Предположим, что теперь нужно изменить схему данных, добавив в нее новые поля:

  • deadline (datetime) в спейс projects;

  • due_date (datetime) в спейс tasks;

  • role (string) в спейс users.

По умолчанию в полях projects.deadline и due_date(datetime) должно быть значение 2999-12-31T00:00:00Z, а в поле users.role – значение not set. Функцию app.get_project_data нужно также переписать, чтобы отображались новые поля.

Новая схема данных будет выглядеть так:

Схема данных

Код миграции приведен в файле ./migrations/002_test.lua002_test.lua примера migrations.

Миграции выполняются в лексикографическом порядке, так им нумерованные названия: (0001_my_migr.lua, 2023_12_24_migr.lua).

Подготовьте данные для миграции:

localhost:3300> box.schema.func.call('__create_example_data')

Способы выполнения миграции

Есть два способа выполнить миграцию:

  • в веб-интерфейсе Tarantool DB;

  • с помощью GraphQL.

Веб-интерфейс

  1. Откройте вкладку Code в меню слева.

  2. Добавьте в migrations/source файл 002_test.lua.

  3. Скопируйте код из файла 002_test.lua в этот файл.

  4. Нажмите кнопку Apply.

  5. Конфигурация успешно применена.

Graphql API

Для выполнения миграции запустите следующий запрос на изменение (мутацию):

curl -v --raw 'http://localhost:8081/admin/api' -X POST --data '{
        "query":"mutation($sections: [ConfigSectionInput!]) {
            cluster {
                config(sections: $sections) {
                    filename
                    content
                }
            }
        }",
        "variables": {
            "sections": [{
                "filename":"migrations/source/002_test.lua",
                "content":"'"$(cat 002_test.lua | sed 's/"/\\"/g' )"'"
            }]
        }
}'

Чтобы выполнить старт миграции, запустите в консоли следующую команду:

curl -X POST localhost:8081/migrations/up

Дождитесь ответа:

{"applied":["002_test.lua"]}

Проверьте, что миграция прошла успешно. Видно, что добавлены новые поля со значениями по умолчанию:

localhost:3300> crud.select('projects')
---
- metadata: [{'name': 'project_id', 'type': 'uuid'}, {'name': 'bucket_id', 'type': 'unsigned'},
    {'name': 'name', 'type': 'string'}, {'type': 'string', 'name': 'description',
      'is_nullable': true}, {'type': 'datetime', 'name': 'deadline', 'is_nullable': true}]
  rows:
  - [46f8e628-d2c2-42ba-984f-29a459a3d0fc, 1033, 'Task Management', 'Development of
      a task management system', '2999-12-31T00:00:00Z']
  - [f53392af-30e3-4bfc-bde8-37043951159a, 21589, 'Website Update', 'Making changes
      to the website design and functionality', '2999-12-31T00:00:00Z']
- null
...

localhost:3300> crud.select('tasks')
---
- metadata: [{'name': 'task_id', 'type': 'uuid'}, {'name': 'bucket_id', 'type': 'unsigned'},
    {'name': 'name', 'type': 'string'}, {'type': 'string', 'name': 'description',
      'is_nullable': true}, {'name': 'status', 'type': 'string'}, {'name': 'project_id',
      'type': 'uuid'}, {'type': 'uuid', 'name': 'assigned_user_id', 'is_nullable': true},
    {'type': 'datetime', 'name': 'due_date', 'is_nullable': true}]
  rows:
  - [5043a3f6-6ffa-4d90-8b66-4fb623878f8e, 1033, 'Optimize Database', 'Optimize the
      database to improve performance.', 'In Progress', 46f8e628-d2c2-42ba-984f-29a459a3d0fc,
    04e7f6a2-2979-46e4-8d71-e80217e3aac3, '2999-12-31T00:00:00Z']
  - [c57d56ef-33fc-453b-880f-5d3ba4dc9d10, 21589, 'Create New Logo', 'Design a new
      logo for the website.', 'Not Started', f53392af-30e3-4bfc-bde8-37043951159a,
    1e63739a-dad0-4c5d-80e4-cd39594fe302, '2999-12-31T00:00:00Z']
- null
...

localhost:3300> crud.select('users')
---
- metadata: [{'name': 'user_id', 'type': 'uuid'}, {'name': 'bucket_id', 'type': 'unsigned'},
    {'name': 'name', 'type': 'string'}, {'type': 'string', 'name': 'email', 'is_nullable': true},
    {'type': 'string', 'name': 'role', 'is_nullable': true}]
  rows:
  - [04e7f6a2-2979-46e4-8d71-e80217e3aac3, 23464, 'john_doe', 'john.doe@example.com',
    'not set']
  - [1e63739a-dad0-4c5d-80e4-cd39594fe302, 1985, 'jane_smith', 'jane.smith@example.com',
    'not set']
- null
...

Теперь проверьте функцию app.get_project_data. В функции должны появиться новые поля в ответе:

localhost:3300> box.schema.func.call('app.get_project_data', require('uuid').fromstr('46f8e628-d2c2-42ba-984f-29a459a3d0fc'))
---
- res:
    tasks:
    - due_date: 2999-12-31T00:00:00Z
      status: In Progress
      user:
        email: john.doe@example.com
        name: john_doe
        role: not set
      name: Optimize Database
      description: Optimize the database to improve performance.
    deadline: 2999-12-31T00:00:00Z
    name: Task Management
    description: Development of a task management system
  err: null
...

Теперь нужно изменить функцию app.get_project_data. Для этого транзакционно удалите старую функцию и добавьте новую. Такой подход гарантирует, что не произойдет ситуации, когда функции app.get_project_data не существует.

 box.atomic(function()
    box.schema.func.drop('app.get_project_data') -- удаляется старый вариант
    box.schema.func.create('app.get_project_data',  {
      language = 'LUA',
        if_not_exists = true,
        body = [[ ... ]]
    }) -- добавляем новый вариант
end)

Узнать подробнее о том, как хранятся персистентные функции, можно в спейсе box.space._func.

Остановка стенда

Чтобы остановить стенд, выполните следующую команду:

docker compose down
Нашли ответ на свой вопрос?
Обратная связь