Made sqlite mirroring tool with Claude and ChatGPT.
This commit is contained in:
commit
7460ea7718
267
sqlitemirrorproxy.py
Normal file
267
sqlitemirrorproxy.py
Normal file
@ -0,0 +1,267 @@
|
|||||||
|
"""
|
||||||
|
This module mirrors SQLite operations across multiple USB devices.
|
||||||
|
Manages device connections, disconnections, and data integrity.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import queue
|
||||||
|
import sqlite3
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Dict, Tuple, List, Optional
|
||||||
|
import pyudev
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SQLiteMirrorProxy:
|
||||||
|
"""
|
||||||
|
A class to manage multiple SQLite database connections across USB devices.
|
||||||
|
|
||||||
|
This class provides functionality to mirror SQLite operations across multiple
|
||||||
|
connected USB storage devices, handle device connections/disconnections,
|
||||||
|
and manage data integrity and write operations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
db_name: str = "data.sqlite",
|
||||||
|
mount_path: str = "/mnt",
|
||||||
|
max_retries: int = 3,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Initialize the SQLiteMirrorProxy.
|
||||||
|
|
||||||
|
:param db_name: Name of the SQLite database file on each USB device
|
||||||
|
:param mount_path: Root path where USB devices are mounted
|
||||||
|
:param max_retries: Maximum number of retry attempts for failed write operations
|
||||||
|
"""
|
||||||
|
self.db_name = db_name
|
||||||
|
self.mount_path = mount_path
|
||||||
|
self.connections = {}
|
||||||
|
self.lock = threading.RLock()
|
||||||
|
self.write_queue = queue.Queue()
|
||||||
|
self.stop_event = threading.Event()
|
||||||
|
self.failed_writes = queue.Queue()
|
||||||
|
self.max_retries = max_retries
|
||||||
|
|
||||||
|
def add_storage(self, db_path: str) -> None:
|
||||||
|
"""Add a new SQLite connection to the database on the specified path."""
|
||||||
|
if os.path.exists(db_path):
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(db_path, check_same_thread=False)
|
||||||
|
with self.lock:
|
||||||
|
self.connections[db_path] = conn
|
||||||
|
logging.info("Connected to %s", db_path)
|
||||||
|
self._log_event(db_path, "connection_added")
|
||||||
|
self._perform_integrity_check(db_path)
|
||||||
|
except sqlite3.Error as e:
|
||||||
|
logging.error("Failed to connect to %s: %s", db_path, e )
|
||||||
|
|
||||||
|
def remove_storage(self, db_path: str) -> None:
|
||||||
|
"""Remove the connection for the specified database path."""
|
||||||
|
with self.lock:
|
||||||
|
conn = self.connections.pop(db_path, None)
|
||||||
|
if conn:
|
||||||
|
conn.close()
|
||||||
|
logging.info("Disconnected from %s", db_path)
|
||||||
|
self._log_event(db_path, "connection_removed")
|
||||||
|
else:
|
||||||
|
logging.warning("Connection %s not found.", db_path)
|
||||||
|
|
||||||
|
def execute(self, query: str, params: Tuple = ()) -> None:
|
||||||
|
"""
|
||||||
|
Execute a query on all connected databases.
|
||||||
|
|
||||||
|
:param query: SQL query to execute
|
||||||
|
:param params: Parameters for the SQL query
|
||||||
|
"""
|
||||||
|
self.write_queue.put((query, params, 0)) # 0 is the initial retry count
|
||||||
|
|
||||||
|
def _process_write_queue(self) -> None:
|
||||||
|
"""Process the write queue, executing queries and handling retries."""
|
||||||
|
while not self.stop_event.is_set():
|
||||||
|
try:
|
||||||
|
query, params, retry_count = self.write_queue.get(timeout=1)
|
||||||
|
success, failures = self._execute_on_all(query, params)
|
||||||
|
if not success and retry_count < self.max_retries:
|
||||||
|
self.write_queue.put((query, params, retry_count + 1))
|
||||||
|
elif not success:
|
||||||
|
logging.error(
|
||||||
|
"Write operation failed after %d attempts: %s", self.max_retries, query
|
||||||
|
)
|
||||||
|
self._log_failed_write(query, params, failures)
|
||||||
|
except queue.Empty:
|
||||||
|
continue
|
||||||
|
|
||||||
|
def _execute_on_all(self, query: str, params: Tuple) -> Tuple[bool, List[str]]:
|
||||||
|
"""Execute a query on all connected databases."""
|
||||||
|
failures = []
|
||||||
|
success = False
|
||||||
|
with self.lock:
|
||||||
|
for db_path, conn in list(self.connections.items()):
|
||||||
|
try:
|
||||||
|
with self._transaction(conn):
|
||||||
|
conn.execute(query, params)
|
||||||
|
success = True
|
||||||
|
self._log_event(db_path, "write_success", {"query": query})
|
||||||
|
except sqlite3.Error as e:
|
||||||
|
logging.error(f"Failed to write to {db_path}: {e}")
|
||||||
|
failures.append(db_path)
|
||||||
|
self.remove_storage(db_path)
|
||||||
|
if failures:
|
||||||
|
logging.error(f"Write failures on: {failures}")
|
||||||
|
return success, failures
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _transaction(self, conn: sqlite3.Connection):
|
||||||
|
"""Context manager for handling transactions."""
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
conn.commit()
|
||||||
|
except sqlite3.Error as e:
|
||||||
|
conn.rollback()
|
||||||
|
logging.error(f"Transaction failed: {e}")
|
||||||
|
raise e
|
||||||
|
|
||||||
|
def _log_event(
|
||||||
|
self, db_path: str, event_type: str, details: Optional[Dict] = None
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Log an event for a specific database.
|
||||||
|
|
||||||
|
:param db_path: Path to the database file
|
||||||
|
:param event_type: Type of event being logged
|
||||||
|
:param details: Additional details about the event
|
||||||
|
"""
|
||||||
|
log_path = f"{db_path}.log"
|
||||||
|
event = {
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
"event_type": event_type,
|
||||||
|
"details": details,
|
||||||
|
}
|
||||||
|
with open(log_path, "a", encoding="utf-8") as log_file:
|
||||||
|
json.dump(event, log_file)
|
||||||
|
log_file.write("\n")
|
||||||
|
|
||||||
|
def _perform_integrity_check(self, db_path: str) -> None:
|
||||||
|
"""Perform an integrity check on the specified database."""
|
||||||
|
conn = self.connections.get(db_path)
|
||||||
|
if conn:
|
||||||
|
try:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("PRAGMA integrity_check")
|
||||||
|
result = cursor.fetchone()[0]
|
||||||
|
self._log_event(db_path, "integrity_check", {"result": result})
|
||||||
|
if result != "ok":
|
||||||
|
logging.warning(f"Integrity check failed for {db_path}: {result}")
|
||||||
|
except sqlite3.Error as e:
|
||||||
|
logging.error(f"Error during integrity check for {db_path}: {e}")
|
||||||
|
self._log_event(db_path, "integrity_check_error", {"error": str(e)})
|
||||||
|
|
||||||
|
def _log_failed_write(self, query: str, params: Tuple, failures: List[str]) -> None:
|
||||||
|
"""
|
||||||
|
Log information about failed write operations.
|
||||||
|
|
||||||
|
:param query: SQL query that failed
|
||||||
|
:param params: Parameters for the failed query
|
||||||
|
:param failures: List of database paths where the write failed
|
||||||
|
"""
|
||||||
|
for db_path in failures:
|
||||||
|
self._log_event(
|
||||||
|
db_path,
|
||||||
|
"write_failure",
|
||||||
|
{
|
||||||
|
"query": query,
|
||||||
|
"params": str(params),
|
||||||
|
"reason": "Max retries reached",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def monitor_usb(self) -> None:
|
||||||
|
"""Monitor USB devices and manage database connections accordingly."""
|
||||||
|
context = pyudev.Context()
|
||||||
|
monitor = pyudev.Monitor.from_netlink(context)
|
||||||
|
monitor.filter_by("block")
|
||||||
|
|
||||||
|
for device in iter(monitor.poll, None):
|
||||||
|
if self.stop_event.is_set():
|
||||||
|
break
|
||||||
|
if device.action == "add" and device.get("ID_FS_TYPE") == "vfat":
|
||||||
|
mount_path = os.path.join(self.mount_path, device.get("ID_SERIAL"))
|
||||||
|
logging.info(f"USB inserted: {device.get('ID_SERIAL')}")
|
||||||
|
self.add_storage(os.path.join(mount_path, self.db_name))
|
||||||
|
elif device.action == "remove":
|
||||||
|
mount_path = os.path.join(self.mount_path, device.get("ID_SERIAL"))
|
||||||
|
logging.info(f"USB removed: {device.get('ID_SERIAL')}")
|
||||||
|
self.remove_storage(os.path.join(mount_path, self.db_name))
|
||||||
|
|
||||||
|
def check_connections(self) -> None:
|
||||||
|
"""Check all database connections and attempt to reconnect if necessary."""
|
||||||
|
with self.lock:
|
||||||
|
for db_path, conn in list(self.connections.items()):
|
||||||
|
try:
|
||||||
|
conn.execute("SELECT 1")
|
||||||
|
except sqlite3.Error:
|
||||||
|
logging.warning(
|
||||||
|
f"Connection to {db_path} lost. Attempting to reconnect..."
|
||||||
|
)
|
||||||
|
self.remove_storage(db_path)
|
||||||
|
self.add_storage(db_path)
|
||||||
|
|
||||||
|
def start_monitoring(self) -> None:
|
||||||
|
"""Start the USB monitoring, write processing, and maintenance threads."""
|
||||||
|
self.monitor_thread = threading.Thread(target=self.monitor_usb)
|
||||||
|
self.monitor_thread.daemon = True
|
||||||
|
self.monitor_thread.start()
|
||||||
|
|
||||||
|
self.write_thread = threading.Thread(target=self._process_write_queue)
|
||||||
|
self.write_thread.daemon = True
|
||||||
|
self.write_thread.start()
|
||||||
|
|
||||||
|
self.maintenance_thread = threading.Thread(target=self._maintenance_loop)
|
||||||
|
self.maintenance_thread.daemon = True
|
||||||
|
self.maintenance_thread.start()
|
||||||
|
|
||||||
|
def _maintenance_loop(self) -> None:
|
||||||
|
"""Perform periodic maintenance tasks such as connection checks and integrity checks."""
|
||||||
|
while not self.stop_event.is_set():
|
||||||
|
self.check_connections()
|
||||||
|
for db_path in self.connections:
|
||||||
|
self._perform_integrity_check(db_path)
|
||||||
|
time.sleep(3600) # Run maintenance every hour
|
||||||
|
|
||||||
|
def shutdown(self) -> None:
|
||||||
|
"""Gracefully shut down the monitoring thread and close all connections."""
|
||||||
|
logging.info("Shutting down...")
|
||||||
|
self.stop_event.set()
|
||||||
|
self.monitor_thread.join()
|
||||||
|
self.write_thread.join()
|
||||||
|
self.maintenance_thread.join()
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
"""Close all active database connections."""
|
||||||
|
with self.lock:
|
||||||
|
for db_path, conn in self.connections.items():
|
||||||
|
conn.close()
|
||||||
|
logging.info(f"Closed connection to {db_path}")
|
||||||
|
|
||||||
|
def get_connection_status(self) -> Dict[str, str]:
|
||||||
|
"""
|
||||||
|
Get the current status of all database connections.
|
||||||
|
|
||||||
|
:return: Dictionary mapping database paths to their connection status
|
||||||
|
"""
|
||||||
|
with self.lock:
|
||||||
|
return {db_path: "Connected" for db_path in self.connections}
|
||||||
|
|
||||||
|
def get_failed_writes_count(self) -> int:
|
||||||
|
"""Get the count of failed write operations."""
|
||||||
|
return self.failed_writes.qsize()
|
161
sqlitemirrorproxy_wo_async.py
Normal file
161
sqlitemirrorproxy_wo_async.py
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
import sqlite3
|
||||||
|
from contextlib import contextmanager
|
||||||
|
import pyudev
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import logging
|
||||||
|
import queue
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||||
|
|
||||||
|
class SQLiteMirrorProxy:
|
||||||
|
def __init__(self, db_name='data.sqlite', mount_path='/mnt'):
|
||||||
|
"""
|
||||||
|
Initializes the SQLiteMirrorProxy.
|
||||||
|
|
||||||
|
:param db_name: The name of the database file on each USB device.
|
||||||
|
:param mount_path: The root path where USB devices are mounted.
|
||||||
|
"""
|
||||||
|
self.db_name = db_name
|
||||||
|
self.mount_path = mount_path
|
||||||
|
self.connections = {}
|
||||||
|
self.lock = threading.RLock()
|
||||||
|
self.write_queue = queue.Queue()
|
||||||
|
self.stop_event = threading.Event()
|
||||||
|
|
||||||
|
def add_storage(self, db_path):
|
||||||
|
"""
|
||||||
|
Adds a new SQLite connection to the database on the specified path.
|
||||||
|
|
||||||
|
:param db_path: The full path to the SQLite database file.
|
||||||
|
"""
|
||||||
|
if os.path.exists(db_path):
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(db_path, check_same_thread=False)
|
||||||
|
with self.lock:
|
||||||
|
self.connections[db_path] = conn
|
||||||
|
logging.info(f"Connected to {db_path}")
|
||||||
|
except sqlite3.Error as e:
|
||||||
|
logging.error(f"Failed to connect to {db_path}: {e}")
|
||||||
|
|
||||||
|
def remove_storage(self, db_path):
|
||||||
|
"""Removes the connection for the specified database path."""
|
||||||
|
with self.lock:
|
||||||
|
conn = self.connections.pop(db_path, None)
|
||||||
|
if conn:
|
||||||
|
conn.close()
|
||||||
|
logging.info(f"Disconnected from {db_path}")
|
||||||
|
else:
|
||||||
|
logging.warning(f"Connection {db_path} not found.")
|
||||||
|
|
||||||
|
def execute(self, query, params=()):
|
||||||
|
"""
|
||||||
|
Executes a query on all connected databases.
|
||||||
|
|
||||||
|
:param query: The SQL query to execute.
|
||||||
|
:param params: Parameters for the SQL query.
|
||||||
|
"""
|
||||||
|
self.write_queue.put((query, params))
|
||||||
|
|
||||||
|
def _process_write_queue(self):
|
||||||
|
while not self.stop_event.is_set():
|
||||||
|
try:
|
||||||
|
query, params = self.write_queue.get(timeout=1)
|
||||||
|
self._execute_on_all(query, params)
|
||||||
|
except queue.Empty:
|
||||||
|
continue
|
||||||
|
|
||||||
|
def _execute_on_all(self, query, params):
|
||||||
|
failures = []
|
||||||
|
with self.lock:
|
||||||
|
for db_path, conn in self.connections.items():
|
||||||
|
try:
|
||||||
|
with self._transaction(conn):
|
||||||
|
conn.execute(query, params)
|
||||||
|
except sqlite3.Error as e:
|
||||||
|
logging.error(f"Failed to write to {db_path}: {e}")
|
||||||
|
failures.append(db_path)
|
||||||
|
if failures:
|
||||||
|
logging.error(f"Write failures on: {failures}")
|
||||||
|
|
||||||
|
async def execute_async(self, query, params=()):
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
return await loop.run_in_executor(None, self.execute, query, params)
|
||||||
|
|
||||||
|
def commit(self):
|
||||||
|
"""Commits the current transaction on all databases."""
|
||||||
|
with self.lock:
|
||||||
|
for db_path, conn in self.connections.items():
|
||||||
|
try:
|
||||||
|
conn.commit()
|
||||||
|
except sqlite3.Error as e:
|
||||||
|
logging.error(f"Failed to commit on {db_path}: {e}")
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
"""Closes all active database connections."""
|
||||||
|
with self.lock:
|
||||||
|
for db_path, conn in self.connections.items():
|
||||||
|
conn.close()
|
||||||
|
logging.info(f"Closed connection to {db_path}")
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _transaction(self, conn):
|
||||||
|
"""Context manager for handling transactions."""
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
conn.commit()
|
||||||
|
except sqlite3.Error as e:
|
||||||
|
conn.rollback()
|
||||||
|
logging.error(f"Transaction failed: {e}")
|
||||||
|
raise e
|
||||||
|
|
||||||
|
def monitor_usb(self):
|
||||||
|
"""Monitor USB devices and manage database connections accordingly."""
|
||||||
|
context = pyudev.Context()
|
||||||
|
monitor = pyudev.Monitor.from_netlink(context)
|
||||||
|
monitor.filter_by('block')
|
||||||
|
|
||||||
|
for device in iter(monitor.poll, None):
|
||||||
|
if self.stop_event.is_set():
|
||||||
|
break
|
||||||
|
if device.action == 'add' and device.get('ID_FS_TYPE') == 'vfat':
|
||||||
|
# Use dynamic mount point for devices
|
||||||
|
mount_path = os.path.join(self.mount_path, device.get('ID_SERIAL'))
|
||||||
|
logging.info(f"USB inserted: {device.get('ID_SERIAL')}")
|
||||||
|
self.add_storage(os.path.join(mount_path, self.db_name))
|
||||||
|
elif device.action == 'remove':
|
||||||
|
mount_path = os.path.join(self.mount_path, device.get('ID_SERIAL'))
|
||||||
|
logging.info(f"USB removed: {device.get('ID_SERIAL')}")
|
||||||
|
self.remove_storage(os.path.join(mount_path, self.db_name))
|
||||||
|
|
||||||
|
def start_monitoring(self):
|
||||||
|
self.monitor_thread = threading.Thread(target=self.monitor_usb)
|
||||||
|
self.monitor_thread.daemon = True
|
||||||
|
self.monitor_thread.start()
|
||||||
|
|
||||||
|
self.write_thread = threading.Thread(target=self._process_write_queue)
|
||||||
|
self.write_thread.daemon = True
|
||||||
|
self.write_thread.start()
|
||||||
|
|
||||||
|
def check_connections(self):
|
||||||
|
with self.lock:
|
||||||
|
for db_path, conn in list(self.connections.items()):
|
||||||
|
try:
|
||||||
|
conn.execute("SELECT 1")
|
||||||
|
except sqlite3.Error:
|
||||||
|
logging.warning(f"Connection to {db_path} lost. Attempting to reconnect...")
|
||||||
|
self.remove_storage(db_path)
|
||||||
|
self.add_storage(db_path)
|
||||||
|
|
||||||
|
def shutdown(self):
|
||||||
|
"""Gracefully shuts down the monitoring thread and closes connections."""
|
||||||
|
logging.info("Shutting down...")
|
||||||
|
self.stop_event.set()
|
||||||
|
if hasattr(self, 'monitor_thread'):
|
||||||
|
self.monitor_thread.join()
|
||||||
|
if hasattr(self, 'write_thread'):
|
||||||
|
self.write_thread.join()
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue
Block a user