хранилище переехало с PocketBase на SQLite со своим каталогом файлов
- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose под файловым замком, одна миграция начальной схемы вместо семи прежних - транспорт переписан на net/http: свои слои, свой ограничитель частоты, отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли - по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета читается справа налево, узнавание известного идёт читающим пулом
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package ident
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Идентификатор — ULID в нижнем регистре: постоянная ширина, алфавит Crockford,
|
||||
// и разбор своего же значения его не меняет.
|
||||
func TestNewIsCanonical(t *testing.T) {
|
||||
id := New()
|
||||
|
||||
assert.Len(t, id, Len)
|
||||
assert.Equal(t, strings.ToLower(id), id, "идентификатор выдан не в нижнем регистре")
|
||||
|
||||
parsed, ok := Parse(id)
|
||||
require.True(t, ok, "свой же идентификатор не разобрался")
|
||||
assert.Equal(t, id, parsed, "разбор изменил канонический вид")
|
||||
}
|
||||
|
||||
// Порядок выдачи и порядок сравнения строк совпадают — в том числе внутри одной
|
||||
// миллисекунды: колонка времени несёт секунды, и «новые сверху» держит ключ.
|
||||
func TestNewIsMonotonic(t *testing.T) {
|
||||
previous := New()
|
||||
for range 10000 {
|
||||
next := New()
|
||||
require.Less(t, previous, next, "идентификатор выдан не по возрастанию")
|
||||
previous = next
|
||||
}
|
||||
}
|
||||
|
||||
// Выдача идёт из нескольких потоков разом: два одинаковых ключа означали бы
|
||||
// отказ вставки на живой базе.
|
||||
func TestNewIsUniqueUnderConcurrency(t *testing.T) {
|
||||
const goroutines = 8
|
||||
const each = 500
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
all = map[string]bool{}
|
||||
)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for range goroutines {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
own := make([]string, 0, each)
|
||||
for range each {
|
||||
own = append(own, New())
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
for _, id := range own {
|
||||
all[id] = true
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
assert.Len(t, all, goroutines*each, "выданы одинаковые идентификаторы")
|
||||
}
|
||||
|
||||
// Разбор проверяет вид и приводит регистр: значение приходит от спрашивающего, а
|
||||
// сравнение в базе побайтово.
|
||||
func TestParse(t *testing.T) {
|
||||
id := New()
|
||||
|
||||
upper, ok := Parse(strings.ToUpper(id))
|
||||
require.True(t, ok, "верхний регистр не принят: алфавит к нему нечувствителен")
|
||||
assert.Equal(t, id, upper, "разбор не привёл регистр к каноническому")
|
||||
|
||||
for name, value := range map[string]string{
|
||||
"пусто": "",
|
||||
"короче": id[:Len-1],
|
||||
"длиннее": id + "0",
|
||||
"знак вне алфавита": strings.Repeat("u", Len),
|
||||
"время за пределом": "8" + id[1:],
|
||||
"не латиница": strings.Repeat("я", Len),
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, ok := Parse(value)
|
||||
assert.False(t, ok, "негодное значение принято разбором")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Время, зашитое в идентификатор, идёт в ту же сторону, что и колонка времени.
|
||||
func TestTimestampGrowsWithIssue(t *testing.T) {
|
||||
first, ok := Timestamp(New())
|
||||
require.True(t, ok)
|
||||
|
||||
second, ok := Timestamp(New())
|
||||
require.True(t, ok)
|
||||
|
||||
assert.False(t, second.Before(first), "время в ключе пошло назад")
|
||||
}
|
||||
Reference in New Issue
Block a user