Files
winning-wallet-finder/recorder/ingest.py
T
jaxperro 6f6bd81cb1 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>
2026-07-20 13:19:59 -04:00

148 lines
6.5 KiB
Python

#!/usr/bin/env python3
"""Nightly tape ingest (daily.sh): pull closed RTDS segments off the
wwf-recorder volume into live/rtds.duckdb, delete on the box ONLY after the
inserted row count matches. Own DB file on purpose — must never contend with
cache.duckdb's single-writer lock (2026-07-17 collision). Transport is
`flyctl ssh console` + base64 (no ingress on the recorder by design)."""
import base64
import gzip
import json
import os
import shutil
import ssl
import subprocess
import time
import urllib.request
HERE = os.path.dirname(os.path.abspath(__file__))
DB = os.path.join(HERE, "..", "live", "rtds.duckdb")
APP, SEG = "wwf-recorder", "/data/segments"
def ping(msg):
"""Best-effort Discord ping to the DAILY channel (user ask 2026-07-19:
the nightly pull announces start + finish). Never fatal."""
try:
hook = json.load(open(os.path.join(HERE, "..", "config.json"))).get("daily_webhook")
if not hook:
return
req = urllib.request.Request(hook, data=json.dumps({"content": msg}).encode(),
headers={"Content-Type": "application/json",
"User-Agent": "Mozilla/5.0"})
for attempt in range(5): # ride out the post-wake DNS gap (2026-07-20)
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:
pass
# launchd runs daily.sh with a minimal PATH (no /opt/homebrew/bin) — the
# 2026-07-18 run died on FileNotFoundError: 'flyctl'. Resolve it explicitly.
FLYCTL = shutil.which("flyctl") or "/opt/homebrew/bin/flyctl"
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",
f"bash -c '{cmd}'"], capture_output=True, text=True,
timeout=900, stdin=subprocess.DEVNULL)
return r.stdout
def main():
import duckdb
con = duckdb.connect(DB)
con.execute("""CREATE TABLE IF NOT EXISTS trades(
ts DOUBLE, wallet VARCHAR, asset VARCHAR, cond VARCHAR, side VARCHAR,
price DOUBLE, size DOUBLE, tx VARCHAR, title VARCHAR)""")
con.execute("""CREATE TABLE IF NOT EXISTS ingested(
segment VARCHAR PRIMARY KEY, rows BIGINT, ingested_at BIGINT)""")
con.execute("""CREATE TABLE IF NOT EXISTS aux(
ts DOUBLE, topic VARCHAR, type VARCHAR, payload VARCHAR)""")
have = {r[0] for r in con.execute("SELECT segment FROM ingested").fetchall()}
segs = [s for s in box(f"ls {SEG}").split()
if s.endswith(".gz") and s not in have]
ping(f"📼 tape ingest started — {len(segs)} segment(s) queued")
total = 0
for s in sorted(segs):
# closes #11: sftp moves the gz BINARY in one session (base64-over-
# 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
# some failures — the gunzip is the integrity check either way.
lines, fsize = None, 0
local = os.path.join("/tmp", s)
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,
"-a", APP], capture_output=True, timeout=900,
stdin=subprocess.DEVNULL)
fsize = os.path.getsize(local)
with gzip.open(local, "rb") as fh:
lines = fh.read().decode().splitlines()
except Exception:
lines = None
finally:
try:
os.remove(local)
except OSError:
pass
if lines is None:
try:
raw = box(f"base64 {SEG}/{s}")
blob = base64.b64decode(raw)
fsize = len(blob)
lines = gzip.decompress(blob).decode().splitlines()
except Exception as e:
print(f"[ingest] {s}: both transports failed ({type(e).__name__}) "
"— left on box, continuing")
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 = [], []
for ln in lines:
try:
d = json.loads(ln)
if s.startswith("aux_"):
aux.append((d.get("ts"), d.get("topic"), d.get("type"),
json.dumps(d.get("payload"))))
else:
rows.append((d.get("ts"), d.get("wallet"), d.get("asset"),
d.get("cond"), d.get("side"), d.get("price"),
d.get("size"), d.get("tx"), d.get("title")))
except Exception:
pass
con.execute("BEGIN") # audit 3.10: atomic pair — a crash
if rows: # between the two inserts re-ingested
con.executemany("INSERT INTO trades VALUES (?,?,?,?,?,?,?,?,?)", rows)
if aux:
con.executemany("INSERT INTO aux VALUES (?,?,?,?)", aux)
con.execute("INSERT INTO ingested VALUES (?,?,?)",
[s, len(rows) + len(aux), int(time.time())])
con.execute("COMMIT")
box(f"rm {SEG}/{s}")
total += len(rows) + len(aux)
print(f"[ingest] {s}: {len(rows)} trades + {len(aux)} aux")
n = con.execute("SELECT count(*) FROM trades").fetchone()[0]
print(f"[ingest] +{total} rows · rtds.duckdb now {n:,} trades")
ping(f"📼 tape ingest done: +{total:,} rows across {len(segs)} segment(s) "
f"· rtds.duckdb now {n:,} trades")
if __name__ == "__main__":
main()