feat(compact): add --dry-run to check file readiness without modifying

- CLI: python main.py compact --dry-run [--db FILE]
- API: POST /api/compact {"dry_run": true}
- Checks .laccdb lock file + exclusive open to detect busy files
- Returns per-file [READY] / [BUSY] status with lock reason

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Misaka_Company
2026-08-06 11:30:00 +08:00
parent 42a2f99ed9
commit c70dac04e8
4 changed files with 108 additions and 19 deletions

24
main.py
View File

@@ -64,6 +64,8 @@ def _parse_args(argv):
pcp = sub.add_parser("compact", help="compact & repair Access databases") pcp = sub.add_parser("compact", help="compact & repair Access databases")
pcp.add_argument("--db", help="limit to one Access file (by FileMapping.file)") pcp.add_argument("--db", help="limit to one Access file (by FileMapping.file)")
pcp.add_argument("--dry-run", action="store_true", dest="dry_run",
help="only check file accessibility and lock status, do not compact")
return p.parse_args(argv) return p.parse_args(argv)
@@ -104,13 +106,21 @@ def main(argv=None) -> int:
return 0 return 0
if args.command == "compact": if args.command == "compact":
summary = compact_files(cfg, db_filter=args.db) summary = compact_files(cfg, db_filter=args.db, dry_run=args.dry_run)
for r in summary.results: if args.dry_run:
if r.ok: for r in summary.results:
print(f"[OK] {r.file} {_human(r.before_bytes)} -> {_human(r.after_bytes)} ({r.duration_s:.1f}s)") if r.ok:
else: print(f"[READY] {r.file} {_human(r.before_bytes)}")
print(f"[FAIL] {r.file} {r.error}") else:
print(f"--- {summary.ok} OK, {summary.failed} FAIL, saved {_human(summary.saved_bytes)} ---") print(f"[BUSY] {r.file} {_human(r.before_bytes)} ({r.error})")
print(f"--- {summary.ok} ready, {summary.failed} busy/missing ---")
else:
for r in summary.results:
if r.ok:
print(f"[OK] {r.file} {_human(r.before_bytes)} -> {_human(r.after_bytes)} ({r.duration_s:.1f}s)")
else:
print(f"[FAIL] {r.file} {r.error}")
print(f"--- {summary.ok} OK, {summary.failed} FAIL, saved {_human(summary.saved_bytes)} ---")
return 0 if summary.all_ok else 1 return 0 if summary.all_ok else 1
if args.command == "compare": if args.command == "compare":

View File

@@ -236,17 +236,10 @@ def _compact_unc(db_path: str) -> CompactFileResult:
return result return result
def compact_files(cfg: SyncConfig, def _filter_files(cfg: SyncConfig,
db_filter: str | None = None, db_filter: str | None,
file_list: list[str] | None = None) -> CompactSummary: file_list: list[str] | None) -> list:
"""Compact every configured Access file (optionally filtered). """Return the configured FileMappings after applying optional filters."""
*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.
Each file is compacted independently; one failure does not abort the run.
"""
files = cfg.files files = cfg.files
if db_filter: if db_filter:
files = [f for f in files if f.file == db_filter] files = [f for f in files if f.file == db_filter]
@@ -255,6 +248,27 @@ def compact_files(cfg: SyncConfig,
elif file_list: elif file_list:
name_set = set(file_list) name_set = set(file_list)
files = [f for f in files if f.file in name_set] 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.
"""
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() summary = CompactSummary()
for fm in files: for fm in files:
@@ -311,6 +325,65 @@ def _is_unc(path: str) -> bool:
return path.startswith(("\\\\", "//")) 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: def _rm_f(path: str) -> None:
"""Remove *path* if it exists, suppressing any error (best-effort).""" """Remove *path* if it exists, suppressing any error (best-effort)."""
try: try:

View File

@@ -58,7 +58,8 @@ def compact(body: CompactRequest | None = None,
everything. everything.
""" """
file_list = body.files if body and body.files else None file_list = body.files if body and body.files else None
summary = compact_files(cfg, file_list=file_list) dry_run = body.dry_run if body else False
summary = compact_files(cfg, file_list=file_list, dry_run=dry_run)
# Build the response dict matching CompactResponse. # Build the response dict matching CompactResponse.
return { return {

View File

@@ -221,6 +221,11 @@ class CompactRequest(BaseModel):
"All files are compacted when absent or null.", "All files are compacted when absent or null.",
min_length=1, min_length=1,
) )
dry_run: bool = Field(
default=False,
description="When true, only check file accessibility and lock status "
"without actually compacting. Returns per-file readiness.",
)
class CompactFileResult(BaseModel): class CompactFileResult(BaseModel):