Files
transcriber/internal/ident/ident.go
T
av c9b7765646 хранилище переехало с PocketBase на SQLite со своим каталогом файлов
- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose
  под файловым замком, одна миграция начальной схемы вместо семи прежних
- транспорт переписан на net/http: свои слои, свой ограничитель частоты,
  отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли
- по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета
  читается справа налево, узнавание известного идёт читающим пулом
2026-08-23 08:06:04 +03:00

193 lines
8.6 KiB
Go

// 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
}