File indexing completed on 2026-09-01 09:34:21
0001
0002 """panda-sandbox-keepalive.py — keep retryable tasks' sandbox tarballs alive.
0003
0004 The PanDA server purges its sandbox cache of files whose modification time
0005 is older than seven days (copyArchive.py), and nothing refreshes the files
0006 of tasks that outlive that window, so a retry of an older task fails when
0007 each job's pre-process cannot download the tarball (executor error 5303).
0008 The server API provides ``touch_cache_file`` for exactly this: it resets
0009 the file's modification time so the cleanup passes it by.
0010
0011 This doer touches the sandbox tarball of every task worth keeping
0012 retryable: epic-VO tasks in a non-final state, plus tasks finished, failed,
0013 or exhausted within the retention window (SysConfig
0014 ``panda_sandbox_keepalive_final_days``). Each task's tarball name and
0015 source server come from its stored parameters (``jedi_taskparams``). A
0016 tarball already purged is retained in the run inventory per task — that
0017 task is not natively retryable. Aborted and broken tasks are not kept
0018 alive. Missing tarballs are established state, not a failure of the
0019 current keepalive pass; API and authentication failures remain errors.
0020
0021 The same pass maintains log-dataset lifetimes: each candidate task's
0022 BNL Rucio datasets (located by ``task_id`` metadata) whose expiry falls
0023 inside the retention window are refreshed to the full window, so the
0024 logs of a task that is still active or recently final never expire on
0025 their original registration-time clock. Datasets carrying no expiry are
0026 left untouched.
0027
0028 The prod-ops agent's doer for the nightly ``catalog_sync`` chain step;
0029 Django-bootstrap standalone script — also usable by hand. Auth is the
0030 production x509 proxy (``X509_USER_PROXY``) as TLS client certificate,
0031 the same credential the payload-log fetch uses. See
0032 ``swf-epicprod/docs/JEDI_INTEGRATION.md``.
0033
0034 Usage::
0035
0036 cd /data/wenauseic/github/swf-monitor/src
0037 source ../../swf-testbed/.venv/bin/activate && source ~/.env
0038 python ../scripts/panda-sandbox-keepalive.py [--dry-run]
0039 """
0040 import argparse
0041 import ast
0042 import json
0043 import os
0044 import re
0045 import sys
0046
0047 THIS_DIR = os.path.dirname(os.path.abspath(__file__))
0048 sys.path.insert(0, os.path.join(THIS_DIR, '..', 'src'))
0049 os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'swf_monitor_project.settings')
0050
0051 import django
0052 django.setup()
0053
0054 import requests
0055 from django.db import connections
0056 from monitor_app.models import SysConfig
0057
0058 X509_PROXY = os.environ.get(
0059 "X509_USER_PROXY", "/data/wenauseic/longproxy-for-rucio")
0060
0061
0062 CA_VERIFY = os.environ.get("REQUESTS_CA_BUNDLE") or False
0063
0064 FINAL_STATUSES = ("done", "finished", "failed", "broken", "aborted",
0065 "exhausted")
0066 RETRYABLE_FINAL_STATUSES = ("finished", "failed", "exhausted")
0067
0068
0069
0070 TARBALL_RE = re.compile(r"(?:jobO|sources)\.[0-9a-f-]+\.tar\.gz")
0071 SOURCE_URL_RE = re.compile(r'"sourceURL":\s*"([^"]+)"')
0072
0073
0074 def _log(msg):
0075 print(msg, file=sys.stderr, flush=True)
0076
0077
0078 def _candidate_tasks(final_days):
0079 cur = connections["panda"].cursor()
0080 cur.execute(
0081 "SELECT jeditaskid, status FROM jedi_tasks"
0082 " WHERE vo = 'epic'"
0083 " AND (NOT (status = ANY(%s))"
0084 " OR (status = ANY(%s)"
0085 " AND modificationtime > now() - make_interval(days => %s)))"
0086 " ORDER BY jeditaskid",
0087 [list(FINAL_STATUSES), list(RETRYABLE_FINAL_STATUSES),
0088 int(final_days)],
0089 )
0090 return cur.fetchall()
0091
0092
0093 def _task_sandbox(jedi_task_id):
0094 """Return (source_url, tarball_name) from stored task parameters, or
0095 (None, None) when the task has no cache-resident sandbox."""
0096 cur = connections["panda"].cursor()
0097 cur.execute(
0098 "SELECT taskparams FROM jedi_taskparams WHERE jeditaskid = %s",
0099 [jedi_task_id],
0100 )
0101 row = cur.fetchone()
0102 if not row or not row[0]:
0103 return None, None
0104 text = row[0] if isinstance(row[0], str) else row[0].read()
0105 tarballs = set(TARBALL_RE.findall(text))
0106 sources = set(SOURCE_URL_RE.findall(text))
0107 if not tarballs or not sources:
0108 return None, None
0109 if len(tarballs) > 1 or len(sources) > 1:
0110 _log(f"WARNING: task {jedi_task_id} has ambiguous sandbox refs "
0111 f"tarballs={sorted(tarballs)} sources={sorted(sources)}")
0112 return sorted(sources)[0], sorted(tarballs)[0]
0113
0114
0115 def _refresh_log_lifetimes(jedi_task_ids, final_days):
0116 """Refresh the lifetime of candidate tasks' BNL Rucio datasets.
0117
0118 A dataset whose expiry falls inside the retention window gets a fresh
0119 ``final_days`` lifetime; one with no expiry is left alone. Failures
0120 are reported per dataset, never raised.
0121 """
0122 result = {"checked": 0, "refreshed": 0, "errors": []}
0123 try:
0124 from rucio.client import Client
0125 except ImportError as e:
0126 result["errors"].append(f"rucio client unavailable: {e}")
0127 _log(f"WARNING: log-lifetime refresh skipped: {e}")
0128 return result
0129 rucio_url = os.environ.get("RUCIO_URL", "https://nprucio01.sdcc.bnl.gov:443")
0130 scope = os.environ.get("RUCIO_SCOPE", "group.EIC")
0131 try:
0132 client = Client(
0133 rucio_host=rucio_url, auth_host=rucio_url,
0134 account=os.environ.get("RUCIO_ACCOUNT", "panda"),
0135 auth_type="x509_proxy", creds={"client_proxy": X509_PROXY},
0136 ca_cert=None, vo=os.environ.get("RUCIO_VO", "eic"))
0137 client.whoami()
0138 except Exception as e:
0139 result["errors"].append(f"BNL Rucio auth failed: {e}")
0140 _log(f"WARNING: log-lifetime refresh skipped: auth failed: {e}")
0141 return result
0142 import datetime as dt
0143 horizon = dt.datetime.utcnow() + dt.timedelta(days=final_days)
0144 for jedi_task_id in jedi_task_ids:
0145 try:
0146 names = list(client.list_dids(
0147 scope=scope, filters={"task_id": int(jedi_task_id)},
0148 did_type="dataset"))
0149 except Exception as e:
0150 result["errors"].append(f"task {jedi_task_id}: list_dids: {e}")
0151 _log(f"WARNING: lifetime lookup failed for task {jedi_task_id}: {e}")
0152 continue
0153 for name in names:
0154 try:
0155 result["checked"] += 1
0156 meta = client.get_metadata(scope=scope, name=name)
0157 expired_at = meta.get("expired_at")
0158 if expired_at is None or expired_at >= horizon:
0159 continue
0160 client.set_metadata(scope=scope, name=name, key="lifetime",
0161 value=final_days * 86400)
0162 result["refreshed"] += 1
0163 _log(f"refreshed lifetime for task {jedi_task_id} dataset "
0164 f"{scope}:{name} (was expiring {expired_at})")
0165 except Exception as e:
0166 result["errors"].append(f"{name}: {e}")
0167 _log(f"WARNING: lifetime refresh failed for {scope}:{name}: {e}")
0168 return result
0169
0170
0171 def _touch(source_url, tarball):
0172 """POST touch_cache_file; returns (status, message) where status is
0173 'touched', 'missing', or 'error'."""
0174 url = f"{source_url}/api/v1/file_server/touch_cache_file"
0175 try:
0176 r = requests.post(url, data={"file_name": tarball},
0177 cert=(X509_PROXY, X509_PROXY),
0178 verify=CA_VERIFY, timeout=30)
0179 except Exception as e:
0180 return "error", f"{type(e).__name__}: {e}"
0181 if r.status_code != 200:
0182 return "error", f"HTTP {r.status_code}: {r.text[:200]}"
0183
0184 try:
0185 body = r.json()
0186 except ValueError:
0187 try:
0188 body = ast.literal_eval(r.text.strip())
0189 except (ValueError, SyntaxError):
0190 return "error", f"unparseable response: {r.text[:200]}"
0191 if not isinstance(body, dict):
0192 return "error", f"unexpected response shape: {r.text[:200]}"
0193 if body.get("success"):
0194 return "touched", ""
0195 message = str(body.get("message") or "")
0196 if "FileNotFoundError" in message or "No such file" in message:
0197 return "missing", message
0198 return "error", message
0199
0200
0201 def main():
0202 parser = argparse.ArgumentParser()
0203 parser.add_argument("--dry-run", action="store_true",
0204 help="enumerate and report; do not touch")
0205 args = parser.parse_args()
0206
0207 final_days = int(SysConfig.get_setting(
0208 "panda_sandbox_keepalive_final_days", 30))
0209
0210 tasks = _candidate_tasks(final_days)
0211
0212 by_tarball = {}
0213 no_sandbox = []
0214 for jedi_task_id, status in tasks:
0215 source_url, tarball = _task_sandbox(jedi_task_id)
0216 if not tarball:
0217 no_sandbox.append(jedi_task_id)
0218 continue
0219 entry = by_tarball.setdefault(
0220 (source_url, tarball), {"tasks": [], "statuses": []})
0221 entry["tasks"].append(jedi_task_id)
0222 entry["statuses"].append(status)
0223
0224 touched, missing, errors = [], [], []
0225 for (source_url, tarball), entry in sorted(by_tarball.items()):
0226 if args.dry_run:
0227 _log(f"dry-run: would touch {tarball} on {source_url} "
0228 f"for tasks {entry['tasks']}")
0229 continue
0230 status, message = _touch(source_url, tarball)
0231 record = {"tarball": tarball, "tasks": entry["tasks"],
0232 "statuses": entry["statuses"]}
0233 if status == "touched":
0234 touched.append(record)
0235 elif status == "missing":
0236 missing.append(record)
0237 _log(f"sandbox already absent for tasks {entry['tasks']} "
0238 f"({tarball}) — not natively retryable")
0239 else:
0240 record["error"] = message
0241 errors.append(record)
0242 _log(f"ERROR: touch failed for {tarball} on {source_url}: "
0243 f"{message}")
0244
0245 if args.dry_run:
0246 lifetimes = {"checked": 0, "refreshed": 0, "errors": [],
0247 "skipped": "dry run"}
0248 else:
0249 lifetimes = _refresh_log_lifetimes(
0250 [jedi_task_id for jedi_task_id, _ in tasks], final_days)
0251
0252 summary = {
0253 "final_days": final_days,
0254 "candidates": len(tasks),
0255 "no_sandbox": no_sandbox,
0256 "tarballs": len(by_tarball),
0257 "touched": len(touched),
0258 "missing": missing,
0259 "errors": errors,
0260 "log_lifetimes": lifetimes,
0261 "dry_run": bool(args.dry_run),
0262 "ok": not errors and not lifetimes["errors"],
0263 }
0264 print(json.dumps(summary, default=str))
0265 return 0 if summary["ok"] else 1
0266
0267
0268 if __name__ == "__main__":
0269 sys.exit(main())