fix(site_finder): SAVEPOINT-isolate supply/market db.execute + kill 2 bare rollbacks (#2464)
All checks were successful
CI / changes (pull_request) Successful in 9s
CI Trade-In / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m41s
CI / backend-tests (pull_request) Successful in 15m25s
All checks were successful
CI / changes (pull_request) Successful in 9s
CI Trade-In / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m41s
CI / backend-tests (pull_request) Successful in 15m25s
Convert two bare db.rollback() calls on the shared per-request session (best_layouts _fetch_supply_only_layouts + Step 2b core-attribution) to db.begin_nested() — a bare rollback there discards ALL uncommitted work of the request and orphans the outer SessionTransaction. Also wrap the swallow sites in market_metrics (_query_stock, _query_sales_window, _price_sensitivity, compute_offer_price_trend), best_layouts step-5 supply, future_supply _query_supply_rows, own_portfolio _query_current/_query_future, and custom_pois get_overlaps_for_scoring so a failed statement no longer poisons the shared tx for later steps. Per-site regression tests assert fallback preserved, begin_nested used, bare rollback NOT called, and session usable after failure. Refs #2464 (cluster A session-poisoning, Wave 2). EOF2 echo "HANDLED"
This commit is contained in:
parent
82fdabccba
commit
73739671a9
10 changed files with 712 additions and 69 deletions
|
|
@ -971,11 +971,17 @@ def _fetch_supply_only_layouts(
|
|||
|
||||
Returns:
|
||||
Список SupplyOnlyLayoutRow (может быть пустым). Ошибка SQL не роняет ответ —
|
||||
логируется, откатывается транзакция, возвращается [] (блок аддитивен).
|
||||
логируется, savepoint откатывается, возвращается [] (блок аддитивен).
|
||||
"""
|
||||
if not project_names:
|
||||
return []
|
||||
try:
|
||||
# SAVEPOINT — db shared с caller (GET .../best-layouts / §22-отчёт,
|
||||
# Depends(get_db)). Голый db.rollback() тут НЕЛЬЗЯ: он откатил бы ВСЮ outer
|
||||
# tx (всю несохранённую работу этого запроса) и orphan'ит outer
|
||||
# SessionTransaction (см. velocity.py:170). begin_nested откатывает ТОЛЬКО
|
||||
# этот savepoint — outer tx остаётся usable, блок аддитивно деградирует в [].
|
||||
with db.begin_nested():
|
||||
rows = (
|
||||
db.execute(
|
||||
_SUPPLY_ONLY_LOTS_SQL,
|
||||
|
|
@ -990,9 +996,6 @@ def _fetch_supply_only_layouts(
|
|||
.all()
|
||||
)
|
||||
except Exception:
|
||||
# Аддитивный блок — не роняем весь ответ. rollback: следующего execute нет,
|
||||
# но транзакция в aborted-state сломала бы commit/дальнейшую работу caller'а.
|
||||
db.rollback()
|
||||
logger.exception(
|
||||
"best_layouts: supply-only lots query failed for %d projects — empty block",
|
||||
len(project_names),
|
||||
|
|
@ -1120,8 +1123,19 @@ def get_best_layouts(
|
|||
# больше не теряет сделки, привязанные к 1-й через mapping).
|
||||
core_group_to_project: dict[str, str] = {}
|
||||
try:
|
||||
# SAVEPOINT — db shared с caller (GET .../best-layouts / §22-отчёт,
|
||||
# Depends(get_db)). Три db.execute здесь перемежаются с ЧИСТО-Python
|
||||
# match_groups_to_projects / _merged_project_name_pool (без db) → оборачиваем
|
||||
# весь блок одним savepoint. Голый db.rollback() НЕЛЬЗЯ: он откатил бы ВСЮ
|
||||
# outer tx + orphan'ит outer SessionTransaction (см. velocity.py:170).
|
||||
# begin_nested откатывает только этот savepoint → следующий execute (velocity
|
||||
# SQL, Step 3) не упадёт InFailedSqlTransaction, «fallback на mapping-only»
|
||||
# состоится.
|
||||
with db.begin_nested():
|
||||
corpus_names = [str(row[0]) for row in db.execute(_OBJECTIVE_PROJECT_NAMES_SQL).all()]
|
||||
lots_names = [str(row[0]) for row in db.execute(_OBJECTIVE_LOTS_PROJECT_NAMES_SQL).all()]
|
||||
lots_names = [
|
||||
str(row[0]) for row in db.execute(_OBJECTIVE_LOTS_PROJECT_NAMES_SQL).all()
|
||||
]
|
||||
project_names = _merged_project_name_pool(corpus_names, lots_names)
|
||||
# Два прохода: сперва кандидаты по ядру БЕЗ dev-confirm — чтобы developers
|
||||
# тянуть ТОЛЬКО для них (0-3 имени, index probe вместо 3.8с-агрегата).
|
||||
|
|
@ -1131,17 +1145,15 @@ def get_best_layouts(
|
|||
if candidate_names:
|
||||
developers_by_project = {
|
||||
str(row[0]): [str(d) for d in (row[1] or []) if d]
|
||||
for row in db.execute(_OBJECTIVE_DEVELOPERS_SQL, {"names": candidate_names}).all()
|
||||
for row in db.execute(
|
||||
_OBJECTIVE_DEVELOPERS_SQL, {"names": candidate_names}
|
||||
).all()
|
||||
}
|
||||
core_group_to_project = match_groups_to_projects(
|
||||
complex_groups, project_names, developers_by_project
|
||||
)
|
||||
except Exception:
|
||||
# Не роняем весь ответ — core-attribution аддитивна к mapping-источнику.
|
||||
# rollback: при реальной DB-ошибке транзакция в aborted-state — без отката
|
||||
# следующий execute (velocity SQL) упал бы InFailedSqlTransaction, и
|
||||
# «fallback на mapping-only» не состоялся бы.
|
||||
db.rollback()
|
||||
logger.exception(
|
||||
"best_layouts: core-attribution failed for cad_num=%s — fallback to mapping-only",
|
||||
cad_num,
|
||||
|
|
@ -1204,6 +1216,12 @@ def get_best_layouts(
|
|||
# 0 мес» / «% продано —». Per-object latest snapshot встроен в _SUPPLY_BATCH_SQL
|
||||
# (flats_latest CTE) → отдельный pre-compute MAX больше не нужен.
|
||||
try:
|
||||
# SAVEPOINT — db shared с caller (GET .../best-layouts, Depends(get_db), sync
|
||||
# get_best_layouts мостится через run_in_threadpool). Голый db.rollback() тут
|
||||
# НЕЛЬЗЯ — orphan'ит outer SessionTransaction (см. velocity.py:170); begin_nested
|
||||
# откатывает только эту savepoint, оставляя db usable для Step 9b
|
||||
# (_fetch_supply_only_layouts) на этой же сессии.
|
||||
with db.begin_nested():
|
||||
supply_rows = (
|
||||
db.execute(
|
||||
_SUPPLY_BATCH_SQL,
|
||||
|
|
|
|||
|
|
@ -251,6 +251,11 @@ def get_overlaps_for_scoring(
|
|||
"""
|
||||
_parcel_cad = parcel_cad or ""
|
||||
try:
|
||||
# SAVEPOINT — db shared с caller (POST .../analyze, Depends(get_db)). Голый
|
||||
# db.rollback() orphan'ит outer SessionTransaction (см. velocity.py:170) —
|
||||
# begin_nested откатывает только эту savepoint, оставляя db usable для
|
||||
# остальных запросов analyze_parcel на этой же сессии.
|
||||
with db.begin_nested():
|
||||
rows = (
|
||||
db.execute(
|
||||
text(_OVERLAPS_SQL),
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ def _future_supply_key(
|
|||
"""
|
||||
return (district, horizon_months, premise_kind)
|
||||
|
||||
|
||||
Confidence = Literal["high", "medium", "low"]
|
||||
|
||||
# Слои склада предложения (м.125): 1=open (в продаже), 2=hidden (запас ПД),
|
||||
|
|
@ -439,6 +440,11 @@ def _query_supply_rows(db: Session, params: Mapping[str, Any]) -> Sequence[Mappi
|
|||
(worker ещё не наполнил м.125) или сбой БД не должны валить расчёт индекса.
|
||||
"""
|
||||
try:
|
||||
# SAVEPOINT — db shared с caller (compute_future_supply_pressure в цепочке
|
||||
# §22-форсайта / analyze). Голый db.rollback() orphan'ит outer SessionTransaction
|
||||
# (см. velocity.py:170) — begin_nested откатывает только эту savepoint, оставляя
|
||||
# db usable для последующих db.execute на этой же сессии.
|
||||
with db.begin_nested():
|
||||
return db.execute(_SUPPLY_LAYERS_SQL, dict(params)).mappings().all()
|
||||
except Exception:
|
||||
logger.exception(
|
||||
|
|
|
|||
|
|
@ -613,6 +613,12 @@ def compute_market_metrics(
|
|||
def _query_stock(db: Session, params: Mapping[str, Any]) -> dict[str, int]:
|
||||
"""Текущий сток. На ошибке/пустых данных → все счётчики 0 (graceful)."""
|
||||
try:
|
||||
# SAVEPOINT — db shared с caller (compute_market_metrics в цепочке §22-форсайта
|
||||
# / analyze). Голый db.rollback() orphan'ит outer SessionTransaction (см.
|
||||
# velocity.py:170) — begin_nested откатывает только эту savepoint, outer tx
|
||||
# остаётся usable для последующих db.execute (_query_sales_window/_price_sensitivity)
|
||||
# на этой же сессии.
|
||||
with db.begin_nested():
|
||||
row = db.execute(_STOCK_SQL, dict(params)).mappings().first()
|
||||
except Exception:
|
||||
logger.exception(
|
||||
|
|
@ -650,6 +656,11 @@ def _query_sales_window(
|
|||
затирать прежние счётчики. На ошибке/пусто → (0, 0.0, {}).
|
||||
"""
|
||||
try:
|
||||
# SAVEPOINT — db shared с caller (см. _query_stock выше). CRITICAL: без него
|
||||
# сбой здесь оставляет транзакцию aborted, и следующий db.execute в этой же
|
||||
# цепочке (_price_sensitivity → _elasticity_coef) падает "current transaction
|
||||
# is aborted" вместо честного graceful-fallback.
|
||||
with db.begin_nested():
|
||||
rows = db.execute(_SALES_WINDOW_SQL, dict(params)).mappings().all()
|
||||
except Exception:
|
||||
logger.exception(
|
||||
|
|
@ -700,6 +711,11 @@ def _price_sensitivity(
|
|||
micros = resolve_objective_districts(db, district)
|
||||
elasticity_window = max(window_months, 24)
|
||||
try:
|
||||
# SAVEPOINT — db shared с caller (compute_market_metrics в цепочке §22-форсайта
|
||||
# / analyze). _elasticity_coef делает свой db.execute внутри без try/except —
|
||||
# оборачиваем ВЕСЬ вызов, чтобы сбой откатывал только эту savepoint (не outer
|
||||
# SessionTransaction, см. velocity.py:170), и db оставалась usable дальше.
|
||||
with db.begin_nested():
|
||||
elast = _elasticity_coef(
|
||||
db,
|
||||
region_code=_EKB_REGION_CODE,
|
||||
|
|
@ -877,6 +893,11 @@ def compute_offer_price_trend(
|
|||
"price_max": _OFFER_PRICE_MAX,
|
||||
}
|
||||
try:
|
||||
# SAVEPOINT — db shared с caller (POST .../analyze / §22-отчёт, Depends(get_db)).
|
||||
# 8с-гвард (parcels.py) может отменить этот запрос (QueryCanceled) → без savepoint
|
||||
# транзакция остаётся aborted и следующий db.execute падает "current transaction
|
||||
# is aborted". begin_nested откатывает только этот savepoint, db остаётся usable.
|
||||
with db.begin_nested():
|
||||
rows = db.execute(_OFFER_TREND_SQL, params).mappings().all()
|
||||
except Exception as exc:
|
||||
# Ожидаемый путь: 8с-гвард (parcels.py) отменяет запрос на холодном кэше
|
||||
|
|
|
|||
|
|
@ -169,6 +169,11 @@ def _query_current(db: Session) -> list[OwnProject]:
|
|||
)
|
||||
return []
|
||||
try:
|
||||
# SAVEPOINT — db shared с caller (get_own_portfolio в цепочке §25.3 / analyze).
|
||||
# Голый db.rollback() orphan'ит outer SessionTransaction (см. velocity.py:170) —
|
||||
# begin_nested откатывает только эту savepoint, оставляя db usable для
|
||||
# _query_future (следующий вызов на этой же сессии в get_own_portfolio).
|
||||
with db.begin_nested():
|
||||
rows = db.execute(_CURRENT_SQL, {"ids": ids}).mappings().all()
|
||||
except Exception:
|
||||
logger.exception("own_portfolio: current (domrf) query failed → []")
|
||||
|
|
@ -200,6 +205,10 @@ def _query_future(db: Session) -> list[OwnProject]:
|
|||
Сбой/отсутствие таблицы (cold-start до миграции 148) → [] (НЕ crash).
|
||||
"""
|
||||
try:
|
||||
# SAVEPOINT — db shared с caller (см. _query_current выше). begin_nested
|
||||
# откатывает только эту savepoint (НЕ голый db.rollback(), см. velocity.py:170),
|
||||
# оставляя db usable для остальных потребителей той же сессии (§25.3 / analyze).
|
||||
with db.begin_nested():
|
||||
rows = db.execute(_FUTURE_SQL).mappings().all()
|
||||
except Exception:
|
||||
logger.exception("own_portfolio: future (own_planned_project) query failed → []")
|
||||
|
|
|
|||
|
|
@ -599,3 +599,94 @@ def test_service_update_custom_poi_no_commit_when_not_found() -> None:
|
|||
|
||||
db.commit.assert_not_called()
|
||||
assert result is None
|
||||
|
||||
|
||||
# ── SAVEPOINT regression (#2464 cluster A) ─────────────────────────────────────
|
||||
#
|
||||
# get_overlaps_for_scoring: db shared с caller (POST .../analyze, Depends(get_db)).
|
||||
# Раньше db.execute-сбой ловился в bare ``except Exception as e`` без
|
||||
# SAVEPOINT/rollback — на реальном Postgres это отравляет db shared с caller:
|
||||
# следующий db.execute на этой же сессии (остальные шаги analyze_parcel) падает
|
||||
# "current transaction is aborted". Фикс — db.execute обёрнут в
|
||||
# ``with db.begin_nested():``. MagicMock не эмулирует реальный aborted-transaction
|
||||
# Postgres — тесты проверяют (1) graceful [] fallback при сбое, (2) что
|
||||
# begin_nested() реально вызван, (3) что db остаётся usable для следующего вызова
|
||||
# на той же mock-сессии.
|
||||
|
||||
|
||||
def _overlap_row(data: dict[str, Any]) -> MagicMock:
|
||||
m = MagicMock()
|
||||
m.__getitem__ = lambda self, k: data[k]
|
||||
return m
|
||||
|
||||
|
||||
def test_get_overlaps_for_scoring_returns_shaped_rows() -> None:
|
||||
"""Happy-path: db.execute успешен → список dict с ожидаемыми ключами."""
|
||||
from app.services.site_finder.custom_pois import get_overlaps_for_scoring
|
||||
|
||||
row = _overlap_row(
|
||||
{
|
||||
"id": 42,
|
||||
"name": "Мой парк",
|
||||
"category": "park",
|
||||
"weight": 2.0,
|
||||
"lon": 60.607,
|
||||
"lat": 56.840,
|
||||
"distance_m": 500.0,
|
||||
}
|
||||
)
|
||||
db = MagicMock()
|
||||
db.execute.return_value.mappings.return_value.all.return_value = [row]
|
||||
|
||||
out = get_overlaps_for_scoring(db, "POINT(60.6 56.8)", _SESSION, parcel_cad="66:41:0:1")
|
||||
|
||||
assert out == [
|
||||
{
|
||||
"id": 42,
|
||||
"name": "Мой парк",
|
||||
"category": "park",
|
||||
"weight": 2.0,
|
||||
"lon": 60.607,
|
||||
"lat": 56.840,
|
||||
"distance_m": 500.0,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_get_overlaps_for_scoring_uses_begin_nested_savepoint() -> None:
|
||||
"""Фикс: db.execute обёрнут в db.begin_nested(), не голый вызов."""
|
||||
from app.services.site_finder.custom_pois import get_overlaps_for_scoring
|
||||
|
||||
db = MagicMock()
|
||||
db.execute.return_value.mappings.return_value.all.return_value = []
|
||||
|
||||
get_overlaps_for_scoring(db, "POINT(60.6 56.8)", _SESSION)
|
||||
|
||||
assert db.begin_nested.call_count == 1
|
||||
|
||||
|
||||
def test_get_overlaps_for_scoring_query_failure_degrades_to_empty_list() -> None:
|
||||
"""Сбой db.execute → [] (graceful), не крашит analyze_parcel."""
|
||||
from app.services.site_finder.custom_pois import get_overlaps_for_scoring
|
||||
|
||||
db = MagicMock()
|
||||
db.execute.side_effect = RuntimeError("simulated DB failure")
|
||||
|
||||
assert get_overlaps_for_scoring(db, "POINT(60.6 56.8)", _SESSION) == []
|
||||
|
||||
|
||||
def test_get_overlaps_for_scoring_session_usable_after_failure() -> None:
|
||||
"""db.execute сбоит один раз, но db остаётся usable для следующего вызова на
|
||||
этой же сессии (имитирует custom-POI overlaps → остальные шаги analyze_parcel
|
||||
на одной shared Session)."""
|
||||
from app.services.site_finder.custom_pois import get_overlaps_for_scoring
|
||||
|
||||
db = MagicMock()
|
||||
db.execute.side_effect = RuntimeError("boom")
|
||||
assert get_overlaps_for_scoring(db, "POINT(60.6 56.8)", _SESSION) == []
|
||||
|
||||
db.execute.side_effect = None
|
||||
next_result = MagicMock()
|
||||
next_result.mappings.return_value.all.return_value = [{"marker": "ok"}]
|
||||
db.execute.return_value = next_result
|
||||
assert db.execute("SELECT 1").mappings().all() == [{"marker": "ok"}]
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ from app.services.site_finder.best_layouts import (
|
|||
MAX_BUCKET_SHARE_PCT,
|
||||
NAMELESS_CLUSTER_M,
|
||||
_cap_and_redistribute,
|
||||
_fetch_supply_only_layouts,
|
||||
get_best_layouts,
|
||||
group_radius_objects,
|
||||
match_groups_to_projects,
|
||||
|
|
@ -1224,3 +1225,193 @@ def test_supply_only_sql_uses_price_sanity_and_on_sale_filter() -> None:
|
|||
assert "'studio'" in sql_text and "'4+'" in sql_text
|
||||
# psycopg3: CAST(:x AS type), НЕ :x::type
|
||||
assert "::text[]" not in sql_text.replace("CAST(:names AS text[])", "")
|
||||
|
||||
|
||||
# ── SAVEPOINT regression (#2464 cluster A) ────────────────────────────────────
|
||||
#
|
||||
# Step 5 (_SUPPLY_BATCH_SQL) db shared с caller (GET .../best-layouts,
|
||||
# Depends(get_db); sync get_best_layouts мостится через run_in_threadpool). Раньше
|
||||
# db.execute-сбой ловился в bare ``except Exception`` без SAVEPOINT/rollback — на
|
||||
# реальном Postgres это отравляет db shared с caller: следующий db.execute на этой
|
||||
# же сессии (напр. Step 9b _fetch_supply_only_layouts) падает "current transaction
|
||||
# is aborted". Фикс — db.execute обёрнут в ``with db.begin_nested():``. MagicMock не
|
||||
# эмулирует реальный aborted-transaction Postgres — тесты проверяют (1) graceful
|
||||
# supply=0 fallback при сбое, (2) что begin_nested() реально вызван вокруг именно
|
||||
# этого db.execute, (3) что db остаётся usable для последующих вызовов на этой же
|
||||
# mock-сессии (get_best_layouts не крашит, ранжирование по-прежнему строится).
|
||||
|
||||
|
||||
def test_supply_query_failure_degrades_to_zero_supply() -> None:
|
||||
"""Сбой Step 5 (_SUPPLY_BATCH_SQL) → supply_units_in_radius=0 для всех строк,
|
||||
остальной pipeline (velocity/ranking) не ломается."""
|
||||
id_rows = [_obj_id_row(1)]
|
||||
vel_rows = [_vel_row("2", deals_window=48.0, avg_area=55.0, obj_ids=[1])]
|
||||
db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows)
|
||||
|
||||
real_dispatch = db.execute.side_effect
|
||||
|
||||
def _execute_raise_on_supply(stmt, params=None, *args, **kwargs):
|
||||
if stmt is _SUPPLY_BATCH_SQL:
|
||||
raise RuntimeError("simulated DB failure")
|
||||
return real_dispatch(stmt, params, *args, **kwargs)
|
||||
|
||||
db.execute.side_effect = _execute_raise_on_supply
|
||||
|
||||
req = _request(time_window="last_quarter")
|
||||
resp = get_best_layouts(db, CAD_NUM, req)
|
||||
|
||||
assert len(resp.top_layouts) == 1
|
||||
assert resp.top_layouts[0].supply_units_in_radius == 0
|
||||
assert resp.top_layouts[0].sold_pct_of_supply is None
|
||||
assert resp.top_layouts[0].is_oversold is False
|
||||
# velocity per-month всё ещё честно посчитан (сбой изолирован в supply-блоке).
|
||||
assert resp.top_layouts[0].velocity_per_month == pytest.approx(16.0, rel=1e-3)
|
||||
|
||||
|
||||
def test_supply_query_uses_begin_nested_savepoint() -> None:
|
||||
"""Фикс: Step 5 запрос обёрнут в db.begin_nested(), не голый db.execute."""
|
||||
id_rows = [_obj_id_row(1)]
|
||||
vel_rows = [_vel_row("2", deals_window=48.0, avg_area=55.0, obj_ids=[1])]
|
||||
supply_rows = [_supply_row("2", "40-60", 80)]
|
||||
db = _make_db(coord=_coord_row(), id_rows=id_rows, vel_rows=vel_rows, supply_rows=supply_rows)
|
||||
|
||||
req = _request(time_window="last_quarter")
|
||||
get_best_layouts(db, CAD_NUM, req)
|
||||
|
||||
assert db.begin_nested.call_count >= 1
|
||||
|
||||
|
||||
def test_supply_query_failure_session_usable_for_supply_only_step() -> None:
|
||||
"""db остаётся usable после Step 5-сбоя: Step 9b (_fetch_supply_only_layouts,
|
||||
отдельный db.execute на той же сессии) по-прежнему выполняется без исключения.
|
||||
|
||||
Сетап зеркалит test_supply_only_group_without_velocity_appears_in_block: две
|
||||
группы, «Траектория» с velocity → top_layouts, «Символ» без velocity → должна
|
||||
дойти до Step 9b (_fetch_supply_only_layouts) на той же db, что и Step 5.
|
||||
"""
|
||||
id_rows = [
|
||||
_obj_id_row(1, "ЖК «Траектория»", "Брусника", 56.85, 60.60),
|
||||
_obj_id_row(2, "ЖК «Символ»", "Эфес", 56.851, 60.601),
|
||||
]
|
||||
vel_rows = [_vel_row("2", deals_window=30.0, obj_ids=[], project_names=["Траектория"])]
|
||||
supply_only_rows = [_supply_only_row("Символ", room_bucket="1", area_bin="25-40")]
|
||||
db = _make_db(
|
||||
coord=_coord_row(),
|
||||
id_rows=id_rows,
|
||||
vel_rows=vel_rows,
|
||||
project_names=["Траектория", "Символ"],
|
||||
developers_by_project={},
|
||||
supply_only_rows=supply_only_rows,
|
||||
)
|
||||
|
||||
real_dispatch = db.execute.side_effect
|
||||
|
||||
def _execute_raise_on_supply(stmt, params=None, *args, **kwargs):
|
||||
if stmt is _SUPPLY_BATCH_SQL:
|
||||
raise RuntimeError("simulated DB failure")
|
||||
return real_dispatch(stmt, params, *args, **kwargs)
|
||||
|
||||
db.execute.side_effect = _execute_raise_on_supply
|
||||
|
||||
req = _request(time_window="last_quarter")
|
||||
# Не должно бросить InFailedSqlTransaction-подобное исключение — Step 9b
|
||||
# выполняется на той же db без проблем (в mock — просто control-flow guard).
|
||||
resp = get_best_layouts(db, CAD_NUM, req)
|
||||
assert resp.top_layouts[0].supply_units_in_radius == 0
|
||||
# Step 9b реально выполнился на этой же db — supply-only блок не пуст.
|
||||
assert len(resp.supply_only_layouts) == 1
|
||||
assert resp.supply_only_layouts[0].project_name == "Символ"
|
||||
|
||||
|
||||
# ── SAVEPOINT regression: ex-bare-rollback sites (#2464 cluster A) ──────────────
|
||||
#
|
||||
# ДВА места в best_layouts.py раньше делали ГОЛЫЙ db.rollback() на SHARED сессии в
|
||||
# except после сбоя db.execute — это ХУЖЕ простого swallow: bare rollback на общей
|
||||
# сессии откатывает ВСЮ outer-транзакцию /analyze-запроса (всю несохранённую работу
|
||||
# ранее в этом же запросе) и orphan'ит outer SessionTransaction (velocity.py:170).
|
||||
# Фикс — savepoint: db.execute обёрнут в ``with db.begin_nested():``, а bare
|
||||
# db.rollback() УДАЛЁН (auto-rollback savepoint'а откатывает только сбойный statement,
|
||||
# не весь запрос). Тесты проверяют: (a) документированный fallback сохранён, (b)
|
||||
# begin_nested() использован И db.rollback() НЕ вызван (весь смысл — outer tx выживает),
|
||||
# (c) follow-up db.execute на той же сессии проходит.
|
||||
|
||||
|
||||
class TestFetchSupplyOnlyLayoutsSavepoint:
|
||||
"""Ex-rollback site #1: _fetch_supply_only_layouts (Step 9b)."""
|
||||
|
||||
def test_uses_begin_nested_savepoint(self) -> None:
|
||||
db = MagicMock()
|
||||
db.execute.return_value.mappings.return_value.all.return_value = []
|
||||
_fetch_supply_only_layouts(db, ["ЖК Резерв"])
|
||||
assert db.begin_nested.call_count == 1
|
||||
|
||||
def test_query_failure_degrades_to_empty_and_no_bare_rollback(self) -> None:
|
||||
db = MagicMock()
|
||||
db.execute.side_effect = RuntimeError("simulated DB failure")
|
||||
out = _fetch_supply_only_layouts(db, ["ЖК Резерв"])
|
||||
assert out == [] # документированный fallback
|
||||
assert db.begin_nested.call_count == 1 # savepoint использован
|
||||
db.rollback.assert_not_called() # bare rollback удалён — outer tx выживает
|
||||
|
||||
def test_empty_project_names_skips_db_entirely(self) -> None:
|
||||
# Пустой вход → SQL не выполняется, savepoint не открывается (штатный ранний []).
|
||||
db = MagicMock()
|
||||
assert _fetch_supply_only_layouts(db, []) == []
|
||||
db.execute.assert_not_called()
|
||||
db.begin_nested.assert_not_called()
|
||||
|
||||
def test_session_usable_after_failure(self) -> None:
|
||||
db = MagicMock()
|
||||
db.execute.side_effect = RuntimeError("boom")
|
||||
assert _fetch_supply_only_layouts(db, ["ЖК Резерв"]) == []
|
||||
|
||||
db.execute.side_effect = None
|
||||
next_result = MagicMock()
|
||||
next_result.mappings.return_value.all.return_value = [{"marker": "ok"}]
|
||||
db.execute.return_value = next_result
|
||||
assert db.execute("SELECT 1").mappings().all() == [{"marker": "ok"}]
|
||||
|
||||
|
||||
def test_core_attribution_failure_uses_savepoint_no_bare_rollback() -> None:
|
||||
"""Ex-rollback site #2: Step 2b core-attribution db.execute сбоит → SAVEPOINT
|
||||
(не bare rollback) → get_best_layouts деградирует в mapping-only.
|
||||
|
||||
Раньше bare db.rollback() здесь откатил бы ВСЮ outer tx /analyze-запроса. Теперь
|
||||
begin_nested откатывает только сбойный savepoint, а velocity через obj_id (mapping)
|
||||
по-прежнему считается на той же сессии → top_layouts не пуст, outer tx выживает.
|
||||
"""
|
||||
id_rows = [_obj_id_row(1)]
|
||||
vel_rows = [_vel_row("2", deals_window=48.0, avg_area=55.0, obj_ids=[1])]
|
||||
supply_rows = [_supply_row("2", "40-60", 80)]
|
||||
db = _make_db(
|
||||
coord=_coord_row(),
|
||||
id_rows=id_rows,
|
||||
vel_rows=vel_rows,
|
||||
supply_rows=supply_rows,
|
||||
project_names=["Символ"],
|
||||
developers_by_project={},
|
||||
)
|
||||
|
||||
real_dispatch = db.execute.side_effect
|
||||
|
||||
def _raise_on_project_names(stmt, params=None, *args, **kwargs):
|
||||
# Первый из трёх Step 2b запросов — сбой на нём отравил бы сессию без savepoint.
|
||||
if stmt is _OBJECTIVE_PROJECT_NAMES_SQL:
|
||||
raise RuntimeError("simulated DB failure")
|
||||
return real_dispatch(stmt, params, *args, **kwargs)
|
||||
|
||||
db.execute.side_effect = _raise_on_project_names
|
||||
|
||||
req = _request(time_window="last_quarter")
|
||||
resp = get_best_layouts(db, CAD_NUM, req)
|
||||
|
||||
# savepoint использован, bare rollback НЕ вызван (outer tx /analyze выживает).
|
||||
assert db.begin_nested.call_count >= 1
|
||||
db.rollback.assert_not_called()
|
||||
# fallback на mapping-only: velocity через obj_id всё равно посчитан на той же
|
||||
# сессии (Step 3 velocity execute не заблокирован aborted-транзакцией).
|
||||
assert len(resp.top_layouts) == 1
|
||||
assert resp.top_layouts[0].room_bucket == "2"
|
||||
assert resp.top_layouts[0].velocity_per_month == pytest.approx(16.0, rel=1e-3)
|
||||
# core-match не дал имён (сбой) → matched-only supply-блок пуст, но ответ валиден.
|
||||
assert resp.data_quality.groups_matched_by_core == 0
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ from app.services.site_finder.future_supply import (
|
|||
_monthly_absorption_units,
|
||||
_months_between,
|
||||
_months_of_pressure,
|
||||
_query_supply_rows,
|
||||
_round_or_none,
|
||||
_saturating_index,
|
||||
compute_future_supply_pressure,
|
||||
|
|
@ -635,3 +636,61 @@ class TestComputeGraceful:
|
|||
res = compute_future_supply_pressure(db, district=None)
|
||||
assert isinstance(res, FutureSupplyPressure)
|
||||
assert res.district is None
|
||||
|
||||
|
||||
# ── SAVEPOINT regression (#2464 cluster A) ─────────────────────────────────────
|
||||
#
|
||||
# db shared с caller (compute_future_supply_pressure в цепочке §22-форсайта /
|
||||
# analyze). Раньше _query_supply_rows ловил db.execute-сбой в bare
|
||||
# ``except Exception`` без SAVEPOINT/rollback — на реальном Postgres это отравляет
|
||||
# db shared с caller: следующий db.execute на этой же сессии (напр.
|
||||
# compute_market_metrics, вызываемый следом в compute_future_supply_pressure)
|
||||
# падает "current transaction is aborted". Фикс — db.execute обёрнут в
|
||||
# ``with db.begin_nested():``. MagicMock не эмулирует реальный aborted-transaction
|
||||
# Postgres — тесты проверяют (1) graceful [] fallback при сбое, (2) что
|
||||
# begin_nested() реально вызван, (3) что db остаётся usable для следующего вызова
|
||||
# на той же mock-сессии.
|
||||
|
||||
|
||||
class TestQuerySupplyRowsSavepoint:
|
||||
def test_uses_begin_nested_savepoint(self) -> None:
|
||||
db = MagicMock()
|
||||
db.execute.return_value.mappings.return_value.all.return_value = []
|
||||
_query_supply_rows(db, {"district": "X", "names": ["X"]})
|
||||
assert db.begin_nested.call_count == 1
|
||||
|
||||
def test_query_failure_degrades_to_empty_list(self) -> None:
|
||||
db = MagicMock()
|
||||
db.execute.side_effect = RuntimeError("simulated DB failure")
|
||||
assert _query_supply_rows(db, {"district": "X", "names": ["X"]}) == []
|
||||
|
||||
def test_session_usable_after_failure(self) -> None:
|
||||
"""db.execute сбоит один раз (supply-layers view), но db остаётся usable
|
||||
для следующего вызова на этой же сессии (имитирует
|
||||
_query_supply_rows → compute_market_metrics в compute_future_supply_pressure)."""
|
||||
db = MagicMock()
|
||||
db.execute.side_effect = RuntimeError("boom")
|
||||
assert _query_supply_rows(db, {"district": "X", "names": ["X"]}) == []
|
||||
|
||||
db.execute.side_effect = None
|
||||
next_result = MagicMock()
|
||||
next_result.mappings.return_value.all.return_value = [{"marker": "ok"}]
|
||||
db.execute.return_value = next_result
|
||||
assert db.execute("SELECT 1").mappings().all() == [{"marker": "ok"}]
|
||||
|
||||
|
||||
class TestComputeFutureSupplyPressureSessionUsableAfterFailure:
|
||||
"""End-to-end: view-запрос сбоит → SAVEPOINT (не bare rollback) → reused
|
||||
compute_market_metrics (отдельный db.execute на той же сессии) не задет."""
|
||||
|
||||
def test_view_failure_does_not_poison_market_metrics_call(self) -> None:
|
||||
db = MagicMock()
|
||||
db.execute.side_effect = RuntimeError("view gone")
|
||||
with _patched(metrics=_metrics_stub(months_of_supply=6.0, n_available=120)) as (mm, _rs):
|
||||
res = compute_future_supply_pressure(db, district="Сбой")
|
||||
assert db.begin_nested.call_count >= 1
|
||||
# compute_market_metrics (reused) вызван несмотря на сбой view-запроса —
|
||||
# сессия не отравлена (в реальном Postgres это была бы "aborted transaction").
|
||||
assert mm.called
|
||||
assert res.hidden_units == 0
|
||||
assert res.index == 0.0
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ from app.services.site_finder.market_metrics import (
|
|||
_months_of_supply,
|
||||
_overstock_index,
|
||||
_price_sensitivity,
|
||||
_query_sales_window,
|
||||
_query_stock,
|
||||
_room_bucket,
|
||||
_sell_through_pct,
|
||||
compute_market_metrics,
|
||||
|
|
@ -1010,3 +1012,161 @@ class TestOfferPriceTrendCompute:
|
|||
assert d["delta_pct"] == 3.3
|
||||
assert d["lots_latest"] == 12
|
||||
assert d["complexes_covered"] == 1
|
||||
|
||||
|
||||
# ── SAVEPOINT regression (#2464 cluster A) ─────────────────────────────────────
|
||||
#
|
||||
# db shared с caller (compute_market_metrics в цепочке §22-форсайта / analyze).
|
||||
# Раньше _query_stock/_query_sales_window/_price_sensitivity ловили db.execute-сбой
|
||||
# в bare ``except Exception`` без SAVEPOINT/rollback — на реальном Postgres это
|
||||
# отравляет db shared с caller: следующий db.execute на этой же сессии падает
|
||||
# "current transaction is aborted". Фикс — db.execute (и, для _price_sensitivity,
|
||||
# весь вызов _elasticity_coef) обёрнут в ``with db.begin_nested():``. MagicMock не
|
||||
# эмулирует реальный aborted-transaction Postgres — тесты проверяют (1) graceful
|
||||
# fallback при сбое, (2) что begin_nested() реально вызван, (3) что db остаётся
|
||||
# usable для следующего вызова на той же mock-сессии.
|
||||
|
||||
|
||||
class TestQueryStockSavepoint:
|
||||
def test_uses_begin_nested_savepoint(self) -> None:
|
||||
db = MagicMock()
|
||||
db.execute.return_value.mappings.return_value.first.return_value = None
|
||||
_query_stock(db, {"districts": ["X"]})
|
||||
assert db.begin_nested.call_count == 1
|
||||
|
||||
def test_query_failure_degrades_to_zero_counts(self) -> None:
|
||||
db = MagicMock()
|
||||
db.execute.side_effect = RuntimeError("simulated DB failure")
|
||||
out = _query_stock(db, {"districts": ["X"]})
|
||||
assert out == {
|
||||
"n_lots": 0,
|
||||
"n_sold": 0,
|
||||
"n_available": 0,
|
||||
"obj_count": 0,
|
||||
"n_long_unsold": 0,
|
||||
}
|
||||
|
||||
def test_session_usable_after_failure(self) -> None:
|
||||
"""db.execute сбоит один раз (stock), но db остаётся usable для следующего
|
||||
(не связанного) вызова на этой же сессии — имитирует
|
||||
_query_stock → _query_sales_window → _price_sensitivity chain."""
|
||||
db = MagicMock()
|
||||
db.execute.side_effect = RuntimeError("boom")
|
||||
out = _query_stock(db, {"districts": ["X"]})
|
||||
assert out["n_lots"] == 0
|
||||
|
||||
db.execute.side_effect = None
|
||||
next_result = MagicMock()
|
||||
next_result.mappings.return_value.all.return_value = [{"marker": "ok"}]
|
||||
db.execute.return_value = next_result
|
||||
assert db.execute("SELECT 1").mappings().all() == [{"marker": "ok"}]
|
||||
|
||||
|
||||
class TestQuerySalesWindowSavepoint:
|
||||
def test_uses_begin_nested_savepoint(self) -> None:
|
||||
db = MagicMock()
|
||||
db.execute.return_value.mappings.return_value.all.return_value = []
|
||||
_query_sales_window(db, {"districts": ["X"]})
|
||||
assert db.begin_nested.call_count == 1
|
||||
|
||||
def test_query_failure_degrades_to_empty(self) -> None:
|
||||
db = MagicMock()
|
||||
db.execute.side_effect = RuntimeError("simulated DB failure")
|
||||
units, area, by_room = _query_sales_window(db, {"districts": ["X"]})
|
||||
assert (units, area, by_room) == (0, 0.0, {})
|
||||
|
||||
def test_session_usable_after_failure(self) -> None:
|
||||
db = MagicMock()
|
||||
db.execute.side_effect = RuntimeError("boom")
|
||||
units, _area, _by_room = _query_sales_window(db, {"districts": ["X"]})
|
||||
assert units == 0
|
||||
|
||||
db.execute.side_effect = None
|
||||
next_result = MagicMock()
|
||||
next_result.mappings.return_value.all.return_value = [{"marker": "ok"}]
|
||||
db.execute.return_value = next_result
|
||||
assert db.execute("SELECT 1").mappings().all() == [{"marker": "ok"}]
|
||||
|
||||
|
||||
class TestPriceSensitivitySavepoint:
|
||||
"""_price_sensitivity делает db.execute КОСВЕННО (через _elasticity_coef) —
|
||||
SAVEPOINT обёрнут вокруг всего вызова, а не голого db.execute."""
|
||||
|
||||
def test_uses_begin_nested_savepoint(self, _patch_resolver: MagicMock) -> None:
|
||||
_patch_resolver.side_effect = lambda _db, d: [d] if d is not None else None
|
||||
db = MagicMock()
|
||||
with patch(_ELAST, return_value={"elasticity": -1.4, "source": "regression"}):
|
||||
_price_sensitivity(db, district="Автовокзал", window_months=6)
|
||||
assert db.begin_nested.call_count == 1
|
||||
|
||||
def test_elasticity_failure_still_degrades_gracefully(self, _patch_resolver: MagicMock) -> None:
|
||||
_patch_resolver.side_effect = lambda _db, d: [d] if d is not None else None
|
||||
db = MagicMock()
|
||||
with patch(_ELAST, side_effect=RuntimeError("simulated DB failure")):
|
||||
value, source = _price_sensitivity(db, district="Автовокзал", window_months=6)
|
||||
assert (value, source) == (None, None)
|
||||
assert db.begin_nested.call_count == 1
|
||||
|
||||
def test_session_usable_after_failure(self, _patch_resolver: MagicMock) -> None:
|
||||
"""Сбой elasticity-цепочки не отравляет db — следующий вызов на этой же
|
||||
сессии (например, offer-price-trend) отрабатывает нормально."""
|
||||
_patch_resolver.side_effect = lambda _db, d: [d] if d is not None else None
|
||||
db = MagicMock()
|
||||
with patch(_ELAST, side_effect=RuntimeError("boom")):
|
||||
_price_sensitivity(db, district="Автовокзал", window_months=6)
|
||||
|
||||
next_result = MagicMock()
|
||||
next_result.mappings.return_value.all.return_value = [{"marker": "ok"}]
|
||||
db.execute.return_value = next_result
|
||||
assert db.execute("SELECT 1").mappings().all() == [{"marker": "ok"}]
|
||||
|
||||
|
||||
class TestComputeMarketMetricsSessionUsableAfterFirstFailure:
|
||||
"""End-to-end (via compute_market_metrics): stock-запрос сбоит → SAVEPOINT
|
||||
(не bare rollback) → sales-window запрос по-прежнему выполняется на той же
|
||||
сессии (не блокируется "current transaction is aborted")."""
|
||||
|
||||
def test_stock_failure_does_not_poison_sales_window(self, _patch_resolver: MagicMock) -> None:
|
||||
db = MagicMock()
|
||||
sales_result = MagicMock()
|
||||
sales_result.mappings.return_value.all.return_value = _FULL_SALES
|
||||
db.execute.side_effect = [RuntimeError("boom on stock"), sales_result]
|
||||
with patch(_ELAST, return_value={"elasticity": -1.4, "source": "regression"}):
|
||||
m = compute_market_metrics(db, district="Автовокзал", window_months=6)
|
||||
assert db.begin_nested.call_count >= 1
|
||||
assert m.n_lots == 0 # stock fallback (graceful, не crash)
|
||||
assert m.confidence == "low"
|
||||
# sales-window execute РЕАЛЬНО выполнился на той же db (2-й вызов не был
|
||||
# заблокирован сбоем 1-го) — session осталась usable после stock-savepoint.
|
||||
assert db.execute.call_count >= 2
|
||||
|
||||
|
||||
class TestOfferPriceTrendSavepoint:
|
||||
"""compute_offer_price_trend: db shared с caller (POST .../analyze / §22-отчёт).
|
||||
db.execute обёрнут в begin_nested — 8с-гвард QueryCanceled не отравляет сессию."""
|
||||
|
||||
def test_uses_begin_nested_savepoint(self) -> None:
|
||||
db = _mock_offer_db([])
|
||||
compute_offer_price_trend(db, center_lat=56.83, center_lon=60.6)
|
||||
assert db.begin_nested.call_count == 1
|
||||
|
||||
def test_query_failure_degrades_and_no_bare_rollback(self) -> None:
|
||||
db = MagicMock()
|
||||
db.execute.side_effect = RuntimeError("query canceled (8s guard)")
|
||||
res = compute_offer_price_trend(db, center_lat=56.83, center_lon=60.6)
|
||||
assert res.points == [] # документированный fallback
|
||||
assert res.has_data is False
|
||||
assert db.begin_nested.call_count == 1
|
||||
db.rollback.assert_not_called() # никакого bare rollback на shared сессии
|
||||
|
||||
def test_session_usable_after_failure(self) -> None:
|
||||
db = MagicMock()
|
||||
db.execute.side_effect = RuntimeError("db down")
|
||||
res = compute_offer_price_trend(db, center_lat=56.83, center_lon=60.6)
|
||||
assert res.points == []
|
||||
|
||||
db.execute.side_effect = None
|
||||
next_result = MagicMock()
|
||||
next_result.mappings.return_value.all.return_value = [{"marker": "ok"}]
|
||||
db.execute.return_value = next_result
|
||||
assert db.execute("SELECT 1").mappings().all() == [{"marker": "ok"}]
|
||||
|
|
|
|||
|
|
@ -23,7 +23,12 @@ from typing import Any
|
|||
from unittest.mock import MagicMock
|
||||
|
||||
from app.services.site_finder import own_portfolio
|
||||
from app.services.site_finder.own_portfolio import OwnProject, get_own_portfolio
|
||||
from app.services.site_finder.own_portfolio import (
|
||||
OwnProject,
|
||||
_query_current,
|
||||
_query_future,
|
||||
get_own_portfolio,
|
||||
)
|
||||
|
||||
|
||||
def _row(data: dict[str, Any]) -> MagicMock:
|
||||
|
|
@ -272,3 +277,81 @@ def test_future_query_failure_returns_empty_gracefully(monkeypatch) -> None:
|
|||
out = get_own_portfolio(db)
|
||||
|
||||
assert out == []
|
||||
|
||||
|
||||
# ── SAVEPOINT regression (#2464 cluster A) ───────────────────────────────────────
|
||||
#
|
||||
# db shared с caller (get_own_portfolio в цепочке §25.3 / analyze). Раньше
|
||||
# _query_current/_query_future ловили db.execute-сбой в bare ``except Exception``
|
||||
# без SAVEPOINT/rollback — на реальном Postgres это отравляет db shared с caller:
|
||||
# следующий db.execute на этой же сессии падает "current transaction is aborted".
|
||||
# Фикс — db.execute обёрнут в ``with db.begin_nested():``. MagicMock не эмулирует
|
||||
# реальный aborted-transaction Postgres — тесты проверяют (1) graceful fallback при
|
||||
# сбое, (2) что begin_nested() реально вызван вокруг db.execute, (3) что db остаётся
|
||||
# usable для следующего вызова на той же mock-сессии.
|
||||
|
||||
|
||||
class TestQueryCurrentSavepoint:
|
||||
def test_uses_begin_nested_savepoint(self, monkeypatch) -> None:
|
||||
monkeypatch.setattr(own_portfolio.settings, "own_developer_ids", [12345])
|
||||
db = MagicMock()
|
||||
db.execute.return_value = _result([])
|
||||
_query_current(db)
|
||||
assert db.begin_nested.call_count == 1
|
||||
|
||||
def test_query_failure_degrades_to_empty_list(self, monkeypatch) -> None:
|
||||
monkeypatch.setattr(own_portfolio.settings, "own_developer_ids", [12345])
|
||||
db = MagicMock()
|
||||
db.execute.side_effect = RuntimeError("simulated DB failure")
|
||||
assert _query_current(db) == []
|
||||
|
||||
def test_session_usable_after_failure(self, monkeypatch) -> None:
|
||||
"""db.execute сбоит один раз, но db остаётся usable для следующего вызова
|
||||
на этой же сессии (имитирует _query_current → _query_future в
|
||||
get_own_portfolio на одной shared Session)."""
|
||||
monkeypatch.setattr(own_portfolio.settings, "own_developer_ids", [12345])
|
||||
db = MagicMock()
|
||||
db.execute.side_effect = RuntimeError("boom")
|
||||
assert _query_current(db) == []
|
||||
|
||||
db.execute.side_effect = None
|
||||
next_result = _result([{"marker": "ok"}])
|
||||
db.execute.return_value = next_result
|
||||
assert db.execute("SELECT 1").mappings().all() == [{"marker": "ok"}]
|
||||
|
||||
|
||||
class TestQueryFutureSavepoint:
|
||||
def test_uses_begin_nested_savepoint(self) -> None:
|
||||
db = MagicMock()
|
||||
db.execute.return_value = _result([])
|
||||
_query_future(db)
|
||||
assert db.begin_nested.call_count == 1
|
||||
|
||||
def test_query_failure_degrades_to_empty_list(self) -> None:
|
||||
db = MagicMock()
|
||||
db.execute.side_effect = RuntimeError("simulated DB failure")
|
||||
assert _query_future(db) == []
|
||||
|
||||
def test_session_usable_after_failure(self) -> None:
|
||||
db = MagicMock()
|
||||
db.execute.side_effect = RuntimeError("boom")
|
||||
assert _query_future(db) == []
|
||||
|
||||
db.execute.side_effect = None
|
||||
next_result = _result([{"marker": "ok"}])
|
||||
db.execute.return_value = next_result
|
||||
assert db.execute("SELECT 1").mappings().all() == [{"marker": "ok"}]
|
||||
|
||||
|
||||
def test_get_own_portfolio_current_failure_savepoint_isolated(monkeypatch) -> None:
|
||||
"""End-to-end (via get_own_portfolio): current db.execute сбоит → SAVEPOINT
|
||||
(не bare rollback) → future по-прежнему считается на той же сессии."""
|
||||
monkeypatch.setattr(own_portfolio.settings, "own_developer_ids", [12345])
|
||||
db = MagicMock()
|
||||
db.execute.side_effect = [RuntimeError("boom on domrf"), _result([_future_row()])]
|
||||
|
||||
out = get_own_portfolio(db)
|
||||
|
||||
assert db.begin_nested.call_count >= 1
|
||||
assert len(out) == 1
|
||||
assert out[0].source == "future"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue