Перейти к содержанию

История в SQLite

Оглавление серии «Агрегатор лент» · Урок 5 из 10.

«Сигнал» уже получает ограниченное тело ленты, нормализует записи и вычисляет устойчивые ключи. Теперь нужно сохранить принятые данные между запусками. При этом нельзя независимо обновить строки подборки и HTTP-валидаторы: несогласованная фиксация могла бы заставить следующий запрос получить 304 для состояния, которого в базе нет. В этом уроке создадим SQLite-хранилище и одну транзакционную границу для записей, истории и состояния источника.

Состояние lesson-05 добавляет schema.sql и store.py. Учебный файл базы называется signal.db. Он не включён в подготовленный архив, поскольку пример не исполнялся и база не создавалась. Рассмотрим исходную схему и ожидаемые изменения на собственных записях demo-rss и demo-atom, сохраняя различие между временем публикации у источника и временем обнаружения нашей программой.

Три группы данных

Таблица sources содержит состояние загрузки каждого настроенного источника: ETag, Last-Modified, отметку сохранённого представления и сведения будущего расписания. Таблица entries хранит текущие нормализованные метаданные материалов. Таблица revisions сохраняет отдельные представления по отпечатку. Их связи выражены ключами: запись относится к источнику, а представление истории относится к записи.

Ниже дан полный schema.sql пятого состояния. Поля next_due, failures и reviewed_hash уже предусмотрены для следующих шагов серии; текущий урок не выдаёт их за завершённый worker или редакторский интерфейс. user_version обозначает версию структуры базы, а не номер выпуска какой-либо внешней ленты.

CREATE TABLE IF NOT EXISTS sources (
 id TEXT PRIMARY KEY, etag TEXT, modified TEXT, has_snapshot INTEGER NOT NULL DEFAULT 0,
 next_due INTEGER NOT NULL DEFAULT 0, failures INTEGER NOT NULL DEFAULT 0,
 last_ok INTEGER, last_error TEXT
);
CREATE TABLE IF NOT EXISTS entries (
 id TEXT PRIMARY KEY, source TEXT NOT NULL REFERENCES sources(id), remote_id TEXT NOT NULL,
 title TEXT NOT NULL, url TEXT, published TEXT, updated TEXT, fingerprint TEXT NOT NULL,
 first_seen INTEGER NOT NULL, last_seen INTEGER NOT NULL, reviewed_hash TEXT
);
CREATE TABLE IF NOT EXISTS revisions (
 entry_id TEXT NOT NULL REFERENCES entries(id), fingerprint TEXT NOT NULL,
 payload TEXT NOT NULL, seen_at INTEGER NOT NULL,
 PRIMARY KEY(entry_id, fingerprint)
);
CREATE INDEX IF NOT EXISTS entries_source ON entries(source);
PRAGMA user_version = 1;

Первичный ключ entries.id использует результат предыдущего урока. Поэтому исправление названия не меняет идентичность строки. Для истории ключ состоит из entry_id и fingerprint: одна запись может иметь несколько представлений, а повторное обнаружение той же нормализованной версии не обязано создавать ещё одну копию payload. Такое ограничение поддерживает идемпотентное повторное чтение.

Внешние ключи включаются для каждого соединения через PRAGMA foreign_keys=ON. Нельзя предполагать, что одна настройка произвольного прежнего соединения автоматически применяется ко всем будущим соединениям Python. В connect это часть обязательной подготовки. Таблицы остаются отдельными сущностями, но ссылка на несуществующий источник или запись не должна молча превратиться в потерянный фрагмент истории.

Порядок принятия

collect_one сначала читает сохранённое состояние источника, затем вызывает fetch. Для ответа 200 тело разбирается и проходит identify до начала записи. Ошибочный XML, слишком большое число строк или противоречивые ID не продвигают базу к новым валидаторам. Этот порядок переносит смысл «мы приняли представление» на весь обработанный набор, а не только на сетевой ответ.

Сетевое ожидание также не выполняется внутри write-транзакции. Держать право записи, пока внешний сервер отвечает, означало бы блокировать базу из-за неопределённого сетевого срока. Мы сначала готовим данные, затем открываем короткую область фиксации. При развитии проекта конкурентное планирование потребует своей политики; следующий урок ограничит сборку одним worker и сохранит состояние повторных попыток.

Ниже показан полный фрагмент transaction из store.py; это вспомогательная функция файла, а не отдельная программа. con получен через connect, где отключена неявная группировка операций isolation_level=None. Функция явно управляет SQL-командами начала, подтверждения и отката.

@contextmanager
def transaction(con):
    con.execute("BEGIN IMMEDIATE")
    try:
        yield
        con.execute("COMMIT")
    except BaseException:
        if con.in_transaction:
            con.execute("ROLLBACK")
        raise

BEGIN IMMEDIATE запрашивает write-транзакцию сразу. Это делает попытку записи явной, но не отменяет возможную занятость базы другим writer. Таймаут соединения ограничивает ожидание соответствующей блокировки; успешное открытие файла не гарантирует, что каждая последующая транзакция немедленно начнётся. Ошибка остаётся отказом сбора и не должна изображаться успешным обновлением источника.

Текущая строка и история

Для каждой подготовленной записи программа читает прежний fingerprint. Новая запись сначала появляется в entries, затем получает первое представление в revisions, чтобы соблюсти внешний ключ. При изменении существующей записи история получает новую нормализованную версию, а текущая строка обновляется. Все операции остаются внутри одной транзакции, поэтому промежуточная половина набора не считается завершённой загрузкой.

UPSERT использует ON CONFLICT(id) DO UPDATE. Изменяются название, ссылка, даты и fingerprint, но сохраняется первоначальное first_seen. Поле last_seen обновляется при обнаружении строки в принятом теле 200. Эти времена принадлежат сборщику и не заменяют published или updated. Если издатель не сообщил дату, соответствующее поле остаётся неизвестным независимо от времени нашего запуска.

Отметка reviewed_hash сохраняется только при неизменном fingerprint. Когда метаданные изменились, она сбрасывается: ранее рассмотренная редактором версия уже не совпадает с текущей. Пока интерфейс подтверждения ещё не добавлен, это лишь подготовленная связь между содержимым и будущим решением. Она не позволяет объявить все новые записи автоматически опубликованными.

История имеет важную границу: это набор различимых нормализованных представлений, а не журнал каждой доставки. Если источник вернулся к ранее известному fingerprint, конфликт составного ключа обновляет соответствующий payload и время наблюдения. Для точной последовательности всех переходов потребовалась бы отдельная таблица событий с собственными идентификаторами. Мы не выдаём текущую схему за полный аудит поведения издателя.

Валидаторы и 304

После успешной обработки всех строк ответ 200 обновляет ETag, Last-Modified и has_snapshot в той же транзакции. Если любая запись или завершение транзакции откажет, новый checkpoint не должен стать принятым отдельно от данных. При следующем запросе сборщик остаётся на последнем согласованном представлении. Это основная причина объединения разных таблиц одной границей фиксации.

Для 304 база проверяет, что ранее было принято представление и есть подходящий сохранённый валидатор. Строки и их история не удаляются, а last_seen отдельных записей не притворяется результатом нового чтения тела. Обновляются сведения успешной проверки источника и время следующей попытки. Пустая лента с 200 и отсутствие нового тела при 304 имеют разные значения.

После самостоятельного разбора ожидается, что повтор одинакового fixture сохранит число записей и различимых версий, а исправление заголовка с прежним ID изменит текущую строку и добавит другое представление. Удаление записи из короткого ответа не удаляет её из базы автоматически: многие ленты показывают лишь последнее окно материалов. При авторстве эти операции не выполнялись и SQL-вывод не получен.

Транзакция не заменяет резервную копию и не обещает устойчивость к любому отказу носителя. Она задаёт согласованность принятого набора в выбранной базе; независимое хранение и проверка восстановления остаются отдельными задачами сопровождения. Результат урока — одна понятная фиксация данных и валидаторов. Дальше добавим расписание и сохраняемый backoff, чтобы ошибки внешнего источника не порождали бесконечный немедленный повтор.

Полное состояние шага — папка lesson-05 архива «Сигнал». Код, сеть, база данных и учебный сервер при подготовке не запускались; описанное поведение ожидается из разбора исходников.