хранилище переехало с PocketBase на SQLite со своим каталогом файлов

- база своя: два пула, захват одним UPDATE ... RETURNING, шаги схемы на goose
  под файловым замком, одна миграция начальной схемы вместо семи прежних
- транспорт переписан на net/http: свои слои, свой ограничитель частоты,
  отдача файла с проверкой владельца; панель /_/ и пространство /api/ исчезли
- по находкам ревью: журнал не пишет путь под корнем приложения, ключ бюджета
  читается справа налево, узнавание известного идёт читающим пулом
This commit is contained in:
av
2026-08-23 08:06:04 +03:00
parent 1edf8cb225
commit c9b7765646
118 changed files with 11668 additions and 6679 deletions
+37 -2
View File
@@ -64,10 +64,38 @@ type ServerConfig struct {
ForceShutdownTimeout int `toml:"force_shutdown_timeout"`
}
// StorageConfig — единственный каталог данных: под ним лежат и база, и файлы
// записей. Двух путей, как было раньше, у хранилища не бывает.
// StorageConfig — хранилище сервиса: каталог данных и числа его базы.
//
// Каталог единственный: под ним лежат и база, и файлы записей. Двух путей, как
// было раньше, у хранилища не бывает.
type StorageConfig struct {
DataDir string `toml:"data_dir"`
// BusyTimeoutMs — сколько ждать занятую базу, миллисекунды.
//
// Ключом, а не константой кода: крутят его при отказе «база занята» под
// несколькими воркерами, и подбор ответа на такой отказ не должен требовать
// пересборки образа.
BusyTimeoutMs int `toml:"busy_timeout_ms"`
// ReadConnections — сколько соединений держит читающий пул. Пишущее
// соединение при этом всегда одно, и настройкой оно не делается: драйвер
// пишет единственным соединением, и второе означало бы отказы по занятости.
ReadConnections int `toml:"read_connections"`
}
// Validate проверяет настройки хранилища. Ноль и отрицательное — опечатка, а не
// режим: нулевое ожидание отдаёт «база занята» первому же воркеру, а нулевой пул
// чтения означает пул без предела, то есть настройку, которой не управляют.
func (c StorageConfig) Validate() error {
if strings.TrimSpace(c.DataDir) == "" {
return errors.New("storage: не заполнен ключ data_dir: сервису негде держать базу и файлы записей")
}
if c.BusyTimeoutMs <= 0 {
return errors.New("storage: busy_timeout_ms задаётся положительным числом миллисекунд")
}
if c.ReadConnections <= 0 {
return errors.New("storage: read_connections задаётся положительным числом соединений")
}
return nil
}
type YandexConfig struct {
@@ -156,6 +184,13 @@ func defaultConfig() *Config {
},
Storage: StorageConfig{
DataDir: "data",
// Пять секунд ожидания и четыре читающих соединения: числа выведены
// из числа воркеров по умолчанию, а не из замера. Смысл ожидания —
// пережить чужую запись, а не чужую работу: пишет сервис короткими
// операциями, и очередь из трёх воркеров укладывается в него с
// запасом.
BusyTimeoutMs: 5000,
ReadConnections: 4,
},
Pipeline: PipelineConfig{
Workers: 3,
+39
View File
@@ -216,3 +216,42 @@ func TestPipelineValidateSeparatesModeFromTypo(t *testing.T) {
}
}
}
// Настройки хранилища проверяются на старте, и каждая ветвь проверки закрывает
// свою поломку. Ноль и отрицательное — опечатка, а не режим: нулевое ожидание
// отдаёт «база занята» первому же воркеру, нулевой пул чтения означает пул без
// предела, а пустой каталог данных оставляет сервис без места под базу и файлы.
// Без проверки такая опечатка проявилась бы отказом под нагрузкой, а не на
// подъёме.
func TestStorageConfigValidate(t *testing.T) {
valid := StorageConfig{DataDir: "data", BusyTimeoutMs: 5000, ReadConnections: 4}
if err := valid.Validate(); err != nil {
t.Fatalf("заполненные настройки отвергнуты: %v", err)
}
cases := []struct {
name string
config StorageConfig
mention string
}{
{"каталог данных не заполнен", StorageConfig{DataDir: "", BusyTimeoutMs: 5000, ReadConnections: 4}, "data_dir"},
{"каталог данных из одних пробелов", StorageConfig{DataDir: " ", BusyTimeoutMs: 5000, ReadConnections: 4}, "data_dir"},
{"ожидание нулевое", StorageConfig{DataDir: "data", BusyTimeoutMs: 0, ReadConnections: 4}, "busy_timeout_ms"},
{"ожидание отрицательное", StorageConfig{DataDir: "data", BusyTimeoutMs: -1, ReadConnections: 4}, "busy_timeout_ms"},
{"пул чтения нулевой", StorageConfig{DataDir: "data", BusyTimeoutMs: 5000, ReadConnections: 0}, "read_connections"},
{"пул чтения отрицательный", StorageConfig{DataDir: "data", BusyTimeoutMs: 5000, ReadConnections: -3}, "read_connections"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := tc.config.Validate()
if err == nil {
t.Fatal("негодная настройка принята: поломка дошла бы до боя")
}
if !strings.Contains(err.Error(), tc.mention) {
t.Fatalf("имя ключа %q не названо: %v", tc.mention, err)
}
})
}
}