"""Access database compact & repair. Uses the DAO ``DBEngine.CompactDatabase`` method (via pywin32 COM) to compact and repair one or more ``.accdb`` / ``.mdb`` files. The compact process: 1. Creates a temporary copy in the same directory (compact requires exclusive access, so we compact the copy, not the live file). 2. Compacts the copy to a second temp file. 3. Atomically replaces the original with the compacted file (delete original, rename compacted → original). 4. Cleans up temp files. Failures at any step leave the original file unchanged. This is intentionally separate from the sync loop — compact should only be requested explicitly (during a maintenance window or when an Access file is bloated). """ from __future__ import annotations import logging import os import shutil import tempfile import time from dataclasses import dataclass, field from .config import SyncConfig from .logging_setup import setup_compact_log log = logging.getLogger(__name__) # ACE DAO ProgID. "DAO.DBEngine.120" ships with Access 2007+ / ACE runtime; # it supports both .mdb (Jet) and .accdb (ACE) formats. _DAO_PROGID = "DAO.DBEngine.120" # Guard: only set up the compact file handler once per process. _compact_log_ready = False # ------------------------------------------------------------------ result types @dataclass class CompactFileResult: """Outcome of compacting one Access file.""" file: str # config FileMapping.file name source_path: str # resolved full path ok: bool before_bytes: int = 0 after_bytes: int = 0 duration_s: float = 0.0 error: str | None = None @dataclass class CompactSummary: """Aggregate summary across files.""" results: list[CompactFileResult] = field(default_factory=list) @property def total_files(self) -> int: return len(self.results) @property def ok(self) -> int: return sum(1 for r in self.results if r.ok) @property def failed(self) -> int: return sum(1 for r in self.results if not r.ok) @property def before_bytes_total(self) -> int: return sum(r.before_bytes for r in self.results) @property def after_bytes_total(self) -> int: return sum(r.after_bytes for r in self.results) @property def saved_bytes(self) -> int: return max(0, self.before_bytes_total - self.after_bytes_total) @property def all_ok(self) -> bool: return self.failed == 0 # --------------------------------------------------------------------- compact def compact_file(db_path: str) -> CompactFileResult: """Compact and repair one Access file in-place. Two strategies depending on the path type: **Local path** (``D:\\...`` or mapped drive): ``rename original → .bak``, ``CompactDatabase(bak → original)``, delete ``.bak``. Zero extra copies — the file never leaves its drive. **UNC path** (``\\\\server\\share\\...``): Copy to a local temp file, compact locally, copy back to the network share. The local temp staging avoids a C-level segfault in DAO when the destination already exists on a network share. *db_path* must be an absolute Windows path (local or UNC). """ if _is_unc(db_path): return _compact_unc(db_path) return _compact_local(db_path) def _compact_local(db_path: str) -> CompactFileResult: """In-place compact for local paths: rename → compact → cleanup.""" file_name = os.path.basename(db_path) result = CompactFileResult(file=file_name, source_path=db_path, ok=False) t0 = time.monotonic() try: before = os.path.getsize(db_path) result.before_bytes = before except OSError as e: result.error = f"cannot stat source: {e}" result.duration_s = time.monotonic() - t0 return result bak = db_path + ".compact_bak" try: # 1. Rename original → bak so DAO can compact bak → original in-place. if os.path.exists(bak): os.remove(bak) os.rename(db_path, bak) # 2. Compact bak → original (the original path is now free). _dao_compact(bak, db_path) # 3. Success — delete bak. os.remove(bak) result.ok = True result.after_bytes = os.path.getsize(db_path) result.duration_s = time.monotonic() - t0 log.info("compact OK (local): %s (%s → %s, %.1fs)", file_name, _human(result.before_bytes), _human(result.after_bytes), result.duration_s) except Exception as e: # Rollback: restore original from bak. if os.path.exists(bak): if os.path.exists(db_path): _rm_f(db_path) os.rename(bak, db_path) result.ok = False result.error = str(e) result.duration_s = time.monotonic() - t0 log.warning("compact FAIL (local): %s — %s", file_name, e) finally: # Best-effort bak cleanup (normally already deleted on success). _rm_f(bak) return result def _compact_unc(db_path: str) -> CompactFileResult: """Compact a UNC-path file via local-temp staging.""" file_name = os.path.basename(db_path) result = CompactFileResult(file=file_name, source_path=db_path, ok=False) t0 = time.monotonic() try: before = os.path.getsize(db_path) result.before_bytes = before except OSError as e: result.error = f"cannot stat source: {e}" result.duration_s = time.monotonic() - t0 return result stem, ext = os.path.splitext(file_name) tmp_dir = tempfile.gettempdir() tmp_src = None # local copy of original tmp_dst = None # compacted output (local) try: # 1. Copy original → local temp. fd, tmp_src = tempfile.mkstemp(suffix=ext, prefix=f"{stem}_cpysrc_", dir=tmp_dir) os.close(fd) _rm_f(tmp_src) shutil.copy2(db_path, tmp_src) # 2. Get a guaranteed-non-existent destination, then compact. fd2, tmp_dst = tempfile.mkstemp(suffix=ext, prefix=f"{stem}_cpydst_", dir=tmp_dir) os.close(fd2) _rm_f(tmp_dst) if os.path.exists(tmp_dst): raise OSError(f"cannot remove stale temp file: {tmp_dst}") _dao_compact(tmp_src, tmp_dst) # 3. Replace original with compacted file. bak = db_path + ".compact_bak" try: os.rename(db_path, bak) except OSError: os.remove(db_path) bak = None try: shutil.copy2(tmp_dst, db_path) except Exception: if bak and os.path.exists(bak): if os.path.exists(db_path): os.remove(db_path) os.rename(bak, db_path) raise if bak and os.path.exists(bak): try: os.remove(bak) except OSError: pass result.ok = True result.after_bytes = os.path.getsize(db_path) result.duration_s = time.monotonic() - t0 log.info("compact OK (UNC): %s (%s → %s, %.1fs)", file_name, _human(result.before_bytes), _human(result.after_bytes), result.duration_s) except Exception as e: result.ok = False result.error = str(e) result.duration_s = time.monotonic() - t0 log.warning("compact FAIL (UNC): %s — %s", file_name, e) finally: for p in (tmp_src, tmp_dst): if p: _rm_f(p) return result def _filter_files(cfg: SyncConfig, db_filter: str | None, file_list: list[str] | None) -> list: """Return the configured FileMappings after applying optional filters.""" files = cfg.files if db_filter: files = [f for f in files if f.file == db_filter] if not files: log.warning("compact: no file matches --db %r", db_filter) elif file_list: name_set = set(file_list) files = [f for f in files if f.file in name_set] return files def compact_files(cfg: SyncConfig, db_filter: str | None = None, file_list: list[str] | None = None, dry_run: bool = False) -> CompactSummary: """Compact every configured Access file (optionally filtered). *db_filter* restricts to a single file (CLI ``--db``). *file_list* restricts to a named subset (API ``files`` body field). Both can be given — *db_filter* wins when both are set. When *dry_run* is True, only checks file accessibility and lock status without modifying anything. Each file is compacted independently; one failure does not abort the run. Logs are written to a dedicated ``logs/compact.log`` file (separate from ``sync.log``) with the same rotation and daily-archive semantics. """ global _compact_log_ready if not _compact_log_ready: setup_compact_log(cfg.logging) _compact_log_ready = True if dry_run: return compact_dry_run(cfg, db_filter=db_filter, file_list=file_list) files = _filter_files(cfg, db_filter, file_list) summary = CompactSummary() for fm in files: path = fm.source_path(cfg) if not os.path.exists(path): r = CompactFileResult( file=fm.file, source_path=path, ok=False, error=f"file not found: {path}", ) summary.results.append(r) continue summary.results.append(compact_file(path)) ok, fail = summary.ok, summary.failed log.info("compact summary: %d OK, %d FAIL, saved %s across %d files", ok, fail, _human(summary.saved_bytes), summary.total_files) return summary # -------------------------------------------------------------------- internal def _dao_compact(src: str, dst: str) -> None: """Compact *src* → *dst* via DAO DBEngine.CompactDatabase. Raises on failure; the caller owns temp-file cleanup and rollback. """ import pythoncom import win32com.client # Per-call COM init so the thread is safe regardless of caller's COM state. pythoncom.CoInitialize() try: dao = win32com.client.Dispatch(_DAO_PROGID) # dbVersion120 = 128 → .accdb (ACE); works for .mdb too when ACE is # installed because ACE can compact Jet formats. dao.CompactDatabase(src, dst, 128) finally: pythoncom.CoUninitialize() def _human(n: int) -> str: """Format a byte count for log messages.""" if n < 1024: return f"{n}B" for unit in ("KB", "MB", "GB"): n /= 1024.0 if n < 1024: return f"{n:.1f}{unit}" return f"{n:.1f}TB" def _is_unc(path: str) -> bool: """True if *path* is a UNC path (``\\\\server\\share\\...``).""" return path.startswith(("\\\\", "//")) def _check_lock(db_path: str) -> str | None: """Check whether *db_path* can be opened for exclusive write. Returns ``None`` if the file is accessible (not locked). Returns an error message string describing why the file cannot be compacted right now. """ if not os.path.exists(db_path): return "file not found" # Access creates a .laccdb / .ldb lock file alongside the database when # it is open (even from another machine over the share). Its presence is # a strong signal; its absence does NOT guarantee the file is free, so # we also try an exclusive open. laccdb = os.path.splitext(db_path)[0] + ".laccdb" ldb = os.path.splitext(db_path)[0] + ".ldb" if os.path.exists(laccdb) or os.path.exists(ldb): return "locked (Access lock file present)" try: fd = os.open(db_path, os.O_RDWR) os.close(fd) except OSError as e: return f"locked ({e})" return None def compact_dry_run(cfg: SyncConfig, db_filter: str | None = None, file_list: list[str] | None = None) -> CompactSummary: """Check which files are ready for compaction without modifying anything. Same filtering as ``compact_files``, but only stats each file and checks for locks. Returns a ``CompactSummary`` where ``ok=True`` means the file is ready to compact, and ``error`` contains the lock reason when not ready. """ files = _filter_files(cfg, db_filter, file_list) summary = CompactSummary() for fm in files: path = fm.source_path(cfg) lock_err = _check_lock(path) if lock_err: size = 0 try: size = os.path.getsize(path) if os.path.exists(path) else 0 except OSError: pass summary.results.append(CompactFileResult( file=fm.file, source_path=path, ok=False, before_bytes=size, error=lock_err, )) else: size = os.path.getsize(path) summary.results.append(CompactFileResult( file=fm.file, source_path=path, ok=True, before_bytes=size, )) log.info("dry-run: %d ready, %d locked/missing across %d files", summary.ok, summary.failed, summary.total_files) return summary def _rm_f(path: str) -> None: """Remove *path* if it exists, suppressing any error (best-effort).""" try: if os.path.exists(path): os.remove(path) except OSError: pass