Architecture First: как небольшой командой с ИИ сделать альтернативу Jira — Часть 18.
В предыдущей статье мы разобрали права доступа. Когда в организации работает несколько человек, им нужно как-то ссылаться на задачи друг друга, а внутреннийidтрудно произнести и запомнить.
Краткое содержание
Как у задач в Boards появились короткие ключи:
- Сначала решили, как будут выглядеть ссылки, чтобы потом их не ломать:
/organizations/{организация}-{суффикс}/issues/{ключ спейса}-{номер}. - URL организации — это slug, который выбирает пользователь, и случайный суффикс из четырёх символов. По URL нельзя занять чужое название или проверить, есть ли организация.
- У каждого спейса есть ключ из трёх символов, уникальный внутри организации.
- Номера issues в спейсе идут подряд: 1, 2, 3 и так далее. Последний выданный номер хранится в таблице-счётчике и увеличивается одним запросом
INSERT ... ON CONFLICT DO UPDATE ... RETURNING. - При переносе доски в другой спейс её 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 появились атрибуты, комментарии, вложения и история изменений, а в проекте появился второй разработчик. Об атрибутах — следующая статья.