Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions config.toml.example
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ BATCH_SIZE = 125
MAX_AGE_BEFORE_DELETION = 120
BIGQUERY_ANALYSIS_PERIOD_DAYS = "28"

[caching]
# Maximum age in minutes for cached data to be considered fresh
CACHE_MAX_AGE_MINUTES = "30"
# Force BigQuery refresh even if fresh cached data exists (true/false)
FORCE_BIGQUERY_REFRESH = "false"

[eligibility_criteria]
MIN_ONLINE_DAYS = "5"
MIN_SUBGRAPHS = "10"
Expand Down
164 changes: 162 additions & 2 deletions src/models/eligibility_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import logging
import shutil
import time
from datetime import date, datetime
from pathlib import Path
from typing import List, Tuple
Expand Down Expand Up @@ -136,8 +137,13 @@ def clean_old_date_directories(self, max_age_before_deletion: int) -> None:
# Remove if older than max_age_before_deletion
if age_days > max_age_before_deletion:
logger.info(f"Removing old data directory: {item} ({age_days} days old)")
shutil.rmtree(item)
directories_removed += 1
try:
shutil.rmtree(item)
directories_removed += 1
except (FileNotFoundError, OSError) as e:
# Directory already deleted by another process or became inaccessible
logger.debug(f"Directory {item} already removed or inaccessible: {e}")
continue

except ValueError:
# Skip directories that don't match date format
Expand All @@ -163,6 +169,160 @@ def get_date_output_directory(self, current_date: date) -> Path:
return self.output_dir / current_date.strftime("%Y-%m-%d")


def has_existing_processed_data(self, current_date: date) -> bool:
"""
Check if processed data already exists for the given date.

Args:
current_date: The date to check for existing data

Returns:
bool: True if all required CSV files exist and are not empty
"""
output_date_dir = self.get_date_output_directory(current_date)

# Check if the date directory exists
if not output_date_dir.exists():
return False

# Define required files
required_files = [
"eligible_indexers.csv",
"indexer_issuance_eligibility_data.csv",
"ineligible_indexers.csv",
]

# Check that all required files exist and are not empty
for filename in required_files:
file_path = output_date_dir / filename
try:
if not file_path.exists() or file_path.stat().st_size == 0:
return False
except (FileNotFoundError, OSError):
# File disappeared between exists() check and stat() call
logger.debug(f"File {file_path} disappeared during existence check")
return False

return True


def get_data_age_minutes(self, current_date: date) -> float:
"""
Calculate the age of existing processed data in minutes.

Args:
current_date: The date for which to check data age

Returns:
float: Age of the data in minutes (based on oldest file)

Raises:
FileNotFoundError: If no CSV files exist for the given date
"""
output_date_dir = self.get_date_output_directory(current_date)

if not output_date_dir.exists():
raise FileNotFoundError(f"No data directory found for date: {current_date}")

csv_files = list(output_date_dir.glob("*.csv"))
if not csv_files:
raise FileNotFoundError(f"No CSV files found in directory: {output_date_dir}")

# Get the oldest file's modification time to be conservative
# Handle race condition where files could disappear between glob() and stat()
file_mtimes = []
for file in csv_files:
try:
file_mtimes.append(file.stat().st_mtime)
except (FileNotFoundError, OSError):
# File disappeared between glob() and stat(), skip it
logger.debug(f"File {file} disappeared during age calculation")
continue

if not file_mtimes:
raise FileNotFoundError(f"All CSV files disappeared during age calculation in: {output_date_dir}")

oldest_mtime = min(file_mtimes)
age_seconds = time.time() - oldest_mtime
return age_seconds / 60.0


def has_fresh_processed_data(self, current_date: date, max_age_minutes: int = 30) -> bool:
"""
Check if processed data exists and is fresh (within the specified age limit).

Args:
current_date: The date to check for existing data
max_age_minutes: Maximum age in minutes for data to be considered fresh

Returns:
bool: True if all required CSV files exist, are complete, and are fresh
"""
# First check if data exists and is complete
if not self.has_existing_processed_data(current_date):
return False

try:
# Check if data is fresh enough
data_age_minutes = self.get_data_age_minutes(current_date)
is_fresh = data_age_minutes <= max_age_minutes

if is_fresh:
logger.info(f"Found fresh cached data for {current_date} (age: {data_age_minutes:.1f} minutes)")
else:
logger.info(
f"Cached data for {current_date} is stale "
f"(age: {data_age_minutes:.1f} minutes, max: {max_age_minutes})"
)

return is_fresh

except FileNotFoundError:
return False


def load_eligible_indexers_from_csv(self, current_date: date) -> List[str]:
"""
Load the list of eligible indexers from existing CSV file.

Args:
current_date: The date for which to load existing data

Returns:
List[str]: List of eligible indexer addresses

Raises:
FileNotFoundError: If the required CSV file doesn't exist
ValueError: If the CSV file is malformed or empty
"""
output_date_dir = self.get_date_output_directory(current_date)
eligible_file = output_date_dir / "eligible_indexers.csv"

if not eligible_file.exists():
raise FileNotFoundError(f"Eligible indexers CSV not found: {eligible_file}")

try:
# Read the CSV file - it should have a header row with 'indexer' column
df = pd.read_csv(eligible_file)

if df.empty:
logger.warning(f"Eligible indexers CSV is empty: {eligible_file}")
return []

if "indexer" not in df.columns:
raise ValueError(
f"CSV file {eligible_file} missing 'indexer' column. Found columns: {list(df.columns)}"
)

indexer_list = df["indexer"].tolist()
logger.info(f"Loaded {len(indexer_list)} eligible indexers from cached CSV for {current_date}")

return indexer_list

except Exception as e:
raise ValueError(f"Error reading CSV file {eligible_file}: {e}")


def validate_dataframe_structure(self, df: pd.DataFrame, required_columns: List[str]) -> bool:
"""
Validate that a DataFrame has the required columns.
Expand Down
9 changes: 7 additions & 2 deletions src/models/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,13 @@ def get_last_run_date(self):
with open(LAST_RUN_FILE) as f:
last_run_str = f.read().strip()
last_run_date = datetime.strptime(last_run_str, "%Y-%m-%d").date()
except Exception as e:
logger.error(f"Error reading or parsing last run date file: {e}")
except (FileNotFoundError, OSError) as e:
# File disappeared between exists() check and open(), or permission issues
logger.warning(f"Last run file disappeared or became inaccessible: {e}")
return None
except (ValueError, IOError) as e:
# File exists but content is corrupted or unreadable
logger.error(f"Error reading or parsing last run date file content: {e}")
return None

today = datetime.now().date()
Expand Down
78 changes: 51 additions & 27 deletions src/models/service_quality_oracle.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,36 +75,60 @@ def main(run_date_override: date = None):
start_date = current_run_date - timedelta(days=config["BIGQUERY_ANALYSIS_PERIOD_DAYS"])
end_date = current_run_date

# --- Data Fetching Stage ---
stage = "Data Fetching from BigQuery"
logger.info(f"Fetching data from {start_date} to {end_date}")

# Construct the full table name from configuration
table_name = (
f"{config['BIGQUERY_PROJECT_ID']}.{config['BIGQUERY_DATASET_ID']}.{config['BIGQUERY_TABLE_ID']}"
)
# Initialize pipeline early to check for cached data
pipeline = EligibilityPipeline(project_root=project_root_path)

bigquery_provider = BigQueryProvider(
project=config["BIGQUERY_PROJECT_ID"],
location=config["BIGQUERY_LOCATION_ID"],
table_name=table_name,
min_online_days=config["MIN_ONLINE_DAYS"],
min_subgraphs=config["MIN_SUBGRAPHS"],
max_latency_ms=config["MAX_LATENCY_MS"],
max_blocks_behind=config["MAX_BLOCKS_BEHIND"],
)
eligibility_data = bigquery_provider.fetch_indexer_issuance_eligibility_data(start_date, end_date)
logger.info(f"Successfully fetched data for {len(eligibility_data)} indexers from BigQuery.")
# Check for fresh cached data first (30 minutes by default)
cache_max_age_minutes = int(config.get("CACHE_MAX_AGE_MINUTES", 30))
force_refresh = config.get("FORCE_BIGQUERY_REFRESH", "false").lower() == "true"

# --- Data Processing Stage ---
stage = "Data Processing and Artifact Generation"
pipeline = EligibilityPipeline(project_root=project_root_path)
eligible_indexers, _ = pipeline.process(
input_data_from_bigquery=eligibility_data,
current_date=current_run_date,
)
logger.info(f"Found {len(eligible_indexers)} eligible indexers.")
if not force_refresh and pipeline.has_fresh_processed_data(current_run_date, cache_max_age_minutes):
# --- Use Cached Data Path ---
stage = "Loading Cached Data"
logger.info(f"Using cached data for {current_run_date} (fresh within {cache_max_age_minutes} minutes)")

try:
eligible_indexers = pipeline.load_eligible_indexers_from_csv(current_run_date)
logger.info(
f"Loaded {len(eligible_indexers)} eligible indexers from cache - "
"skipping BigQuery and processing"
)
except (FileNotFoundError, ValueError) as cache_error:
logger.warning(f"Failed to load cached data: {cache_error}. Falling back to BigQuery.")
force_refresh = True

if force_refresh or not pipeline.has_fresh_processed_data(current_run_date, cache_max_age_minutes):
# --- Fresh Data Path (BigQuery + Processing) ---
stage = "Data Fetching from BigQuery"
reason = "forced refresh" if force_refresh else "no fresh cached data available"
logger.info(f"Fetching fresh data from BigQuery ({reason}) - period: {start_date} to {end_date}")

# Construct the full table name from configuration
table_name = (
f"{config['BIGQUERY_PROJECT_ID']}.{config['BIGQUERY_DATASET_ID']}.{config['BIGQUERY_TABLE_ID']}"
)

bigquery_provider = BigQueryProvider(
project=config["BIGQUERY_PROJECT_ID"],
location=config["BIGQUERY_LOCATION_ID"],
table_name=table_name,
min_online_days=config["MIN_ONLINE_DAYS"],
min_subgraphs=config["MIN_SUBGRAPHS"],
max_latency_ms=config["MAX_LATENCY_MS"],
max_blocks_behind=config["MAX_BLOCKS_BEHIND"],
)
eligibility_data = bigquery_provider.fetch_indexer_issuance_eligibility_data(start_date, end_date)
logger.info(f"Successfully fetched data for {len(eligibility_data)} indexers from BigQuery.")

# --- Data Processing Stage ---
stage = "Data Processing and Artifact Generation"
eligible_indexers, _ = pipeline.process(
input_data_from_bigquery=eligibility_data,
current_date=current_run_date,
)
logger.info(f"Found {len(eligible_indexers)} eligible indexers after processing.")

# Clean up old data directories (run this regardless of cache hit/miss)
pipeline.clean_old_date_directories(config["MAX_AGE_BEFORE_DELETION"])

# --- Blockchain Submission Stage ---
Expand Down
Loading