Sync streamline introduced for cached data

This commit is contained in:
SimplifiedPrivacy 2026-06-22 17:30:42 -04:00
parent 8e2ea64b6b
commit dc46d4136a
19 changed files with 771 additions and 20 deletions

1
.gitignore vendored
View file

@ -1,3 +1,4 @@
.dev
prototype_client.py
.idea
.venv

View file

@ -0,0 +1,49 @@
fields:
- name: country_code
path: ['country', 'code']
required: true
- name: code # this is city code
path: ['code']
required: true
- name: id
path: ['id']
- name: country_name
path: ['country', 'name']
- name: name # this is CITY name
path: ['name']
- name: time_zone
path: ['time_zone', 'code']
- name: operator_id
path: ['operator_id']
required: true
- name: provider_name
path: ['provider', 'name']
- name: is_proxy_capable
path: ['is_proxy_capable']
- name: is_wireguard_capable
path: ['is_wireguard_capable']
required: true
# required: true
# locations.append((
# location['country']['code'],
# location['code'],
# location['id'],
# location['country']['name'],
# location['name'],
# location['time_zone']['code'],
# location['operator_id'],
# location['provider']['name'],
# location['is_proxy_capable'],
# location['is_wireguard_capable']))

View file

@ -0,0 +1,8 @@
fields:
- name: group_a_code
path: ['group_a', 'code']
- name: code
path: ['code']
- name: id
path: ['id']
required: true

View file

@ -0,0 +1,19 @@
fields:
- name: id
path: ['id']
required: true
- name: name
path: ['name']
- name: public_key # this is ed25519
path: ['public_key']
- name: nostr_public_key
path: ['nostr_public_key']
- name: nostr_profile_reference
path: ['nostr_profile_reference']
- name: nostr_attestation_event_reference
path: ['nostr_attestation', 'event_reference']

1
core/.metadata.md Normal file
View file

@ -0,0 +1 @@
this is june 13 version

View file

@ -1,3 +1,7 @@
# new sync refactor:
from core.services.sync.sync_service import coordinate_cache_sync
from core.errors.logger import logger
# prior versions:
from core.Constants import Constants
from core.Errors import UnknownClientPathError, UnknownClientVersionError, CommandNotFoundError
from core.controllers.ApplicationController import ApplicationController
@ -44,8 +48,40 @@ class ClientController:
@staticmethod
def sync(client_observer: ClientObserver = None, connection_observer: ConnectionObserver = None):
from core.controllers.ConnectionController import ConnectionController
ConnectionController.with_preferred_connection(task=ClientController.__sync, client_observer=client_observer, connection_observer=connection_observer)
from core.models.manage.session_management import init_session, close_session
# Use Cached Method:
init_session()
result = coordinate_cache_sync(ClientObserver, ConnectionObserver)
logger.info(f"We got a Result from the API of {result}")
close_session()
# Outright Error:
if not result["success"]:
error_msg = result["error"]
if client_observer is not None:
client_observer.notify('synchronizing', f'Error! {error_msg}')
return
# Same:
changed_tables = result["changed_tables"]
if not changed_tables:
if client_observer is not None:
client_observer.notify('synchronized')
return
# We only make it past this point if there's New Data
# Fetch and update those models...
try:
from core.controllers.ConnectionController import ConnectionController
ConnectionController.with_preferred_connection(task=ClientController.__sync, changed_tables=changed_tables, client_observer=client_observer, connection_observer=connection_observer)
except:
# sync failed here,
if client_observer is not None:
client_observer.notify('synchronizing', 'Error! Sync Failed.')
@staticmethod
def update(client_observer: ClientObserver = None, connection_observer: ConnectionObserver = None):
@ -61,32 +97,62 @@ class ClientController:
return path
@staticmethod
def __sync(client_observer: Optional[ClientObserver] = None, proxies: Optional[dict] = None):
def __sync(changed_tables: list, client_observer: Optional[ClientObserver] = None, proxies: Optional[dict] = None):
if client_observer is not None:
client_observer.notify('synchronizing')
# noinspection PyProtectedMember
ApplicationController._sync(proxies=proxies)
# noinspection PyProtectedMember
ApplicationVersionController._sync(proxies=proxies)
# noinspection PyProtectedMember
ClientVersionController._sync(proxies=proxies)
# noinspection PyProtectedMember
OperatorController._sync(proxies=proxies)
# noinspection PyProtectedMember
LocationController._sync(proxies=proxies)
# noinspection PyProtectedMember
SubscriptionPlanController._sync(proxies=proxies)
if "applications" in changed_tables:
logger.info("Sync applications..")
if client_observer is not None:
client_observer.notify('synchronizing', 'Fetching Browser List')
# noinspection PyProtectedMember
ApplicationController._sync(proxies=proxies)
if "application_versions" in changed_tables:
logger.info("Sync Application Versions..")
if client_observer is not None:
client_observer.notify('synchronizing', 'Fetching Browser Version List')
# noinspection PyProtectedMember
ApplicationVersionController._sync(proxies=proxies)
if "client_version" in changed_tables:
logger.info("Sync of client version")
if client_observer is not None:
client_observer.notify('synchronizing', 'Fetching Client Version List')
# noinspection PyProtectedMember
ClientVersionController._sync(proxies=proxies)
if "operators" in changed_tables:
logger.info("Sync of Operators")
if client_observer is not None:
client_observer.notify('synchronizing', 'Fetching Operators List')
# noinspection PyProtectedMember
OperatorController._sync(proxies=proxies)
if "locations" in changed_tables:
logger.info("Sync of Locations")
if client_observer is not None:
client_observer.notify('synchronizing', 'Fetching Locations List')
# noinspection PyProtectedMember
LocationController._sync(proxies=proxies)
if "subscriptions" in changed_tables:
logger.info("Sync of Subscriptions")
if client_observer is not None:
client_observer.notify('synchronizing', 'Fetching Subscription List')
# noinspection PyProtectedMember
SubscriptionPlanController._sync(proxies=proxies)
ConfigurationController.update_last_synced_at()
logger.info("Entire Sync Completed Successfully")
if client_observer is not None:
client_observer.notify('synchronized')
@staticmethod
def __update(client_observer: Optional[ClientObserver] = None, proxies: Optional[dict] = None):
if ClientController.can_be_updated():

View file

@ -29,6 +29,13 @@ class ConnectionController:
@staticmethod
def with_preferred_connection(*args, task: Callable[..., Any], connection_observer: Optional[ConnectionObserver] = None, **kwargs):
"""
This function does a task with a preferred connection type.
It is currently being CALLED UPON with keyword based kwargs only, because any args would execute immediately.
However, the *args keeps it open to future uses.
"""
connection = ConfigurationController.get_connection()

View file

@ -0,0 +1,35 @@
from core.models.manage.session_management import get_session
from typing import Type, List, Dict, Any
from sqlalchemy.exc import SQLAlchemyError
def get_from_model(model_class: Type, **filters) -> List[Dict[str, Any]]:
"""
Retrieve data from a model. Keeping this generic for reuse
Args:
model_class: The ORM model class
**filters: Optional column=value pairs to filter by (e.g., id=1, name='John')
Returns:
List of dictionaries representing matching rows
"""
with get_session() as session:
try:
query = session.query(model_class)
# Apply filters dynamically
if filters:
for column, value in filters.items():
if hasattr(model_class, column):
query = query.filter(getattr(model_class, column) == value)
# Convert to list of dicts
results = [
{c.name: getattr(row, c.name) for c in row.__table__.columns}
for row in query.all()
]
return results
except SQLAlchemyError as e:
raise ValueError(f"Query failed: {e}")

View file

@ -0,0 +1,61 @@
from core.errors.logger import logger
from sqlalchemy.orm import sessionmaker
from sqlalchemy import create_engine
from typing import Type, Dict, Any
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
from core.models.orm_models.Base import Base
from core.models.orm_models.CachedSync import CachedSync
from core.models.manage.session_management import get_session
def insert_into_model(model_class: Type, all_data: dict | list, override=False) -> bool:
"""
Generic ORM insert for any model. Keeping it generic for reuse.
Args:
model_class: The ORM model class
all_data: Dictionary or list of dictionaries with data matching the model
override: If True, wipe the table before inserting
Returns:
True if successful
"""
logger.info(f"All the public data preparing to be inserted is {all_data}")
with get_session() as session:
# Step 1: Wipe the table if override=True
if override:
logger.info(f"First, WIPING the pre-existing data for {model_class.__name__}. Are you sure you intended to completely delete the old data?")
session.query(model_class).delete()
session.commit()
logger.info(f"Starting insert for {model_class.__name__}")
# Normalize to list for uniform handling
data_list = all_data if isinstance(all_data, list) else [all_data]
try:
for each_json in data_list:
instance = model_class(**each_json)
session.add(instance)
session.commit()
logger.info(f"Completed! SQL Insertion Successful following the {model_class.__name__} Models Type Rules")
return True
except TypeError as e:
print(f"TypeError caught: {e}")
session.rollback()
raise ValueError(f"Invalid fields for {model_class.__name__}: {e}")
except IntegrityError as e:
print(f"IntegrityError caught: {e.orig}")
session.rollback()
raise ValueError(f"Constraint violation: {e.orig}")
except SQLAlchemyError as e:
print(f"SQLAlchemyError caught: {e}")
session.rollback()
raise
except Exception as e:
print(f"Generic exception caught: {e}")
session.rollback()
raise e

View file

@ -0,0 +1,40 @@
from sqlalchemy.orm import sessionmaker
from sqlalchemy import create_engine
from core.models.orm_models.Base import Base
from core.Constants import Constants
database_path = Constants.HV_STORAGE_DATABASE_PATH
engine = create_engine(f"sqlite:///{database_path}")
Session = sessionmaker(bind=engine)
_session = None
def init_session():
"""
Called once at application startup using global variables for the session,
which initialize prior to being called with a false flag.
The reason for this is so the app can load prior to be called,
while keeping it a globally accessible variable
"""
global _session
Base.metadata.create_all(engine)
_session = Session()
def get_session():
"""Get the persistent session"""
if _session is None:
raise RuntimeError("Session not initialized. Call init_session() first.")
return _session
def close_session():
"""
Used prior to shutting down the application to break SQL connections.
In the current refactor, it may be used prior to shut down, such as sync completion,
because a low amount of the total models currently use the new system.
"""
global _session
if _session:
_session.close()
_session = None

View file

@ -0,0 +1,9 @@
# base.py
from sqlalchemy.orm import declarative_base
Base = declarative_base()
"""
This will eventually replace the legacy base model,
but is still required, even if empty, to setup the declarative base.
"""

View file

@ -0,0 +1,24 @@
from sqlalchemy import Integer, String, ForeignKey
from sqlalchemy.orm import declarative_base, mapped_column, Mapped, relationship
from typing import Optional
from sqlalchemy.orm import Mapped
from core.models.orm_models.Base import Base
"""
This is one of the SQLAlchemy Models from the refactor.
Thats why it lacks functions to get or insert data.
"""
class CachedSync(Base):
__tablename__ = 'cached_sync'
# version of the cached sync itself is the primary key
version: Mapped[int] = mapped_column(Integer, primary_key=True)
applications: Mapped[Optional[str]] = mapped_column(Integer, nullable=True, default=None)
application_versions: Mapped[Optional[str]] = mapped_column(Integer, nullable=True, default=None)
client_version: Mapped[Optional[str]] = mapped_column(Integer, nullable=True, default=None)
operators: Mapped[Optional[str]] = mapped_column(Integer, nullable=True, default=None)
locations: Mapped[Optional[str]] = mapped_column(Integer, nullable=True, default=None)
subscriptions: Mapped[Optional[str]] = mapped_column(Integer, nullable=True, default=None)

View file

@ -212,3 +212,15 @@ class WebServiceApiService:
headers = None
return requests.post(Constants.SP_API_BASE_URL + path, headers=headers, json=body, proxies=proxies)
@staticmethod
def get_cached_sync(proxies: Optional[dict] = None):
from requests.status_codes import codes as status_codes
response = WebServiceApiService.__get('/cachedsync', None, proxies)
if response.status_code == status_codes.OK:
return response.json()
else:
return None

View file

@ -5,8 +5,5 @@ from core.Constants import Constants
def make_url(which_endpoint: str) -> str:
domain = Constants.TICKET_API_BASE_URL
# hardcode option:
# domain = "https://ticket.hydraveil.net"
final_url = f"{domain}/{which_endpoint}"
return final_url

View file

@ -15,6 +15,7 @@ def regular_get_request(url: str, connection_observer: ConnectionObserver):
import requests
try:
print(f"we are doing a regular GET request to: {url}")
response = requests.get(url)
# Raises HTTPError for 4xx/5xx

View file

@ -0,0 +1,70 @@
import logging
from typing import Optional
logger = logging.getLogger(__name__)
def compare_tables(new_data: dict, old_table: dict) -> tuple[list[str], list[str]]:
"""
Compare version numbers in new and old metadata dictionaries.
Args:
new_data: Latest metadata from API (e.g., {"version": 2, "model_one": 2})
old_table: Previously cached metadata from database
Returns:
Tuple of (changed_tables, new_model_types)
- changed_tables: model keys where version increased
- new_model_types: model keys that don't exist in old_table (reserved for future use)
Raises:
TypeError: If either argument is not a dictionary
ValueError: If a model version is not an integer
"""
# Validate inputs
if not isinstance(new_data, dict) or not isinstance(old_table, dict):
raise TypeError("Both new_data and old_table must be dictionaries")
changed_tables = []
new_model_types = []
for model_key, new_version in new_data.items():
# Skip the "version" metadata key itself
if model_key == "version":
continue
# Type check: versions must be integers
if not isinstance(new_version, int):
raise ValueError(
f"Model '{model_key}' has non-integer version: {new_version!r}. "
f"Expected int."
)
# Identify new model types (not in old data)
if model_key not in old_table:
new_model_types.append(model_key)
continue
# Identify changed models (version increased)
if new_version > old_table[model_key]:
changed_tables.append(model_key)
return changed_tables, new_model_types
# ============== ISOLATION TESTING ==================
# SPOOF ISOLATION TEST:
# new_data = {
# "a": 1,
# "b": 2,
# "c": 4,
# "d": 1
# }
# old_data = {
# "a": 1,
# "b": 2,
# "c": 3,
# }
# changed_tables, new_data_types = compare_tables(new_data, old_data)
# print(f"changed tables: {changed_tables}")
# print(f"new data types: {new_data_types}")

View file

@ -0,0 +1,311 @@
# from __future__ import annotations
# from typing import TYPE_CHECKING
# if TYPE_CHECKING:
# from core.essentials.observers.ConnectionObserver import ConnectionObserver
# from core.observers.TicketObserver import TicketObserver
# comparisons:
from core.services.sync.compare_tables import compare_tables
# unique database:
from core.models.manage.session_management import get_session
from core.models.manage.get_from_model import get_from_model
from core.models.manage.insert import insert_into_model
from core.models.orm_models.CachedSync import CachedSync
from core.Constants import Constants
from core.observers.BaseObserver import BaseObserver
from core.services.networking.get_data_from_server import get_data_from_server
from core.errors.logger import logger
from core.observers.ClientObserver import ClientObserver
from core.observers.ConnectionObserver import ConnectionObserver
# generic
from sqlalchemy import func
from typing import Optional
from typing import Optional
def _get_cached_metadata():
"""Retrieve cached metadata from the database.
Returns:
tuple: (old_data dict, previous_highest version number)
If no cache exists: ({}, 0)
Raises:
Exception: Any database query errors propagate up.
"""
old_data_as_list = get_from_model(CachedSync)
if not old_data_as_list:
logger.warning("No cached sync data in database. Treating as first sync.")
return {}, 0
if not isinstance(old_data_as_list, list):
logger.critical("Error with get_from_model improperly returning non-lists.")
return {}, 0
old_data = old_data_as_list[0]
previous_highest = old_data.get("version", 0)
logger.debug(f"Cached metadata version: {previous_highest}")
return old_data, previous_highest
def _get_changed_models(new_data, old_data):
"""Determine which models changed between API and cached versions.
Args:
new_data: Dict of new metadata from API
old_data: Dict of old metadata from cache (empty dict if first sync)
Returns:
tuple: (changed_tables list, new_model_types list)
Raises:
TypeError: If inputs have invalid structure
ValueError: If version values are invalid
"""
if not old_data:
# First sync: no baseline, treat all models as needing fetch
changed_tables = [key for key in new_data if key != "version"]
new_model_types = []
logger.info(f"First sync: fetching all models: {changed_tables}")
else:
# Subsequent sync: compare against baseline
changed_tables, new_model_types = compare_tables(new_data, old_data)
return changed_tables, new_model_types
def _filter_valid_models(data, invalid_keys):
"""Remove invalid model types from data dict.
Args:
data: Dict of metadata
invalid_keys: List of keys to exclude
Returns:
Dict with invalid keys removed
"""
filtered = {}
for key, value in data.items():
if key not in invalid_keys:
filtered[key] = value
return filtered
def save_data(data: dict) -> bool:
"""Save metadata to database."""
try:
logger.debug(f"Inserting metadata into database: {data}")
did_it_work = insert_into_model(CachedSync, data, True)
if did_it_work:
logger.debug("Metadata saved successfully")
else:
logger.warning("insert_into_model returned False")
return did_it_work
except Exception as e:
logger.error(f"Failed to save metadata to database: {str(e)}", exc_info=True)
return False
def _log_what_changed(new_model_types, changed_tables):
if new_model_types:
logger.debug(f"New model types detected (reserved): {new_model_types}")
if changed_tables:
logger.info(f"Models to sync: {changed_tables}")
else:
logger.info("No models changed between versions.")
# Highest Level in this Flow:
def coordinate_cache_sync(
client_observer: Optional[ClientObserver] = None,
connection_observer: Optional[ConnectionObserver] = None
) -> dict:
"""
Fetch version metadata from API and return changed/new model keys.
Returns:
Dictionary with keys:
- 'success' (bool): Whether comparison succeeded
- 'changed_tables' (list): Model keys that changed
- 'new_model_types' (list): New model keys (reserved for future use)
- 'error' (str or None): Error message if success is False
"""
api_result = _get_sync_cache_from_api(ClientObserver, ConnectionObserver)
# we trust the '_get_sync_cache_from_api' function to give us a dictionary:
if not api_result.get("success"):
error_msg = api_result.get("error", "Unknown API error")
logger.error(f"API sync failed: {error_msg}")
return {
"success": False,
"changed_tables": [],
"new_model_types": [],
"error": error_msg,
}
# Extract the data from the client's own trusted function,
new_data = api_result.get("data")
# Validate the API's payload, (we don't trust the API's structure)
if not isinstance(new_data, dict):
error_msg = "API returned invalid metadata: 'data' is not a dict"
logger.error(error_msg)
return {
"success": False,
"changed_tables": [],
"new_model_types": [],
"error": error_msg,
}
if "version" not in new_data:
error_msg = "API returned invalid metadata: missing 'version' key"
logger.error(error_msg)
return {
"success": False,
"changed_tables": [],
"new_model_types": [],
"error": error_msg,
}
new_highest = new_data.get("version")
logger.debug(f"API metadata version: {new_highest}")
# Fetch old cached version from database
try:
old_data, previous_highest = _get_cached_metadata()
except Exception as e:
error_msg = f"Database query failed: {str(e)}"
logger.error(error_msg, exc_info=True)
return {
"success": False,
"changed_tables": [],
"new_model_types": [],
"error": error_msg,
}
# Version hasn't changed
if new_highest == previous_highest:
logger.info(f"Versions match (both {new_highest}). No sync needed.")
return {
"success": True,
"changed_tables": [],
"new_model_types": [],
"error": None,
}
# Determine which models changed
try:
changed_tables, new_model_types = _get_changed_models(new_data, old_data)
except (TypeError, ValueError) as e:
error_msg = f"Invalid metadata: {str(e)}"
logger.error(error_msg)
return {
"success": False,
"changed_tables": [],
"new_model_types": [],
"error": error_msg,
}
# this is not saving it to SQL, it's a temp debug log:
_log_what_changed(new_model_types, changed_tables)
# Filter out new model types for the upcoming save:
filtered_data = _filter_valid_models(new_data, new_model_types)
# Save only currently existing models to SQL:
save_successful = save_data(filtered_data)
if save_successful:
return {
"success": True,
"changed_tables": changed_tables,
"new_model_types": new_model_types,
"error": None,
}
else:
return {
"success": False,
"changed_tables": [],
"new_model_types": [],
"error": "Failed to save metadata to database",
}
def _get_sync_cache_from_api(
client_observer: Optional[ClientObserver] = None,
connection_observer: Optional[ConnectionObserver] = None
) -> dict:
logger.info("Syncing with the API...")
# Get and validate the base URL
rejected_list = [None, False, ""]
base_url = Constants.SP_API_BASE_URL
if base_url in rejected_list:
error_msg = "Invalid base URL from configuration"
logger.error(error_msg)
return {"success": False, "data": None, "error": error_msg}
# Construct the full endpoint URL
url = f"{base_url}/cachedsync"
logger.debug(f"API endpoint: {url}")
try:
logger.debug("Fetching data from API server...")
sync_results = get_data_from_server(url, connection_observer)
if sync_results in rejected_list:
error_msg = "API returned no data"
logger.error(error_msg)
return {"success": False, "data": None, "error": error_msg}
logger.debug(f"Raw API response: {sync_results}")
final_result = sync_results.get("data", None)
if final_result:
logger.info("Successfully retrieved sync versions metadata from API")
return {"success": True, "data": final_result, "error": None}
error_msg = "API response missing 'data' field"
logger.error(error_msg)
return {"success": False, "data": None, "error": error_msg}
except Exception as e:
error_msg = f"API fetch failed: {str(e)}"
logger.error(error_msg)
return {"success": False, "data": None, "error": error_msg}
# ============== ISOLATION TESTING ==================
# SPOOF ISOLATION TEST:
# def _get_sync_cache_from_api(
# final_result = {
# "version": 5,
# "applications": 3,
# "application_versions": 2,
# "client_version": 2,
# "operators": 2,
# "locations": 2,
# "subscriptions": 1
# }
# return {"success": True, "data": final_result, "error": None}

40
prototype_sync.py Normal file
View file

@ -0,0 +1,40 @@
from core.controllers.ClientController import ClientController
from core.observers.ClientObserver import ClientObserver
from core.observers.ConnectionObserver import ConnectionObserver
from core.models.manage.session_management import init_session, close_session
class PrototypeSync():
def __init__(self, parent=None):
client_observer = ClientObserver()
connection_observer = ConnectionObserver()
client_observer.subscribe('synchronizing', lambda event: print('Synchronizing...\n'))
client_observer.subscribe('updating', lambda event: print('Updating client...'))
client_observer.subscribe('update_progressing', lambda event: print(f'Current progress: {event.meta.get('progress'):.2f}%', flush=True, end='\r'))
client_observer.subscribe('updated', lambda event: print('\n'))
connection_observer.subscribe('connecting', lambda event: self.update_status(
f'[{event.subject.get("attempt_count")}/{event.subject.get("maximum_number_of_attempts")}] Performing connection attempt...'))
connection_observer.subscribe('tor_bootstrapping', lambda event: self.update_status(
'Establishing Tor connection...'))
connection_observer.subscribe('tor_bootstrap_progressing', lambda event: self.update_status(
f'Bootstrapping Tor {event.meta.get('progress'):.2f}%'))
connection_observer.subscribe(
'tor_bootstrapped', lambda event: self.update_status('Tor connection established.'))
init_session()
print("Sync..")
ClientController.sync(client_observer=client_observer, connection_observer=connection_observer)
print("Done")
# On app shutdown
close_session()
def update_status(self, text, clear=False):
if text:
print(text)

View file

@ -1,6 +1,6 @@
[project]
name = "sp-hydra-veil-core"
version = "2.3.4"
version = "2.3.5"
authors = [
{ name = "Simplified Privacy" },
]