Як ми синхронізуємо 380M+ записів з 40+ джерел даних, які постійно падають
Multi-IP імпорт, автоматичний scheduler, freshness-моніторинг, міжнародна експансія — інженерія data pipeline для відкритих даних. Від першого 404 до стабільного оновлення 110+ таблиць щоночі.
Як ми синхронізуємо 380M+ записів з 40+ джерел даних, які постійно падають
Коли будуєш юридичну AI-платформу на відкритих даних, найбільший виклик — не AI і не пошук. Це надійне отримання даних з десятків джерел — українських державних реєстрів, міжнародних баз, санкційних списків — кожне з яких має свої обмеження, формати і проблеми зі стабільністю.
Ця стаття — інженерний розбір того, як ми побудували повністю автоматизований pipeline синхронізації для 380+ мільйонів записів з 40+ джерел. Від архітектури multi-IP імпорту до cron-scheduler'а, системи моніторингу freshness і міжнародної експансії на 6 юрисдикцій.
Оновлено: травень 2026 — актуальні цифри з продакшн-серверів.
Проблема: державні API — це не Stripe
Коли ви працюєте з API data.gov.ua, НАІС, УІПВ чи spending.gov.ua, ви стикаєтесь із реальністю:
- Rate limits без документації — один сервіс блокує після 100 запитів/хв, інший — після 10
- Формати змінюються — JSON-поле раптом стає null замість рядка, або відповідь приходить не як JSON, а як HTML-сторінка помилки
- Таймаути — ZIP-архів реєстру боржників на 200MB може завантажуватись 20 хвилин, або не завантажитись взагалі
- Відсутність idempotency — немає
ETag,Last-Modified, diff endpoint'ів. Кожна синхронізація — повний перезапис - URL зникають — ресурси на data.gov.ua переїжджають без повідомлення, повертаючи 404
Ми не можемо дозволити собі ручний імпорт. Юристи покладаються на актуальність даних: реєстр розшукуваних осіб має оновлюватись щодня, не щомісяця.
Архітектура: три рівні надійності
Наш pipeline складається з трьох незалежних компонентів:
┌─────────────────────────────────────────┐
│ opendata-sync (Docker container) │
│ ├─ node-cron scheduler │
│ ├─ 26 джерел із розкладом │
│ └─ Triggers → backend / openreyestr │
└───────────┬─────────────────┬───────────┘
│ │
▼ ▼
┌───────────────────┐ ┌──────────────────┐
│ ImportTaskService │ │ OpenReyestr │
│ (mcp_backend) │ │ sync-registry │
│ ├─ 10 source IP │ │ ├─ ZIP download │
│ ├─ round-robin │ │ ├─ XML parsing │
│ ├─ retry logic │ │ └─ UPSERT │
│ └─ progress track │ │ │
└────────┬──────────┘ └────────┬─────────┘
│ │
▼ ▼
┌─────────────────────────────────────────┐
│ PostgreSQL: 110+ data таблиць (1.26 TB)│
│ Моніторинг: db-status.py + freshness │
└─────────────────────────────────────────┘
Рівень 1: Scheduler — opendata-sync
Перший рівень — легкий Node.js мікросервіс, який не завантажує дані сам. Він лише відповідає за розклад і тригери.
Конфігурація джерел
Кожне джерело описане декларативно:
{
name: 'mvs_wanted_persons',
title: 'МВС — Особи в розшуку',
cron: '0 3 * * *', // 03:00 щодня
target: 'backend', // куди відправити тригер
sourceName: 'mvs_wanted_persons',
enabled: true
}
Розклад синхронізації
| Час | Джерела | Цільовий сервіс |
|---|---|---|
| 03:00 щодня | МВС розшук, МВС зниклі, МВС авто, МВС недійсні паспорти, НАЗК корупціонери, НАЗК правопорушники | backend |
| 03:30 щодня | Статуси справ, розклад засідань, адвокати, люстрація, держдопомога, великі платники, боржники зарплат | backend |
| 04:00–05:00 щодня | Арбітражні керуючі, банкрутство, виконавчі провадження, боржники | openreyestr |
| Неділя 02:00 | УІПВ патенти, марки, моделі, зразки | backend |
| Понеділок 02:00–05:00 | Нотаріуси, судові експерти, спецбланки, вулиці, АТУ | openreyestr |
Захист від дублювання
Перед кожним тригером scheduler перевіряє, чи не працює вже імпорт цього джерела. Якщо статус — running, нова задача не створюється.
Health endpoint
Scheduler надає ендпоінт /health з повною картиною:
{
"status": "healthy",
"uptime": "4d 12h 33m",
"sources": 15,
"recentFailures": 1,
"lastFailure": {
"source": "nipo_trademarks",
"error": "ECONNRESET",
"at": "2026-03-27T02:15:00Z"
}
}
Рівень 2: ImportTaskService — multi-IP імпорт
Це серце pipeline. Коли scheduler надсилає тригер, ImportTaskService бере на себе всю роботу із завантаженням.
Три режими імпорту
Державні джерела використовують різні формати, тому ми підтримуємо три стратегії:
| Режим | Джерела | Як працює |
|---|---|---|
api_paginated |
УІПВ (патенти, марки) | Посторінковий обхід API, 1100ms між запитами |
json_array |
МВС, НАЗК | Один HTTP-запит → масив JSON об'єктів |
file_download |
НАІС реєстри | ZIP → XML → парсинг → UPSERT |
Multi-IP: 10 адрес × 5 потоків = 50 паралельних завантажень
Для джерел із rate limits на IP-адресу ми використовуємо пул з 10 мережевих інтерфейсів (AWS ENI). Сторінки розподіляються round-robin:
Сторінка 1 → IP 172.31.x.1
Сторінка 2 → IP 172.31.x.2
...
Сторінка 10 → IP 172.31.x.10
Сторінка 11 → IP 172.31.x.1 (знову перша)
З 5 потоками на кожну IP отримуємо 50 паралельних з'єднань. Для УІПВ з rate limit 1100ms/запит це дає ~45 сторінок/секунду замість 0.9.
Retry з exponential backoff
Кожен запит має до 5 спроб із зростаючою затримкою:
Спроба 1: одразу
Спроба 2: через 2 секунди
Спроба 3: через 4 секунди
Спроба 4: через 8 секунд
Спроба 5: через 16 секунд
Для помилки 429 (Too Many Requests) — окрема логіка: чекаємо Retry-After з відповіді сервера.
Progress tracking без навантаження на базу
Прогрес зберігається в пам'яті і записується в PostgreSQL кожні 100 сторінок:
// В пам'яті — оновлення кожну сторінку (мікросекунди)
taskProgress.set(taskId, {
pagesDone: 4521,
recordsImported: 45210,
currentPage: 4522,
lastError: null
});
// В базу — flush кожні 100 сторінок
// UPDATE import_tasks SET pages_done=$2, records_imported=$3 WHERE id=$1
Це дає точний real-time прогрес через API без навантаження на базу тисячами UPDATE-запитів.
MCP-інструменти для контролю
Весь процес керується через 4 MCP-інструменти:
| Інструмент | Призначення |
|---|---|
list_import_sources |
Каталог всіх джерел: URL, тип, таблиця, rate limit |
start_import |
Запуск фонової задачі: source_name → task_id |
get_import_status |
Прогрес: %, ETA, швидкість, помилки |
cancel_import |
Зупинка через AbortController зі збереженням прогресу |
Це означає, що AI-асистент може сам запустити імпорт, слідкувати за прогресом і повідомити юриста, коли дані оновлені.
Рівень 3: Моніторинг freshness
Дані без моніторингу — це тикаюча бомба. Ми побудували систему, яка показує наскільки свіжі дані в кожній таблиці.
Матриця очікуваної частоти
Кожна таблиця має визначену норму оновлення:
| Частота | Кількість таблиць | Приклади |
|---|---|---|
| Щодня (1д) | 24 | Розшук МВС, недійсні паспорти, корупціонери НАЗК, боржники, виконавчі провадження, статуси справ, адвокати |
| Щотижня (7д) | 48 | Патенти, марки, санкції OpenSanctions, депутати, судді, законопроекти |
| Щомісяця (30д) | 8 | Графіки засідань, великі платники, судові експерти, спецбланки |
Індикатори freshness
🟢 в межах норми (freq × 1.5) — все працює
🟡 трохи прострочено (freq × 1.5–2.5) — варто перевірити
🟠 прострочено (freq × 2.5–4) — щось пішло не так
🔴 критично (> freq × 4) — потрібне втручання
⛔ імпорт завершився з помилкою
🔄 імпорт працює зараз
Dashboard: db-status.py
Скрипт підключається до продакшн-бази через SSH і показує повну картину:
═══════════════════════════════════════════════════════════════
📦 SecondLayer (основна) — 110+ таблиць, 1.26 TB загалом
═══════════════════════════════════════════════════════════════
# Таблиця Рядків Розмір Норма Давність
──────────────────────────────────────────────────────────────────────
1 opendata_vehicle_registrations 19.6M 5.9 GB 7д 3д тому 🟢
2 spending_acts 9.45M 8.3 GB 7д 5д тому 🟢
3 opendata_invalid_passports 2.89M 1.0 GB 1д 2хв тому 🟢
4 opendata_court_case_status 1.25M 846 MB 1д 12хв тому 🟢
5 opensanctions_entities 1.25M 522 MB 30д 8д тому 🟢
6 opendata_trademarks 382K 4.3 GB 7д 3д тому 🟢
7 opendata_patents 345K 5.0 GB 7д 3д тому 🟢
8 opendata_missing_persons 117K 119 MB 1д 12хв тому 🟢
9 opendata_wanted_persons 71K 49 MB 1д 2хв тому 🟢
10 opendata_corruption 58K 106 MB 1д 3год тому 🟢
...
Кожна таблиця перевіряється по двох каналах:
- pg_stat_user_tables — коли було останнє INSERT/UPDATE
- import_tasks / import_log — статус останнього імпорту (success/failed/running)
Реальні проблеми і як ми їх вирішили
Проблема 1: Docker не може bind до ENI IP
json_array джерела (МВС, НАЗК) — це один HTTP-запит, не пагінація. Коли ми передавали ENI IP для bind, Docker-контейнер отримував EADDRNOTAVAIL — він не бачить host-мережу.
Рішення: multi-IP потрібен тільки для пагінованих джерел. Для json_array — звичайний fetch без bind.
Проблема 2: URL зникають без попередження
data.gov.ua періодично оновлює resource ID для МВС та НАЗК. Старі URL повертають 404.
Рішення: URL зберігаються в import_source_catalog таблиці, а не захардкоджені. Оновлення URL — один UPDATE-запит, без перезбірки коду.
Проблема 3: NULL bytes в PDF/XML
Деякі реєстри містять \x00 символи, які PostgreSQL відкидає з помилкою:
ERROR: invalid byte sequence for encoding "UTF8": 0x00
Рішення: strip null bytes на етапі парсингу, до INSERT.
Проблема 4: Відповідь — не JSON
Коли сервер перевантажений, замість JSON деякі API повертають HTML-сторінку з помилкою або порожній рядок.
Рішення: парсинг обгорнуто у try/catch з перевіркою Content-Type. Якщо відповідь не JSON — retry з наступної IP.
Проблема 5: Витік пам'яті на великих імпортах
Імпорт 9.45M записів spending_acts тримав всі записи в пам'яті.
Рішення: streaming парсинг — обробка chunk'ами по 1000 записів, UPSERT, звільнення пам'яті.
Цифри
| Метрика | Значення |
|---|---|
| Загальний обсяг даних | 380M+ записів, 1.26 TB (2 бази) |
| Кількість джерел | 26 в import_source_catalog + 20 міжнародних імпортерів |
| Кількість таблиць | 110+ data-таблиць (31 opendata + 20 spain + 43 openreyestr + 50+ ЄДРСР партицій) |
| MCP-інструментів для пошуку | 30+ (opendata + spending + registries + international) |
| Щоденна синхронізація | 12 джерел (03:00–05:00 UTC) |
| Щотижнева синхронізація | 14 джерел (вихідні) |
| Паралельних з'єднань | до 50 (10 IP × 5 потоків) |
| Час повного імпорту УІПВ | ~45 хв (345K записів) |
| Час імпорту МВС розшук | ~30 сек (71K записів, один запит) |
| Найбільша таблиця | enforcement_proceedings: 29.4M записів, 19 GB |
| Міжнародні юрисдикції | 6 (Іспанія, Ірландія, Нідерланди, Швейцарія, Люксембург, ЄС) |
Міжнародна експансія: від 15 українських джерел до 40+ глобальних
З березня 2026 pipeline вийшов далеко за межі українських реєстрів. Ось що додалося:
ICIJ Offshore Leaks — 4.9M записів
Повна база Panama Papers, Paradise Papers, Pandora Papers. 814K entities, 771K officers, 2.9M relationships, 402K addresses. Імпорт з CSV за ~2 хвилини, дані оновлюються при кожному новому leak.
Іспанія — 20 таблиць, 780K записів
Найскладніший міжнародний імпорт. 14 джерел: Tribunal Constitucional (27K рішень), BOE (48K анонсів + 12K законів), BORME (276K компаній), EUR-Lex (8.6K актів), CENDOJ (2.3K кримінальних рішень). CENDOJ виявився geo-blocked для non-EU IP — довелося використовувати Playwright + auto IP rotation (81 ротація EIP, 3 паралельних EC2 workers).
Нідерланди — 1.1M судових рішень
Rechtspraak Open Data API — 1,106,921 рішення. Один з найчистіших API серед усіх джерел: XML з чіткою схемою, пагінація працює, rate limits документовані.
Швейцарія — 661K судових рішень
Entscheidsuche.ch — федеральні та кантональні суди. Zefix (1.7M компаній) і SHAB (2.18M HR records) поки заблоковані через 403/timeout.
Ірландія — 812K компаній
Companies Registration Office (CRO) — повний реєстр ірландських компаній.
Люксембург — 3.3M записів
GLEIF LEI — Global Legal Entity Identifier. 3,282,067 записів міжнародних юридичних осіб.
OpenSanctions — 1.25M записів
Агрегований санкційний список: 1,020K фізичних осіб, 108K компаній, 71K юридичних осіб. 330 унікальних датасетів з усього світу.
Що далі
✅ Зроблено з попереднього плану
- Більше джерел — з 15 до 26 автоматизованих + 20 міжнародних імпортерів
- Incremental sync — реалізовано для ЄДРСР (
sync-edrsr-incremental.sh) - Data quality checks — базова перевірка row count drop після імпорту
🔜 Наступні кроки
- ЄДРСР fulltext gap 2022-2026 — 32.9M документів без повного тексту, активний backfill через /Review/ endpoint (~4M вже відновлено)
- Qdrant hybrid search — вектори ЄДРСР (103M+ points) таймаутять на 60с, потрібне tune HNSW або чекати завершення індексації
- Іспанія Tier 2 — ще 12 імпортерів: Plataforma Contratación (~5-8M тендерів), Congreso votes (~25M), CENDOJ non-penal, Catastro INSPIRE
- Швейцарія — 12 імпортерів на ~9.2M записів: kantonsblatt.ch, fedlex, parlament.ch, Zefix, opendata.swiss
- data.gov.ua OSINT — виявлено 150+ нових датасетів категорій P0-P2, поступова інтеграція
- Alerting — Telegram-бот для повідомлень про failed imports
Висновок
Побудувати pipeline для відкритих даних — це не про fetch → insert. Це про інженерію надійності: retry, rate limit, multi-IP, моніторинг freshness, graceful degradation. А коли pipeline виходить на міжнародний рівень — це ще й про Playwright для geo-blocked сайтів, EIP rotation для обходу бан-листів, і парсинг XML-схем 6 різних юрисдикцій.
Кожне з 40+ джерел — це окрема історія з унікальними проблемами. Але коли pipeline працює стабільно, юрист задає питання в чат і отримує актуальні дані з МВС, НАЗК, УІПВ, НАІС, spending.gov.ua, ICIJ, Rechtspraak і CENDOJ — навіть не замислюючись, скільки інженерної роботи стоїть за кожною відповіддю.
Реєстрація: legal.org.ua