Files
aiRtrafficNN/Code/python/src/adsbpy/fetch.py
T
2025-11-19 11:57:59 +01:00

312 lines
12 KiB
Python

import asyncio
import aiohttp
import aiofiles
import tarfile
import shutil
from pathlib import Path
from typing import List, Set
import logging
from concurrent.futures import ThreadPoolExecutor
import os
# --- configuration ---
BASE_DIR = Path(__file__).parent.resolve() / Path("../../../..")
DATA_DIR = BASE_DIR / "data"
CSV_DIR = BASE_DIR / "data/csv"
EXTRACT_ADSB = BASE_DIR / "Code/cpp/extract-adsb"
# Performance tuning
MAX_CONCURRENT_DOWNLOADS = 2 # Limit concurrent HTTP requests
MAX_CONCURRENT_EXTRACTIONS = 4 # Limit concurrent tar extractions (CPU intensive)
MAX_CONCURRENT_DAYS = 3 # Limit concurrent day processing
# -----------------------
# Set up logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
def pretty_path(path: os.PathLike | str) -> Path:
"""Return a path relative to the current working directory."""
path = Path(path).resolve()
return Path(os.path.relpath(path, Path.cwd()))
def pretty_url(url: str) -> str:
"""Return a shortened version of the URL for logging."""
if len(url) <= 60:
return url
return f"{url[:30]}...{url[-30:]}"
async def download(session: aiohttp.ClientSession, url: str, dest: Path, semaphore: asyncio.Semaphore) -> None:
"""Download a file asynchronously with semaphore limiting and retry logic."""
async with semaphore:
dest.parent.mkdir(parents=True, exist_ok=True)
logger.info(f"Downloading {pretty_url(url)}{pretty_path(dest)}")
max_retries = 3
retry_delay = 10 # seconds
timeout = 600 # seconds
for attempt in range(max_retries + 1):
try:
async with session.get(url, timeout=aiohttp.ClientTimeout(total=timeout)) as response:
if response.status == 429: # Too Many Requests
wait_time = int(response.headers.get('Retry-After', retry_delay * (attempt + 1)))
logger.warning(f"Rate limited for {pretty_url(url)}. Waiting {wait_time}s before retry {attempt + 1}/{max_retries}")
await asyncio.sleep(wait_time)
continue
response.raise_for_status()
async with aiofiles.open(dest, 'wb') as f:
async for chunk in response.content.iter_chunked(8192 * 4):
await f.write(chunk)
logger.info(f"Download completed: {pretty_path(dest)}")
return # Success, exit function
except aiohttp.ClientResponseError as e:
if e.status >= 500 and attempt < max_retries: # Server errors are retryable
logger.warning(f"Server error {e.status} for {pretty_url(url)} on attempt {attempt + 1}/{max_retries}: {e}")
await asyncio.sleep(retry_delay * (2 ** attempt)) # Exponential backoff
continue
else:
logger.error(f"HTTP error {e.status} for {pretty_url(url)}: {e}")
raise
except aiohttp.ClientConnectorError as e:
if attempt < max_retries:
logger.warning(f"Connection error for {pretty_url(url)} on attempt {attempt + 1}/{max_retries}: {e}")
await asyncio.sleep(retry_delay * (2 ** attempt))
continue
else:
logger.error(f"Connection failed for {pretty_url(url)}: {e}")
raise
except asyncio.TimeoutError as e:
if attempt < max_retries:
logger.warning(f"Timeout for {pretty_url(url)} on attempt {attempt + 1}/{max_retries}: {e}")
await asyncio.sleep(retry_delay * (2 ** attempt))
continue
else:
logger.error(f"Download timeout for {pretty_url(url)}: {e}")
raise
except Exception as e:
if attempt < max_retries:
logger.warning(f"Unexpected error for {pretty_url(url)} on attempt {attempt + 1}/{max_retries}: {e}")
await asyncio.sleep(retry_delay * (2 ** attempt))
continue
else:
logger.error(f"Download failed for {pretty_url(url)} after {max_retries} attempts: {e}")
raise
# This should never be reached, but just in case
raise Exception(f"Download failed for {pretty_url(url)} after {max_retries} attempts")
def get_name_from_url(url: str) -> str:
"""Extract filename from URL."""
return url.split("/")[-1]
async def extract_adsb_data(cmd: List[str]) -> None:
"""Run extract-adsb binary asynchronously."""
logger.info(f"Running: {' '.join(cmd)}")
process = await asyncio.create_subprocess_exec(*cmd)
await process.wait()
if process.returncode != 0:
raise Exception(f"Command failed with return code {process.returncode}: {' '.join(cmd)}")
def extract_tar_sync(tar_paths: List[Path], extract_dir: Path) -> Path:
"""Synchronous tar extraction (CPU-bound, run in thread pool)."""
extract_dir.mkdir(parents=True, exist_ok=True)
if len(tar_paths) == 1:
tar_path = tar_paths[0]
else:
# Keep only multipart fragments like .aa, .ab, .ac ...
parts = [p for p in tar_paths if len(p.suffix) == 3]
# Derive correct base archive name: strip only the multipart suffix
base = parts[0].with_suffix("") # "foo.tar.aa" → "foo.tar"
tar_path = extract_dir / base.name
logger.info(f"Concatenating {len(parts)} parts → {pretty_path(tar_path)}")
with open(tar_path, "wb") as outfile:
for part in sorted(parts):
with open(part, "rb") as infile:
shutil.copyfileobj(infile, outfile)
logger.info(f"Extracting {pretty_path(tar_path)}{pretty_path(extract_dir)}")
with tarfile.open(tar_path) as tf:
tf.extractall(path=extract_dir)
return extract_dir
async def extract_tar(tar_paths: List[Path], extract_dir: Path, executor: ThreadPoolExecutor) -> Path:
"""Run tar extraction in thread pool to avoid blocking event loop."""
loop = asyncio.get_event_loop()
return await loop.run_in_executor(executor, extract_tar_sync, tar_paths, extract_dir)
async def move_csv_files(src_dir: Path, dest_dir: Path) -> None:
"""Move all CSV files from source to destination directory."""
dest_dir.mkdir(parents=True, exist_ok=True)
for p in src_dir.rglob("*.csv"):
dest = dest_dir / p.name
shutil.move(str(p), str(dest))
async def remove_dir(dir_path: Path) -> None:
"""Remove directory and all its contents."""
if dir_path.exists():
shutil.rmtree(dir_path)
async def remove_file(file_path: Path) -> None:
"""Remove a single file."""
if file_path.exists():
file_path.unlink()
async def download_day_parts(session: aiohttp.ClientSession, urls: List[str],
download_semaphore: asyncio.Semaphore) -> List[Path]:
"""Download all parts for a single day concurrently."""
tar_files = []
download_tasks = []
for url in urls:
filename = DATA_DIR / get_name_from_url(url)
tar_files.append(filename)
download_tasks.append(
download(session, url, filename, download_semaphore)
)
await asyncio.gather(*download_tasks)
return tar_files
async def process_single_day(session: aiohttp.ClientSession, line: str,
download_semaphore: asyncio.Semaphore,
extraction_executor: ThreadPoolExecutor) -> None:
"""Process a single day's data with concurrent downloads and parallel extraction."""
parts = line.strip().split(",")
if not parts or not parts[0]:
return
if len(parts) > 1:
# Remove any part that is not of ending .tar.aa, .tar.ab, etc.
parts = [p for p in parts if len(p.split(".")[-1]) == 2]
logger.info(f"Processing day with {len(parts)} parts")
try:
# Download all parts for this day concurrently
tar_files = await download_day_parts(session, parts, download_semaphore)
# Extract the tar files
extract_dir = tar_files[0].parent / tar_files[0].stem
await extract_tar(tar_files, extract_dir, extraction_executor)
# Process ADSB data
traces_dir = extract_dir / "traces"
if traces_dir.exists():
await extract_adsb_data([str(EXTRACT_ADSB), str(traces_dir)])
# Move CSV files
csv_files_dir = CSV_DIR / extract_dir.name
await move_csv_files(extract_dir, csv_files_dir)
# Cleanup
cleanup_tasks = [
asyncio.create_task(remove_file(tar_file))
for tar_file in tar_files
if tar_file.exists()
]
cleanup_tasks.append(asyncio.create_task(remove_dir(extract_dir)))
await asyncio.gather(*cleanup_tasks)
logger.info(f"Completed processing day: {extract_dir.name}")
except Exception as e:
logger.error(f"Failed to process day: {e}")
raise
else:
# If successful, write to ignore file
await write_line_to_ignore(BASE_DIR / "Code/python/adsbpy/.ignore", line.strip())
async def write_line_to_ignore(file: Path, line: str) -> None:
"""Append a line to the ignore file asynchronously."""
async with aiofiles.open(file, 'a') as f:
await f.write(line + '\n')
async def process_days_concurrently(lines: List[str]) -> None:
"""Process multiple days concurrently with controlled parallelism."""
# Create semaphores for controlling concurrency
download_semaphore = asyncio.Semaphore(MAX_CONCURRENT_DOWNLOADS)
day_semaphore = asyncio.Semaphore(MAX_CONCURRENT_DAYS)
# Thread pool for CPU-bound tar extraction
with ThreadPoolExecutor(max_workers=MAX_CONCURRENT_EXTRACTIONS) as extraction_executor:
connector = aiohttp.TCPConnector(limit=MAX_CONCURRENT_DOWNLOADS)
async with aiohttp.ClientSession(connector=connector) as session:
async def process_day_with_semaphore(line: str) -> None:
"""Wrapper to limit concurrent day processing."""
async with day_semaphore:
await process_single_day(session, line, download_semaphore, extraction_executor)
# Process all days concurrently with limits
tasks = [
process_day_with_semaphore(line)
for line in lines
if line.strip()
]
# Process in batches to avoid overwhelming the system
batch_size = MAX_CONCURRENT_DAYS * 5
for i in range(0, len(tasks), batch_size):
batch = tasks[i:i + batch_size]
logger.info(f"Processing batch {i//batch_size + 1}/{(len(tasks)-1)//batch_size + 1}")
await asyncio.gather(*batch, return_exceptions=True)
async def fetch_lines(lines: List[str]) -> None:
"""Process multiple lines (days) concurrently."""
await process_days_concurrently(lines)
async def fetch_file(file: Path) -> None:
"""Fetch and process URLs from a file."""
async with aiofiles.open(file, 'r') as f:
lines = await f.readlines()
logger.info(f"Processing {len(lines)} days")
await fetch_lines(lines)
async def main() -> None:
"""Main entry point if running as a script."""
import sys
if len(sys.argv) > 1:
input_file = Path(sys.argv[1])
else:
input_file = Path("urls.txt")
if input_file.exists():
logger.info(f"Starting processing of {input_file}")
await fetch_file(input_file)
logger.info("Processing completed successfully")
else:
logger.error(f"Input file {input_file} not found")
sys.exit(1)
if __name__ == "__main__":
asyncio.run(main())