Files
ProductionDataBaseSync_Data…/src/sync/compact.py
Misaka_Company e2631d5dd7 feat(compact): dedicated compact.log with same daily-archive semantics as sync.log
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>
2026-08-06 11:35:16 +08:00

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