Coverage for src/local_deep_research/database/research_cascade.py: 95%

266 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:20 +0000

1"""Explicit deletion of research rows and their foreign-key descendants. 

2 

3Legacy SQLite databases retain the FK rules from table creation, and several 

4current download edges use NO ACTION. Derive a child-before-parent plan from 

5model metadata instead of relying on database cascades. The survival policy 

6clears links to chat, library, sync and synthesis rows that outlive a research. 

7Shared download trackers can move to another research's resource before the 

8plan runs, preserving their URL-level attempt and duplicate history. 

9Documents referenced by a surviving research likewise move to that resource; 

10their storage, collection links and indexing state remain attached to the same 

11document ID. Both ownership fields move together, including on legacy schemas. 

12 

13Rows without an FK path to research_history, including document_chunks, are 

14outside this plan. Report files and UserActiveResearch remain caller concerns. 

15All writes use a nested savepoint; the caller owns the outer transaction. 

16""" 

17 

18from loguru import logger 

19from sqlalchemy import ( 

20 not_, 

21 select, 

22 text, 

23 tuple_, 

24) 

25from sqlalchemy.orm import Session 

26 

27from ..constants import ResearchStatus 

28from .models import ( 

29 Base, 

30 Document, 

31 DocumentChunk, 

32 DocumentCollection, 

33 DownloadDuplicates, 

34 DownloadTracker, 

35 QueuedResearch, 

36 ResearchHistory, 

37 ResearchResource, 

38) 

39from ..utilities.download_url import get_url_hash, get_url_hash_candidates 

40 

41 

42def collect_research_document_rag_targets( 

43 db_session: Session, research_id: str 

44) -> list[tuple[str, list[str]]]: 

45 """Snapshot owned documents with chunk rows before the research cascade. 

46 

47 ``DocumentChunk.source_id`` has no foreign key. The cascade deletes the 

48 owning Document and its collection links, but must leave chunk rows until 

49 the post-commit vector purge has used their FAISS IDs. Include linked 

50 collections as well as those named by chunks, since an interrupted index 

51 can leave the two sets different. 

52 """ 

53 # Legacy SQLite SELECTs do not open a database transaction. Discovery 

54 # must share the cascade's snapshot, or a newly committed document can 

55 # be deleted without appearing in these post-commit cleanup targets. 

56 _ensure_outer_db_transaction(db_session) 

57 chunk_rows = ( 

58 db_session.query(Document.id, DocumentChunk.collection_name) 

59 .join( 

60 DocumentChunk, 

61 (DocumentChunk.source_id == Document.id) 

62 & (DocumentChunk.source_type == "document"), 

63 ) 

64 .filter(Document.research_id == research_id) 

65 .distinct() 

66 .all() 

67 ) 

68 targets: dict[str, set[str]] = {} 

69 for document_id, collection_name in chunk_rows: 

70 collections = targets.setdefault(document_id, set()) 

71 if collection_name and collection_name.startswith("collection_"): 71 ↛ 69line 71 didn't jump to line 69 because the condition on line 71 was always true

72 collections.add(collection_name.removeprefix("collection_")) 

73 

74 if not targets: 

75 return [] 

76 

77 links = ( 

78 db_session.query( 

79 DocumentCollection.document_id, DocumentCollection.collection_id 

80 ) 

81 .join(Document, Document.id == DocumentCollection.document_id) 

82 .filter(Document.research_id == research_id) 

83 .all() 

84 ) 

85 for document_id, collection_id in links: 

86 if document_id in targets: 86 ↛ 85line 86 didn't jump to line 85 because the condition on line 86 was always true

87 targets[document_id].add(collection_id) 

88 

89 return [ 

90 (document_id, sorted(collections)) 

91 for document_id, collections in sorted(targets.items()) 

92 ] 

93 

94 

95_CASCADE_ROOT_TABLE = "research_history" 

96 

97# (child_table, child_column) pairs whose rows SURVIVE a research 

98# delete with the link cleared. Every other edge reached by the 

99# metadata walk becomes a bulk DELETE. Keep this list to exactly the 

100# cases where deletion would destroy data owned by someone else: 

101# chat history outlives the research it discusses; library, sync and 

102# synthesis rows outlive any single research. 

103_CASCADE_SET_NULL = frozenset( 

104 { 

105 ("chat_messages", "research_id"), 

106 ("documents", "resource_id"), 

107 ("research_resources", "document_id"), 

108 ("download_tracker", "library_document_id"), 

109 ("note_syntheses", "result_document_id"), 

110 ("zotero_item_map", "document_id"), 

111 } 

112) 

113 

114_cascade_plan_cache = None 

115 

116 

117def _research_cascade_plan(): 

118 """Derive ``(set_null_steps, delete_steps, table_to_class)`` from 

119 ``Base.metadata``. 

120 

121 Each step is ``(child_table, child_column, parent_table, 

122 parent_column)``. Delete steps additionally carry their runtime 

123 order: deepest tables first, children before the parents they 

124 reference (topological over the reached subgraph). The result is 

125 cached — model metadata is fixed per process. 

126 """ 

127 global _cascade_plan_cache 

128 if _cascade_plan_cache is not None: 

129 return _cascade_plan_cache 

130 

131 table_to_class = {} 

132 for mapper in Base.registry.mappers: 

133 cls = mapper.class_ 

134 table_name = getattr(cls.__table__, "name", None) 

135 if table_name: 135 ↛ 132line 135 didn't jump to line 132 because the condition on line 135 was always true

136 if table_name in table_to_class: 136 ↛ 137line 136 didn't jump to line 137 because the condition on line 136 was never true

137 raise AssertionError( 

138 f"cascade planner: table {table_name!r} maps to two classes" 

139 ) 

140 table_to_class[table_name] = cls 

141 

142 children_of: dict = {} 

143 for table in Base.metadata.tables.values(): 

144 for fk in table.foreign_keys: 

145 children_of.setdefault(fk.column.table.name, []).append( 

146 (table.name, fk.parent.name, fk.column.name) 

147 ) 

148 

149 reached_edges = [] 

150 levels = {_CASCADE_ROOT_TABLE: 0} 

151 queue = [_CASCADE_ROOT_TABLE] 

152 seen_edges = set() 

153 while queue: 

154 parent = queue.pop(0) 

155 for child, column, parent_column in sorted(children_of.get(parent, [])): 

156 key = (child, column, parent, parent_column) 

157 if key in seen_edges: 157 ↛ 158line 157 didn't jump to line 158 because the condition on line 157 was never true

158 continue 

159 seen_edges.add(key) 

160 if child not in table_to_class: 160 ↛ 161line 160 didn't jump to line 161 because the condition on line 160 was never true

161 raise AssertionError( 

162 "cascade planner: table " 

163 f"{child!r} references {parent!r} but has no " 

164 "mapped class — map it or exclude it explicitly" 

165 ) 

166 reached_edges.append(key) 

167 if child not in levels: 

168 levels[child] = levels[parent] + 1 

169 queue.append(child) 

170 

171 # A table's rows are deleted only when a deleted parent references 

172 # them through a non-surviving edge. Tables reached solely through 

173 # ``_CASCADE_SET_NULL`` edges (chat history, syntheses, sync state) 

174 # survive: edges targeting them need no step, since a row that is 

175 # never deleted cannot block anything. 

176 deleted_tables = {_CASCADE_ROOT_TABLE} 

177 changed = True 

178 while changed: 

179 changed = False 

180 for child, column, parent, _parent_column in reached_edges: 

181 if ( 

182 parent in deleted_tables 

183 and (child, column) not in _CASCADE_SET_NULL 

184 and child not in deleted_tables 

185 ): 

186 deleted_tables.add(child) 

187 changed = True 

188 

189 set_null_steps = [ 

190 key for key in reached_edges if (key[0], key[1]) in _CASCADE_SET_NULL 

191 ] 

192 delete_steps = [ 

193 key 

194 for key in reached_edges 

195 if (key[0], key[1]) not in _CASCADE_SET_NULL 

196 and key[2] in deleted_tables 

197 ] 

198 

199 # Order deletes so every child goes before the parents it 

200 # references (Kahn's algorithm, table-name tie-break for 

201 # determinism). The planner asserts acyclicity: a future cyclic 

202 # FK must fail loudly here, not as a runtime IntegrityError. 

203 delete_tables = {step[0] for step in delete_steps} 

204 waiting_on = {table: set() for table in delete_tables} 

205 for child, _column, parent, _parent_column in delete_steps: 

206 if parent in waiting_on: 

207 waiting_on[parent].add(child) 

208 ordered_tables: list = [] 

209 ready = sorted( 

210 table for table, blockers in waiting_on.items() if not blockers 

211 ) 

212 while ready: 

213 table = ready.pop(0) 

214 ordered_tables.append(table) 

215 for other, blockers in waiting_on.items(): 

216 if table in blockers: 

217 blockers.discard(table) 

218 if not blockers and other not in ordered_tables: 

219 ready.append(other) 

220 ready.sort() 

221 if len(ordered_tables) != len(waiting_on): 221 ↛ 222line 221 didn't jump to line 222 because the condition on line 221 was never true

222 raise AssertionError( 

223 "cascade planner: cyclic FKs among " 

224 f"{sorted(waiting_on)} — cannot order deletes" 

225 ) 

226 table_rank = {table: rank for rank, table in enumerate(ordered_tables)} 

227 delete_steps.sort(key=lambda step: (table_rank[step[0]], step[0])) 

228 

229 _cascade_plan_cache = (set_null_steps, delete_steps, table_to_class) 

230 return _cascade_plan_cache 

231 

232 

233def _ensure_outer_db_transaction(db_session: Session) -> None: 

234 """Start a DB-level transaction when none exists yet. 

235 

236 SQLite (and SQLCipher) in legacy transaction mode — the mode this 

237 app uses on every production connection (``isolation_level=""``, 

238 DEFERRED) — only opens a database transaction implicitly before 

239 DML. Pure ``SELECT``s leave the driver with no transaction even 

240 though the SQLAlchemy ``Session`` reports ``in_transaction() == 

241 True``. Calling ``Session.begin_nested()`` in that state issues a 

242 standalone ``SAVEPOINT`` outside any ``BEGIN``; its ``RELEASE`` 

243 then commits immediately instead of joining the caller's 

244 transaction, so a later ``db_session.rollback()`` cannot restore 

245 the rows (``clear_history`` ends up partly applied after a later 

246 failure). See the SQLAlchemy SQLite dialect docs on legacy 

247 transaction mode with the pysqlite driver. 

248 

249 Probing the driver-level ``in_transaction`` flag and issuing a 

250 plain (DEFERRED) ``BEGIN`` when it is ``False`` makes the 

251 following ``begin_nested()`` a true nested savepoint: ``RELEASE`` 

252 keeps the outer transaction open and the caller still owns 

253 ``commit`` / ``rollback``. DEFERRED (not IMMEDIATE) preserves the 

254 read-concurrency property production relies on (SELECTs take only 

255 a SHARED lock). If the transaction cannot be established, refuse 

256 deletion rather than risk using a stale snapshot or committing early. 

257 ``session_context.telemetry_savepoint`` reads the same driver flag 

258 but deliberately issues no ``BEGIN``: best-effort telemetry joins a 

259 transaction that is already open and otherwise commits on its own. 

260 Keep the two probes in step. 

261 """ 

262 connection = db_session.connection() 

263 raw = getattr( 

264 getattr(connection, "connection", None), "driver_connection", None 

265 ) 

266 if raw is None: 266 ↛ 267line 266 didn't jump to line 267 because the condition on line 266 was never true

267 raise RuntimeError("research cascade: driver_connection unavailable") 

268 in_txn = getattr(raw, "in_transaction", None) 

269 if in_txn is None: 269 ↛ 270line 269 didn't jump to line 270 because the condition on line 269 was never true

270 raise RuntimeError( 

271 "research cascade: driver does not expose in_transaction" 

272 ) 

273 is_in_txn = in_txn() if callable(in_txn) else bool(in_txn) 

274 if not is_in_txn: 

275 db_session.execute(text("BEGIN")) 

276 

277 

278# Bound on bind parameters per IN list; SQLite builds before 3.32 cap a 

279# statement at 999. 

280_IN_CHUNK = 500 

281 

282 

283def _chunks(values): 

284 values = list(values) 

285 for start in range(0, len(values), _IN_CHUNK): 

286 yield values[start : start + _IN_CHUNK] 

287 

288 

289def _owned_trackers(db_session: Session, research_ids): 

290 """``(id, url, url_hash)`` of trackers whose first resource belongs to 

291 one of ``research_ids``.""" 

292 owned = [] 

293 for chunk in _chunks(research_ids): 

294 owned.extend( 

295 db_session.query( 

296 DownloadTracker.id, 

297 DownloadTracker.url, 

298 DownloadTracker.url_hash, 

299 ) 

300 .filter( 

301 DownloadTracker.first_resource_id.in_( 

302 select(ResearchResource.id).where( 

303 ResearchResource.research_id.in_(chunk) 

304 ) 

305 ) 

306 ) 

307 .all() 

308 ) 

309 return owned 

310 

311 

312def _tracker_references(db_session: Session, trackers): 

313 """Map each tracker id to the sorted ``(resource_id, research_id)`` of 

314 every existing resource that references it. 

315 

316 A resource references a tracker when its URL is canonically equivalent 

317 to the tracker's URL, when its URL has the tracker's ``url_hash`` under the 

318 download service's current or legacy normalization (the service reuses 

319 trackers this way without recording a duplicate), or when a 

320 ``DownloadDuplicates`` row links it. Stored URLs and hashes are only 

321 read, never rewritten. 

322 

323 Cost: ``research_resources.url`` has no index and the normalization 

324 cannot use one, so the URL match scans that table once for the whole 

325 ``trackers`` set. Stream rows in bounded batches and check the same 

326 hash candidates as download lookups in Python. The old SQL base filter 

327 cannot express canonical URL identity (userinfo, ports, fragments and 

328 tracking parameters), and could omit a surviving reference. 

329 """ 

330 if not trackers: 

331 return {} 

332 references: dict = {tracker_id: set() for tracker_id, _u, _h in trackers} 

333 by_hash = {url_hash: tracker_id for tracker_id, _url, url_hash in trackers} 

334 by_url: dict = {} 

335 by_canonical_hash: dict = {} 

336 for tracker_id, url, _url_hash in trackers: 

337 by_url.setdefault(url, []).append(tracker_id) 

338 # A canonical resource URL cannot reconstruct a legacy hash of the 

339 # original decorated URL. Normalize both URLs without re-keying rows. 

340 # Several legacy trackers can represent the same canonical URL. 

341 by_canonical_hash.setdefault(get_url_hash(url), []).append(tracker_id) 

342 

343 for chunk in _chunks(by_hash): 

344 for url_hash, resource_id, research_id in ( 

345 db_session.query( 

346 DownloadDuplicates.url_hash, 

347 ResearchResource.id, 

348 ResearchResource.research_id, 

349 ) 

350 .join( 

351 ResearchResource, 

352 ResearchResource.id == DownloadDuplicates.resource_id, 

353 ) 

354 .join( 

355 ResearchHistory, 

356 ResearchHistory.id == ResearchResource.research_id, 

357 ) 

358 .filter(DownloadDuplicates.url_hash.in_(chunk)) 

359 ): 

360 references[by_hash[url_hash]].add((resource_id, research_id)) 

361 

362 url_column = ResearchResource.url 

363 candidates = ( 

364 db_session.query( 

365 ResearchResource.id, ResearchResource.research_id, url_column 

366 ) 

367 .join( 

368 ResearchHistory, ResearchHistory.id == ResearchResource.research_id 

369 ) 

370 .filter(url_column.isnot(None)) 

371 ) 

372 for resource_id, research_id, url in candidates.yield_per(_IN_CHUNK): 

373 tracker_ids = set(by_url.get(url, ())) 

374 url_hashes = get_url_hash_candidates(url) 

375 tracker_ids.update(by_canonical_hash.get(url_hashes[0], ())) 

376 for url_hash in url_hashes: 

377 tracker_id = by_hash.get(url_hash) 

378 if tracker_id is not None: 

379 tracker_ids.add(tracker_id) 

380 for tracker_id in tracker_ids: 

381 references[tracker_id].add((resource_id, research_id)) 

382 return {tracker_id: sorted(refs) for tracker_id, refs in references.items()} 

383 

384 

385class SharedDownloadIndex: 

386 """Shared-download references for a batch of research deletes. 

387 

388 ``clear_history`` deletes many researches in one transaction. Scanning 

389 ``research_resources`` once per tracker-owning research would repeat a 

390 full-table scan per research while holding the user database's write 

391 lock. :meth:`build` collects, in one scan, every resource that 

392 references a tracker owned by the batch. Trackers only move to 

393 resources that reference them, so this covers every tracker a batch 

394 member can own while the batch runs. 

395 

396 Each delete then hands a tracker to the lowest-id referencing resource 

397 of a research that is neither being deleted nor already deleted 

398 (:meth:`mark_deleted`). This is the choice a fresh per-research scan 

399 would make, so a tracker handed to a later batch member moves again 

400 when that member is deleted, and stays with it when its guarded delete 

401 is refused. Researches outside the batch, including protected ones, 

402 are never deleted, so they stay eligible. A tracker the index has not 

403 seen (for example one created after :meth:`build`) is looked up with 

404 its own scan. 

405 """ 

406 

407 def __init__(self): 

408 self._references: dict = {} 

409 self._first_live: dict = {} 

410 self._deleted: set = set() 

411 

412 @classmethod 

413 def build(cls, db_session: Session, research_ids) -> "SharedDownloadIndex": 

414 # The snapshot must belong to the same database transaction as the 

415 # deletes. Legacy SQLite SELECTs alone do not begin a transaction. 

416 _ensure_outer_db_transaction(db_session) 

417 index = cls() 

418 owned = _owned_trackers(db_session, list(dict.fromkeys(research_ids))) 

419 index._references = _tracker_references(db_session, owned) 

420 return index 

421 

422 def mark_deleted(self, research_id: str) -> None: 

423 self._deleted.add(research_id) 

424 

425 def replacements(self, db_session: Session, research_id: str) -> dict: 

426 """Map each tracker this research owns to its surviving resource. 

427 

428 Returns early, after one tracker query, when the research owns no 

429 tracker. Trackers without a surviving reference are left out and 

430 deleted with the research. 

431 """ 

432 owned = _owned_trackers(db_session, [research_id]) 

433 if not owned: 

434 return {} 

435 unseen = [row for row in owned if row[0] not in self._references] 

436 if unseen: 

437 self._references.update(_tracker_references(db_session, unseen)) 

438 replacements = {} 

439 for tracker_id, _url, _url_hash in owned: 

440 refs = self._references[tracker_id] 

441 start = self._first_live.get(tracker_id, 0) 

442 # Deleted researches never come back; skip them for good. 

443 while start < len(refs) and refs[start][1] in self._deleted: 

444 start += 1 

445 self._first_live[tracker_id] = start 

446 for resource_id, ref_research_id in refs[start:]: 

447 if ( 

448 ref_research_id != research_id 

449 and ref_research_id not in self._deleted 

450 ): 

451 replacements[tracker_id] = resource_id 

452 break 

453 return replacements 

454 

455 

456def _shared_download_replacements(db_session: Session, research_id: str): 

457 """Choose a surviving resource for each tracker owned by this research. 

458 

459 One-off form of :meth:`SharedDownloadIndex.replacements`: no scan when 

460 the research owns no tracker, otherwise one scan of 

461 ``research_resources``. 

462 """ 

463 return SharedDownloadIndex().replacements(db_session, research_id) 

464 

465 

466def _shared_document_replacements( 

467 db_session: Session, research_id: str 

468) -> dict[str, tuple[int, str]]: 

469 """Choose a surviving explicit reference for each shared document. 

470 

471 Resource URLs may differ for identical downloaded bytes. The canonical 

472 document link is the evidence of sharing, not URL equality. Choose the 

473 lowest resource ID deterministically and keep resource/research ownership 

474 aligned, preserving ADR-0012's protection on legacy resource-CASCADE DBs. 

475 """ 

476 references = ( 

477 db_session.query( 

478 Document.id, ResearchResource.id, ResearchResource.research_id 

479 ) 

480 .join(ResearchResource, ResearchResource.document_id == Document.id) 

481 .join( 

482 ResearchHistory, ResearchHistory.id == ResearchResource.research_id 

483 ) 

484 .filter( 

485 Document.research_id == research_id, 

486 ResearchResource.research_id != research_id, 

487 ) 

488 .order_by(ResearchResource.id) 

489 .all() 

490 ) 

491 replacements = {} 

492 for document_id, resource_id, surviving_research_id in references: 

493 replacements.setdefault( 

494 document_id, (resource_id, surviving_research_id) 

495 ) 

496 return replacements 

497 

498 

499def delete_research_with_dependents( 

500 db_session: Session, 

501 research_id: str, 

502 *, 

503 require_status_not_in_progress: bool = True, 

504 require_no_claimed_queue_row: bool = True, 

505 shared_downloads: SharedDownloadIndex | None = None, 

506) -> bool: 

507 """Delete a research and its owned FK descendants atomically. 

508 

509 Callers deleting research outside the wired entry points must also 

510 collect the per-research RAG targets inside the same transaction and 

511 run ``DocumentDeletionService.purge_deleted_research_documents`` 

512 after the guarded commit (never after a refusal or rollback) — see 

513 ``web/routers/research.py`` for the pattern. Skipping it leaves 

514 orphaned, still-searchable vector and chunk rows behind. 

515 

516 URL-level download trackers move to a surviving resource when one exists; 

517 their shared attempt history and other resources' duplicate links survive. 

518 Shared documents transfer both owner fields to a surviving resource, 

519 preserving their ID, stored PDF, collections and existing index entries. 

520 Deleting the last referencing research retains ordinary cascade behavior; 

521 explicit global document deletion remains a separate operation. 

522 A caller deleting many researches in one transaction passes one 

523 ``shared_downloads`` index built for the whole set 

524 (:meth:`SharedDownloadIndex.build`); the helper records each successful 

525 delete in it. Without one, a research that owns a tracker pays its own 

526 scan of ``research_resources``. 

527 

528 All child-table deletes and the parent delete are issued against the 

529 same SQLAlchemy session without an intervening commit, so the row 

530 is removed in one transaction. The two ``require_*`` flags gate the 

531 parent DELETE on the same predicates the original ``#5074`` guard 

532 embedded inline: 

533 

534 * ``require_status_not_in_progress`` — refuses rows still 

535 ``IN_PROGRESS`` (a worker owns them). 

536 * ``require_no_claimed_queue_row`` — refuses rows whose 

537 ``QueuedResearch`` entry has ``is_processing = True`` (a worker is 

538 about to start or has not finished writing back the row). 

539 

540 When either flag is ``False`` the corresponding predicate is dropped 

541 entirely. Both default to ``True`` so the helper applies the full 

542 guard by default — callers that genuinely want an unconditional 

543 cascade (e.g. the chat service's per-attempt teardown, where a 

544 pre-loaded liveness check has already ruled out a live worker) opt 

545 out explicitly. The predicates run **inside** the parent ``DELETE`` 

546 so a concurrent claim that lands between the helper's read and its 

547 delete is observed by SQL and the row is preserved. 

548 

549 Atomicity per research: child writes run inside a ``SAVEPOINT`` 

550 (``Session.begin_nested``). When the parent ``DELETE`` deletes zero 

551 rows — ghost row or a guard refusal — the savepoint is rolled back 

552 so the ``False`` return leaves parent *and* children untouched. A 

553 ``True`` return releases the savepoint into the caller's outer 

554 transaction (the caller still owns ``commit`` / ``rollback``). This 

555 is what makes ``clear_history``'s multi-row loop safe: one 

556 protected row's rollback does not discard its siblings' deletes. 

557 

558 The reference lookup and savepoint share a real database transaction. 

559 In legacy transaction mode (production SQLCipher/SQLite with 

560 ``isolation_level=""``), SELECTs do not open one, so a DEFERRED 

561 ``BEGIN`` precedes Phase A (and the batch index's initial lookup). 

562 This both prevents a standalone ``RELEASE`` from committing early 

563 and prevents a concurrent new reference from being silently missed. 

564 If a WAL writer changes the snapshot, upgrading it to a write fails; 

565 the caller must roll back and retry with a fresh index. 

566 

567 The helper performs an explicit, application-level cascade driven 

568 by ``_research_cascade_plan()`` (derived from ``Base.metadata``, 

569 so new plain-data tables are picked up with no code change). See 

570 the module docstring for why we do not rely on the DB-level 

571 ``ON DELETE CASCADE``. Caller is responsible for the surrounding 

572 transaction (``commit`` / ``rollback``). 

573 

574 Returns ``True`` iff the ``research_history`` row was deleted. 

575 """ 

576 _ensure_outer_db_transaction(db_session) 

577 set_null_steps, delete_steps, table_to_class = _research_cascade_plan() 

578 

579 def _pk_columns(table): 

580 return [table.c[name] for name in table.primary_key.columns.keys()] 

581 

582 def _pk_filter(table, pk_values): 

583 pk_cols = _pk_columns(table) 

584 if len(pk_cols) == 1: 584 ↛ 586line 584 didn't jump to line 586 because the condition on line 584 was always true

585 return pk_cols[0].in_([row[0] for row in pk_values]) 

586 return tuple_(*pk_cols).in_(pk_values) 

587 

588 # Phase A: resolve scopes top-down. ``scopes[table]`` holds the PK 

589 # tuples reachable from the named research; each step's parent 

590 # values are the referenced-column values over those rows. Edges 

591 # resolve in rounds (a step resolves once its parent table's scope 

592 # is final), so same-level parents — e.g. ``note_references`` 

593 # scoped by both ``research_history`` and ``documents`` — work. 

594 # Read-only SELECTs only; no writes happen before Phase B. 

595 root_table = table_to_class[_CASCADE_ROOT_TABLE].__table__ 

596 scopes = { 

597 _CASCADE_ROOT_TABLE: db_session.query(*_pk_columns(root_table)) 

598 .filter(root_table.c.id == research_id) 

599 .all() 

600 } 

601 # Ghost row fast-path: no writes, no savepoint. 

602 if not scopes[_CASCADE_ROOT_TABLE]: 

603 return False 

604 

605 if shared_downloads is None: 

606 shared_downloads = SharedDownloadIndex() 

607 download_replacements = shared_downloads.replacements( 

608 db_session, research_id 

609 ) 

610 document_replacements = _shared_document_replacements( 

611 db_session, research_id 

612 ) 

613 edge_parent_values: dict = {} 

614 

615 def _resolve_step(child, column, parent, parent_column): 

616 parent_table = table_to_class[parent].__table__ 

617 parent_pks = scopes.get(parent, []) 

618 if not parent_pks: 

619 edge_parent_values[(child, column, parent, parent_column)] = [] 

620 return [] 

621 values = [ 

622 value 

623 for (value,) in db_session.query(parent_table.c[parent_column]) 

624 .filter(_pk_filter(parent_table, parent_pks)) 

625 .distinct() 

626 .all() 

627 if value is not None 

628 ] 

629 edge_parent_values[(child, column, parent, parent_column)] = values 

630 return values 

631 

632 # A table's scope is final once every DELETE edge into it has 

633 # resolved (SET NULL edges never extend a scope — nulled rows 

634 # survive, so nothing traverses from them). DELETE edges form a 

635 # DAG (asserted by the planner), and every such edge is processed 

636 # parent-first, so this loop always terminates; the AssertionError 

637 # below is a safety net, not an expected path. 

638 all_steps = set_null_steps + delete_steps 

639 delete_step_set = set(delete_steps) 

640 resolved_tables = {_CASCADE_ROOT_TABLE} 

641 remaining_steps = set(all_steps) 

642 while remaining_steps: 

643 resolvable = sorted( 

644 step for step in remaining_steps if step[2] in resolved_tables 

645 ) 

646 if not resolvable: 646 ↛ 647line 646 didn't jump to line 647 because the condition on line 646 was never true

647 raise AssertionError( 

648 "cascade planner: unresolvable scope chain from " 

649 f"{sorted(remaining_steps)} — cyclic FKs?" 

650 ) 

651 for step in resolvable: 

652 child, column, parent, parent_column = step 

653 values = _resolve_step(child, column, parent, parent_column) 

654 if step in delete_step_set and values: 

655 child_table = table_to_class[child].__table__ 

656 children = db_session.query(*_pk_columns(child_table)).filter( 

657 child_table.c[column].in_(values) 

658 ) 

659 if child == "download_tracker" and download_replacements: 

660 # These trackers will move before deleting resources. 

661 # Exclude them from descendant scopes so their shared 

662 # attempts and other researches' duplicates survive. 

663 children = children.filter( 

664 child_table.c.id.notin_(download_replacements) 

665 ) 

666 if child == "documents" and document_replacements: 

667 # Exclude retained documents before computing descendant 

668 # scopes, or blobs/collection links would still be deleted 

669 # even though the document's owner changes below. 

670 children = children.filter( 

671 child_table.c.id.notin_(document_replacements) 

672 ) 

673 scopes.setdefault(child, []).extend( 

674 tuple(row) for row in children.all() 

675 ) 

676 remaining_steps.discard(step) 

677 # Mark a table resolved once every DELETE edge into it has 

678 # resolved. NB: this must scan tables, not remaining steps — 

679 # a table whose edges all resolved in an earlier round is no 

680 # longer in ``remaining_steps`` but still needs marking so 

681 # deeper edges become resolvable. 

682 done_steps = set(all_steps) - remaining_steps 

683 for table in {step[0] for step in delete_steps}: 

684 if table not in resolved_tables and all( 

685 incoming in done_steps 

686 for incoming in delete_steps 

687 if incoming[0] == table 

688 ): 

689 resolved_tables.add(table) 

690 

691 # Phases B–D run inside a SAVEPOINT so a zero-row parent DELETE 

692 # (ghost or guard refusal) rolls back the child writes for *this* 

693 # research only. Without this, ``clear_history`` would commit the 

694 # orphaned children of a skipped row along with its siblings. 

695 # Phase A already started the outer DB transaction, so RELEASE 

696 # cannot commit ahead of the caller's commit / rollback. 

697 savepoint = db_session.begin_nested() 

698 try: 

699 for document_id, ( 

700 resource_id, 

701 owner_id, 

702 ) in document_replacements.items(): 

703 db_session.query(Document).filter( 

704 Document.id == document_id, Document.research_id == research_id 

705 ).update( 

706 {"resource_id": resource_id, "research_id": owner_id}, 

707 synchronize_session=False, 

708 ) 

709 

710 # Keep URL-level tracking with a surviving research. These updates 

711 # share the savepoint with the cascade, so a refused parent delete 

712 # restores the original ownership along with all child rows. 

713 for tracker_id, resource_id in download_replacements.items(): 

714 db_session.query(DownloadTracker).filter( 

715 DownloadTracker.id == tracker_id 

716 ).update( 

717 {"first_resource_id": resource_id}, synchronize_session=False 

718 ) 

719 

720 # Phase B: clear the surviving links first (all such columns are 

721 # nullable). Nulling before deleting means the deletes below never 

722 # meet a row that still points at them, on any schema. 

723 for child, column, parent, parent_column in set_null_steps: 

724 values = edge_parent_values[(child, column, parent, parent_column)] 

725 if not values: 

726 continue 

727 cls = table_to_class[child] 

728 db_session.query(cls).filter( 

729 getattr(cls, column).in_(values) 

730 ).update({column: None}, synchronize_session=False) 

731 

732 # Phase C: bulk deletes in planner order (deepest first), each 

733 # scoped to its own edge. Scopes were fully resolved in Phase A, 

734 # so no reads are needed here. 

735 for child, column, parent, parent_column in delete_steps: 

736 values = edge_parent_values[(child, column, parent, parent_column)] 

737 if not values: 

738 continue 

739 cls = table_to_class[child] 

740 child_table = cls.__table__ 

741 db_session.query(cls).filter( 

742 child_table.c[column].in_(values) 

743 ).delete(synchronize_session=False) 

744 

745 # Phase D: finally, the parent row itself. The ``#5074`` 

746 # predicates are applied INSIDE this DELETE so a concurrent queue 

747 # claim or status write observed by the row sees them atomically — 

748 # mirroring the old ``DELETE ... WHERE id = ? AND status != 

749 # IN_PROGRESS AND ~claimed_queue_row`` shape. A zero-row return 

750 # means the row did not exist or one of the guards blocked it; 

751 # callers that map the latter to a 400 must check the row 

752 # externally (the helper deliberately does not distinguish). 

753 parent_filters = [ResearchHistory.id == research_id] 

754 if require_status_not_in_progress: 

755 parent_filters.append( 

756 ResearchHistory.status != ResearchStatus.IN_PROGRESS 

757 ) 

758 if require_no_claimed_queue_row: 

759 claimed_queue_row = db_session.query(QueuedResearch.id).filter( 

760 QueuedResearch.research_id == research_id, 

761 QueuedResearch.is_processing.is_(True), 

762 ) 

763 parent_filters.append(not_(claimed_queue_row.exists())) 

764 

765 deleted = ( 

766 db_session.query(ResearchHistory) 

767 .filter(*parent_filters) 

768 .delete(synchronize_session=False) 

769 ) 

770 

771 if not deleted: 

772 # Guard refused (or ghost raced away): undo this research's 

773 # child writes, keep the outer transaction for siblings. 

774 savepoint.rollback() 

775 return False 

776 savepoint.commit() 

777 shared_downloads.mark_deleted(research_id) 

778 return True 

779 except Exception: 

780 try: 

781 savepoint.rollback() 

782 except Exception as rollback_exc: 

783 logger.debug( 

784 f"research cascade savepoint rollback failed: {rollback_exc}" 

785 ) 

786 raise