import datetime
import os
import subprocess
import hashlib
import base64
import json
from typing import Tuple, Optional
import time
import tempfile
import coscine
from sqlalchemy.orm import Session
from ccat_ops_db import models
from .database import DatabaseConnection
from .setup_celery_app import app, make_celery_task
from .utils import (
get_s3_client,
get_redis_connection,
get_s3_key_for_package,
safe_join,
resolve_s3_method,
resolve_coscine_config,
deduplicate_by_id,
)
from .logging_utils import get_structured_logger
from .exceptions import OperationNotFoundError, ScheduleError
from .boundary import ensure_readable, diagnose_source_missing
from .metrics import HousekeepingMetrics
from .queue_discovery import route_task_by_location
from .operation_types import OperationType
from .task_state_manager import TaskStateManager
# Use only task loggers
logger = get_structured_logger(__name__)
redis_ = get_redis_connection()
# s3_client = boto3.client(
# "s3",
# endpoint_url=ccat_data_transfer_settings.s3_endpoint_url,
# aws_access_key_id=ccat_data_transfer_settings.s3_access_key_id,
# aws_secret_access_key=ccat_data_transfer_settings.s3_secret_access_key,
# region_name=ccat_data_transfer_settings.s3_region_name,
# config=boto3.session.Config(signature_version="s3v4"),
# )
[docs]
class LongTermArchiveTask(make_celery_task()):
"""Base class for long term archive tasks.
Drives the uniform ``ArchiveOperation`` (OperationKind.ARCHIVE): the archive
lifecycle -- status, ``retry_count``, consume/produce lineage -- lives on the
Operation row, not on the legacy ``LongTermArchiveTransfer`` (which remains
additively until #95). The task argument is the ArchiveOperation id.
"""
[docs]
def __init__(self):
super().__init__()
# Identity sourced from the enum (single source of truth), kept as the
# plain .value so TaskStateManager / circuit-breaker f-string key builds
# stay byte-identical -- a bare OperationKind member would render its NAME
# in an f-string under Python 3.12 and corrupt the Redis key.
self.operation_type = models.OperationKind.ARCHIVE.value
# No get_retry_count override: the archive operation carries the uniform
# Operation.retry_count, so it falls through to the base reader (#75/#155).
[docs]
def mark_in_progress(self, session, operation_id):
"""Set the ArchiveOperation IN_PROGRESS at task start (the base-task path).
This is the single seam through which the running signal is set; the
archive-local IN_PROGRESS set in the task body is removed (#155). It is
the honest running marker, NOT the duplicate guard -- correctness rests
on idempotent completion (ADR-0004): the COMPLETED early-return and the
existing-destination-copy no-op.
The seam runs (and commits) BEFORE the task body, so it must never
resurrect a settled operation: a redelivered/recovery-re-dispatched run
of an already-terminal op is left untouched, otherwise the COMPLETED ->
IN_PROGRESS flip would defeat the body's COMPLETED early-return and a
post-retention re-delivery could re-run the transfer (ADR-0004).
"""
archive_operation = session.get(models.ArchiveOperation, operation_id)
if archive_operation and archive_operation.status not in (
models.Status.COMPLETED,
models.Status.FAILED,
):
archive_operation.status = models.Status.IN_PROGRESS
[docs]
def reset_state_on_failure(self, session, archive_operation_id, exc):
"""Reset the archive operation state for retry."""
archive_operation = session.get(models.ArchiveOperation, archive_operation_id)
if archive_operation:
archive_operation.status = models.Status.PENDING
raw_data_package = _archive_raw_data_package(session, archive_operation)
if raw_data_package:
raw_data_package.state = models.PackageState.TRANSFERRING
archive_operation.failure_error_message = None
# error_context mirrors failure_error_message: NULLed on reset (#117);
# the durable trail is kept in OperationFailureEvent.
archive_operation.error_context = None
archive_operation.retry_count += 1
logger.info(
"reset archive operation for retry",
archive_operation_id=archive_operation_id,
retry_count=archive_operation.retry_count,
)
_add_archive_log(
session,
archive_operation,
f"Transfer failed, scheduling retry: {str(exc)}",
)
session.commit()
redis_.publish(
"transfer:overview",
json.dumps(
{
"type": "long_term_archive_transfer_reset",
"data": archive_operation_id,
}
),
)
[docs]
def mark_permanent_failure(self, session, archive_operation_id, exc):
"""Mark the archive operation as permanently failed."""
archive_operation = session.get(models.ArchiveOperation, archive_operation_id)
if archive_operation:
archive_operation.status = models.Status.FAILED
raw_data_package = _archive_raw_data_package(session, archive_operation)
if raw_data_package:
raw_data_package.state = models.PackageState.FAILED
archive_operation.failure_error_message = str(exc)
# Cache the latest Tier-1 breadcrumb on the row for the UI (#117).
archive_operation.error_context = self._current_error_context
logger.info(
"archive operation marked as permanently failed",
archive_operation_id=archive_operation_id,
)
_add_archive_log(
session,
archive_operation,
f"Transfer permanently failed after {archive_operation.retry_count} attempts: {str(exc)}",
)
session.commit()
redis_.publish(
"transfer:overview",
json.dumps(
{
"type": "long_term_archive_transfer_failed",
"data": archive_operation_id,
}
),
)
[docs]
def get_operation_info(self, args, kwargs):
"""Get additional context for long term archive tasks."""
if not args or len(args) == 0:
return {}
with self.session_scope() as session:
try:
archive_operation = session.get(models.ArchiveOperation, args[0])
if archive_operation:
return {
"archive_operation_id": str(archive_operation.id),
"package_id": str(archive_operation.raw_data_package_id),
"retry_count": archive_operation.retry_count,
"status": (
archive_operation.status.value
if archive_operation.status
else None
),
}
except Exception as e:
logger.error(
"error getting archive operation info",
archive_operation_id=args[0] if args else None,
error=str(e),
)
return {}
@app.task(
base=LongTermArchiveTask,
name="ccat:data_transfer:long_term_archive",
bind=True,
)
def send_data_to_long_term_archive(
self, archive_operation_id: int, session: Session = None
) -> None:
"""
Transfers a raw data package to the long term archive using dynamic queue routing.
Parameters
----------
self : celery.Task
The Celery task instance.
archive_operation_id : int
The ID of the ArchiveOperation object in the database.
Returns
-------
None
Notes
-----
- Fetches the ArchiveOperation object from the database.
- Uses dynamic queue routing based on the destination location.
- Executes the transfer command to move the data.
- Updates the ArchiveOperation status and logs in the database.
"""
logger.info(
"archive operation task started",
archive_operation_id=archive_operation_id,
)
if session is None:
with self.session_scope() as session:
return _send_archive_operation_internal(session, archive_operation_id)
else:
return _send_archive_operation_internal(session, archive_operation_id)
def _send_archive_operation_internal(
session: Session, archive_operation_id: int
) -> None:
"""Archive a raw data package on the uniform ArchiveOperation row.
Idempotent under at-least-once delivery (ADR-0004): this task may be
redelivered by the broker, re-run after a heartbeat reset, or re-scheduled by
the stuck reconciler. An already-COMPLETED operation therefore returns
immediately without re-transferring, and an existing destination copy skips
the transfer body. IN_PROGRESS is set by the base task via mark_in_progress,
NOT here (the #160 archive-local set is removed by #155); the duplicate guard
is the COMPLETED early-return plus the existing-copy no-op, never IN_PROGRESS.
"""
start_time = datetime.datetime.now()
archive_operation = _get_archive_operation(session, archive_operation_id)
# Idempotency guard: a redelivered/retried run for a finished operation must
# not move the data again.
if archive_operation.status == models.Status.COMPLETED:
logger.info(
"archive operation already completed, skipping",
archive_operation_id=archive_operation_id,
)
return
# Duplicate-killer (ADR-0004): if a physical copy already exists at the
# destination, the data is already there (e.g. a prior run completed but the
# operation row was not settled before redelivery/recovery). Skip the
# transfer body entirely and just settle the operation as COMPLETED -- running
# the body again would be a redundant re-transfer.
if _existing_destination_physical_copy(session, archive_operation):
logger.info(
"archive destination copy already present, skipping transfer body",
archive_operation_id=archive_operation_id,
)
_mark_archive_operation_successful(
session,
archive_operation,
datetime.datetime.now() - start_time,
destination_url=None,
)
return
source_url, destination_url, destination_bucket = _construct_transfer_urls(
archive_operation, session
)
_execute_transfer(
session,
source_url,
archive_operation.raw_data_package_id,
destination_url,
destination_bucket,
archive_operation,
)
end_time = datetime.datetime.now()
duration = end_time - start_time
_mark_archive_operation_successful(
session,
archive_operation,
duration,
destination_url,
)
logger.info(
"archive operation completed",
archive_operation_id=archive_operation_id,
duration_s=duration.total_seconds(),
)
redis_.publish(
"transfer:overview",
json.dumps(
{
"type": "long_term_archive_transfer_completed",
"data": archive_operation_id,
}
),
)
def _archive_raw_data_package(
session: Session, archive_operation: models.ArchiveOperation
) -> Optional[models.RawDataPackage]:
"""Resolve the RawDataPackage an ArchiveOperation archives.
ArchiveOperation carries only the FK columns (no ORM relationship), so the
package is fetched explicitly.
"""
if archive_operation.raw_data_package_id is None:
return None
return session.get(models.RawDataPackage, archive_operation.raw_data_package_id)
def _archive_destination_location(
session: Session, archive_operation: models.ArchiveOperation
) -> Optional[models.DataLocation]:
"""Resolve the destination DataLocation of an ArchiveOperation (FK-only)."""
if archive_operation.destination_location_id is None:
return None
return session.get(models.DataLocation, archive_operation.destination_location_id)
def _archive_origin_location(
session: Session, archive_operation: models.ArchiveOperation
) -> Optional[models.DataLocation]:
"""Resolve the origin DataLocation of an ArchiveOperation (FK-only)."""
if archive_operation.origin_location_id is None:
return None
return session.get(models.DataLocation, archive_operation.origin_location_id)
def _existing_destination_physical_copy(
session: Session,
archive_operation: models.ArchiveOperation,
) -> Optional[models.RawDataPackagePhysicalCopy]:
"""Return the existing copy at the operation's destination, if any.
Looks up by (raw_data_package, destination DataLocation) -- the natural key
of a physical copy. Used as the idempotency / duplicate guard (ADR-0004). The
airtight DB unique constraint on
(raw_data_package_id, destination_data_location_id), as defense-in-depth, is
deferred to #70.
"""
return (
session.query(models.RawDataPackagePhysicalCopy)
.filter(
models.RawDataPackagePhysicalCopy.raw_data_package_id
== archive_operation.raw_data_package_id,
models.RawDataPackagePhysicalCopy.data_location_id
== archive_operation.destination_location_id,
)
.first()
)
def _get_archive_operation(
session: Session, archive_operation_id: int
) -> models.ArchiveOperation:
"""Retrieve the ArchiveOperation object from the database."""
try:
archive_operation = session.get(models.ArchiveOperation, archive_operation_id)
if not archive_operation:
# Non-retryable: a missing/dangling id never reappears, so retrying
# only loops forever. A bare ValueError defaulted to retryable in
# should_retry, which is what produced the infinite archive retry.
raise OperationNotFoundError(
f"Archive operation {archive_operation_id} not found",
operation_id=archive_operation_id,
)
logger.info(
"archive operation record retrieved",
archive_operation_id=archive_operation_id,
)
return archive_operation
except Exception as e:
logger.error(
"archive operation record not found",
archive_operation_id=archive_operation_id,
error=str(e),
)
raise
def _validate_long_term_archive_path(
long_term_archive_transfer: models.LongTermArchiveTransfer,
) -> bool:
"""Validate the long term archive raw data package path."""
if (
not long_term_archive_transfer.data_archive.long_term_archive_raw_data_package_path
):
logger.error(
"LTA raw data package path not set",
lta_transfer_id=long_term_archive_transfer.id,
location_name=long_term_archive_transfer.data_archive.name,
)
return False
return True
def _construct_transfer_urls(
archive_operation: models.ArchiveOperation,
session: Session,
) -> Tuple[str, str, str]:
"""Construct the source and destination URLs using the new DataLocation system."""
# Get the source and destination locations (FK-only on ArchiveOperation).
source_location = _archive_origin_location(session, archive_operation)
destination_location = _archive_destination_location(session, archive_operation)
if not source_location or not destination_location:
raise ValueError(
"Source or destination location not set for archive operation"
)
# Get the raw data package
raw_data_package = _archive_raw_data_package(session, archive_operation)
# Construct source path based on source location type
if isinstance(source_location, models.DiskDataLocation):
source_url = safe_join(source_location.path, raw_data_package.relative_path)
elif isinstance(source_location, models.S3DataLocation):
# For source S3 locations, use the location's bucket name
source_url = (
f"s3://{source_location.bucket_name}/{raw_data_package.relative_path}"
)
else:
raise ValueError(
f"Unsupported source location type: {source_location.storage_type}"
)
# Construct destination path based on destination location type
if isinstance(destination_location, models.DiskDataLocation):
destination_url = safe_join(
destination_location.path, raw_data_package.relative_path,
)
destination_bucket = None
elif isinstance(destination_location, models.S3DataLocation):
# Use the shared function to construct the S3 key consistently
destination_url = get_s3_key_for_package(destination_location, raw_data_package)
destination_bucket = destination_location.bucket_name
else:
raise ValueError(
f"Unsupported destination location type: {destination_location.storage_type}"
)
logger.debug(
"transfer paths resolved",
source_path=source_url,
destination_path=destination_url,
source_location_type=source_location.storage_type.value,
destination_location_type=destination_location.storage_type.value,
)
return source_url, destination_url, destination_bucket
def _get_s3_metadata(
session: Session,
raw_data_package: models.RawDataPackage,
file_size: int,
) -> dict:
"""Generate comprehensive metadata for S3 upload from database models.
This function extracts metadata from the database models and constructs a dictionary
suitable for S3 object metadata. It follows a hierarchical structure for different
types of metadata (obs_, file_, archive_, etc.).
Database Relationships:
- RawDataPackage -> ObsUnit -> Source (FixedSource or other)
- RawDataPackage -> ExecutedObsUnit (direct)
- RawDataPackage -> ObsUnit -> PrimaryInstrumentModuleConfiguration -> InstrumentModule -> Instrument
- RawDataPackage -> ObsUnit -> ObservingProgram
- RawDataPackage -> ObsUnit -> SubObservingProgram
Parameters
----------
session : Session
SQLAlchemy database session
raw_data_package : models.RawDataPackage
The raw data package being uploaded
file_size : int
Size of the file in bytes
Returns
-------
dict
Dictionary of metadata key-value pairs for S3 upload
"""
# Get observation information
obs_unit = raw_data_package.obs_unit
executed_obs_unit = raw_data_package.executed_obs_unit
# Get source information
source = obs_unit.source
if isinstance(source, models.FixedSource):
ra_deg = source.ra_deg
dec_deg = source.dec_deg
target_name = source.name
else:
ra_deg = None
dec_deg = None
target_name = source.name
# Get instrument information
instrument = (
obs_unit.primary_instrument_module_configuration.instrument_module.instrument
)
# Get observing program information
program = obs_unit.observing_program
subprogram = obs_unit.sub_observing_program
# Generate a timestamp for the dataset ID if executed_obs_unit is None
timestamp = (
executed_obs_unit.start_time.strftime("%Y%m%d")
if executed_obs_unit and executed_obs_unit.start_time
else datetime.datetime.now().strftime("%Y%m%d")
)
# Log debug information
logger.debug(
"generating S3 metadata",
raw_package_id=raw_data_package.id,
obs_unit_id=obs_unit.id,
executed_obs_unit_id=executed_obs_unit.id if executed_obs_unit else None,
source_type=source.__class__.__name__,
instrument_name=instrument.name,
program_id=program.short_name,
)
# Construct metadata
metadata = {
"obs_dataset_id": f"{instrument.name}-{timestamp}-{obs_unit.id}",
"obs_telescope": instrument.telescope.name,
"obs_instrument": instrument.name,
"obs_date_obs": (
executed_obs_unit.start_time.isoformat()
if executed_obs_unit and executed_obs_unit.start_time
else None
),
# Target information
"obs_target_name": target_name,
"obs_ra_deg": str(ra_deg) if ra_deg is not None else None,
"obs_dec_deg": str(dec_deg) if dec_deg is not None else None,
# Program tracking
"obs_program_id": program.short_name,
"obs_subprogram_id": subprogram.short_name if subprogram else None,
# Database linkage
"db_obs_unit_id": str(obs_unit.id),
"db_executed_obs_unit_id": (
str(executed_obs_unit.id) if executed_obs_unit else None
),
# Data quality and status
"file_xxhash64": raw_data_package.checksum,
}
# Remove None values
metadata = {k: v for k, v in metadata.items() if v is not None}
# Log final metadata keys
logger.debug(
"s3 metadata generated",
metadata_keys=list(metadata.keys()),
metadata_count=len(metadata),
)
return metadata
def _upload_extended_metadata(
s3_client,
destination_bucket: str,
destination_url: str,
metadata: dict,
) -> bool:
"""Upload extended metadata to S3 as a separate JSON file.
Parameters
----------
s3_client : boto3.client
S3 client instance
destination_bucket : str
S3 bucket name
destination_url : str
Base destination path in S3
metadata : dict
Extended metadata to upload
Returns
-------
bool
True if upload was successful, False otherwise
"""
try:
# save to tmp file and then read and upload
with tempfile.NamedTemporaryFile(delete=False, suffix=".json") as tmp_file:
tmp_file.write(json.dumps(metadata, indent=2).encode("utf-8"))
tmp_file_path = tmp_file.name
# Read the file content for upload
with open(tmp_file_path, "rb") as f:
file_content = f.read()
# Calculate checksum from the content
metadata_checksum = base64.b64encode(
hashlib.sha256(file_content).digest()
).decode("utf-8")
# Construct metadata file path
metadata_path = f"{os.path.splitext(destination_url)[0]}_metadata.json"
# Upload metadata file with content and checksum
s3_client.put_object(
Bucket=destination_bucket,
Key=metadata_path,
Body=file_content,
ContentType="application/json",
ChecksumSHA256=metadata_checksum,
)
# Clean up temporary file
os.unlink(tmp_file_path)
logger.info(
"extended metadata upload succeeded",
metadata_path=f"s3://{destination_bucket}/{metadata_path}",
)
return True
except Exception as e:
logger.error(
"extended metadata upload failed",
error=str(e),
metadata_path=f"s3://{destination_bucket}/{metadata_path}",
)
# Clean up temporary file on error
try:
if "tmp_file_path" in locals():
os.unlink(tmp_file_path)
except Exception:
pass
return False
def _get_raw_data_package_id_from_db(session: Session, filename: str) -> int:
"""Get raw data package ID from database using filename.
Parameters
----------
session : Session
SQLAlchemy database session
filename : str
The filename to look up
Returns
-------
int
The raw data package ID
Raises
------
ValueError
If the raw data package cannot be found
"""
try:
# Get just the filename without the path
basename = os.path.basename(filename)
# Log the lookup attempt
logger.debug(
"looking up raw data package by filename", filename=basename, full_path=filename
)
# Try to find the raw data package by filename
raw_data_package = (
session.query(models.RawDataPackage)
.filter(models.RawDataPackage.name == basename)
.first()
)
if not raw_data_package:
# Try to find by relative path
raw_data_package = (
session.query(models.RawDataPackage)
.filter(models.RawDataPackage.relative_path.like(f"%{basename}"))
.first()
)
if not raw_data_package:
# Log the failed lookup
logger.error(
"raw data package not found by filename", filename=basename, full_path=filename
)
raise ValueError(f"No raw data package found for filename: {basename}")
# Log successful lookup
logger.debug(
"raw data package found", raw_package_id=raw_data_package.id, filename=basename
)
return raw_data_package.id
except Exception as e:
logger.error(
"error looking up raw data package",
error=str(e),
filename=os.path.basename(filename),
full_path=filename,
)
raise ValueError(f"Error looking up raw data package: {str(e)}")
def _execute_transfer(
session: Session,
source_url: str,
raw_data_package_id: int,
destination_url: str,
destination_bucket: str,
archive_operation: models.ArchiveOperation,
) -> None:
"""Execute the transfer between different storage types using the new DataLocation system."""
try:
start_time = time.time()
# Get the source and destination locations (FK-only on ArchiveOperation).
source_location = _archive_origin_location(session, archive_operation)
destination_location = _archive_destination_location(
session, archive_operation
)
# Determine transfer method based on location types
if isinstance(source_location, models.DiskDataLocation) and isinstance(
destination_location, models.DiskDataLocation
):
# Disk to disk transfer
_execute_disk_to_disk_transfer(source_url, destination_url)
elif isinstance(source_location, models.DiskDataLocation) and isinstance(
destination_location, models.S3DataLocation
):
logger.debug(
"disk to S3 transfer params",
source_url=source_url,
raw_package_id=raw_data_package_id,
destination_url=destination_url,
destination_bucket=destination_bucket,
)
# Disk to S3 transfer
_execute_disk_to_s3_transfer(
session,
source_url,
raw_data_package_id,
destination_url,
destination_bucket,
destination_location,
source_location.site.short_name if source_location.site else None,
source_location=source_location,
)
elif isinstance(source_location, models.S3DataLocation) and isinstance(
destination_location, models.DiskDataLocation
):
# S3 to disk transfer
_execute_s3_to_disk_transfer(source_url, destination_url)
elif isinstance(source_location, models.S3DataLocation) and isinstance(
destination_location, models.S3DataLocation
):
# S3 to S3 transfer
# _execute_s3_to_s3_transfer(source_url, destination_url, destination_bucket)
raise ValueError("S3 to S3 transfer is not supported yet")
else:
raise ValueError(
f"Unsupported transfer combination: {source_location.storage_type} to {destination_location.storage_type}"
)
# Calculate transfer metrics
end_time = time.time()
duration = end_time - start_time
# Get file size for metrics
file_size = 0
if isinstance(source_location, models.DiskDataLocation) and os.path.exists(
source_url
):
file_size = os.path.getsize(source_url)
# Send metrics to InfluxDB
if file_size > 0:
transfer_rate = (file_size / duration) / (1024 * 1024) # MB/s
metrics = HousekeepingMetrics()
try:
metrics.send_transfer_metrics(
operation="long_term_archive_transfer",
source_path=source_url,
destination_path=destination_url,
file_size=file_size,
duration=duration,
success=True,
error_message=None,
additional_fields={
"transfer_rate_mbps": transfer_rate,
},
additional_tags={
"source_location": source_location.name,
"destination_location": destination_location.name,
"transfer_id": str(archive_operation.id),
"transfer_method": f"{source_location.storage_type.value}_to_{destination_location.storage_type.value}",
},
)
except Exception as e:
logger.error(
"metrics send failed",
error=e,
transfer_id=archive_operation.id,
)
finally:
metrics.close()
except Exception as e:
logger.error(
"transfer execution failed",
error=str(e),
source_url=source_url,
destination_url=destination_url,
)
raise
def _execute_disk_to_disk_transfer(source_url: str, destination_url: str) -> None:
"""Execute disk to disk transfer using cp command."""
try:
# Create destination directory if it doesn't exist
dest_dir = os.path.dirname(destination_url)
if not os.path.exists(dest_dir):
os.makedirs(dest_dir, exist_ok=True)
# Execute copy command
cp_command = ["cp", source_url, destination_url]
logger.info("executing disk to disk transfer", command=" ".join(cp_command))
result = subprocess.run(
cp_command,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
if result.returncode == 0:
logger.info(
"disk to disk transfer succeeded",
source=source_url,
destination=destination_url,
)
else:
logger.error(
"disk to disk transfer failed",
source=source_url,
destination=destination_url,
error=result.stderr,
)
except Exception as e:
logger.error(
"disk to disk transfer exception",
error=str(e),
source=source_url,
destination=destination_url,
)
raise
def _execute_disk_to_s3_transfer(
session: Session,
source_url: str,
raw_data_package_id: int,
destination_url: str,
destination_bucket: str,
destination_location: Optional[models.S3DataLocation] = None,
site_name: Optional[str] = None,
source_location: Optional[models.DiskDataLocation] = None,
) -> None:
"""Execute disk to S3 transfer.
The access method is resolved per-location (``access_method`` on the
S3DataLocation), falling back to the deployment-wide ``S3_METHOD``.
``source_location`` is threaded through so the upload functions can diagnose
a missing source against the right PhysicalCopy row (#118).
"""
method = resolve_s3_method(destination_location)
if method == "boto3":
_execute_s3_upload(
session,
source_url,
raw_data_package_id,
destination_url,
destination_bucket,
None, # No source_archive in new system
destination_location,
site_name,
source_location=source_location,
)
elif method == "coscine":
_execute_coscine_s3_upload(
session,
source_url,
raw_data_package_id,
destination_url,
destination_bucket,
destination_location,
site_name,
source_location=source_location,
)
else:
raise ValueError(f"Unsupported s3 access method: {method}")
def _execute_s3_to_disk_transfer(source_url: str, destination_url: str) -> None:
"""Execute S3 to disk transfer using aws s3 cp command."""
try:
# Create destination directory if it doesn't exist
dest_dir = os.path.dirname(destination_url)
if not os.path.exists(dest_dir):
os.makedirs(dest_dir, exist_ok=True)
# Execute S3 download command
s3_command = ["aws", "s3", "cp", source_url, destination_url]
logger.info("executing S3 to disk transfer", command=" ".join(s3_command))
result = subprocess.run(
s3_command,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
if result.returncode == 0:
logger.info(
"s3 to disk transfer succeeded",
source=source_url,
destination=destination_url,
)
else:
logger.error(
"s3 to disk transfer failed",
source=source_url,
destination=destination_url,
error=result.stderr,
)
except Exception as e:
logger.error(
"s3 to disk transfer exception",
error=str(e),
source=source_url,
destination=destination_url,
)
raise
# def _execute_s3_to_s3_transfer(
# source_url: str, destination_url: str, destination_bucket: str
# ) -> None:
# """Execute S3 to S3 transfer using aws s3 cp command."""
# try:
# # Execute S3 copy command
# s3_command = ["aws", "s3", "cp", source_url, destination_url]
# logger.info("executing_s3_to_s3_transfer", command=" ".join(s3_command))
# result = subprocess.run(
# s3_command,
# stdout=subprocess.PIPE,
# stderr=subprocess.PIPE,
# text=True,
# )
# if result.returncode == 0:
# logger.info(
# "s3_to_s3_transfer_successful",
# source=source_url,
# destination=destination_url,
# )
# else:
# logger.error(
# "s3_to_s3_transfer_failed",
# source=source_url,
# destination=destination_url,
# error=result.stderr,
# )
# except Exception as e:
# logger.error(
# "s3_to_s3_transfer_exception",
# error=str(e),
# source=source_url,
# destination=destination_url,
# )
# raise
def _execute_s3_upload(
session: Session,
source_url: str,
raw_data_package_id: int,
destination_url: str,
destination_bucket: str,
source_archive: models.DataArchive,
destination_location: Optional[models.S3DataLocation] = None,
site_name: Optional[str] = None,
source_location: Optional[models.DiskDataLocation] = None,
) -> None:
"""Execute the command to upload files to S3 with MD5 verification and comprehensive metadata.
This function uploads a file to S3 with MD5 verification to ensure data integrity during transfer.
It also includes comprehensive metadata from the database to ensure proper data discovery and
provenance tracking. Additionally, it generates and uploads an extended metadata file in JSON format.
Parameters
----------
source_url : str
Path to the source file to upload
destination_url : str
Destination path in S3
destination_bucket : str
S3 bucket name
destination_location : Optional[models.S3DataLocation]
Destination S3 location for specific configuration
site_name : Optional[str]
Name of the site for credential loading
Raises
------
RuntimeError
If the S3 upload operation fails
"""
start_time = time.time()
# Guard the source read so a missing archive source surfaces as a diagnosed
# SourceMissingError rather than a bare FileNotFoundError (#118).
ensure_readable(
source_url,
side="source",
step="archive_upload_read",
host=getattr(source_location, "host", None),
diagnose=lambda: diagnose_source_missing(
session,
models.RawDataPackagePhysicalCopy,
fk_attr="raw_data_package_id",
fk_value=raw_data_package_id,
location=source_location,
)
if source_location is not None
else None,
)
# Read file data and calculate MD5
with open(source_url, "rb") as f:
file_data = f.read()
sha256_hash = hashlib.sha256(file_data).digest()
sha256_b64 = base64.b64encode(sha256_hash).decode("utf-8")
# Get file size
file_size = os.path.getsize(source_url)
# Get S3 client with location-specific configuration
s3_client = get_s3_client(destination_location, site_name)
# Query the database for package information
raw_data_package = session.query(models.RawDataPackage).get(raw_data_package_id)
if not raw_data_package:
raise ValueError(f"Raw data package {raw_data_package_id} not found")
# Get metadata from database
metadata = _get_s3_metadata(session, raw_data_package, file_size)
# Upload with MD5 verification and metadata
s3_client.put_object(
Bucket=destination_bucket,
Key=destination_url,
Body=file_data,
ChecksumSHA256=sha256_b64,
Metadata=metadata,
)
# Calculate transfer metrics
end_time = time.time()
# Generate and upload extended metadata
extended_metadata = _generate_ivoa_metadata(session, raw_data_package, file_size)
metadata_upload_success = _upload_extended_metadata(
s3_client, destination_bucket, destination_url, extended_metadata
)
if not metadata_upload_success:
logger.warning(
"extended metadata upload failed but data upload succeeded",
source=source_url,
destination=f"s3://{destination_bucket}/{destination_url}",
)
logger.info(
"s3 upload succeeded",
source=source_url,
destination=f"s3://{destination_bucket}/{destination_url}",
md5_verified=True,
metadata_keys=list(metadata.keys()),
extended_metadata_uploaded=metadata_upload_success,
)
duration = end_time - start_time
transfer_rate = (file_size / duration) / (1024 * 1024) # MB/s
transfer_metrics = {
"bytes_transferred": file_size,
"duration": duration,
"transfer_rate_mbps": transfer_rate,
}
# Send metrics to InfluxDB
metrics = HousekeepingMetrics()
try:
metrics.send_transfer_metrics(
operation="s3_upload",
source_path=source_url,
destination_path=f"s3://{destination_bucket}/{destination_url}",
file_size=transfer_metrics["bytes_transferred"],
duration=transfer_metrics["duration"],
success=True,
error_message=None,
additional_fields={
"transfer_rate_mbps": transfer_metrics["transfer_rate_mbps"],
},
additional_tags={
"source_archive": (
source_archive.short_name if source_archive else "unknown"
),
"destination_archive": "s3",
"transfer_id": str(raw_data_package_id),
"transfer_method": "s3",
},
)
except Exception as e:
logger.error("metrics send failed", error=e, transfer_id=raw_data_package_id)
finally:
metrics.close()
def _execute_coscine_s3_upload(
session: Session,
source_url: str,
raw_data_package_id: int,
destination_url: str,
destination_bucket: str,
destination_location: Optional[models.S3DataLocation] = None,
site_name: Optional[str] = None,
source_location: Optional[models.DiskDataLocation] = None,
) -> None:
"""Execute the command to upload files to S3 using Coscine with MD5 verification and comprehensive metadata.
This function uploads a file to S3 using the Coscine API with MD5 verification to ensure data integrity during transfer.
It also includes comprehensive metadata from the database to ensure proper data discovery and
provenance tracking. Additionally, it generates and uploads an extended metadata file in JSON format.
Parameters
----------
source_url : str
Path to the source file to upload
destination_url : str
Destination path in S3
destination_bucket : str
S3 bucket name
destination_location : Optional[models.S3DataLocation]
Destination S3 location for specific configuration
site_name : Optional[str]
Name of the site for credential loading
Raises
------
RuntimeError
If the S3 upload operation fails
"""
from .config.config import ccat_data_transfer_settings
start_time = time.time()
# Resolve Coscine config per-location (project/resource from the location,
# token from the env), falling back to the deployment-wide settings.
coscine_api_token, coscine_project, coscine_resource = resolve_coscine_config(
destination_location, site_name
)
# Check if COSCINE is configured
if (
not coscine_api_token
or coscine_api_token == "none"
or not coscine_project
or coscine_project == "none"
or not coscine_resource
or coscine_resource == "none"
):
raise ValueError(
"COSCINE configuration is not set. Set COSCINE_PROJECT / COSCINE_RESOURCE "
"on the data location (or in settings) and the COSCINE_API_TOKEN in the env."
)
# Guard the source read so a missing archive source surfaces as a diagnosed
# SourceMissingError rather than a bare FileNotFoundError (#118).
ensure_readable(
source_url,
side="source",
step="archive_upload_read",
host=getattr(source_location, "host", None),
diagnose=lambda: diagnose_source_missing(
session,
models.RawDataPackagePhysicalCopy,
fk_attr="raw_data_package_id",
fk_value=raw_data_package_id,
location=source_location,
)
if source_location is not None
else None,
)
# Read file data
with open(source_url, "rb") as f:
file_data = f.read()
# Note: Coscine handles integrity verification internally
# Get file size
file_size = os.path.getsize(source_url)
# Query the database for package information
raw_data_package = session.query(models.RawDataPackage).get(raw_data_package_id)
if not raw_data_package:
raise ValueError(f"Raw data package {raw_data_package_id} not found")
# Get metadata from database
metadata = _get_s3_metadata(session, raw_data_package, file_size)
# Initialize Coscine client
client = coscine.ApiClient(coscine_api_token)
project = client.project(coscine_project)
resource = project.resource(coscine_resource)
# Get metadata form and populate it
coscine_metadata = resource.metadata_form()
# Map our metadata to Coscine metadata format
# Basic metadata mapping
if "obs_target_name" in metadata:
coscine_metadata["Title"] = f"CCAT Observation: {metadata['obs_target_name']}"
else:
coscine_metadata["Title"] = (
f"CCAT Data Package: {raw_data_package.relative_path}"
)
if "obs_creator" in metadata:
coscine_metadata["Creator"] = metadata["obs_creator"]
else:
coscine_metadata["Creator"] = "CCAT Observatory"
# Add additional metadata fields only if the form supports them
if "Date" in coscine_metadata and "obs_date_obs" in metadata:
coscine_metadata["Date"] = metadata["obs_date_obs"]
if "Subject" in coscine_metadata and "obs_instrument" in metadata:
coscine_metadata["Subject"] = f"Instrument: {metadata['obs_instrument']}"
# Upload file with metadata
try:
# Create a file-like object from the file data
import io
file_obj = io.BytesIO(file_data)
# Sanitize destination path for Coscine compatibility
# Preserve readability: @NEPTUNE:Cal:BS → At_NEPTUNE-Cal-BS
destination_url = destination_url.replace("@", "At_").replace(":", "-")
# Upload the file
resource.upload(destination_url, file_obj, coscine_metadata)
# Calculate transfer metrics
end_time = time.time()
duration = end_time - start_time
transfer_rate = (file_size / duration) / (1024 * 1024) # MB/s
# Generate and upload extended metadata
extended_metadata = _generate_ivoa_metadata(
session, raw_data_package, file_size
)
# For Coscine, we'll upload the extended metadata as a separate file
metadata_upload_success = _upload_coscine_extended_metadata(
client, project, resource, destination_url, extended_metadata
)
if not metadata_upload_success:
logger.warning(
"extended metadata upload failed but data upload succeeded",
source=source_url,
destination=f"coscine://{ccat_data_transfer_settings.coscine_project}/{ccat_data_transfer_settings.coscine_resource}/{destination_url}",
)
logger.info(
"coscine S3 upload succeeded",
source=source_url,
destination=f"coscine://{ccat_data_transfer_settings.coscine_project}/{ccat_data_transfer_settings.coscine_resource}/{destination_url}",
md5_verified=True,
metadata_keys=list(metadata.keys()),
extended_metadata_uploaded=metadata_upload_success,
)
transfer_metrics = {
"bytes_transferred": file_size,
"duration": duration,
"transfer_rate_mbps": transfer_rate,
}
# Send metrics to InfluxDB
metrics = HousekeepingMetrics()
try:
metrics.send_transfer_metrics(
operation="coscine_s3_upload",
source_path=source_url,
destination_path=f"coscine://{ccat_data_transfer_settings.coscine_project}/{ccat_data_transfer_settings.coscine_resource}/{destination_url}",
file_size=transfer_metrics["bytes_transferred"],
duration=transfer_metrics["duration"],
success=True,
error_message=None,
additional_fields={
"transfer_rate_mbps": transfer_metrics["transfer_rate_mbps"],
},
additional_tags={
"source_archive": (
destination_location.name
if destination_location
else "unknown"
),
"destination_archive": "coscine",
"transfer_id": str(raw_data_package_id),
"transfer_method": "coscine",
},
)
except Exception as e:
logger.error(
"metrics send failed", error=e, transfer_id=raw_data_package_id
)
finally:
metrics.close()
except Exception as e:
logger.error(
"coscine S3 upload failed",
source=source_url,
destination=f"coscine://{ccat_data_transfer_settings.coscine_project}/{ccat_data_transfer_settings.coscine_resource}/{destination_url}",
error=str(e),
)
raise RuntimeError(f"COSCINE S3 upload failed: {str(e)}")
def _upload_coscine_extended_metadata(
client,
project,
resource,
destination_url: str,
metadata: dict,
) -> bool:
"""Upload extended metadata to Coscine as a separate JSON file.
Parameters
----------
client : coscine.ApiClient
Coscine API client instance
project : coscine.Project
Coscine project instance
resource : coscine.Resource
Coscine resource instance
destination_url : str
Base destination path
metadata : dict
Extended metadata to upload
Returns
-------
bool
True if upload was successful, False otherwise
"""
try:
# Create metadata filename
metadata_filename = f"{destination_url}_metadata.json"
# Convert metadata to JSON
metadata_json = json.dumps(metadata, indent=2)
# Create a file-like object from the JSON data
import io
metadata_file_obj = io.BytesIO(metadata_json.encode("utf-8"))
metadata_form = resource.metadata_form()
metadata_form["Title"] = "Extended Metadata"
metadata_form["Creator"] = "CCAT Observatory"
# Upload the metadata file
resource.upload(
metadata_filename,
metadata_file_obj,
metadata_form,
# {"Title": "Extended Metadata", "Creator": "CCAT Observatory"},
)
return True
except Exception as e:
logger.error(
"coscine metadata upload failed",
error=str(e),
metadata_filename=metadata_filename,
)
return False
def _mark_archive_operation_successful(
session: Session,
archive_operation: models.ArchiveOperation,
duration: datetime.timedelta,
destination_url: str,
) -> None:
"""Settle a successful archive operation and record its copy-anchored lineage.
Produces the destination archive PhysicalCopy and records the consume/produce
lineage on the ArchiveOperation (ADR-0003): it consumes the source buffer copy
and produces the destination archive copy.
"""
raw_data_package = _archive_raw_data_package(session, archive_operation)
destination_location = _archive_destination_location(session, archive_operation)
logger.info(
"archive operation marked complete",
archive_operation_id=archive_operation.id,
rel_path=raw_data_package.relative_path if raw_data_package else None,
duration_s=duration.total_seconds(),
)
# Create the physical copy record for the destination LTA location.
#
# Idempotent by construction (ADR-0004): under at-least-once delivery a prior
# run may already have created the copy, so we no-op when a copy for this
# (raw_data_package, destination DataLocation) already exists rather than
# inserting a duplicate row. This is the real duplicate-killer, and also
# guards the path where this function is reached directly (not via the
# orchestrator's pre-body check). The airtight DB unique constraint on
# (raw_data_package_id, destination_data_location_id), as defense-in-depth,
# is deferred to #70.
destination_copy = _existing_destination_physical_copy(session, archive_operation)
if destination_copy is None:
destination_copy = models.RawDataPackagePhysicalCopy(
raw_data_package=raw_data_package,
data_location=destination_location,
checksum=raw_data_package.checksum if raw_data_package else None,
verified_at=datetime.datetime.now(),
)
session.add(destination_copy)
else:
logger.info(
"physical copy already exists, skipping creation",
archive_operation_id=archive_operation.id,
raw_data_package_id=archive_operation.raw_data_package_id,
data_location_id=(
destination_location.id if destination_location else None
),
)
_record_archive_lineage(session, archive_operation, destination_copy)
archive_operation.status = models.Status.COMPLETED
if raw_data_package:
raw_data_package.state = models.PackageState.ARCHIVED
archive_operation.end_time = datetime.datetime.now()
session.commit()
redis_.publish(
"transfer:overview",
json.dumps(
{
"type": "long_term_archive_transfer_completed",
"data": archive_operation.id,
}
),
)
_add_archive_log(
session,
archive_operation,
f"Transfer successful to long term archive in {duration}",
)
def _record_archive_lineage(
session: Session,
archive_operation: models.ArchiveOperation,
destination_copy: models.RawDataPackagePhysicalCopy,
) -> None:
"""Record the operation's consume/produce PhysicalCopy lineage (ADR-0003).
The archive operation consumes the source buffer copy (the package copy at
the origin location) and produces the destination archive copy. Membership is
set-idempotent so a redelivered run does not duplicate lineage edges.
"""
if destination_copy not in archive_operation.produced_copies:
archive_operation.produced_copies.append(destination_copy)
source_copy = (
session.query(models.RawDataPackagePhysicalCopy)
.filter(
models.RawDataPackagePhysicalCopy.raw_data_package_id
== archive_operation.raw_data_package_id,
models.RawDataPackagePhysicalCopy.data_location_id
== archive_operation.origin_location_id,
)
.first()
)
if source_copy is not None and source_copy not in archive_operation.consumed_copies:
archive_operation.consumed_copies.append(source_copy)
def _add_archive_log(
session: Session,
archive_operation: models.ArchiveOperation,
log_message: str,
) -> None:
"""Emit a structured log line for the archive operation.
The legacy ``LongTermArchiveTransferLog`` rows are tied to the legacy transfer
FK; the durable failure trail now lives in ``OperationFailureEvent`` (#117),
so the operation lifecycle logs through the structured logger rather than the
legacy log table.
"""
logger.info(
"archive operation log",
archive_operation_id=archive_operation.id,
log=log_message,
)
[docs]
def transfer_raw_data_packages_to_long_term_archive(
verbose: bool = False, site_name: Optional[str] = None
) -> None:
"""
Schedule long term archive transfer tasks for pending raw data packages using the new DataLocation system.
Args:
verbose (bool): If True, sets logging to DEBUG level. Defaults to False.
site_name (Optional[str]): If provided, only schedules transfers for the specified site.
Raises:
SQLAlchemyError: If there's an issue with database operations.
"""
db = DatabaseConnection()
session, _ = db.get_connection()
# Liveness probe for the stuck-operation backstop (#161); reuses the module
# Redis client that _mark_archive_operation_successful publishes through.
task_state_manager = TaskStateManager(redis_)
# Get all sites with LTA locations
lta_sites = _get_sites_with_lta_locations(session, site_name)
logger.debug(
"LTA sites found",
sites=[site.short_name for site in lta_sites],
count=len(lta_sites),
)
for site in lta_sites:
# Get all LTA locations for this site
lta_locations = _get_lta_locations_for_site(session, site)
for lta_location in lta_locations:
# Find pending existing archive operations for this LTA location
pending_existing_operations = (
_get_pending_existing_operations_to_lta_location(session, lta_location)
)
if len(pending_existing_operations) > 0:
for operation in pending_existing_operations:
_schedule_archive_task(session, operation)
# Backstop recovery for genuinely dead operations (#161). The primary
# dead-worker path is task_monitor heartbeat recovery; this only
# surfaces operations with no live heartbeat AND a start_time past the
# reset floor (default = broker visibility_timeout), so it never races
# the broker's redelivery window nor false-resets a healthy
# long-running archive (#151).
stuck_operations = _get_stuck_archive_operations(
session, lta_location, task_state_manager
)
if len(stuck_operations) > 0:
logger.info(
"recovering stuck archive operations",
site=site.short_name,
location=lta_location.name,
stuck_count=len(stuck_operations),
)
for operation in stuck_operations:
previous_status = operation.status.value
logger.warning(
"resetting stuck archive operation (no live heartbeat past floor)",
lta_transfer_id=operation.id,
location=lta_location.name,
previous_status=previous_status,
retry_count=operation.retry_count,
)
operation.status = models.Status.PENDING
operation.retry_count += 1
_add_archive_log(
session,
operation,
f"Transfer stuck in {previous_status}, resetting for retry",
)
session.commit()
_schedule_archive_task(session, operation)
# Find new archive operations that need to be created for this location
pending_new_packages = _get_pending_new_transfers_to_lta_location(
session, lta_location
)
if len(pending_new_packages) > 0:
logger.info(
"new archive operations pending",
site=site.short_name,
location=lta_location.name,
package_count=len(pending_new_packages),
)
for package in pending_new_packages:
archive_operation = _create_archive_operation(
session, package, lta_location
)
_schedule_archive_task(session, archive_operation)
session.commit()
redis_.publish(
"transfer:overview",
json.dumps(
{
"type": "long_term_archive_transfer_scheduled",
}
),
)
def _get_pending_existing_operations_to_lta_location(
session: Session, lta_location: models.DataLocation
) -> list:
return (
session.query(models.ArchiveOperation)
.filter(
models.ArchiveOperation.destination_location_id == lta_location.id,
models.ArchiveOperation.status == models.Status.PENDING,
)
.all()
)
def _get_stuck_archive_operations(
session: Session,
lta_location: models.DataLocation,
task_state_manager: TaskStateManager,
stuck_reset_floor_seconds: Optional[int] = None,
) -> list:
"""
Retrieve archive operations that are genuinely stuck with no live worker (#161).
This is the BACKSTOP, not the primary dead-worker path. ``task_monitor``
heartbeat recovery (task_monitor_service.py) is the primary mechanism: it
resets a registered-but-stale task as soon as its heartbeat lapses. This
reconciler only catches operations that path can miss -- tasks that were
never registered in Redis, or where the DB and Redis have diverged.
An operation is reset SCHEDULED/IN_PROGRESS -> PENDING only when BOTH:
(a) no sibling task is alive for the operation -- ``is_operation_alive``
is False (a live heartbeat means a worker is still on it; resetting it
would be the #151 false-positive that kills a healthy long-running
archive on a blind DB clock); AND
(b) ``start_time`` predates the reset floor. The floor defaults to the
broker ``visibility_timeout`` (3600s, see setup_celery_app.py) so this
path can never race the broker's own redelivery window: inside the
floor the broker may still redeliver the original task.
The ``retry_count < 3`` guard caps retries.
Args:
session: The database session.
lta_location: The LTA location to check.
task_state_manager: Provides the Redis-backed liveness probe
(``is_operation_alive``).
stuck_reset_floor_seconds: Lower time bound before an operation may be
reset. Defaults to ``TASK_RECOVERY.STUCK_RESET_FLOOR_SECONDS``.
Returns:
list: ArchiveOperation objects that appear stuck and dead.
"""
from .config.config import ccat_data_transfer_settings
if stuck_reset_floor_seconds is None:
stuck_reset_floor_seconds = (
ccat_data_transfer_settings.TASK_RECOVERY.STUCK_RESET_FLOOR_SECONDS
)
cutoff = datetime.datetime.now() - datetime.timedelta(
seconds=stuck_reset_floor_seconds
)
candidates = (
session.query(models.ArchiveOperation)
.filter(
models.ArchiveOperation.destination_location_id == lta_location.id,
models.ArchiveOperation.status.in_(
[models.Status.SCHEDULED, models.Status.IN_PROGRESS]
),
models.ArchiveOperation.start_time < cutoff,
models.ArchiveOperation.retry_count < 3,
)
.all()
)
# Heartbeat gate: drop any candidate whose worker is still beating, so the
# backstop never overrides a live task that task_monitor would not touch.
# Keyed by OperationKind.ARCHIVE (identity), whose value is the unchanged
# Redis key fragment — no literal op-kind string here (ADR-0003).
return [
operation
for operation in candidates
if not task_state_manager.is_operation_alive(
models.OperationKind.ARCHIVE, operation.id
)
]
def _get_sites_with_lta_locations(
session: Session, site_name: Optional[str] = None
) -> list:
"""Get all sites that have LTA locations."""
query = (
session.query(models.Site)
.join(models.DataLocation)
.filter(
models.DataLocation.location_type == models.LocationType.LONG_TERM_ARCHIVE,
models.DataLocation.active == True, # noqa: E712
)
.distinct()
)
if site_name:
query = query.filter(models.Site.short_name == site_name)
return query.all()
def _get_lta_locations_for_site(session: Session, site: models.Site) -> list:
"""Get all LTA locations for a specific site."""
return (
session.query(models.DataLocation)
.filter(
models.DataLocation.site_id == site.id,
models.DataLocation.location_type == models.LocationType.LONG_TERM_ARCHIVE,
models.DataLocation.active == True, # noqa: E712
)
.all()
)
def _get_pending_new_transfers_to_lta_location(
session: Session, lta_location: models.DataLocation
) -> list:
"""Get raw data packages that need to be transferred to a specific LTA location."""
# Find packages that are in buffer locations and haven't been transferred to this LTA location yet
# Debug: Log the LTA location details
logger.debug(
"checking LTA location",
location_id=lta_location.id,
location=lta_location.name,
site_id=lta_location.site_id,
site=lta_location.site.short_name,
)
# Debug: Check what buffer locations exist for this site
buffer_locations = (
session.query(models.DataLocation.id, models.DataLocation.name)
.filter(
models.DataLocation.location_type == models.LocationType.BUFFER,
models.DataLocation.active == True, # noqa: E712
models.DataLocation.site_id == lta_location.site_id,
)
.all()
)
logger.debug(
"buffer locations for site",
site_id=lta_location.site_id,
site=lta_location.site.short_name,
buffer_locations=[{"id": loc.id, "name": loc.name} for loc in buffer_locations],
)
# Check existing archive operations for this destination so a package already
# being archived here is not re-created.
existing_operations = (
session.query(models.ArchiveOperation.raw_data_package_id)
.filter(
models.ArchiveOperation.destination_location_id == lta_location.id,
)
.all()
)
existing_package_ids = [o.raw_data_package_id for o in existing_operations]
logger.debug(
"existing archive operations for destination",
destination_location_id=lta_location.id,
location=lta_location.name,
existing_package_count=len(existing_package_ids),
existing_package_ids=existing_package_ids,
)
# A package present in several buffer locations is returned once per matching
# physical copy, so dedupe by id in Python rather than SELECT DISTINCT (Postgres
# cannot DISTINCT the json error_context column on this model — see
# deduplicate_by_id).
pending_packages = deduplicate_by_id(
session.query(models.RawDataPackage)
.join(models.RawDataPackagePhysicalCopy)
.filter(
models.RawDataPackagePhysicalCopy.data_location_id.in_(
[loc.id for loc in buffer_locations]
),
models.RawDataPackagePhysicalCopy.status
== models.PhysicalCopyStatus.PRESENT,
~models.RawDataPackage.id.in_(existing_package_ids),
)
.all()
)
logger.info(
"pending packages for LTA location",
location=lta_location.name,
site=lta_location.site.short_name,
package_count=len(pending_packages),
)
# Debug: Log details of found packages
if pending_packages:
package_details = []
for package in pending_packages:
physical_copies = [
{
"location_id": pc.data_location_id,
"location_name": pc.data_location.name,
"status": pc.status.value,
}
for pc in package.physical_copies
]
package_details.append(
{
"id": package.id,
"name": package.name,
"physical_copies": physical_copies,
}
)
logger.debug(
"pending package details",
packages=package_details,
)
return pending_packages
def _create_archive_operation(
session: Session, package: models.RawDataPackage, lta_location: models.DataLocation
) -> models.ArchiveOperation:
"""Create a new archive operation for a package to a specific LTA location."""
# Find the source buffer location for this package
source_physical_copy = (
session.query(models.RawDataPackagePhysicalCopy)
.join(models.DataLocation)
.filter(
models.RawDataPackagePhysicalCopy.raw_data_package_id == package.id,
models.DataLocation.location_type == models.LocationType.BUFFER,
models.DataLocation.site_id == lta_location.site_id,
models.DataLocation.active == True, # noqa: E712
models.RawDataPackagePhysicalCopy.status
== models.PhysicalCopyStatus.PRESENT,
)
.first()
)
if not source_physical_copy:
raise ValueError(f"No source buffer found for package {package.id}")
archive_operation = models.ArchiveOperation(
raw_data_package_id=package.id,
origin_location_id=source_physical_copy.data_location_id,
destination_location_id=lta_location.id,
status=models.Status.PENDING,
)
session.add(archive_operation)
session.flush()
session.commit()
redis_.publish(
"transfer:overview",
json.dumps(
{
"type": "long_term_archive_transfer_created",
"data": archive_operation.id,
}
),
)
return archive_operation
def _max_retries_reached(
archive_operation: models.ArchiveOperation,
) -> bool:
return archive_operation.retry_count >= 3
def _schedule_archive_task(
session: Session,
archive_operation: models.ArchiveOperation,
) -> None:
"""Schedule an archive operation task using dynamic queue routing."""
destination_location = _archive_destination_location(session, archive_operation)
try:
# Use dynamic queue routing based on the destination location
queue_name = route_task_by_location(
OperationType.LONG_TERM_ARCHIVE_TRANSFER,
destination_location,
)
logger.info(
"scheduling archive task",
archive_operation_id=archive_operation.id,
destination_location=(
destination_location.name if destination_location else None
),
queue=queue_name,
)
# Schedule the task with the appropriate queue
send_data_to_long_term_archive.apply_async(
args=[archive_operation.id],
queue=queue_name,
)
archive_operation.status = models.Status.SCHEDULED
archive_operation.start_time = datetime.datetime.now()
except Exception as e:
logger.error(
"archive task scheduling failed",
archive_operation_id=archive_operation.id,
error=str(e),
)
raise ScheduleError(
f"Error scheduling archive operation task: {str(e)}"
)
finally:
session.commit()
logger.info(
"archive task scheduled",
archive_operation_id=archive_operation.id,
queue=queue_name,
)
redis_.publish(
"transfer:overview",
json.dumps(
{
"type": "long_term_archive_transfer_scheduled",
"data": archive_operation.id,
"queue": queue_name,
}
),
)
def _generate_ivoa_metadata(
session: Session,
raw_data_package: models.RawDataPackage,
file_size: int,
) -> dict:
"""Generate IVOA-compatible extended metadata for a raw data package.
This function creates a comprehensive metadata document that follows IVOA standards
while maintaining flexibility for different instruments and observatories. It combines
data from the core database models with additional metadata stored in the
RawDataPackageMetadata table.
Parameters
----------
session : Session
SQLAlchemy database session
raw_data_package : models.RawDataPackage
The raw data package being uploaded
file_size : int
Size of the file in bytes
Returns
-------
dict
IVOA-compatible metadata document
Notes
-----
The metadata structure follows IVOA standards while maintaining flexibility:
1. Core identifiers follow IVOA URI conventions
2. Facility information is standardized but extensible
3. Instrument-specific metadata is stored in RawDataPackageMetadata
4. All numeric values are stored with units
5. Timestamps are in ISO 8601 format with timezone
"""
# Get observation information
obs_unit = raw_data_package.obs_unit
executed_obs_unit = raw_data_package.executed_obs_unit
# Get source information
source = obs_unit.source
if isinstance(source, models.FixedSource):
ra_deg = source.ra_deg
dec_deg = source.dec_deg
target_name = source.name
else:
ra_deg = None
dec_deg = None
target_name = source.name
# Get instrument information
instrument = (
obs_unit.primary_instrument_module_configuration.instrument_module.instrument
)
# Get observing program information
program = obs_unit.observing_program
subprogram = obs_unit.sub_observing_program
# Get additional metadata
additional_metadata = raw_data_package.package_metadata
# Construct base metadata
metadata = {
# Core identifiers (IVOA compatible)
"id": {
"dataset_id": f"{instrument.name}-{executed_obs_unit.start_time.strftime('%Y%m%d')}-{obs_unit.id}",
"obs_unit_id": str(obs_unit.id),
"executed_obs_unit_id": (
str(executed_obs_unit.id) if executed_obs_unit else None
),
"publisher_did": f"ivo://org.ccat-p/raw/{instrument.name}-{executed_obs_unit.start_time.strftime('%Y%m%d')}-{obs_unit.id}",
"ivoa_collection": "ivo://org.ccat-p/collection/raw-observations",
},
# Facility information (standardized)
"facility": {
"observatory": {
"name": instrument.telescope.observatory.name,
"altitude_m": instrument.telescope.alt_m,
"longitude_deg": instrument.telescope.lon_deg,
"latitude_deg": instrument.telescope.lat_deg,
},
"telescope": {
"name": instrument.telescope.name,
"longitude_deg": instrument.telescope.lon_deg,
"latitude_deg": instrument.telescope.lat_deg,
"altitude_m": instrument.telescope.alt_m,
},
"instrument": {
"name": instrument.name,
"type": instrument.instrument_type,
"description": instrument.description,
"modules": [
{
"name": module.instrument_module.name,
}
for module in obs_unit.instrument_module_configurations
],
},
},
# Target information
"target": {
"name": target_name,
"type": source.__class__.__name__.lower(),
"coordinates": {
"ra": {
"degrees": ra_deg,
"sexagesimal": (
_degrees_to_sexagesimal(ra_deg) if ra_deg is not None else None
),
},
"dec": {
"degrees": dec_deg,
"sexagesimal": (
_degrees_to_sexagesimal(dec_deg)
if dec_deg is not None
else None
),
},
"frame": "ICRS",
"epoch": "J2000",
},
},
# Observation configuration (IVOA compatible)
"observation": {
"program": {
"id": program.short_name,
"name": program.name,
# "pi": program.pi_name,
# "pi_affiliation": program.pi_affiliation,
# "proposal_id": program.proposal_id,
},
"subprogram": {
"id": subprogram.short_name if subprogram else None,
"name": subprogram.name if subprogram else None,
},
"timing": {
"start_time": (
executed_obs_unit.start_time.isoformat()
if executed_obs_unit
else None
),
"end_time": (
executed_obs_unit.end_time.isoformat()
if executed_obs_unit
else None
),
"duration_s": (
(
executed_obs_unit.end_time - executed_obs_unit.start_time
).total_seconds()
if executed_obs_unit
else None
),
},
},
# Contents information
"contents": {
"file_count": len(raw_data_package.raw_data_files),
"size_bytes": file_size,
"files": [
{
"filename": file.name,
"type": file.file_type,
"size_bytes": file.size,
"checksum": {"algorithm": "xxh64", "value": file.checksum},
}
for file in raw_data_package.raw_data_files
],
},
# Data quality assessment
"quality": {
"status": executed_obs_unit.status if executed_obs_unit else None,
},
# Processing information
"processing": {
"level": "raw",
"pipeline": {
"name": instrument.name,
"version": "1.0", # Default version since it's not in the model
},
},
# Data rights and access information
# "access": {
# "policy": program.data_policy,
# },
# Full provenance chain
# "provenance": {
# "creator": instrument.name, # Use instrument name since data_acquisition_system doesn't exist
# "created": raw_data_package.created_at.isoformat(),
# "contributors": (
# [
# {
# "name": executed_obs_unit.observer_name,
# "role": "Observer",
# "affiliation": executed_obs_unit.observer_affiliation,
# }
# ]
# if executed_obs_unit
# else []
# ),
# },
}
# Add additional metadata if available
if additional_metadata:
# Add instrument-specific metadata
if additional_metadata.instrument_specific:
metadata["instrument_specific"] = additional_metadata.instrument_specific
# Add quality metrics
if additional_metadata.quality_metrics:
metadata["quality"].update(additional_metadata.quality_metrics)
# Add extended provenance
if additional_metadata.provenance:
metadata["provenance"].update(additional_metadata.provenance)
# Add custom metadata
if additional_metadata.custom_metadata:
metadata["custom"] = additional_metadata.custom_metadata
# Remove None values
metadata = _remove_none_values(metadata)
return metadata
def _degrees_to_sexagesimal(degrees: float) -> str:
"""Convert decimal degrees to sexagesimal format."""
if degrees is None:
return None
# Handle negative degrees for declination
sign = "-" if degrees < 0 else "+"
degrees = abs(degrees)
# Convert to hours/degrees, minutes, seconds
hours = int(degrees)
minutes = int((degrees - hours) * 60)
seconds = (degrees - hours - minutes / 60) * 3600
return f"{sign}{hours:02d}h{minutes:02d}m{seconds:06.3f}s"
def _remove_none_values(d: dict) -> dict:
"""Recursively remove None values from a dictionary."""
if not isinstance(d, dict):
return d
return {
k: _remove_none_values(v)
for k, v in d.items()
if v is not None and (not isinstance(v, dict) or _remove_none_values(v))
}