Назад к статьям
Статья

Последовательные номера задач — счётчик на INSERT ON CONFLICT в PostgreSQL, ключи спейсов и URL организаций

Architecture First: как небольшой командой с ИИ сделать альтернативу Jira — Часть 18.
В предыдущей статье мы разобрали права доступа. Когда в организации работает несколько человек, им нужно как-то ссылаться на задачи друг друга, а внутренний id трудно произнести и запомнить.

Краткое содержание

Как у задач в Boards появились короткие ключи:

  1. Сначала решили, как будут выглядеть ссылки, чтобы потом их не ломать: /organizations/{организация}-{суффикс}/issues/{ключ спейса}-{номер}.
  2. URL организации — это slug, который выбирает пользователь, и случайный суффикс из четырёх символов. По URL нельзя занять чужое название или проверить, есть ли организация.
  3. У каждого спейса есть ключ из трёх символов, уникальный внутри организации.
  4. Номера issues в спейсе идут подряд: 1, 2, 3 и так далее. Последний выданный номер хранится в таблице-счётчике и увеличивается одним запросом INSERT ... ON CONFLICT DO UPDATE ... RETURNING.
  5. При переносе доски в другой спейс её issues получают новые номера в этом спейсе.

Сначала — как будут выглядеть ссылки

Пока у задачи нет понятного имени, ссылку на неё можно только скопировать из адресной строки. Нам нужны были номера задач, и мы посмотрели, как это сделано в Jira: BRD-42, где BRD — ключ спейса, а 42 — номер задачи в нём.

Перед номерами нужно было определиться с форматом ссылки на задачу. Ссылки отправляют в чаты и вставляют в другие задачи и документы, и если поменять формат позже, старые ссылки перестанут открываться. Поэтому сначала мы сделали URL организаций, а уже потом номера задач.

URL организации: slug и случайный суффикс

Первой идеей было сделать URL организации из её названия: /organizations/laraue. У этого подхода были две проблемы. Любой может создать организацию с чужим названием и занять URL, к которому не имеет отношения. И теоретически по URL можно было бы узнать, существует ли организация, например по времени ответа сервера: есть ли в Boards организация laraue.

Поэтому мы решили собирать URL из двух частей: slug, который выбирает пользователь, и случайного суффикса из четырёх символов, который генерирует приложение.

/// <summary>
/// Organization slug. The public name to access this organization.
/// </summary>
[MaxLength(64)]
public string Slug { get; set; } = string.Empty;

/// <summary>
/// Postfix to allow slugs be ununique and make it harder to access something else organization.
/// </summary>
[MaxLength(4)]
public string SlugPostfix { get; set; } = string.Empty;

Slug не обязан быть уникальным: две организации могут называться laraue, но URL у них будут разные, например laraue-a1B2 и laraue-x7Kq. Угадать чужой URL по названию сложно, а занимать названия бессмысленно.

Ключ спейса

Ключ спейса — три символа, которые пользователь задаёт при создании спейса, например BRD. Приложение приводит его к верхнему регистру. Ключ должен быть уникальным внутри организации, и за этим следит уникальный индекс в базе:

modelBuilder.Entity<Space>(entity =>
{
    entity
        .HasIndex(x => new { x.OrganizationId, x.Key })
        .IsUnique();
});

Номер issue: почему не последовательность

У PostgreSQL есть готовый механизм для номеров — последовательности (sequence). Но последовательность допускает пропуски: если транзакция, которая взяла номер, откатилась, этот номер уже не вернётся. Мы хотели, чтобы номера в спейсе шли строго по порядку и ни один не терялся.

Поэтому последний выданный номер хранится в отдельной таблице, по одной строке на спейс:

public class SpaceCounter
{
    public long SpaceId { get; set; }
    public int LastNumber { get; set; }
}

Следующий номер выдаёт один запрос:

INSERT INTO space_counters (space_id, last_number)
VALUES ({0}, {1})
ON CONFLICT (space_id) DO UPDATE
SET last_number = space_counters.last_number + {1}
RETURNING last_number

Если строки для спейса ещё нет, запрос её создаёт. Если есть — увеличивает last_number и возвращает новое значение. Так не нужно отдельно проверять, существует ли счётчик, а значит, нет и гонки между проверкой и вставкой.

Запрос выполняется внутри той же транзакции, что и создание issue:

private async Task<int> GetLastNumber(long spaceId, int count, CancellationToken cancellationToken)
{
    context.Database.EnsureTransactionStarted();

    var query = FormattableStringFactory.Create(SqlQuery, spaceId, count);
    var result = await context.Database
        .SqlQuery<int>(query)
        .ToListAsyncEF(cancellationToken);

    return result.First();
}

Именно это и убирает пропуски. UPDATE блокирует строку счётчика до конца транзакции, поэтому второй запрос на номер в том же спейсе ждёт, пока первый закончится. Если транзакция откатилась, увеличение счётчика откатывается вместе с ней, и следующий issue получит тот же номер. Цена — issues одного спейса создаются по очереди, но для таск-трекера это незаметно.

Второй параметр, count, позволяет зарезервировать сразу несколько номеров. Он нужен при переносе доски, о котором ниже.

Номера для уже созданных issues

К моменту, когда появились номера, у пользователей уже были issues. Миграция пронумеровала их внутри каждого спейса в порядке создания и записала в счётчики последний номер:

WITH issue_counters as (
    select i.id, e.space_id, row_number() over (partition by e.space_id order by i.id) as number from issues i
    inner join statuses s on i.status_id = s.id
    inner join epics e on s.epic_id = e.id
    order by i.id
)
insert into issue_numbers
select ic.id, ic.space_id, ic.number
from issue_counters ic;

Здесь важно явно указать порядок: без order by i.id внутри row_number() PostgreSQL нумерует строки в произвольном порядке, и старые задачи могут получить номера вперемешку. У нас такое было во время разработки, но ошибку поймали тесты.

Перенос доски в другой спейс

Номер issue — это номер внутри спейса. Когда доска переезжает в другой спейс, её issues получают новые номера в нём: сервис резервирует в счётчике нового спейса столько номеров, сколько issues переезжает, и раздаёт их по порядку.

Старый ключ после переноса перестаёт работать. Перенаправления со старого ключа на новый пока нет: досками переезжают редко, а добавить перенаправление будет несложно, когда оно понадобится.

Итоги

  • Формат ссылок решили заранее. URL организаций появились раньше номеров задач, чтобы ссылки не пришлось ломать потом.
  • URL организации нельзя занять. Slug выбирает пользователь, а случайный суффикс делает URL уникальным и не даёт угадать чужой.
  • Ключ спейса уникален внутри организации. Это обеспечивает уникальный индекс в базе.
  • Последовательные номера. Вместо последовательности PostgreSQL — таблица-счётчик и INSERT ... ON CONFLICT DO UPDATE ... RETURNING внутри транзакции создания issue.
  • Перенос меняет номера. Перенаправления со старых ключей пока нет.

Что дальше

Дальше в Boards начали появляться новые функции: у issues появились атрибуты, комментарии, вложения и история изменений, а в проекте появился второй разработчик. Об атрибутах — следующая статья.