Версия 0.1.0. Начато 23 октября, завершено 24 октября 2026 года. Новый навык — надёжная обработка очереди, стек Python 3.12 и встроенная SQLite. Запуск и команды в README.md, карта файлов — START_HERE.md. Все примеры вымышленные.
Есть события об оплаченных товарах, которые нужно включить в сумму. Источник может повторить событие, обработчик может завершиться в неподходящий момент, а часть данных может оказаться непонятной новой версией программы. Если просто читать строки и увеличивать счётчик, повтор удвоит сумму. Если отмечать строку выполненной до записи результата, авария потеряет эффект. Если отмечать после отдельной записи, возникает окно между двумя действиями, в котором повтор опять удвоит результат.
Проект разделяет получение сообщения и принятие результата. Сообщение выдаётся на ограниченный срок, а результат и подтверждение записываются одной транзакцией. За E0001 магазин должен учесть 10000 копеек ровно один раз. Во входе E0001 встречается дважды; в очереди остаётся одна запись. После успешного выполнения ещё одна загрузка того же ID не создаёт вторую сумму.
Queue — сохранённые события и их состояния. Lease — временное право обработать одно событие. ACK — подтверждение завершённой обработки. Retry — новая попытка после временного отказа. Backoff — задержка перед ней. DLQ, dead-letter queue, — место для сообщений, по которым нужно отдельное решение. Здесь оно логическое: строки messages со state=dead, а не отдельная база или брокер.
Fencing — проверка номера выдачи. Одного срока недостаточно: старый медленный worker может проснуться после того, как сообщение уже выдано другому. У каждого нового claim растёт generation. Завершающая операция сверяет этот номер с текущим в БД. Это не пароль и не механизм разграничения доступа; это защита протокола обработки между доверенными локальными клиентами.
В предыдущем проекте dbt строил аналитические модели целиком. Здесь важно другое: сохранить состояние маленькой единицы работы, повторить доставку и не удвоить эффект. SQLite выбран, чтобы показать протокол без установки брокера. Позже его можно сравнить с consumer groups и подтверждениями настоящих распределённых очередей.
Строка JSONL имеет ровно event_id и payload. ID — E и четыре цифры; всего такая учебная схема допускает 10000 различных ID. Payload — JSON-объект до 10000 байт. Файл ограничен миллионом байт и 1000 непустыми строками. UTF-8 BOM и CRLF допускаются, пустые строки игнорируются. Повтор ключа JSON и нечисловые константы NaN/Infinity запрещены. Перед транзакцией проверяется весь конверт партии.
JSON приводится к устойчивому виду: ключи сортируются, лишние пробелы убираются, типы значений сохраняются. Поэтому разный порядок ключей не создаёт конфликт, но int и float не следует считать взаимозаменяемым бизнес-значением. Одинаковый ID с другим каноническим содержимым отклоняет всю партию, включая её новые строки. Исправлять payload уже принятого ID молча нельзя: нужна новая единица события.
Транспорт принимает любой допустимый объект, а бизнес-обработчик требует category из book/pen/bag и целый amount_cents от 1 до 9999999. bool специально не принимается: в Python он является подклассом int. Разделение важно: синтаксически верное событие новой категории может находиться в очереди, но старая версия handler отправит его в DLQ и продолжит работать с остальными событиями.
В messages находятся payload, state, attempts, generation, available_at, lease_until и последний код ошибки. В effects — отдельный результат с уникальным event_id, категорией и суммой. FK связывает эффект с известным сообщением. STRICT и CHECK дополнительно ограничивают типы, состояния и согласованность lease_until.
Переходы: ready → leased → done; временный отказ возвращает leased → ready с будущим available_at; постоянный отказ ведёт в dead. Истёкшая выдача также возвращается в ready, если бюджет не исчерпан. Три выдачи расходуют бюджет независимо от того, был явный отказ или процесс умер без ответа. После третьей смерти следующий claim переведёт сообщение в dead. Без нового claim ленивое восстановление сроков не выполняется: status лишь показывает сохранённые значения.
store.py управляет протоколом и транзакциями; worker.py знает бизнес-поля; examples.py создаёт синтетику и запускает аварийный дочерний процесс. main.py разбирает аргументы. Каждый запрос использует своё соединение, которое явно закрывается. Контекст соединения SQLite сам по себе не является командой close; явный finally помогает корректно удалить временные файлы и на Windows.
claim начинает BEGIN IMMEDIATE до чтения готового сообщения. SQLite допускает только одного писателя одновременно. Второй клиент дождётся окончания короткой транзакции или получит ошибку timeout, затем увидит уже обновлённое состояние. Нельзя выбирать сообщение отдельным SELECT вне этой границы и считать его своим.
Критерий выбора — ready и available_at <= now, порядок по available_at/event_id. После retry это не строгий FIFO: независимое событие может пройти раньше отложенного. Выбранная строка получает attempts+1, generation+1 и lease_until. Возвращается неизменный объект Lease, но payload для эффекта всё равно читается из БД: вызывающий не должен подменять результат изменённой копией словаря.
Обработка результата выглядит так:
BEGIN IMMEDIATE;
-- Проверка state, generation и lease_until.
INSERT INTO effects(event_id, category, amount_cents) VALUES(?, ?, ?);
-- Повторная проверка срока перед ACK.
UPDATE messages SET state='done', lease_until=NULL WHERE event_id=?;
COMMIT;Значения передаются параметрами. Повторная проверка срока нужна даже без конкурента: операция могла затянуться. При утрате выдачи транзакция откатывает уже сделанный INSERT. Уникальный ключ effects — дополнительная защита, а не замена атомарному протоколу.
Демо принимает пять строк: E0001 book=10000 дважды, E0002 pen=2000, E0003 book=5000, E0004 toy=3000. После enqueue получается четыре сообщения. E0001 завершается нормально. E0002 получает управляемую временную ошибку и available_at=1001. E0003 в отдельном процессе делает INSERT, затем os._exit(86) до COMMIT. Это реальное завершение процесса без finally; БД восстанавливает незавершённую транзакцию. Ранее зафиксированный claim при этом остаётся leased. E0004 сразу становится dead из-за категории toy.
В промежуточном снимке есть только эффект E0001: total=10000; по одному сообщению ready, leased, done и dead. В модельную секунду 1001 повторяется E0002, в 1002 истекает срок E0003 и выдаётся новое поколение. Итог — три эффекта, book=15000, pen=2000, total=17000, одно dead. Ещё одна загрузка всех пяти строк даёт пять дублей и те же деньги. Модельные часы исключают сон из тестов и не меняют даты Git.
Временная ошибка даёт задержку 1, затем 2 секунды; после третьей выдачи сообщение dead. Постоянная ошибка payload не расходует три одинаковые бессмысленные попытки: она сразу попадает в DLQ с кодом invalid_payload. В БД сохраняется исходное событие, а не только обрезанное сообщение исключения.
redrive разрешён только для dead. Он сбрасывает attempts для нового цикла, но не generation и не payload. Если причина — новая допустимая категория, сначала нужно добавить поддержку handler, затем вернуть сообщение. Для toy после такого изменения общая сумма становится 20000, done=4, dead=0. Без исправления правила оно снова dead. Старый worker предыдущего цикла не получает права ACK, даже если attempts снова 1.
Обычные команды используют системное время в секундах. Все клиенты находятся на одном хосте; скачки его часов могут преждевременно истечь или задержать lease. Внешний HTTP нельзя вставить вместо локального INSERT и сохранить ту же гарантию: одна SQLite-транзакция не откатывает отправленное письмо. Для такой интеграции потребуется протокол идемпотентности внешнего получателя или outbox/inbox.
26 предметных тестов используют настоящую SQLite: конфликт партии, повтор, границу истечения, старую generation, отказ во время apply, бюджет попыток, redrive, двух конкурирующих клиентов и двадцать событий с двумя потребителями. Авария проверяется отдельным настоящим процессом. Моделируются только часы, не поведение БД. Три проверки публикации проверяют allowlist и типовые секреты. Smoke дополнительно проходит через CLI и проверяет ручное восстановление после поддержки toy.
При чтении начатого кода обнаружен случай category как JSON-массива: проверка принадлежности множеству без проверки типа могла дать TypeError вместо понятной DLQ. Добавлена проверка str до обращения к множеству; набор ошибочных типов проверен настоящими сообщениями. Разбор NaN упрощён до именованной функции, глубокий недопустимый payload получает понятный отказ. После этих исправлений первые предметные тесты и demo прошли. Неудачные результаты не придумывались: окончательные команды, версии и ОС фиксируются в VALIDATION.
Это локальная очередь, не замена Kafka. Один писатель, конечный worker без демона, нет миграций, приоритетов, продления lease и автоматической очистки. DONE сохраняется для дедупликации; произвольное удаление истории разрушит это допущение. Все процессы должны использовать этот протокол, прямой SQL владельца файла может его обойти. Падение процесса проверено, отключение питания и неисправность носителя — нет. Synchronous FULL не заменяет резервное копирование и проверку реального оборудования.
Следующий полезный шаг — метрики количества ready/dead, возраста очереди и задержек, потом миграции и политика хранения. Распределённый брокер имеет смысл при работе нескольких хостов; его добавление потребует заново сформулировать границу эффекта.
Честная формулировка: «В учебной локальной очереди реализовал lease и fencing, проверил настоящую аварию между INSERT и ACK и не получил двойной результат».
- Почему доставка повторяется? Worker мог умереть после claim, не подтвердив результат.
- Зачем generation? Просроченный worker может проснуться во время новой выдачи.
- Почему транзакция начинается до SELECT? Выбор и захват должны сериализоваться.
- Чем DLQ помогает? Отделяет сообщения, по которым автоматический повтор уже не полезен.
- Где заканчивается гарантия результата? На транзакции одного файла SQLite.
EXERCISES.md содержит пять опытов: изменить сумму до 19000, откатить конфликтную партию без E0005, восстановить toy до 20000, отвергнуть старую generation и добавить метрику попыток DLQ. Проверяй одновременно деньги, число эффектов и состояния: правильная сумма при потерянном сообщении ещё не доказывает правильную очередь.
- Транзакции SQLite: единственный писатель и BEGIN IMMEDIATE.
- Python sqlite3: autocommit и явное управление транзакциями.
- STRICT: типы и проверки схемы.
- Атомарный commit: механизм журнала восстановления.
Выдача, retry-политика, бизнес-категории и выбранная граница эффекта — решения этого приложения, а не универсальные свойства любой очереди.