fix tape ingest data loss + daily digest send
ingest: sftp needs stdin=DEVNULL under launchd (no tty) — without it, empty/partial files decoded to 0 lines and the missing integrity guard MARKED+DELETED them (36 segments lost). Guard: a >200B gz that yields 0 lines is a failed fetch, left on box to retry. digest: retry+backoff moved INTO _post so the digest send (not just the start ping) survives the post-wake DNS gap; ingest ping gets the same. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+19
-12
@@ -24,12 +24,24 @@ HERE = os.path.dirname(os.path.abspath(__file__))
|
|||||||
MAX_ROWS = 30 # embed description caps at 4096 chars; 30 rows fits
|
MAX_ROWS = 30 # embed description caps at 4096 chars; 30 rows fits
|
||||||
|
|
||||||
|
|
||||||
def _post(hook, payload):
|
def _post(hook, payload, tries=6):
|
||||||
|
"""POST with retry+backoff on ANY error (2026-07-20: the DIGEST send — not
|
||||||
|
just the start ping — was dying on the post-wake DNS gap / transient network
|
||||||
|
blips with no retry, so the whole daily digest silently never arrived).
|
||||||
|
Backoff 5,10,20,40,80s ≈ 2.5min of coverage; raises only if all tries fail."""
|
||||||
req = urllib.request.Request(
|
req = urllib.request.Request(
|
||||||
hook, data=json.dumps(payload).encode(),
|
hook, data=json.dumps(payload).encode(),
|
||||||
headers={"Content-Type": "application/json",
|
headers={"Content-Type": "application/json",
|
||||||
"User-Agent": "Mozilla/5.0"}) # Discord 403s the default UA
|
"User-Agent": "Mozilla/5.0"}) # Discord 403s the default UA
|
||||||
urllib.request.urlopen(req, timeout=15, context=ssl._create_unverified_context())
|
for attempt in range(tries):
|
||||||
|
try:
|
||||||
|
urllib.request.urlopen(req, timeout=15,
|
||||||
|
context=ssl._create_unverified_context())
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
if attempt == tries - 1:
|
||||||
|
raise
|
||||||
|
time.sleep(5 * (2 ** attempt))
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
@@ -48,16 +60,11 @@ def main():
|
|||||||
# after wake, so a single try fails instantly with getaddrinfo "nodename
|
# after wake, so a single try fails instantly with getaddrinfo "nodename
|
||||||
# nor servname" (harmless: the end-of-run digest lands fine). Retry a few
|
# nor servname" (harmless: the end-of-run digest lands fine). Retry a few
|
||||||
# times with backoff to ride out the post-wake network gap.
|
# times with backoff to ride out the post-wake network gap.
|
||||||
for attempt in range(4):
|
try:
|
||||||
try:
|
_post(hook, {"content": sys.argv[2]}) # _post now retries internally
|
||||||
_post(hook, {"content": sys.argv[2]})
|
print("[discord] ping sent")
|
||||||
print("[discord] ping sent" + (f" (attempt {attempt + 1})" if attempt else ""))
|
except Exception as e:
|
||||||
return
|
print("[discord] ping failed after retries:", e)
|
||||||
except Exception as e:
|
|
||||||
if attempt == 3:
|
|
||||||
print("[discord] ping failed after retries:", e)
|
|
||||||
else:
|
|
||||||
time.sleep(5 * (attempt + 1)) # 5s, 10s, 15s — DNS usually up by 15s
|
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
sharps = json.load(open(os.path.join(HERE, "watch_sharps.json")))
|
sharps = json.load(open(os.path.join(HERE, "watch_sharps.json")))
|
||||||
|
|||||||
+30
-9
@@ -29,8 +29,14 @@ def ping(msg):
|
|||||||
req = urllib.request.Request(hook, data=json.dumps({"content": msg}).encode(),
|
req = urllib.request.Request(hook, data=json.dumps({"content": msg}).encode(),
|
||||||
headers={"Content-Type": "application/json",
|
headers={"Content-Type": "application/json",
|
||||||
"User-Agent": "Mozilla/5.0"})
|
"User-Agent": "Mozilla/5.0"})
|
||||||
urllib.request.urlopen(req, timeout=10,
|
for attempt in range(5): # ride out the post-wake DNS gap (2026-07-20)
|
||||||
context=ssl._create_unverified_context()).read()
|
try:
|
||||||
|
urllib.request.urlopen(req, timeout=10,
|
||||||
|
context=ssl._create_unverified_context()).read()
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
if attempt < 4:
|
||||||
|
time.sleep(5 * (2 ** attempt))
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -41,9 +47,11 @@ FLYCTL = shutil.which("flyctl") or "/opt/homebrew/bin/flyctl"
|
|||||||
|
|
||||||
|
|
||||||
def box(cmd):
|
def box(cmd):
|
||||||
|
# stdin=DEVNULL: under launchd there is no tty, and any flyctl subcommand
|
||||||
|
# that reads stdin will otherwise hang or read garbage (2026-07-20).
|
||||||
r = subprocess.run([FLYCTL, "ssh", "console", "-a", APP, "-C",
|
r = subprocess.run([FLYCTL, "ssh", "console", "-a", APP, "-C",
|
||||||
f"bash -c '{cmd}'"], capture_output=True, text=True,
|
f"bash -c '{cmd}'"], capture_output=True, text=True,
|
||||||
timeout=900) # busy-hour segments (~15MB gz) outgrow 300s over ssh
|
timeout=900, stdin=subprocess.DEVNULL)
|
||||||
return r.stdout
|
return r.stdout
|
||||||
|
|
||||||
|
|
||||||
@@ -67,11 +75,17 @@ def main():
|
|||||||
# console was +33% bytes and needed 900s timeouts at busy-hour sizes);
|
# console was +33% bytes and needed 900s timeouts at busy-hour sizes);
|
||||||
# the old path stays as the fallback because sftp exits 0 even on
|
# the old path stays as the fallback because sftp exits 0 even on
|
||||||
# some failures — the gunzip is the integrity check either way.
|
# some failures — the gunzip is the integrity check either way.
|
||||||
lines = None
|
lines, fsize = None, 0
|
||||||
local = os.path.join("/tmp", s)
|
local = os.path.join("/tmp", s)
|
||||||
try:
|
try:
|
||||||
|
# stdin=DEVNULL is LOAD-BEARING (2026-07-20): without it, sftp
|
||||||
|
# under launchd (no tty) produced empty/partial files with rc 0,
|
||||||
|
# which decoded to [] and — via the missing guard below — got
|
||||||
|
# marked ingested and DELETED. 36 segments were lost this way.
|
||||||
subprocess.run([FLYCTL, "ssh", "sftp", "get", f"{SEG}/{s}", local,
|
subprocess.run([FLYCTL, "ssh", "sftp", "get", f"{SEG}/{s}", local,
|
||||||
"-a", APP], capture_output=True, timeout=900)
|
"-a", APP], capture_output=True, timeout=900,
|
||||||
|
stdin=subprocess.DEVNULL)
|
||||||
|
fsize = os.path.getsize(local)
|
||||||
with gzip.open(local, "rb") as fh:
|
with gzip.open(local, "rb") as fh:
|
||||||
lines = fh.read().decode().splitlines()
|
lines = fh.read().decode().splitlines()
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -84,14 +98,21 @@ def main():
|
|||||||
if lines is None:
|
if lines is None:
|
||||||
try:
|
try:
|
||||||
raw = box(f"base64 {SEG}/{s}")
|
raw = box(f"base64 {SEG}/{s}")
|
||||||
lines = gzip.decompress(base64.b64decode(raw)).decode().splitlines()
|
blob = base64.b64decode(raw)
|
||||||
|
fsize = len(blob)
|
||||||
|
lines = gzip.decompress(blob).decode().splitlines()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# BOTH transports failed — skip, never crash: a single monster
|
|
||||||
# segment (rtds_20260718_21, 2026-07-19) blocked the whole
|
|
||||||
# sorted backlog behind it when this timeout escaped the try
|
|
||||||
print(f"[ingest] {s}: both transports failed ({type(e).__name__}) "
|
print(f"[ingest] {s}: both transports failed ({type(e).__name__}) "
|
||||||
"— left on box, continuing")
|
"— left on box, continuing")
|
||||||
continue
|
continue
|
||||||
|
# INTEGRITY GUARD (2026-07-20): a non-trivial gz that yields ZERO lines
|
||||||
|
# is a truncated/empty FETCH, not an empty hour (a genuinely empty hour
|
||||||
|
# gzips to ~20-30 bytes). NEVER mark+delete it — leave it on the box to
|
||||||
|
# retry. This is the guard whose absence cost 36 segments.
|
||||||
|
if not lines and fsize > 200:
|
||||||
|
print(f"[ingest] {s}: {fsize}B gz decoded to 0 lines — FETCH FAILED, "
|
||||||
|
"left on box for retry")
|
||||||
|
continue
|
||||||
rows, aux = [], []
|
rows, aux = [], []
|
||||||
for ln in lines:
|
for ln in lines:
|
||||||
try:
|
try:
|
||||||
|
|||||||
Reference in New Issue
Block a user