Files
miem_workers/docs/publication-import.md
codex 25eb6481f7
CI / test (pull_request) Successful in 9m10s
CI / deploy (pull_request) Has been skipped
fix: address publication import review findings
2026-10-05 12:12:35 +03:00

25 KiB
Raw Permalink Blame History

Разовый импорт публикаций

Инструмент: scripts/import_publications.py. Он не импортирует приложение, не читает .env, не вызывает init_db, не создаёт таблиц и не удаляет записи. Подключение берётся только из libpq environment/service, настроенных владельцем. Production в рамках подготовки не проверялся и не изменялся.

Локальные проверки: синтетические сотрудники, повторный запуск, дубликаты, неоднозначности, конфликты DOI/года, сохранение заполненных значений и транзакционный путь на тестовом соединении (read-only / rollback при второй ошибочной вставке / успешный commit). Прогон на настоящем PostgreSQL и проверка grants не выполнялись; первый owner-run dry-run с read-only ролью обязателен.

Вход и сопоставление

Файл владельца Статьи.csv: UTF-8 с возможным BOM, разделитель ;, 1797 записей, SHA256 1f236be56d99677b1403df29a4c1d3a7e6d10cb90bd355d678201a027921b0ce. Обязательные колонки: Название публикации, Год, DOI, Авторы НИУ ВШЭ. Номер строки в отчёте — номер CSV-записи с заголовком, а не физической строки.

Сотрудник сопоставляется по полному ФИО: Unicode NFKC, регистр, пробелы и ё/е. Учитываются все существующие статусы; новые сотрудники не создаются. Ноль совпадений → employee_not_found, несколько → ambiguous_employee. Инициалы, переводы, перестановки слов и приблизительные совпадения не используются.

Публикация сопоставляется внутри конкретного сотрудника по нормализованному DOI или полному названию и году. Отсутствующий год БД допускает заполнение при совпадении названия. Если эти способы указывают на разные записи, импорт пропускается (ambiguous_publication). Разные заполненные DOI или годы — doi_conflict / year_conflict. Существующие дубликаты не объединяются и не удаляются. Одна статья у разных сотрудников — предусмотренные моделью авторские связи, а не дубли внутри карточки.

Повтор CSV внутри сотрудника не создаёт записи. Каждая поздняя строка дополняет ещё пустые метаданные; первый непустой источник имеет приоритет, в том числе ранее сохранённое значение БД. Повтор без изменений — csv_duplicate, дополнение запланированной вставки — merged_csv_duplicate. При конфликте непустых метаданных первое значение сохраняется; остальные пустые поля всё равно дополняются. Отчёт содержит metadata_conflict_first_value_kept, список conflict_fields (только названия полей, без значений) и сводный счётчик metadata_conflicts. Конфликт DOI/года идентификации по-прежнему пропускает всю связь. Если строка без DOI была запланирована первой, следующая строка с DOI может дополнить ту же вставку. При повторном запуске уже заполненные записи дают already_present. Хеш новой записи детерминирован по сотруднику и DOI либо названию/году. source_hash существующих записей не меняется.

Пустые ячейки ничего не стирают. Пустые название/авторы, некорректный год (допустимо 1000–9999) или DOI пропускаются как invalid_fields. Повреждённая структура CSV прерывает весь запуск до подключения. Несовместимый JSON БД пропускается (unsupported_raw_data, unsupported_csv_metadata).

Таблицы, столбцы и права

Объект Операция / столбцы
public.employees Только SELECT: id, full_name
public.employee_publications SELECT: id, employee_id, title, year, doi_url, raw_data
Новая публикация INSERT: employee_id, title, year, doi_url при наличии, raw_data, source_hash, created_at, updated_at
Существующая публикация UPDATE: только пустые title, year, doi_url; raw_data; служебный updated_at
Sequence идентификатора публикации USAGE для nextval; имя определяется pg_get_serial_sequence, обычно public.employee_publications_id_seq

Остальные таблицы и sequences не затрагиваются. В raw_data сохраняются прежние ключи; добавляется/дополняется csv_enrichment только отсутствующими или пустыми значениями. Уже заполненные CSV-метаданные не заменяются. Поля authors, publication_id, тип материала, язык, аннотация, ссылки HSE и статус не меняются. CSV содержит только авторов НИУ ВШЭ, поэтому он не считается полным составом авторов и не заменяет authors. Идентификаторы Scopus/WoS, квартиль и остальные непустые исходные ячейки сохраняются в raw_data.csv_enrichment без интерпретации. Карточки employees.current_data и snapshots не изменяются; отчёт и данные таблицы не означают обновления всех представлений интерфейса.

Минимальные собственные grants роли: CONNECT к БД, USAGE схемы public, перечисленные SELECT/INSERT/UPDATE по столбцам, USAGE найденной sequence. Не нужны DELETE, TRUNCATE, CREATE, ALTER, ownership, superuser, доступ к прочим таблицам и sequence UPDATE/setval. Роль новая, без членства в других ролях. Права PUBLIC всё равно наследуются: владелец должен отдельно проверить их; этот набор команд не меняет общие права PUBLIC и не гарантирует изоляцию при уже существующих широких общих grants. В частности TEMP может быть выдан PUBLIC.

Применение и ограничения

Без --apply используется PostgreSQL SERIALIZABLE READ ONLY, без INSERT, UPDATE и nextval, с rollback. С --apply весь план применяется одной транзакцией SERIALIZABLE. Advisory lock предотвращает одновременное применение этим импортером. Таймаут запроса — 60 секунд, ожидания блокировок — 5 секунд. Ошибка записи/конфликт сериализации откатывает все изменения таблиц; автоматических повторов нет. Sequence может получить пропуски при откате — это нормальное поведение PostgreSQL. Изменение входного файла во время запуска прерывает импорт.

JSON-отчёт содержит количества insert/update/skip, причины и пары номер CSV-записи — employee_id, без ФИО, названий статей и секретов. Счётчики относятся к авторским связям, а не уникальным статьям. Консоль выводит только сводку. Отчёт сначала пишется в .pending до commit, после успеха переименовывается. Если остался .pending, следующий запуск блокируется: сначала проверьте исход транзакции. Отчёт .pending не доказывает commit; ошибка переименования после commit также возможна. Безопасный повторный dry-run позволяет проверить состояние. При необходимости владелец перемещает .pending в архив перед повтором.

До применения нужен свежий backup БД. Во время импорта владелец должен исключить конкурентные записи публикаций на время транзакции, согласовав окно работы; эта подготовка не отключает crawler. Не выполняйте production apply до независимого ревью и развёртывания исправления crawler из этого PR.

До исправления _upsert_employee запускал синхронизацию публикаций даже для неизменённой карточки. При пустом списке публикаций или отсутствии таблицы синхронизация пропускалась. При непустом списке _sync_employee_publications удалял все записи сотрудника, чей source_hash отсутствовал среди хешей ответа ВШЭ, включая CSV-вставки. Найденные по publication_id/хешу записи полностью получали новый raw_data, поэтому теряли CSV-метаданные даже без удаления.

Исправление сохраняет записи с dict raw_data.csv_enrichment при очистке устаревших публикаций. При обновлении сохраняется этот ключ и заполненные title/year/doi_url, если ВШЭ вернул пустые значения. Непустые значения ВШЭ продолжают обновлять канонические поля, а исходные CSV-значения остаются в метаданных. Если статья впервые появилась у ВШЭ, crawler сопоставляет её с импортированной записью по DOI или названию/году до новой вставки, сохраняет id и добавляет HSE publication_id. Конфликт/неоднозначность откатывает весь savepoint синхронизации публикаций и регистрирует CrawlError; карточка сотрудника продолжает обрабатываться. Удаление обычных устаревших HSE-записей остаётся прежним. Здесь не приведены команды рестарта или отключения сервисов.

Уже установленный HSE publication_id CSV-записи нельзя заменить другим HSE ID, даже когда DOI/название/год совпадают. Это проверяется перед изменением полей при любом пути сопоставления (ID, source_hash, CSV-поля). Поэтому две HSE-статьи не могут по очереди перезаписать одну CSV-запись: весь savepoint откатывается независимо от порядка статей. Отсутствующий ID во входе не очищает существующую CSV-привязку. Отчёт обхода (errors в деталях запуска) содержит CrawlError с идентификатором записи БД и конфликтующими HSE ID, без ФИО и названий статей.

Команды владельцу (не выполнялись)

Ниже — подготовка разового доступа после ревью. Выполняет владелец на сервере. Рабочая директория — проверенная копия этого PR; файлы программы и CSV должны быть защищены от записи временным SSH-пользователем. Пароли/ключи не включать в репозиторий или чат. Имена контейнеров соответствуют текущему Compose-проекту; владелец проверяет их локально перед выполнением.

Сначала резервная копия в приватный каталог владельца:

install -d -m 700 /var/backups/miem-publication-import
docker exec miem_workers-postgres-1 pg_dump -U miem -d miem_workers -Fc -f /tmp/publications-before.dump
docker cp miem_workers-postgres-1:/tmp/publications-before.dump /var/backups/miem-publication-import/before.dump
chmod 600 /var/backups/miem-publication-import/before.dump

При повторной операции используйте новый путь backup; не перезаписывайте нужную предыдущую копию. Проверьте exit codes и архив pg_restore --list до выдачи write.

Откройте psql владельцем БД:

docker exec -it miem_workers-postgres-1 psql -U miem -d miem_workers

Выполните SQL (без пароля в SQL/истории):

\set ON_ERROR_STOP on
SELECT format('CREATE ROLE miem_publication_import LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS CONNECTION LIMIT 1 VALID UNTIL %L', CURRENT_TIMESTAMP + interval '24 hours') \gexec
\password miem_publication_import
GRANT CONNECT ON DATABASE miem_workers TO miem_publication_import;
GRANT USAGE ON SCHEMA public TO miem_publication_import;
GRANT SELECT (id, full_name) ON public.employees TO miem_publication_import;
GRANT SELECT (id, employee_id, title, year, doi_url, raw_data) ON public.employee_publications TO miem_publication_import;
-- Сначала выдаются только read-права для dry-run.
SELECT pg_get_serial_sequence('public.employee_publications', 'id');
SELECT has_schema_privilege('miem_publication_import', 'public', 'CREATE') AS unexpected_create,
       has_table_privilege('miem_publication_import', 'public.employees', 'INSERT') AS unexpected_employee_insert,
       has_table_privilege('miem_publication_import', 'public.employee_publications', 'DELETE') AS unexpected_delete;

Три проверки должны вернуть false. Не выдавайте write-права при неожиданном доступе. Если sequence не найдена, выясните фактический механизм id перед импортом. Срок роли ограничивает парольную авторизацию, но не заменяет отзыв доступа и завершение открытых сессий.

После принятого dry-run, непосредственно перед разрешённым apply:

GRANT INSERT (employee_id, title, year, doi_url, raw_data, source_hash, created_at, updated_at) ON public.employee_publications TO miem_publication_import;
GRANT UPDATE (title, year, doi_url, raw_data, updated_at) ON public.employee_publications TO miem_publication_import;
SELECT format('GRANT USAGE ON SEQUENCE %s TO miem_publication_import', pg_get_serial_sequence('public.employee_publications', 'id')) \gexec

Подготовьте внутри API-контейнера защищённые файлы libpq pg_service.conf и pgpass из окружения/секрет-хранилища владельца: service miem_publication_import, БД miem_workers, пользователь miem_publication_import. Используйте внутренний PostgreSQL endpoint Compose, порт 5432; не публикуйте порт БД. Поле password хранится только в pgpass, доступном UID 65534 с mode 600. Не копируйте .env приложения. Этот шаг зависит от секрет-хранилища владельца; универсальной команды получения секрета нет.

Установите файлы (команды root; каталог контейнера не должен быть volume с непроверенными файлами). Статьи.csv расположен рядом с каталогом репозитория:

docker exec miem_workers-api-1 mkdir -p /opt/miem-import /tmp/miem-import
docker cp scripts/import_publications.py miem_workers-api-1:/opt/miem-import/import_publications.py
docker cp ../Статьи.csv miem_workers-api-1:/opt/miem-import/articles.csv
docker exec miem_workers-api-1 chown -R root:root /opt/miem-import
docker exec miem_workers-api-1 chmod 755 /opt/miem-import
docker exec miem_workers-api-1 chmod 644 /opt/miem-import/import_publications.py /opt/miem-import/articles.csv
docker exec miem_workers-api-1 chown 65534:65534 /opt/miem-import/pg_service.conf /opt/miem-import/pgpass /tmp/miem-import
docker exec miem_workers-api-1 chmod 600 /opt/miem-import/pg_service.conf /opt/miem-import/pgpass
docker exec miem_workers-api-1 chmod 700 /tmp/miem-import
docker exec miem_workers-api-1 sha256sum /opt/miem-import/articles.csv
install -m 755 -o root -g root scripts/import-publications-run /usr/local/sbin/miem-publication-import
install -m 755 -o root -g root scripts/import-publications-ssh /usr/local/sbin/miem-publication-import-ssh
useradd --create-home --shell /bin/sh miem-pub-import
install -d -m 700 -o miem-pub-import -g miem-pub-import /home/miem-pub-import/.ssh

Проверьте SHA256 CSV по указанному выше значению и script по проверенной копии PR. Секретные файлы должны существовать перед командами chown/chmod. Root-владелец каталога не позволяет UID 65534 заменить программу/CSV; этот UID может писать только отчёт и свои файлы конфигурации. Не предоставляйте SSH-пользователю группу docker или общий sudo. Каталоги и executables выше должны быть root-owned и недоступны для записи этому пользователю. Убедитесь, что абсолютный путь Docker на сервере — /usr/bin/docker.

Создайте /etc/sudoers.d/miem-publication-import с mode 440:

miem-pub-import ALL=(root) NOPASSWD: /usr/local/sbin/miem-publication-import dry-run, /usr/local/sbin/miem-publication-import apply
visudo -cf /etc/sudoers.d/miem-publication-import

В /home/miem-pub-import/.ssh/authorized_keys добавьте только публичный ключ владельца с ограничениями (замените <PUBLIC_KEY> самим публичным ключом):

restrict,command="/usr/local/sbin/miem-publication-import-ssh" <PUBLIC_KEY>
chown root:root /home/miem-pub-import/.ssh/authorized_keys
chmod 644 /home/miem-pub-import/.ssh/authorized_keys
chown root:root /home/miem-pub-import /home/miem-pub-import/.ssh
chmod 755 /home/miem-pub-import
chmod 755 /home/miem-pub-import/.ssh

Root-владение home/.ssh предотвращает замену ограниченного authorized_keys. Убедитесь, что SSH не разрешает этому пользователю другие ключи, пароль, keyboard-interactive, пользовательские environment/rc или общий shell. При необходимости задайте в sshd Match User miem-pub-import с AuthenticationMethods publickey, PasswordAuthentication no, KbdInteractiveAuthentication no, DisableForwarding yes, PermitTTY no, PermitUserRC no и указанным ForceCommand. Проверка/reload SSH зависит от конфигурации сервера; перед reload сохраните действующую сессию владельца.

С клиентской машины параметры хоста/ключа берутся из окружения владельца:

ssh -i "$IMPORT_SSH_KEY" -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes "miem-pub-import@$PROD_HOST" 'import-publications dry-run'

После проверки сводки, выдачи write-grants и отдельного решения владельца:

ssh -i "$IMPORT_SSH_KEY" -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes "miem-pub-import@$PROD_HOST" 'import-publications apply'
ssh -i "$IMPORT_SSH_KEY" -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=yes "miem-pub-import@$PROD_HOST" 'import-publications dry-run'

Повторный dry-run должен показать ноль insert/update. При concurrent changes сводки могут различаться; без production-проверки фактические количества неизвестны. Владелец забирает отчёты без предоставления SCP/general shell временному ключу:

docker cp miem_workers-api-1:/tmp/miem-import/apply.json ./publication-import-apply.json
docker cp miem_workers-api-1:/tmp/miem-import/dry-run.json ./publication-import-dry-run.json

Отзыв доступа

Владелец прекращает новые SSH-входы: перемещает временный authorized_keys в свой закрытый архив и удаляет только этот sudoers-файл. Не удаляйте данные БД или volumes.

mv /home/miem-pub-import/.ssh/authorized_keys /var/backups/miem-publication-import/authorized_keys.disabled
rm /etc/sudoers.d/miem-publication-import

В psql владельцем:

ALTER ROLE miem_publication_import NOLOGIN;
SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE usename = 'miem_publication_import' AND pid <> pg_backend_pid();
REVOKE ALL PRIVILEGES ON DATABASE miem_workers FROM miem_publication_import;
REVOKE ALL PRIVILEGES ON SCHEMA public FROM miem_publication_import;
REVOKE SELECT (id, full_name) ON public.employees FROM miem_publication_import;
REVOKE SELECT (id, employee_id, title, year, doi_url, raw_data),
       INSERT (employee_id, title, year, doi_url, raw_data, source_hash, created_at, updated_at),
       UPDATE (title, year, doi_url, raw_data, updated_at)
       ON public.employee_publications FROM miem_publication_import;
SELECT format('REVOKE ALL PRIVILEGES ON SEQUENCE %s FROM miem_publication_import', pg_get_serial_sequence('public.employee_publications', 'id')) \gexec
DROP ROLE miem_publication_import;

После сохранения отчётов и backup удалите только временные секреты подключения:

docker exec miem_workers-api-1 rm /opt/miem-import/pgpass /opt/miem-import/pg_service.conf

Эти действия не используют DROP OWNED и не удаляют публикации. SQL роли не является миграцией схемы приложения. Если DROP ROLE не проходит, проверьте оставшиеся grants; не применяйте CASCADE/удаление данных.