Compact audit trail now writes to logs/compact.log via its own RotatingFileHandler. The sync.compact logger does not propagate to root, so compact messages are isolated. Archive/ rotation happens on next setup_logging call (service restart) via the existing mechanism. Co-Authored-By: Claude <noreply@anthropic.com>
406 lines
13 KiB
Python
406 lines
13 KiB
Python
"""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
|