Back to home page

EIC code displayed by LXR

 
 

    


File indexing completed on 2026-11-09 10:47:28

0001 """Credential-free orchestration for removing datasets from a disk RSE
0002 (swf-epicprod docs/STORAGE.md, Removing datasets from an RSE).
0003 
0004 Two phases, both run by the production operations agent: a preview, the
0005 live catalog's answer for every dataset matching the pattern; and, once
0006 an operator confirms by typing the RSE name, the removal of the datasets
0007 that preview found eligible, each checked again live. The web tier
0008 validates and queues; it holds no catalog credential and writes nothing.
0009 
0010 No table holds the requests. The agent keeps each request's working
0011 state in a file under ``$SWF_TMP_DIR/rse-deletions/<id>.json``, the way
0012 it keeps fetched payload logs, and the page reads it; the record that
0013 stands is the action stream, where the agent logs each preview
0014 (``rse_deletion_preview``) and each removal (``rse_deletion``, high
0015 sublevel). One removal per RSE runs at a time, held by the agent.
0016 Acting is for the operations role (docs/AUTHORITY.md).
0017 """
0018 
0019 import json
0020 import uuid
0021 from datetime import datetime, timedelta
0022 from pathlib import Path
0023 
0024 from django.conf import settings
0025 from django.utils import timezone
0026 
0027 from monitor_app.activemq_connection import ActiveMQConnectionManager
0028 
0029 # A preview older than this is read again before anything is removed.
0030 PREVIEW_VALID = timedelta(hours=1)
0031 # A request the agent has not written for this long has lost its agent
0032 # run (a lost message, an agent restart): past the agent's removal
0033 # timeout (an hour) with margin, and a running removal is rewritten as
0034 # each dataset is decided.
0035 STALE_AFTER = timedelta(hours=2)
0036 STATUS_LABELS = {
0037     'previewing': 'Previewing',
0038     'previewed': 'Previewed',
0039     'queued': 'Queued for removal',
0040     'running': 'Removing',
0041     'done': 'Done',
0042     'partial': 'Done with failures',
0043     'failed': 'Failed',
0044 }
0045 PENDING_STATUSES = ('previewing', 'queued', 'running')
0046 ACTIONS = ('rse_deletion_preview', 'rse_deletion')
0047 
0048 
0049 class RseDeletionRequestError(Exception):
0050     def __init__(self, detail, status=400):
0051         super().__init__(detail)
0052         self.detail = detail
0053         self.status = status
0054 
0055 
0056 def request_dir():
0057     return Path(getattr(settings, 'SWF_TMP_DIR', '/data/swf-tmp')) / 'rse-deletions'
0058 
0059 
0060 def read_request(deletion_id):
0061     """The agent's working record of one request, or None."""
0062     try:
0063         path = request_dir() / f'{uuid.UUID(str(deletion_id))}.json'
0064         with open(path) as f:
0065             return json.load(f)
0066     except (ValueError, OSError):
0067         return None
0068 
0069 
0070 def _parse(stamp):
0071     try:
0072         return datetime.fromisoformat(str(stamp))
0073     except (TypeError, ValueError):
0074         return None
0075 
0076 
0077 def serialize(record):
0078     """The page's view of a request. A pending request the agent stopped
0079     writing reads as failed, with what it recorded kept."""
0080     out = dict(record)
0081     updated = _parse(out.get('updated_at'))
0082     if (out.get('status') in PENDING_STATUSES and updated
0083             and timezone.now() - updated > STALE_AFTER):
0084         out['status'] = 'failed'
0085         out['diagnostic'] = (
0086             f'No word from the agent for {int(STALE_AFTER.total_seconds() // 3600)} h; '
0087             'abandoned. Any recorded outcomes are partial.')
0088     out.setdefault('plan', {})
0089     out.setdefault('result', {})
0090     out['pending'] = out.get('status') in PENDING_STATUSES
0091     out['status_display'] = STATUS_LABELS.get(out.get('status'), out.get('status'))
0092     return out
0093 
0094 
0095 def _validate(rse, pattern):
0096     from swf_epicprod.rse_deletion import RseDeletionError, validate
0097     try:
0098         return validate(rse, pattern)
0099     except RseDeletionError as exc:
0100         raise RseDeletionRequestError(str(exc))
0101 
0102 
0103 def _send(message):
0104     try:
0105         sent = ActiveMQConnectionManager().send_message(
0106             '/queue/epicprod.ops', json.dumps(message))
0107         failure = 'ops-agent queue unreachable'
0108     except Exception as exc:                                  # noqa: BLE001
0109         sent, failure = False, str(exc)
0110     if not sent:
0111         raise RseDeletionRequestError(f'Could not queue: {failure}', 503)
0112 
0113 
0114 def request_preview(*, rse, pattern, requested_by):
0115     """Queue a live preview; the agent writes the request on receipt."""
0116     rse, pattern = _validate(rse, pattern)
0117     now = timezone.now().isoformat()
0118     record = {'id': str(uuid.uuid4()), 'rse': rse, 'pattern': pattern,
0119               'requested_by': requested_by, 'confirmed_by': '',
0120               'status': 'previewing', 'diagnostic': '',
0121               'requested_at': now, 'updated_at': now}
0122     _send({'msg_type': 'rse_deletion', 'namespace': 'prodops',
0123            'phase': 'preview', 'deletion_id': record['id'],
0124            'rse': rse, 'pattern': pattern, 'created_by': requested_by,
0125            'requested_at': now})
0126     return record
0127 
0128 
0129 def request_execute(deletion_id, *, confirm_rse, confirmed_by):
0130     """Queue the removal of what the request's preview found eligible."""
0131     record = read_request(deletion_id)
0132     if record is None:
0133         raise RseDeletionRequestError('Request not found.', 404)
0134     record = serialize(record)
0135     if record['status'] != 'previewed':
0136         raise RseDeletionRequestError(
0137             f"This request is {record['status_display'].lower()}; "
0138             'only a previewed request can be confirmed.', 409)
0139     if str(confirm_rse or '').strip() != record['rse']:
0140         raise RseDeletionRequestError(
0141             f"Type {record['rse']} exactly to confirm the removal.")
0142     previewed = _parse(record.get('previewed_at'))
0143     if not previewed or timezone.now() - previewed > PREVIEW_VALID:
0144         raise RseDeletionRequestError(
0145             'The preview is more than an hour old; preview again.', 409)
0146     names = [d['name'] for d in (record.get('plan') or {}).get('datasets', [])
0147              if d.get('eligible')]
0148     if not names:
0149         raise RseDeletionRequestError('The preview found nothing eligible.', 409)
0150     now = timezone.now().isoformat()
0151     _send({'msg_type': 'rse_deletion', 'namespace': 'prodops',
0152            'phase': 'execute', 'deletion_id': record['id'],
0153            'rse': record['rse'], 'pattern': record['pattern'], 'names': names,
0154            'created_by': confirmed_by, 'confirmed_at': now})
0155     return dict(record, status='queued', confirmed_by=confirmed_by,
0156                 confirmed_at=now, pending=True,
0157                 status_display=STATUS_LABELS['queued'])
0158 
0159 
0160 def recent(rse, limit=20):
0161     """The RSE's latest previews and removals from the action stream,
0162     newest first, for the page's Requests table."""
0163     from monitor_app.models import AppLog
0164     rows = (AppLog.objects
0165             .filter(app_name='epicprod', extra_data__action__in=ACTIONS,
0166                     extra_data__subject_key=rse)
0167             .order_by('-timestamp')[:limit])
0168     out = []
0169     for row in rows:
0170         x = row.extra_data or {}
0171         removal = x.get('action') == 'rse_deletion'
0172         out.append({
0173             'at': row.timestamp,
0174             'kind': 'Removal' if removal else 'Preview',
0175             'pattern': x.get('pattern') or '',
0176             'by': x.get('username') or '',
0177             'outcome': x.get('status') or x.get('outcome') or '',
0178             'matched': x.get('matched'),
0179             'eligible': x.get('eligible'),
0180             'removed': x.get('removed') if removal else None,
0181             'removed_tb': (x['removed_bytes'] / 1e12
0182                            if removal and x.get('removed_bytes') is not None else None),
0183             'note': x.get('reason') or '',
0184         })
0185     return out