Очередь фоновых задач на SQLite: приоритеты, ретраи с backoff, cron, идемпотентные ключи. Главное — гарантии: at-least-once, lease и fencing.
Go
SQLite
REST API
SSE
Prometheus
Docker
25 тестов
19 маршрутов
2 161 строк Go
Клиент / входСервисХранилище
Потоки данных
1Задача ставится через REST под Bearer-токеном; повторная постановка с тем же идемпотентным ключом возвращает уже существующую задачу.
2Каждый запуск cron ставится с ключом cron:<id>:<время>, а next_run сдвигается условным UPDATE, поэтому несколько процессов не создадут дубль.
3Самая приоритетная готовая задача выбирается по priority DESC, run_at, id.
4Один оператор выставляет статус, счётчик попыток, срок lease и уникальный токен аренды и отдаёт задачу воркеру.
5Воркер продлевает аренду heartbeat-ом каждую треть срока и завершает задачу (Complete/Fail), предъявляя токен: «зомби»-воркер получает ErrLeaseLost.
6Reaper возвращает задачи с истёкшей арендой в очередь с backoff или в dead, если попытки кончились.
7Снимок состояния раз в секунду уходит в дашборд по SSE; счётчики и гистограмма доступны на /metrics.
Решения
Атомарный захват одним UPDATE … RETURNING
Выбор задачи и выдача аренды происходят в одном операторе, поэтому два воркера, даже из разных процессов на одном файле БД, не получат одну задачу.
Lease, heartbeat и fencing-токен
Упавший воркер не теряет задачу: аренда истекает, а Complete/Fail/Extend проверяют токен, так что очнувшийся «зомби» не может испортить результат.
At-least-once, а не exactly-once
Обработчик может быть вызван повторно, поэтому обработчики должны быть идемпотентными. Очередь не обещает того, чего не может гарантировать.
Backoff с jitter
Задержка BACKOFF_BASE · 2^(n-1) с потолком, умноженная на случайный множитель 0,5–1; Permanent-ошибка и неизвестный тип сразу уходят в dead.
Компромиссы
Один файл SQLite: масштабирование только на одной машине (несколько процессов на одном томе допустимы, по сети нет).
Счётчики метрик живут в памяти и обнуляются при перезапуске; нет цепочек задач, rate limit по очередям и ролей (один токен).
Тесты
Конкурентный захват (400 задач, два «процесса» на одном файле, 12 воркеров — ни одного двойного исполнения), истечение lease и fencing, идемпотентные ключи в гонке, cron из двух процессов, graceful shutdown.
Исходники: README проекта job-queue: «Архитектура», «Гарантии доставки», «Ограничения»
Монитор доступности: HTTP и TCP проверки, аптайм, p50/p95, инциденты, живой дашборд и публичная статус-страница.
Go
SQLite
SSE
REST API
Docker
18 тестов
15 маршрутов
1 445 строк Go
Клиент / входСервисХранилище
Потоки данных
1Manager держит отдельную горутину на каждую проверку; следующий запуск планируется после завершения предыдущего, поэтому проверки одного адреса не накладываются.
2Run делает HTTP GET (до 5 редиректов, ожидаемый код или любой < 400) либо TCP connect с таймаутом.
3Результат записывается в SQLite.
4После записи результат публикуется в Hub.
5Detector получает результат: fail_threshold падений подряд открывают инцидент, первая успешная проверка его закрывает.
6Смена состояния пишется как инцидент; открытый инцидент переживает перезапуск.
7Аптайм за 24 ч, 7 и 30 дней и p50/p95 считаются запросами по сырой истории (чистые функции в internal/stats).
8REST отдаёт цифры дашборду и публичной статус-странице (только public-проверки, без адресов и текстов ошибок).
9Hub рассылает события SSE-подписчикам неблокирующе: медленный клиент теряет события, а не тормозит проверки.
Решения
Перцентили методом ближайшего ранга
p-й процентиль — элемент отсортированного среза с номером ceil(p/100·N). Простая чистая функция без интерполяции, легко проверить тестом.
Не накладывать проверки
Следующий запуск планируется после завершения предыдущего: медленный адрес не копит параллельные проверки сам на себя.
Медленный клиент не тормозит мониторинг
Рассылка в Hub неблокирующая; после переподключения дашборд заново читает состояние.
SSE через fetch, а не EventSource
Так токен передаётся в заголовке Authorization, а не в URL.
Компромиссы
Аптайм считается по числу проверок, а не по длительности: у проверок с разным интервалом цифры несопоставимы по точности.
Агрегаты считаются по сырой истории: для десятков проверок мгновенно, для тысяч понадобятся предагрегаты. Одна нода, один токен, уведомлений нет.
Тесты
Планировщик, перцентили и аптайм, жизненный цикл инцидента (в том числе после перезапуска), HTTP/TCP проверки с таймаутом, API, SSE.
Исходники: README проекта uptime-monitor: «Возможности», «Архитектура», «Ограничения»
Комнаты на WebSocket: чат с историей, присутствие, общая доска. Протокол RFC 6455 написан на stdlib, чтобы показать, что происходит под new WebSocket(…).
Go
WebSocket
SQLite
Canvas
Docker
48 тестов
7 маршрутов
1 822 строк Go
Клиент / входСервисХранилище
Потоки данных
1Браузер открывает WebSocket: GET с Upgrade, версия 13, ключ, проверка Origin; ответ 101 с Sec-WebSocket-Accept.
2Фреймы разбираются в internal/ws: маскирование, фрагментация, лимит размера, UTF-8, ping/pong; JSON-сообщение уходит в хаб (read pump).
3Рассылка кладёт сообщение в ограниченную очередь каждого клиента; при переполнении медленный клиент отключается кодом 1008, остальные не ждут.
4write pump забирает сообщения из очереди и пишет фреймы с дедлайном 10 с.
5Фреймы доходят до браузеров; эфемерные сообщения (курсоры, «печатает») пропускаются при заполнении очереди на 3/4.
6Сообщение рассылается только после успешной записи в SQLite; при ошибке отправитель получает error: store.
7Штрихи доски хранятся в памяти комнаты и отдаются вошедшим позже.
Решения
Свой слой WebSocket
Около 530 строк в internal/ws: рукопожатие, фреймы, маскирование, фрагментация, ping/pong и close-коды. Цель — понять протокол; для продакшена обычно берут проверенную библиотеку.
Backpressure вместо ожидания
Ограниченная очередь на клиента и отключение медленного клиента: одна медленная вкладка не задерживает комнату.
Одинаковый порядок для всех
Сохранение и рассылка выполняются под одним мьютексом комнаты; история в welcome читается под тем же мьютексом, поэтому нет ни пересечения, ни разрыва с живым потоком.
Лимиты на соединение
Токен-бакет для чата (2 сообщения/с, пачка 5) и общий лимит (200/с, пачка 400); флуд закрывает соединение кодом 1008.
Компромиссы
Один процесс: хаб в памяти, горизонтального масштабирования нет. Доска не переживает перезапуск сервера, очистить её может любой участник.
Нет permessage-deflate, TLS и аутентификации; Autobahn Test Suite не запускался. Ники анонимные и не защищены.
Тесты
Пример рукопожатия из RFC, 12 видов нарушений протокола с проверкой close-кода, фрагментация с ping посередине, отключение медленного клиента, а также нагрузочный тест: 360 соединений в 6 комнатах.
Исходники: README проекта realtime-rooms: «Почему свой слой WebSocket», «Возможности», «Гарантии», «Ограничения»
Онлайн-запись: свободные слоты по длительности услуги и рабочим часам, защита от двойной брони, админка.
Go
SQLite
Docker
12 тестов
18 маршрутов
891 строк Go
Клиент / входСервисХранилище
Потоки данных
1Клиент запрашивает 14 дней вперёд и слоты дня, затем отправляет POST /api/bookings.
2Владелец управляет записями, услугами, часами работы и закрытыми датами.
3Слоты считаются по длительности услуги, часам дня недели и занятым интервалам, шаг сетки 30 минут.
4Book проверяет свободность слота и вставляет запись в одной транзакции.
5Расчёт читает часы работы, закрытые даты и активные записи.
6Вставка защищена частичным UNIQUE-индексом; при конфликте клиент получает 409.
Решения
Две линии защиты от двойной брони
Проверка свободности и вставка идут в одной транзакции, а частичный UNIQUE-индекс по (date, start_min) для активных записей ловит то, что проверка пропустила. Отменённая запись освобождает время.
Часовой пояс в данных
Пояс берётся из TZ_NAME, база tzdata встроена в бинарник: расписание не зависит от настроек машины.
Компромиссы
Один специалист/ресурс: все услуги делят одно расписание.
Закрытие даты не отменяет созданные записи; клиент не может отменить запись сам, уведомлений нет; админка за общим токеном.
Тесты
Расчёт слотов и границы периода, пересечения, конкурентная бронь: 30 горутин на одно время — успешна ровно одна.
Исходники: README проекта booking-slots: «Возможности», «Тесты», «Ограничения» и код internal/booking
API-шлюз на чистой stdlib: маршруты из JSON-конфига с горячей перезагрузкой, балансировка, health-checks, circuit breaker на узел, ретраи, rate limit. Главное — неизменяемое состояние и защита от open-proxy и SSRF.
Go
stdlib only
Reverse proxy
Circuit breaker
Token bucket
SSE
Prometheus
slog
Vanilla JS
embed
Docker
GitHub Actions
48 тестов
11 маршрутов
2 334 строк Go
Клиент / входСервисХранилище
Потоки данных
1Запрос приходит на :8087, получает request-id, маршрут ищется по хосту и самому длинному префиксу; не найден — 404, шлюз не работает как open-proxy.
2Оператор меняет конфиг через SIGHUP (только Unix) или PUT/POST в админке на :8088; конфиг проверяется целиком, ошибочный отклоняется, старый продолжает работать.
3Новое состояние (маршруты, апстримы, ключи) подменяется одним atomic.Pointer: слушающий сокет не трогается, запрос в полёте доезжает на старой версии.
4Ключ API хэшируется sha256 и сверяется с конфигом; rate limit по token bucket ведётся по IP (IPv6 по /64) или по ключу, лишний запрос не доходит до апстрима.
5Балансировщик выбирает узел round-robin или least-conn, но только среди узлов в состоянии healthy.
6Отдельный health-loop на каждый узел опрашивает GET /health и по порогам помечает узел healthy или нет.
7У каждого узла свой breaker (closed, open, half-open); ошибкой считаются сбой соединения, таймаут и любой 5xx кроме 501, обрыв запроса клиентом не считается.
8Соединение идёт через guarded Dialer, который проверяет IP в момент соединения (SSRF, DNS-rebinding); при 502/503/504 или сбое сети GET, HEAD, OPTIONS, PUT и DELETE повторяются на другом узле.
9Окно статистики и счётчики уходят в дашборд по SSE и на /metrics в формате Prometheus; метки ограничены конфигом, путь и ключи в метки не попадают.
Решения
Неизменяемое состояние под atomic.Pointer
Маршруты, апстримы и ключи собираются в новое состояние и подменяются одним указателем. Апстримы с неизменённым описанием переиспользуются целиком, поэтому health, breaker и счётчики не сбрасываются при перезагрузке.
Не open-proxy и защита от SSRF
Адрес назначения берётся только из конфига, Host, absolute-URI, CONNECT/TRACE и пути с «..» не дают выйти за маршруты. IP, в который резолвится имя, проверяется ещё раз при соединении, а разрешение приватных сетей задаётся окружением, а не конфигом.
Breaker на каждый узел
Состояния closed, open и half-open ведутся отдельно у каждого узла: один больной узел выпадает из ротации, остальные продолжают принимать трафик.
Ретраи только идемпотентных запросов
POST не повторяется, чтобы не выполнить операцию дважды; тело буферизуется до 1 МиБ, а более крупные запросы идут без ретраев.
Компромиссы
Rate limit и состояние breaker живут в памяти одного процесса: при нескольких репликах лимиты не общие, а за балансировщиком все клиенты выглядят одним IP, потому что X-Forwarded-For шлюз не доверяет.
Нет TLS на входе, WebSocket и Upgrade не поддерживаются (501); перцентили по гистограмме с погрешностью до 25%; админка с одним токеном; SIGHUP только на Unix.
Тесты
Балансировщики, состояния breaker на фейковых часах, token bucket, SSRF-кейсы, ретраи (GET, PUT с телом; POST не ретраится), горячая перезагрузка с запросом «в полёте», защита от open-proxy, нагрузочный тест (4000 запросов, 40 клиентов); go test -race в CI.
Исходники: README проекта api-gateway: «Архитектура», «Гарантии», «Что считается ошибкой», «Ограничения»
База Markdown-заметок на Go и SQLite: вики-ссылки с backlinks и графом, поиск FTS5 с bm25, версии с собственным diff. Главное — безопасный собственный рендер и поиск по стемам.
Go
SQLite
FTS5
bm25
modernc.org/sqlite (без CGO)
REST API
Markdown (свой безопасный рендер)
SVG force-layout
Vanilla JS
embed
Docker
GitHub Actions
32 тестов
19 маршрутов
2 027 строк Go
Клиент / входСервисХранилище
Потоки данных
1Редактор шлёт PUT с rev; список слева обновляется сразу при вводе и откатывается, если сервер вернул ошибку.
2Поиск, Ctrl+K и граф запрашивают /api/search и /api/graph под Bearer-токеном; граф раскладывается в браузере (Фрухтерман — Рейнгольд, SVG).
3Текст заметки идёт в собственный рендер: исходник экранируется целиком, теги создаёт только рендер из закрытого набора, схемы ссылок из белого списка.
4Название, тело и теги проходят токенизацию и стеммер (около 70 окончаний, ё → е); запрос превращается в термы «стем»* через AND, и ввод пользователя не попадает в синтаксис FTS.
5Из текста выделяются [[ссылки]] и пишутся в links по title_key; backlinks — это соединение по dst_key, поэтому ссылка на ещё не созданную заметку оживает сама.
6В notes_fts пишутся стемы, ранжирование bm25(notes_fts, 8, 1, 4) (название, тело, теги), сниппет строит Go-код; сохранение — одна транзакция: заметка, теги, ссылки, FTS и версия.
7Правки автосохранения в пределах 5 минут вливаются в последнюю версию, Ctrl+S создаёт новую; diff построчный на LCS; при устаревшем rev сервер отвечает 409 с актуальной заметкой.
8Экспорт отдаёт zip из .md с front matter; импорт папки, файлов или zip идёт через API, пути из архива никуда не пишутся, поэтому zip-slip невозможен.
Решения
Свой Markdown-рендер вместо goldmark
Безопасность — свойство конструкции, а не очистка после: исходник экранируется, теги из закрытого набора. Цена — меньше возможностей, чем у CommonMark (нет таблиц и картинок); зато зависимости остаются на уровне stdlib и SQLite.
FTS5 над стемами, сниппет в Go
В индекс пишутся стемы, а не исходный текст, поэтому штатный snippet() FTS5 видел бы только стемы. Совпадения подсвечивает Go-код, он же экранирует HTML.
title_key вместо NOCASE
Уникальность и вики-ссылки строятся на названии в нижнем регистре с нормализованными пробелами, потому что NOCASE в SQLite для кириллицы не работает.
rev для конфликтов, ver для истории
rev считает любые изменения и защищает от затирания чужой правки, ver нумерует версии. «Перезаписать моей» фиксирует версию, так что чужая правка остаётся в истории.
Компромиссы
Морфология упрощённая: беглые гласные, чередования и неправильные формы не работают, у коротких слов возможны ложные совпадения. Переименование заметки не переписывает [[ссылки]] в других заметках.
Один общий токен, одно соединение с БД, конфликты разрешаются целиком; граф рассчитан на сотни заметок (раскладка O(n²) на шаг). В Markdown нет таблиц, картинок и сносок.
Тесты
Ранжирование bm25 и вес названия, морфология, мусорные запросы, backlinks и граф, версии и слияние правок, конфликт rev, XSS (около 30 нагрузок), diff, экспорт → импорт, безопасность zip; go test -race в CI.
Исходники: README проекта notes-search: «Архитектура», «Схема БД», «Поиск», «Решения», «Ограничения»
Учебная реализация Raft на чистой stdlib: три узла, выборы, репликация, коммит по кворуму, WAL с fsync, снапшоты, read-index. Главное — ядро без времени и сети, которое один и тот же код гоняет и в рантайме, и в детерминированном симуляторе.
Go
Raft
Distributed systems
Consensus
stdlib only
HTTP/JSON
SSE
WAL + fsync
Snapshots
Linearizability
Deterministic simulation testing
Vanilla JS
Docker
GitHub Actions
27 тестов
13 маршрутов
2 646 строк Go
Клиент / входСервисХранилище
Потоки данных
1Клиент шлёт PUT, GET или DELETE на любой из узлов 8095–8097; не лидер отвечает 307 с Location на лидера (и заголовком X-Raft-Leader), а если лидер неизвестен, то 503 с Retry-After.
2Дашборд раз в 200 мс получает по SSE роль, term, commit/applied и хвост лога; кнопки «остановить узел», «оборвать сеть» и «убить лидера» работают только при SEED_DEMO=1 и идут по настоящему HTTP.
3Рантайм тикает ядро каждые 25 мс (Tick) и передаёт ему входящие сообщения (Step); исходящие сообщения возвращаются вызывающему, сам raft.Node не знает ни о времени, ни о сети.
4Выборы (vote), репликация (append) и snapshot идут HTTP/JSON-сообщениями, ответы это отдельные сообщения, поэтому сеть может терять и переупорядочивать пакеты; индекс коммитится, когда его хранит большинство и запись относится к текущему term.
5Закоммиченные записи применяются к автомату состояний (словарь, команды put и del); ответ PUT уходит после коммита на кворуме, а линеаризуемый GET ждёт applied >= readIndex.
6Ответ уходит только после fsync: term и votedFor пишутся в state.json (временный файл, fsync, rename), записи лога в WAL с CRC32; битый хвост после крэша обрезается при загрузке.
7После SNAPSHOT_EVERY применённых записей (по умолчанию 200) снапшот словаря пишется новым поколением файлов: новый WAL, затем атомарная публикация snapshot.bin, затем удаление старого WAL.
8Тот же raft.Node работает в симуляторе: время виртуальное, сеть управляемая (задержки, потери, обрывы), крэш и рестарт узла идут из его хранилища; один seed воспроизводит запуск побайтно, а инварианты и линеаризуемость проверяются после каждого шага.
Решения
Ядро без горутин и часов
raft.Node получает время только через Tick() и сообщения через Step(), а исходящие возвращает вызывающему. Поэтому один и тот же код работает в рантайме (тик 25 мс, HTTP) и в симуляторе, а сбой воспроизводится по seed.
Коммит только записей текущего term
Индекс коммитится, когда его хранит большинство и запись относится к текущему term (раздел 5.4.2 статьи), поэтому новый лидер сразу кладёт в лог no-op. Целенаправленного теста на фигуру 8 нет, и это записано в ограничениях.
Read-index вместо лизов
Лидер запоминает commitIndex, рассылает heartbeat, ждёт подтверждения большинства и только потом отвечает на GET. Корректность не зависит от часов, а отрезанный лидер ответить не может.
Снапшот «поколениями»
Новый WAL, затем атомарная публикация snapshot.bin (точка коммита), затем удаление старого WAL. Крэш на любом шаге оставляет целое старое или целое новое состояние.
Компромиссы
Состав фиксирован (ровно три узла), нет динамического членства, pre-vote и check-quorum, клиентских сессий и exactly-once: после 504 исход неизвестен. Снапшот идёт одним сообщением и целиком в памяти, каждый PUT это отдельный fsync, TLS и аутентификации нет.
Проверка собственная: симулятор с идеальным диском и проверка линеаризуемости на малых историях, это не Jepsen. Одна ручная мутация, коммит записей прошлых term, тестами не ловится.
Тесты
Выбор одного лидера на term (40 seed-ов с потерями, крэшами и обрывами), переизбрание после падения лидера, репликация, отсутствие коммита без кворума, откат хвоста отрезанного лидера, рестарт с диска, голос не отдаётся дважды после рестарта, снапшоты и догон, линеаризуемость под хаосом (30 seed-ов), детерминизм трассы; HTTP-тесты узла и сам чекер линеаризуемости; go test -race в CI.
Исходники: README проекта raft-kv: «Архитектура», «Как устроены части», «Инварианты», «Тесты», «Чего здесь нет»
Сервис аутентификации на stdlib crypto и SQLite: EdDSA-JWT с ротацией ключей и JWKS, refresh-токены с ротацией и reuse-detection, TOTP, RBAC, lockout, аудит. Главное — потоки токенов и честная модель угроз с тем, что остаётся незакрытым.
Go
stdlib crypto
SQLite
modernc.org/sqlite (без CGO)
JWT EdDSA/Ed25519
JWKS
PBKDF2
TOTP
RBAC
REST
OpenAPI 3
Vanilla JS
embed
Docker
GitHub Actions
50 тестов
35 маршрутов
2 826 строк Go
Клиент / входСервисХранилище
Потоки данных
1Вход: логин и пароль, при включённой 2FA второй шаг с кодом TOTP или резервным кодом; в ответе access JWT на 10 минут, а refresh приходит в HttpOnly-cookie SameSite=Strict вместе с CSRF-токеном.
2Middleware проверяет Origin, CSRF и лимит запросов с IP, затем подпись и срок access-токена, потом по БД что сессия жива, а права берутся из БД на каждом запросе, поэтому отзыв и смена роли действуют сразу.
3Токены подписывает кольцо ключей Ed25519: принимается только EdDSA, kid берётся из белого списка; POST /api/admin/keys/rotate выпускает новый ключ, а старый остаётся для проверки ещё access_ttl + 1 минуту.
4В БД лежат хеши пароля (PBKDF2, 600 000 итераций), refresh-токенов (SHA-256) и резервных кодов; refresh гасит старый токен и выдаёт новый, а повторное предъявление использованного отзывает всю семью и пишет token.reuse_detected.
5Публичные ключи отдаются на /.well-known/jwks.json (RFC 7517/8037) вместе с kid.
6Сторонний сервис берёт kid из заголовка JWT, достаёт ключ из JWKS (кеш на минуту) и проверяет подпись, iss, aud, typ и exp; об отзыве сессии он узнает не раньше конца жизни access-токена.
7Вход, неудачи, блокировки, 2FA, сброс пароля, отзывы сессий, действия админа, ротация ключей и отказы по правам пишутся в таблицу audit; триггеры БД запрещают UPDATE и DELETE, пароли и токены в журнал не попадают.
Решения
Проверка сессии по БД на каждом запросе
Access-токен сверяется с БД (сессия не отозвана, пользователь не отключён, права актуальны). Это стоит одного запроса к SQLite и взамен даёт немедленные «выйти на всех устройствах», блокировку и смену роли.
Семья refresh-токенов и reuse-detection
Каждое обновление гасит старый токен и выдаёт новый. Если использованный токен предъявлен снова, считается, что его украли: отзывается вся сессия, мёртвыми становятся и украденный, и легитимный токен.
Только EdDSA и свой разбор JWT на stdlib
Алгоритмы none и HS256 отклоняются до проверки подписи (защита от alg-confusion), kid из белого списка, длина токена ограничена. Тесты включают HS256 с публичным ключом в роли секрета.
Одинаковый ответ и время для неизвестного логина
Для несуществующего пользователя считается фиктивный хеш, ответ и время совпадают с неверным паролем, а forgot всегда отвечает 202. Исключение осталось: регистрация сообщает, что логин или почта заняты.
Компромиссы
Секрет TOTP и приватные ключи подписи лежат в БД открытым текстом: с дампом БД можно подделать токены и 2FA. Пароли хешируются PBKDF2, а не argon2id, резервные коды быстрым SHA-256.
Не прошёл независимый аудит, нет OAuth/OIDC, WebAuthn и подтверждения почты; письмо сброса это заглушка в логе. Лимитер и блокировки живут в памяти и в SQLite одного процесса, а access-токен не отозвать у сторонних потребителей до exp.
Тесты
JWT (подделка, exp/nbf/iss/aud/typ, alg=none, HS256 с публичным ключом, ротация), TOTP по векторам RFC 6238 и RFC 4226, ротация refresh и reuse-detection, блокировка с экспоненциальной задержкой, 2FA и резервные коды, RBAC, сброс пароля, аудит (запрет UPDATE/DELETE), cookie-флаги, CSRF, CORS, Origin, rate limit; JWKS проверяется независимым кодом; go test -race в CI.
Учёт личных финансов: Go-бинарник с SQLite и встроенным фронтендом на Nuxt 4 (SPA). Главное — деньги целыми минорными единицами, мультивалютность с ручными курсами, расписание и импорт CSV, изоляция пользователей.
Go
SQLite
modernc.org/sqlite (без CGO)
REST API
Nuxt 4
Vue 3
TypeScript
Pinia
SVG-графики
vitest
ESLint
Docker
GitHub Actions
PBKDF2
CSV import/export
30 тестов
45 маршрутов
3 741 строк Go
Клиент / входСервисХранилище
Потоки данных
1Браузер работает с API через JSON; сессия живёт в httpOnly-cookie (SameSite=Lax), в базе хранится только SHA-256 токена, а у запросов на запись проверяется Origin.
2Файл CSV и экспорт (CSV с защитой от formula-injection и полный JSON) проходят через тот же API, отдельных каналов нет.
3Каждый запрос привязан к user_id из сессии; обычный CRUD счетов, категорий, курсов и операций идёт прямо в слой счетов и операций, с фильтрами q, kind, account_id, category_id, tag, from, to.
4POST /api/import/preview определяет разделитель и кодировку и показывает сопоставление колонок; /commit пишет строки, пропускает дубли и возвращает отчёт по ошибочным строкам (не более 20 000 строк за раз).
5Cron-подобное правило из пяти полей (с точностью до дня) исполняется при открытии приложения и по кнопке /api/recurring/run; пропущенные даты нагоняются, фонового планировщика нет.
6Импортированные строки становятся обычными транзакциями: дубли пропускаются, недостающие категории создаются, переводы не импортируются.
7Регулярные платежи тоже становятся обычными транзакциями и хранят recurring_id; last_date правила двигается вперёд.
8Отчёты читают операции целиком в память и считают в базовой валюте по текущим курсам; наклон тренда считается МНК в целых числах.
9Суммы пишутся целыми минорными единицами (amount_minor > 0), курс хранится целым ×10⁶, пересчёт идёт через big.Int; баланс счёта не хранится, а считается запросом.
10Лимиты бюджета (limit_minor) и пороги предупреждения (warn_pct) лежат в таблице budgets и сравниваются с расходами месяца; отчёты читают те же таблицы и ничего не пишут.
Решения
Деньги только целыми минорными единицами
В базе, API и браузере суммы это целые числа копеек или центов, без float. Пересчёт валют идёт через big.Int с округлением «от нуля», а округление и разбор сумм покрыты тестами.
Свои SVG-графики вместо библиотеки
Нужны три простых типа графиков, а библиотека добавила бы десятки КБ JS. Свои (около 250 строк) дают контроль над доступностью (role="img", текстовая таблица), темами через CSS-переменные и строгим CSP.
SPA без SSR
Nuxt собирается как nuxt generate с ssr:false: данные сессии нельзя пререндерить. Go отдаёт встроенную web/dist и 200.html для неизвестных путей, а CSP получает хэши встроенных скриптов вместо unsafe-inline для скриптов.
Баланс считается, а не хранится
Баланс счёта = начальный остаток + доходы − расходы − исходящие переводы + входящие переводы. Хранимого поля нет, поэтому оно не может разойтись с операциями.
Компромиссы
Курсы ручные и текущие, истории курсов нет: прошлое пересчитывается по сегодняшним курсам. Базовую валюту нельзя сменить, бюджет одинаков каждый месяц, платежи создаются только при открытии приложения.
Нет смены и восстановления пароля, SQLite и один процесс, отчёты читают операции целиком в память. В style-src остался unsafe-inline (стили Vue), SSR отключён.
Тесты
Деньги (разбор, формат, округление, пересчёт, round-trip), бюджеты, cron-правила и нагон пропущенных дат, импорт CSV, изоляция пользователей, API, отчёты, демо-данные, TestRealDist (встроенная сборка отдаётся, CSP без unsafe-inline для скриптов); во фронтенде vitest, ESLint и typecheck; go test -race в CI.
Исходники: README проекта finance-tracker: «Возможности», «Архитектура», «Схема БД», «API», «Ограничения»
Совместный холст 256×256 на WebSocket: свой RFC 6455 на stdlib, бинарный протокол, снимок и дельты, снапшот плюс журнал. Главное — согласованность дельт и восстановление после падения, при том что каждый пиксель стоит 4 бита.
Go
stdlib only
WebSocket
Canvas 2D
Vanilla JS
embed
Binary protocol
Snapshot + journal
Docker
GitHub Actions
77 тестов
16 маршрутов
2 715 строк Go
Клиент / входСервисХранилище
Потоки данных
1Браузер открывает WS /ws?sid=…, получает сжатый снимок (0x01: seq, размер, deflate-raw холста) и дальше только дельты (0x02: firstSeq и до 2000 записей); пиксель уходит в 6 байтах (0x10: x, y, цвет).
2Админ-запросы идут с заголовком Authorization: Bearer; токен сравнивается за постоянное время, неверные попытки лимитируются по IP, а без ADMIN_TOKEN админ-API выключено (403).
3Сервер проверяет Origin, размеры сообщений (WS до 64 байт, тело до 4 КиБ) и подписанную сессию, затем передаёт установку в wall; хаб рассылает накопленные дельты раз в 50 мс, а медленный клиент отключается и перечитывает снимок.
4Перед записью wall спрашивает кулдаун: строго один пиксель за COOLDOWN_SEC на сессию и не больше IP_BURST за то же скользящее окно на IP; бан сессии или IP отвечает 403, лишнее 429.
5Единый мьютекс упорядочивает запись в холст, журнал и очередь дельт; пиксель занимает 4 бита (чётный индекс в старшей тетраде байта), 256×256 = 32 КиБ.
6Каждая установка добавляется в историю (до MAX_HISTORY записей) со старым и новым цветом; клиент откатывает таймлапс назад от текущего состояния, базовый снимок не нужен.
7Снапшот canvas.snap пишется раз в SNAPSHOT_SEC во временный файл с crc32, fsync и rename; восстановление берёт снапшот, затем записи журнала с большим seq и обрезает недописанный хвост.
8journal.log (10 байт на запись: время, x, y, старый и новый цвет, флаги очистки и бота) дописывается при каждом пикселе, fsync раз в 30 секунд; при превышении MAX_HISTORY на 25% журнал переписывается с хвостом.
Решения
WebSocket вместо SSE и POST
Нужен двунаправленный бинарный канал: пиксель в 6 байтах и сразу ok или err, дельты бинарными. SSE это текст (base64 раздувает трафик на треть) и отдельные POST на каждое действие. Запасной путь без сокета есть: POST /api/pixel.
Снимок со seq и дельты с firstSeq
Клиент регистрируется в хабе и под той же блокировкой получает снимок со seq. Дельта несёт firstSeq: клиент пропускает записи с seq не больше своего, а при пропуске переподключается.
В журнале хранится старый цвет
Запись «старый<<4 | новый» позволяет клиенту откатывать историю от текущего состояния, поэтому базовый снимок для таймлапса не нужен, а усечение журнала лишь сокращает глубину истории.
Снапшот и журнал вместо одного файла
Снапшот через временный файл и rename гарантирует целостность, журнал даёт последние изменения; битый снапшот (crc) восстанавливается из журнала, а не стыкующийся журнал начинается заново.
Компромиссы
Всё в одном процессе и на одном диске; кулдауны, сессии и rate limit в памяти и сбрасываются при рестарте. Сессия это идентификатор в localStorage, поэтому защита от простых ботов, а не от атаки с множества IP.
Журнал без fsync на каждый пиксель (fsync раз в 30 с), журнал целиком в памяти, расширений WebSocket нет, клиенту нужен DecompressionStream. Браузерных тестов в репозитории нет.
Тесты
Кулдаун (сессия, IP, скользящее окно, переполнение таблицы), упаковка и граничные координаты, кодеки и битые сообщения, хранилище (crc, оборванный хвост), восстановление без снапшота и из снапшота с журналом, компактация, откат истории, непрерывность дельт, API (лимиты, Origin, 401/403/429), WebSocket с двумя клиентами и флуд, права админа, бан, нагрузка на 300 клиентов; go test -race в CI.
CLI для анализа git-репозиториев: разбор потока git log, агрегаторы метрик и три вывода из одной структуры — терминал, самодостаточный HTML и локальный сервер. Главное — не писать свой разбор .git, а доверить историю и переименования самому git.
Go
stdlib only
CLI
git
html/template
SVG
ANSI
os/exec
GitHub Actions
Docker
32 тестов
3 маршрутов
2 133 строк Go
Клиент / входСервисХранилище
Потоки данных
1Команда разбирает флаги (-rev, -since, -until, -max-commits, -top, -depth, -json, -color) и проверяет репозиторий и ревизию; нет git, не репозиторий или неизвестная ревизия дают код возврата 3, неверные аргументы 2.
2git читает историю и .mailmap сам; поэтому имена авторов приходят уже с учётом %aN/%aE, а переименования находит git -M, без собственного разбора packfile.
3gitlog запускает git log -z --numstat -M через os/exec и разбирает поток по одной записи: -z исключает проблемы с кавычками и юникодом в путях, в памяти живёт один коммит; на терминале в stderr рисуется прогресс.
4Aggregator принимает коммиты от новых к старым и копит метрики; при встрече old => new запоминает переименование (с разрешением цепочек), поэтому более ранние правки old попадают на актуальное имя; lock-файлы, vendor и min-файлы пропускаются, слияния считаются отдельно.
5Результат — одна структура analyze.Result (JSON при -json); term рисует из неё таблицы с цветами, столбцы и спарклайны, NO_COLOR и запись в канал или файл отключают цвет, на Windows включаются VT и UTF-8.
6report через html/template собирает HTML с inline SVG-графиками, CSS и небольшим JS переключателя темы; внешних запросов нет, имена и пути экранируются, тема следует за prefers-color-scheme.
7report пишет один файл (-o файл, по умолчанию gitpulse-report.html) или в stdout с -o -.
8serve принимает только loopback-адрес в -addr, отвергает запросы с чужим заголовком Host (защита от DNS-rebinding), ставит CSP и no-store, держит результат в кеше (по умолчанию 30 с, /?refresh=1 сбрасывает) и отдаёт /, /api/stats и /healthz.
Решения
git через os/exec, а не разбор .git
Собственный разбор объектов и packfile с дельта-цепочками потребовал бы ещё commit-graph, .mailmap, замены ссылок, shallow- и partial-клонов и обнаружения переименований: тысячи строк с риском ошибиться в метриках. git log --numstat -M решает это надёжно, цена — зависимость от установленного git, которая проверяется явно.
Поток с -z вместо загрузки всего лога
Лог читается по одной записи, память растёт от числа различных файлов и авторов, а не коммитов; -max-commits помечает отчёт как усечённый.
Одна структура Result для всех выводов
Терминал, HTML, JSON и сервер показывают числа из одной analyze.Result, поэтому расходиться им не из чего, а тесты сверяют метрики в одном месте.
Самодостаточный HTML и loopback-сервер
В отчёте есть имена авторов, поэтому он одним файлом без внешних запросов и шрифтов, а serve слушает только 127.0.0.1 и проверяет Host.
Компромиссы
Нужен установленный git; время по UTC автора; слияния не анализируются; копирование файлов не отслеживается, переименования видны лишь там, где их находит git -M.
Рост языков — оценка «добавлено минус удалено», а bus factor и риск — эвристики по числу строк; память растёт с числом файлов и авторов, в -json есть почты авторов, в HTML нет.
Тесты
Синтетические репозитории через git и метрики на заранее посчитанных данных: слияния, переименования (в том числе правка по новому пути), бинарные файлы, кириллица в путях и именах, lock-файлы, пустой репозиторий, один коммит, лимит коммитов, диапазоны, плохие ревизии; разбор потока (таб в пути, битые записи), golden-тест структуры HTML, самодостаточность и экранирование отчёта, сервер (кеш, Host-guard, методы), коды возврата, JSON CLI, NO_COLOR; go test -race в CI.
Исходники: README проекта gitpulse: «Команды», «Что считается», «Архитектура», «HTML-отчёт», «Тесты», «Ограничения»; код internal/gitlog, internal/analyze, internal/report, internal/term, internal/serve