// Package ident — единая точка выдачи идентификаторов строк. // // Идентификатор — ULID в нижнем регистре: 48 бит времени в миллисекундах плюс // 80 бит случайности, записанные алфавитом Crockford base32. Так требует // конвенция проекта [docs/conventions/database.md]; прежде идентификаторы // выдавало встроенное хранилище своим алфавитом, и точки выдачи у приложения не // было вовсе. // // Почему ULID, а не UUID: ширина записи постоянная, а старшие разряды несут // время — лента записей упорядочивается парой «время заведения и ключ», и ключ // в этой паре не спорит с временем, а продолжает его. // // Регистр нижний, и это тоже правило конвенции: сравнение строк в SQLite // побайтово, поэтому канонический вид обязан быть один. Пришедший снаружи // идентификатор приводится к нему разбором на границе — Parse. package ident import ( "crypto/rand" "strings" "sync" "time" "git.vakhrushev.me/av/transcriber/internal/clock" ) // alphabet — Crockford base32 в нижнем регистре. Из него исключены `i`, `l`, // `o` и `u`: первые три неотличимы от цифр в наборах без засечек, последняя // исключена, чтобы случайная строка не складывалась в бранное слово. const alphabet = "0123456789abcdefghjkmnpqrstvwxyz" // Len — длина записи ULID: 10 знаков времени и 16 знаков случайности. const Len = 26 // decode — обратная таблица алфавита. Заполняется один раз: разбор идёт на // каждом запросе с идентификатором в пути, и собирать таблицу по месту значило // бы платить за неё столько же раз. var decode = func() [256]int8 { var table [256]int8 for i := range table { table[i] = -1 } for i, r := range alphabet { table[byte(r)] = int8(i) // Заглавный знак принимается разбором наравне со строчным: алфавит // Crockford к регистру нечувствителен, и человек, скопировавший // идентификатор из чужого письма, не обязан знать про канонический вид. table[byte(strings.ToUpper(string(r))[0])] = int8(i) } return table }() // Состояние выдачи: последняя метка времени и последняя случайная часть. // // Нужно ради **монотонности внутри миллисекунды**. Колонка времени несёт // секунды, и порядок записей, заведённых в одну секунду, задаёт ключ: случайная // часть, выданная заново, поставила бы их в произвольном порядке — «новые // сверху» стало бы «как повезёт», а страница ленты читалась бы через раз. var ( mu sync.Mutex lastMs uint64 lastRand [10]byte ) // New выдаёт новый идентификатор. // // Время берётся единой точкой чтения времени, а не `time.Now`: запрет держит // линтер, и обойти его здесь значило бы завести вторые часы у ключей. // // Случайность берётся у `crypto/rand`. Он не отказывает: с Go 1.24 чтение из // него не возвращает ошибки вовсе, а невозможность получить случайность — отказ // такого рода, из которого не стартуют. // // В пределах одной миллисекунды случайная часть **растёт на единицу**, а не // выдаётся заново: два идентификатора одной миллисекунды обязаны идти в порядке // выдачи. Переполнение прибавляет миллисекунду — исход недостижимый на любой // мыслимой нагрузке, но названный, потому что молчаливый откат назад испортил бы // порядок сильнее любой случайности. func New() string { ms := uint64(clock.Now().UnixMilli()) mu.Lock() switch { case ms > lastMs: lastMs = ms // Ошибку `rand.Read` не проверяем сознательно: с Go 1.24 он её не // возвращает, а `errcheck` довольствуется явным присваиванием в // пустышку. _, _ = rand.Read(lastRand[:]) default: // Часы могли и отступить назад: метка тогда остаётся прежней, а порядок // держит растущая случайная часть. if !increment(&lastRand) { lastMs++ _, _ = rand.Read(lastRand[:]) } } ms, entropy := lastMs, lastRand mu.Unlock() var raw [16]byte raw[0] = byte(ms >> 40) raw[1] = byte(ms >> 32) raw[2] = byte(ms >> 24) raw[3] = byte(ms >> 16) raw[4] = byte(ms >> 8) raw[5] = byte(ms) copy(raw[6:], entropy[:]) return encode(raw) } // increment прибавляет единицу к случайной части. Ложь означает переполнение — // все восемьдесят разрядов были заняты. func increment(value *[10]byte) bool { for i := len(value) - 1; i >= 0; i-- { value[i]++ if value[i] != 0 { return true } } return false } // encode переводит шестнадцать байт в двадцать шесть знаков алфавита. // // Разрядов у записи 130, а байт — 128, поэтому старший знак несёт только два // младших бита первого байта; остальные знаки идут ровными пятёрками бит. func encode(raw [16]byte) string { var n [130]byte for i := range 128 { n[i+2] = raw[i/8] >> (7 - i%8) & 1 } out := make([]byte, Len) for i := range out { var v byte for j := range 5 { v = v<<1 | n[i*5+j] } out[i] = alphabet[v] } return string(out) } // Parse разбирает идентификатор, пришедший снаружи: проверяет вид и приводит // регистр. Второе значение ложно у всего, что видом не совпало. // // Разбор стоит на границе, а не в запросе к базе: строка приходит от // спрашивающего, а сравнение в базе побайтово — идентификатор в верхнем // регистре не совпал бы ни с одной строкой, и «моя запись пропала» читалось бы // как отказ разграничения. func Parse(value string) (string, bool) { if len(value) != Len { return "", false } out := make([]byte, Len) for i := range Len { v := decode[value[i]] if v < 0 { return "", false } out[i] = alphabet[v] } // Старший знак несёт всего два бита времени: запись, у которой он больше // семёрки, описывает время за пределами разрядности и годным ULID не // является. if decode[out[0]] > 7 { return "", false } return string(out), true } // Timestamp отдаёт время, зашитое в идентификатор. Нужен проверкам: по нему // видно, что ключ и колонка времени идут в одну сторону. func Timestamp(id string) (time.Time, bool) { parsed, ok := Parse(id) if !ok { return time.Time{}, false } var ms uint64 for i := range 10 { ms = ms<<5 | uint64(decode[parsed[i]]) } return time.UnixMilli(int64(ms)).UTC(), true }