2026-07-17 13:02:39 -04:00
|
|
|
#!/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
|
2026-07-19 12:18:22 -04:00
|
|
|
import shutil
|
2026-07-19 16:34:51 -04:00
|
|
|
import ssl
|
2026-07-17 13:02:39 -04:00
|
|
|
import subprocess
|
|
|
|
|
import time
|
2026-07-19 16:34:51 -04:00
|
|
|
import urllib.request
|
2026-07-17 13:02:39 -04:00
|
|
|
|
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
|
|
|
DB = os.path.join(HERE, "..", "live", "rtds.duckdb")
|
|
|
|
|
APP, SEG = "wwf-recorder", "/data/segments"
|
|
|
|
|
|
|
|
|
|
|
2026-07-19 16:34:51 -04:00
|
|
|
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"})
|
2026-07-20 13:19:57 -04:00
|
|
|
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))
|
2026-07-19 16:34:51 -04:00
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
2026-07-19 12:18:22 -04:00
|
|
|
# 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"
|
|
|
|
|
|
|
|
|
|
|
2026-07-17 13:02:39 -04:00
|
|
|
def box(cmd):
|
2026-07-20 13:19:57 -04:00
|
|
|
# stdin=DEVNULL: under launchd there is no tty, and any flyctl subcommand
|
|
|
|
|
# that reads stdin will otherwise hang or read garbage (2026-07-20).
|
2026-07-19 12:18:22 -04:00
|
|
|
r = subprocess.run([FLYCTL, "ssh", "console", "-a", APP, "-C",
|
2026-07-17 13:02:39 -04:00
|
|
|
f"bash -c '{cmd}'"], capture_output=True, text=True,
|
2026-07-20 13:19:57 -04:00
|
|
|
timeout=900, stdin=subprocess.DEVNULL)
|
2026-07-17 13:02:39 -04:00
|
|
|
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)""")
|
2026-07-19 15:55:10 -04:00
|
|
|
con.execute("""CREATE TABLE IF NOT EXISTS aux(
|
|
|
|
|
ts DOUBLE, topic VARCHAR, type VARCHAR, payload VARCHAR)""")
|
2026-07-17 13:02:39 -04:00
|
|
|
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]
|
2026-07-19 16:34:51 -04:00
|
|
|
ping(f"📼 tape ingest started — {len(segs)} segment(s) queued")
|
2026-07-17 13:02:39 -04:00
|
|
|
total = 0
|
|
|
|
|
for s in sorted(segs):
|
2026-07-19 16:46:28 -04:00
|
|
|
# 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.
|
2026-07-20 13:19:57 -04:00
|
|
|
lines, fsize = None, 0
|
2026-07-19 16:46:28 -04:00
|
|
|
local = os.path.join("/tmp", s)
|
2026-07-17 13:02:39 -04:00
|
|
|
try:
|
2026-07-20 13:19:57 -04:00
|
|
|
# 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.
|
2026-07-19 16:46:28 -04:00
|
|
|
subprocess.run([FLYCTL, "ssh", "sftp", "get", f"{SEG}/{s}", local,
|
2026-07-20 13:19:57 -04:00
|
|
|
"-a", APP], capture_output=True, timeout=900,
|
|
|
|
|
stdin=subprocess.DEVNULL)
|
|
|
|
|
fsize = os.path.getsize(local)
|
2026-07-19 16:46:28 -04:00
|
|
|
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:
|
2026-07-19 17:12:45 -04:00
|
|
|
raw = box(f"base64 {SEG}/{s}")
|
2026-07-20 13:19:57 -04:00
|
|
|
blob = base64.b64decode(raw)
|
|
|
|
|
fsize = len(blob)
|
|
|
|
|
lines = gzip.decompress(blob).decode().splitlines()
|
2026-07-19 16:46:28 -04:00
|
|
|
except Exception as e:
|
2026-07-19 17:12:45 -04:00
|
|
|
print(f"[ingest] {s}: both transports failed ({type(e).__name__}) "
|
|
|
|
|
"— left on box, continuing")
|
2026-07-19 16:46:28 -04:00
|
|
|
continue
|
2026-07-20 13:19:57 -04:00
|
|
|
# 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
|
2026-07-19 15:55:10 -04:00
|
|
|
rows, aux = [], []
|
2026-07-17 13:02:39 -04:00
|
|
|
for ln in lines:
|
|
|
|
|
try:
|
|
|
|
|
d = json.loads(ln)
|
2026-07-19 15:55:10 -04:00
|
|
|
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")))
|
2026-07-17 13:02:39 -04:00
|
|
|
except Exception:
|
|
|
|
|
pass
|
2026-07-19 14:07:41 -04:00
|
|
|
con.execute("BEGIN") # audit 3.10: atomic pair — a crash
|
|
|
|
|
if rows: # between the two inserts re-ingested
|
2026-07-17 13:02:39 -04:00
|
|
|
con.executemany("INSERT INTO trades VALUES (?,?,?,?,?,?,?,?,?)", rows)
|
2026-07-19 15:55:10 -04:00
|
|
|
if aux:
|
|
|
|
|
con.executemany("INSERT INTO aux VALUES (?,?,?,?)", aux)
|
2026-07-17 13:02:39 -04:00
|
|
|
con.execute("INSERT INTO ingested VALUES (?,?,?)",
|
2026-07-19 15:55:10 -04:00
|
|
|
[s, len(rows) + len(aux), int(time.time())])
|
2026-07-19 14:07:41 -04:00
|
|
|
con.execute("COMMIT")
|
2026-07-17 13:02:39 -04:00
|
|
|
box(f"rm {SEG}/{s}")
|
2026-07-19 15:55:10 -04:00
|
|
|
total += len(rows) + len(aux)
|
|
|
|
|
print(f"[ingest] {s}: {len(rows)} trades + {len(aux)} aux")
|
2026-07-17 13:02:39 -04:00
|
|
|
n = con.execute("SELECT count(*) FROM trades").fetchone()[0]
|
|
|
|
|
print(f"[ingest] +{total} rows · rtds.duckdb now {n:,} trades")
|
2026-07-19 16:34:51 -04:00
|
|
|
ping(f"📼 tape ingest done: +{total:,} rows across {len(segs)} segment(s) "
|
|
|
|
|
f"· rtds.duckdb now {n:,} trades")
|
2026-07-17 13:02:39 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
main()
|