Skip to content

fix(procurement): ре-синк источника апсертом, а не «удалить и вставить» - #851

Merged
ShaerWare merged 1 commit into
mainfrom
local/fix/offer-upsert
Sep 30, 2026
Merged

ShaerWare merged 1 commit into
mainfrom
local/fix/offer-upsert

Conversation

@ShaerWare

Copy link
Copy Markdown
Owner

Summary

Ночь 29.09.2026: синк SunWell/EKF свалился на первой же строке sunwell#0, оставшейся с предыдущего дня — UNIQUE constraint failed: product_offers.source, product_offers.source_key. Транзакция откатилась целиком, и 20 728 позиций крупнейшего поставщика сутки стояли непересчитанными; остальные пять поставщиков прошли нормально.

Что установил разбор:

  • удаление исправно: DELETE ... LIKE 'sunwell#%' убирает все 20 728 строк, и sunwell#0 в той же транзакции после него не виден (проверено на проде);
  • ключи чистые: sunwell#0..20727, 20 728 уникальных, все попадают под шаблон удаления, дублей в батче нет;
  • второго писателя нет: единственный процесс приложения, ни второго парса в логах, ни вызова sync-suppliers, ни моих ручных юнитов той ночью;
  • воспроизвести не удаётся: ни одиночный прогон sunwell, ни все шесть поставщиков подряд отдельным процессом не падают.

Гонка не доказана, а падение повторяемо и бьёт по самому большому прайсу — поэтому убран класс отказа, а не сценарий:

  • replace_source_offers() пишет апсертом (ON CONFLICT (source, source_key) DO UPDATE). Оставшаяся строка и параллельная вставка того же ключа больше не конфликтуют; строка переписывается целиком, иначе у неё сохранились бы старые цена и остаток.
  • Устаревшие строки удаляются по метке времени прогона, а не списком ключей: NOT IN (20 тыс. значений) упёрся бы в лимит параметров SQLite.
  • INSERT частями по 500 строк — 16 колонок × 500 = 8000 параметров, с запасом от лимита 32766.
  • Попутно исчезает окно, когда источник в поиске пуст: раньше между удалением и коммитом позиций поставщика в таблице не было.

NEWS

🛠️ Ночное обновление больше не спотыкается на крупном поставщике

Самый большой прайс — двадцать тысяч позиций — при ночном пересчёте иногда срывался, и цены по нему оставались вчерашними до следующей ночи. Причина была в том, как система перезаписывала данные: теперь она обновляет строки на месте, а не удаляет и создаёт заново. Побочный плюс: в момент обновления товары поставщика больше не исчезают из поиска ни на секунду.

Test plan

  • 4 новых теста в test_supplier_stock_unknown.py: повторный ре-синк по существующим ключам не падает (именно упавший сценарий), строка обновляется а не дублируется и подхватывает обнуление полей, пропавшая из выборки позиция уходит из поиска, прогон одного поставщика не вычищает соседнего
  • Весь procurement-набор — 60 passed
  • ruff check . + ruff format --check . — чисто
  • Прогон на проде на реальных 20 728 строках SunWell: два ре-синка подряд по существующим ключам проходят оба; после них 20 728 строк, 20 728 уникальных ключей, дублей нет, суммарно 3 548 позиций в наличии
  • Ночью 01.10: в логе procurement supplier sync: N офферов, ошибок у 0 поставщиков

🤖 Generated with Claude Code

Ночь 29.09.2026: синк SunWell/EKF свалился на первой же строке
`sunwell#0`, оставшейся с предыдущего дня —
`UNIQUE constraint failed: product_offers.source, product_offers.source_key`.
Транзакция откатилась целиком, и 20 728 позиций крупнейшего поставщика
сутки стояли непересчитанными; остальные пять прошли нормально.

Разбор показал, что удаление само по себе исправно: `DELETE ... LIKE
'sunwell#%'` убирает все 20 728 строк и `sunwell#0` в той же транзакции
после него не виден. Второго писателя в логах нет, отдельным процессом
гонка не воспроизводится — ни одиночным прогоном, ни всеми шестью
поставщиками подряд. Поэтому убран сам класс отказа, а не конкретный
сценарий.

- `replace_source_offers()` пишет апсертом (`ON CONFLICT (source,
  source_key) DO UPDATE`): оставшаяся строка и параллельная вставка того
  же ключа больше не конфликтуют, строка переписывается целиком — иначе у
  неё сохранились бы старые цена и остаток.
- Устаревшие строки удаляются по метке времени прогона, а не списком
  ключей: `NOT IN (20 тыс. значений)` упёрся бы в лимит параметров SQLite.
- INSERT идёт частями по 500 строк — 16 колонок × 500 = 8000 параметров,
  с запасом от лимита 32766.
- Попутно исчезает окно, когда источник в поиске пуст: раньше между
  удалением и коммитом позиций поставщика в таблице не было.

Проверено на проде на реальных 20 728 строках SunWell: два прогона подряд
по существующим ключам — ровно то, что падало, — проходят оба.

## NEWS

🛠️ **Ночное обновление больше не спотыкается на крупном поставщике**

Самый большой прайс — двадцать тысяч позиций — при ночном пересчёте иногда
срывался, и цены по нему оставались вчерашними до следующей ночи. Причина
была в том, как система перезаписывала данные: теперь она обновляет строки
на месте, а не удаляет и создаёт заново. Побочный плюс: в момент обновления
товары поставщика больше не исчезают из поиска ни на секунду.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ShaerWare
ShaerWare merged commit 4d628b6 into main Sep 30, 2026
3 checks passed
@ShaerWare
ShaerWare deleted the local/fix/offer-upsert branch September 30, 2026 21:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant