Skip to content

Repository files navigation

CANDIDATE=[email protected]

Mail threads

Сервис на TypeScript: выгружает ленту писем из provider, складывает в SQLite и раскладывает письма по разговорам.

Как запустить

cp .env.example .env   # прописать свой CANDIDATE
docker compose up worker
docker compose run --rm exporter

Результат — ./out/result.jsonl. Самопроверка: node selfcheck.js out/result.jsonl. Локальные тесты (вне Docker): npm install && npm test. Линтер и форматтер — Biome: npm run lint, npm run format (конфиг — biome.json). Lint, сборка и тесты также гоняются в CI (.github/workflows/ci.yml) на каждый push и pull request.

Как устроено хранение и почему

SQLite в режиме WAL, файл в named volume mail-data — отдельная служба БД не нужна, миграции это CREATE TABLE IF NOT EXISTS при старте приложения. Две таблицы:

  • messages — одна строка на письмо, первичный ключ external_id; повторные письма от провайдера гасятся через INSERT OR IGNORE. Ссылки (in_reply_to, references) хранятся в том виде, в котором пришли; thread_key и parent_id заполняются финальным разбором.
  • state — key-value: текущий курсор пагинации и флаг завершения обхода.

Страница писем и новый курсор сохраняются одной транзакцией, поэтому после остановки обход продолжается с последней полностью сохранённой страницы.

Если появится второй почтовый сервис

Новый источник присылает письма сам (push), отдаёт готовые разговоры, но не имеет своих message-id. Без изменений останутся: таблица messages, экспортер и формат result.jsonl, дедупликация по первичному ключу, docker-инфраструктура. Придётся изменить: вместо цикла пагинации — приёмник push-сообщений; добавить колонку source и генерировать суррогатный external_id (например, хеш содержимого с префиксом источника); для этого источника не нужны union-find и выбор parent_id — разговор берём готовый, пространства thread_key источников разводим префиксом.

При одновременном запуске двух сервисов над одной базой

Данные не испортятся: вставки защищены первичным ключом, SQLite сериализует транзакции, курсор по сути детерминирован (одна и та же страница даёт тот же next_cursor), а финальный разбор идемпотентен. Нарушится экономика обхода: оба процесса запросят одни и те же страницы, удвоив частоту обращений к провайдеру, — это почти гарантированно приведёт к 429 и удлинит паузы. Защита — один экземпляр worker (в compose он и запускается одиночным) либо advisory lock в БД, если бы БД была клиент-серверной.

Шаг, который нельзя выполнить дважды

Такого шага нет. Вставка писем — INSERT OR IGNORE по первичному ключу; сохранение курсора — перезапись одного значения; финальный разбор разговоров — чистая функция от содержимого messages, её можно гонять сколько угодно раз; экспорт — полная перезапись файла.

Что решили не усложнять

  • SQLite вместо клиент-серверной БД: один писатель и десятки тысяч строк — серверная БД добавила бы службу, не дав ничего взамен. Решение изменится, если появится второй активный писатель или конкурентные читатели.
  • Разбор разговоров одним финальным проходом в памяти вместо инкрементального union-find при вставке каждого письма: письма приходят вразнобой и «мосты» всё равно требуют слияний, а полный пересчёт проще, идемпотентен и занимает секунды. Пришлось бы перейти на инкремент, если бы результат был нужен онлайн, по ходу обхода.

Что перестанет работать при десятикратном объёме

Первым упрётся финальный разбор: он держит в памяти все письма и union-find целиком — на миллионах писем это сотни мегабайт; лечится потоковой обработкой. Следом — полная перезапись thread_key/parent_id одной транзакцией. Сам обход ленты масштабируется линейно и памятью не ограничен, экспортер уже потоковый (курсорная выборка + backpressure).

About

Тестовое задание: сервис на TypeScript, который выгружает ленту писем в SQLite и раскладывает письма по разговорам (Docker, better-sqlite3, Biome)

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages