Snapshot final KB paths before a mutation starts. ``hardlink_dirs`` marks directories whose backup may be hardlinks instead of copies (O(1), no per-file byte copy). A directory is only safe to list here if every writer into it is either atomic temp+replace (new inode, so the hardlin
(
kb_dir: Path,
paths: list[Path],
*,
operation: str,
details: dict | None = None,
hardlink_dirs: set[Path] | None = None,
)
| 306 | |
| 307 | |
| 308 | def snapshot_paths( |
| 309 | kb_dir: Path, |
| 310 | paths: list[Path], |
| 311 | *, |
| 312 | operation: str, |
| 313 | details: dict | None = None, |
| 314 | hardlink_dirs: set[Path] | None = None, |
| 315 | ) -> MutationSnapshot: |
| 316 | """Snapshot final KB paths before a mutation starts. |
| 317 | |
| 318 | ``hardlink_dirs`` marks directories whose backup may be hardlinks instead |
| 319 | of copies (O(1), no per-file byte copy). A directory is only safe to list |
| 320 | here if every writer into it is either atomic temp+replace (new inode, so |
| 321 | the hardlink backup keeps the old bytes) or append-only. This is the |
| 322 | required caller contract for hardlinked dirs; any in-place writer into one |
| 323 | of those trees would silently corrupt the backup and make rollback a no-op |
| 324 | for that file. |
| 325 | """ |
| 326 | kb_dir = kb_dir.resolve() |
| 327 | hardlink_resolved = {p.resolve() for p in (hardlink_dirs or ())} |
| 328 | journal_id = uuid.uuid4().hex |
| 329 | backup_dir = kb_dir / ".openkb" / "staging" / f"rollback-{journal_id}" |
| 330 | backup_dir.mkdir(parents=True, exist_ok=False) |
| 331 | snapshot = MutationSnapshot( |
| 332 | kb_dir=kb_dir, |
| 333 | backup_dir=backup_dir, |
| 334 | journal_path=kb_dir / ".openkb" / "journal" / f"{journal_id}.json", |
| 335 | operation=operation, |
| 336 | details=details or {}, |
| 337 | ) |
| 338 | try: |
| 339 | for path in paths: |
| 340 | target = path.resolve() |
| 341 | if target in snapshot.entries: |
| 342 | continue |
| 343 | if not target.exists(): |
| 344 | snapshot.entries[target] = None |
| 345 | continue |
| 346 | rel = target.relative_to(kb_dir) |
| 347 | backup = backup_dir / rel |
| 348 | backup.parent.mkdir(parents=True, exist_ok=True) |
| 349 | if target.is_dir(): |
| 350 | if target in hardlink_resolved: |
| 351 | shutil.copytree(target, backup, copy_function=_hardlink_or_copy) |
| 352 | snapshot.hardlinked_dirs.add(target) |
| 353 | else: |
| 354 | shutil.copytree(target, backup) |
| 355 | else: |
| 356 | _copy_file_atomic(target, backup) |
| 357 | snapshot.entries[target] = backup |
| 358 | # The active journal is the recovery signal: once this exists, a future |
| 359 | # process can restore every recorded target even if the current one exits. |
| 360 | snapshot.write_journal("active") |
| 361 | except Exception: |
| 362 | # Partial snapshot: backup_dir exists on disk but no journal was |
| 363 | # written. recover_pending_journals only scans journals, so remove the |
| 364 | # orphan backup here — otherwise it leaks forever with nothing able to |
| 365 | # reach or clean it. |