cmboulanger's picture
disaster-recovery deploy of pdf-tei-editor
6a49f21 verified
Raw
History Blame Contribute Delete
14.5 kB
"""
Remote Metadata Manager for WebDAV synchronization.
Manages the shared metadata.db file on the WebDAV server, enabling:
- Database-driven sync without filesystem scanning
- Deletion tracking via database flags (no .deleted marker files)
- Metadata synchronization between instances
- O(1) change detection queries
"""
import gc
import json
import os
import sqlite3
import tempfile
import time
import shutil
from pathlib import Path
from typing import Optional, List, Dict, Any
from datetime import datetime, timezone
from contextlib import contextmanager
from webdav4.fsspec import WebdavFileSystem
# Remote database schema (shared on WebDAV server)
REMOTE_SCHEMA = """
CREATE TABLE IF NOT EXISTS file_metadata (
-- Identity
id TEXT PRIMARY KEY, -- Content hash (SHA-256)
stable_id TEXT UNIQUE NOT NULL, -- Stable short ID
filename TEXT NOT NULL,
-- Document organization
doc_id TEXT NOT NULL,
doc_id_type TEXT DEFAULT 'doi',
-- File classification
file_type TEXT NOT NULL,
mime_type TEXT,
file_size INTEGER,
-- File-specific
label TEXT,
variant TEXT,
version INTEGER DEFAULT 1,
is_gold_standard BOOLEAN DEFAULT 0,
-- Metadata (JSON)
doc_collections TEXT, -- ["corpus1", "corpus2"]
doc_metadata TEXT, -- {author, title, ...}
file_metadata TEXT, -- {extraction_method, ...}
-- Deletion (replaces .deleted marker files!)
deleted BOOLEAN DEFAULT 0,
-- Version tracking
remote_version INTEGER,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_doc_id ON file_metadata(doc_id);
CREATE INDEX IF NOT EXISTS idx_stable_id ON file_metadata(stable_id);
CREATE INDEX IF NOT EXISTS idx_deleted ON file_metadata(deleted) WHERE deleted = 1;
CREATE TABLE IF NOT EXISTS sync_metadata (
key TEXT PRIMARY KEY,
value TEXT,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
"""
class RemoteMetadataManager:
"""
Manages shared metadata.db on WebDAV server.
This manager handles:
- Downloading/uploading metadata.db to/from WebDAV
- Querying remote file metadata
- Tracking deletions via database (no marker files)
- Managing sync metadata (version, timestamps)
"""
def __init__(
self,
webdav_config: Dict[str, str],
logger=None
):
"""
Initialize remote metadata manager.
Args:
webdav_config: Dict with 'base_url', 'username', 'password', 'remote_root'
logger: Optional logger instance
"""
self.logger = logger
self.webdav_config = webdav_config
self.remote_root = webdav_config['remote_root'].rstrip('/')
# Initialize WebDAV filesystem
self.fs = WebdavFileSystem(
webdav_config['base_url'],
auth=(webdav_config['username'], webdav_config['password'])
)
self.remote_db_path = f"{self.remote_root}/metadata.db"
self.local_db_conn: Optional[sqlite3.Connection] = None
self.temp_db_path: Optional[Path] = None
def download(self) -> Path:
"""
Download metadata.db from WebDAV to temporary file.
Creates database with schema if it doesn't exist on remote.
Returns:
Path to temporary database file
Raises:
Exception: If download fails
"""
try:
# Create temporary file for downloaded database
temp_fd, temp_path = tempfile.mkstemp(suffix='.db', prefix='remote_metadata_')
os.close(temp_fd)
self.temp_db_path = Path(temp_path)
if self.fs.exists(self.remote_db_path):
if self.logger:
self.logger.info(f"Downloading remote metadata.db from {self.remote_db_path}")
# Download remote database
with self.fs.open(self.remote_db_path, 'rb') as remote_file:
with open(self.temp_db_path, 'wb') as local_file:
shutil.copyfileobj(remote_file, local_file)
if self.logger:
self.logger.info(f"Downloaded to {self.temp_db_path}")
else:
if self.logger:
self.logger.info("Remote metadata.db not found, creating new database")
# Create new database with schema
conn = sqlite3.connect(self.temp_db_path)
try:
conn.executescript(REMOTE_SCHEMA)
# Initialize version
conn.execute(
"INSERT INTO sync_metadata (key, value) VALUES (?, ?)",
('version', '1')
)
conn.commit()
finally:
conn.close()
return self.temp_db_path
except Exception as e:
if self.logger:
self.logger.error(f"Failed to download remote metadata: {e}")
raise
def upload(self, local_path: Path) -> None:
"""
Upload metadata.db from local file to WebDAV.
Args:
local_path: Path to local database file to upload
Raises:
Exception: If upload fails
"""
try:
if self.logger:
self.logger.info(f"Uploading metadata.db to {self.remote_db_path}")
# Ensure remote directory exists
remote_dir = self.remote_root
if not self.fs.exists(remote_dir):
self.fs.makedirs(remote_dir)
# Upload database file
with open(local_path, 'rb') as local_file:
with self.fs.open(self.remote_db_path, 'wb') as remote_file:
shutil.copyfileobj(local_file, remote_file)
if self.logger:
self.logger.info("Uploaded metadata.db successfully")
except Exception as e:
if self.logger:
self.logger.error(f"Failed to upload remote metadata: {e}")
raise
def connect(self, db_path: Path) -> None:
"""
Connect to downloaded database.
Args:
db_path: Path to database file
"""
if self.local_db_conn:
self.local_db_conn.close()
self.local_db_conn = sqlite3.connect(db_path)
self.local_db_conn.row_factory = sqlite3.Row
def disconnect(self) -> None:
"""Disconnect from database and clean up temporary file."""
if self.local_db_conn:
self.local_db_conn.close()
del self.local_db_conn # Explicitly delete to release reference
self.local_db_conn = None
# Force garbage collection to release file handles on Windows
gc.collect()
if self.temp_db_path and self.temp_db_path.exists():
# Retry deletion with delays (Windows file handle release issue)
max_retries = 5
for attempt in range(max_retries):
try:
self.temp_db_path.unlink()
self.temp_db_path = None
break
except (PermissionError, OSError):
if attempt < max_retries - 1:
time.sleep(0.1 * (attempt + 1)) # Exponential backoff
gc.collect() # Try again after another collection
else:
# Last resort: silently ignore cleanup failure (OS temp cleanup will handle it)
self.temp_db_path = None
@contextmanager
def transaction(self):
"""Context manager for database transactions."""
if not self.local_db_conn:
raise RuntimeError("Not connected to database")
try:
yield self.local_db_conn
self.local_db_conn.commit()
except Exception:
self.local_db_conn.rollback()
raise
def get_all_files(self, include_deleted: bool = False) -> List[Dict[str, Any]]:
"""
Get all file metadata from remote database.
Args:
include_deleted: If True, include files with deleted=1
Returns:
List of file metadata dictionaries with parsed JSON fields
"""
if not self.local_db_conn:
raise RuntimeError("Not connected to database")
deleted_filter = "" if include_deleted else "WHERE deleted = 0"
query = f"SELECT * FROM file_metadata {deleted_filter}"
cursor = self.local_db_conn.cursor()
cursor.execute(query)
rows = cursor.fetchall()
# Parse JSON fields from TEXT to proper Python objects
result = []
for row in rows:
row_dict = dict(row)
for json_field in ['doc_collections', 'doc_metadata', 'file_metadata']:
if json_field in row_dict and row_dict[json_field]:
try:
row_dict[json_field] = json.loads(row_dict[json_field])
except (json.JSONDecodeError, TypeError):
row_dict[json_field] = [] if json_field == 'doc_collections' else {}
result.append(row_dict)
return result
def get_deleted_files(self) -> List[Dict[str, Any]]:
"""
Get files marked as deleted in remote database.
Returns:
List of deleted file metadata dictionaries with parsed JSON fields
"""
if not self.local_db_conn:
raise RuntimeError("Not connected to database")
query = "SELECT * FROM file_metadata WHERE deleted = 1"
cursor = self.local_db_conn.cursor()
cursor.execute(query)
rows = cursor.fetchall()
result = []
for row in rows:
row_dict = dict(row)
for json_field in ['doc_collections', 'doc_metadata', 'file_metadata']:
if json_field in row_dict and row_dict[json_field]:
try:
row_dict[json_field] = json.loads(row_dict[json_field])
except (json.JSONDecodeError, TypeError):
row_dict[json_field] = [] if json_field == 'doc_collections' else {}
result.append(row_dict)
return result
def get_file_by_id(self, file_id: str) -> Optional[Dict[str, Any]]:
"""
Get file metadata by ID.
Args:
file_id: File ID (content hash)
Returns:
File metadata dict or None if not found
"""
if not self.local_db_conn:
raise RuntimeError("Not connected to database")
query = "SELECT * FROM file_metadata WHERE id = ?"
cursor = self.local_db_conn.cursor()
cursor.execute(query, (file_id,))
row = cursor.fetchone()
return dict(row) if row else None
def upsert_file(self, file_data: Dict[str, Any]) -> None:
"""
Insert or update file metadata in remote database.
Args:
file_data: File metadata dictionary (must include 'id' key)
"""
if not self.local_db_conn:
raise RuntimeError("Not connected to database")
file_data = file_data.copy()
file_data['updated_at'] = datetime.now(timezone.utc).isoformat()
columns = list(file_data.keys())
placeholders = ', '.join('?' * len(columns))
column_names = ', '.join(columns)
update_clause = ', '.join(f"{col} = excluded.{col}" for col in columns if col != 'id')
# Use INSERT OR REPLACE to handle both id conflicts and stable_id conflicts
# (stable_id changes when file content changes but the logical file is the same)
query = f"""
INSERT OR REPLACE INTO file_metadata ({column_names})
VALUES ({placeholders})
"""
with self.transaction():
cursor = self.local_db_conn.cursor()
cursor.execute(query, tuple(file_data.values()))
def mark_deleted(self, file_id: str, remote_version: int) -> None:
"""
Mark file as deleted in remote database.
Args:
file_id: File ID (content hash)
remote_version: Version number for this change
"""
if not self.local_db_conn:
raise RuntimeError("Not connected to database")
query = """
UPDATE file_metadata
SET deleted = 1,
remote_version = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
"""
with self.transaction():
cursor = self.local_db_conn.cursor()
cursor.execute(query, (remote_version, file_id))
def get_sync_metadata(self, key: str) -> Optional[str]:
"""
Get sync metadata value.
Args:
key: Metadata key
Returns:
Metadata value or None if not found
"""
if not self.local_db_conn:
raise RuntimeError("Not connected to database")
query = "SELECT value FROM sync_metadata WHERE key = ?"
cursor = self.local_db_conn.cursor()
cursor.execute(query, (key,))
row = cursor.fetchone()
return row['value'] if row else None
def set_sync_metadata(self, key: str, value: str) -> None:
"""
Set sync metadata value.
Args:
key: Metadata key
value: Metadata value
"""
if not self.local_db_conn:
raise RuntimeError("Not connected to database")
query = """
INSERT OR REPLACE INTO sync_metadata (key, value, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP)
"""
with self.transaction():
cursor = self.local_db_conn.cursor()
cursor.execute(query, (key, value))
def increment_version(self) -> int:
"""
Increment and return the remote version number.
Returns:
New version number
"""
current_version = int(self.get_sync_metadata('version') or '0')
new_version = current_version + 1
self.set_sync_metadata('version', str(new_version))
return new_version
def get_version(self) -> int:
"""
Get current remote version number.
Returns:
Current version number
"""
version_str = self.get_sync_metadata('version')
return int(version_str) if version_str else 0