Files
jaxperro 7e579aca98 sync_tape: batch acks into one ssh call + make ack failures non-fatal
Per-file 'flyctl ssh console' acks ride WireGuard; travel networks throttle
it past the 120s timeout, and the raised TimeoutExpired killed whole
backlog runs mid-flight (52/64 files, twice). The delete invariant is
one-directional — the box may delete ONLY what the Mac acked — so a
MISSING ack is always safe. Batch at end, best-effort, log and continue.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 11:00:52 -04:00

205 lines
8.3 KiB
Python

#!/usr/bin/env python3
"""Parquet mirror sync (Stage-0 warehouse, replaces the nightly bulk ingest).
Pulls NEW parquet files the recorder's fold sidecar produced (/data/parquet
on the wwf-recorder volume) into live/parquet/, row-verifies each against
the box manifest, appends its rows into live/rtds.duckdb's NATIVE tables
(same tables as ever — research and tape_sharps keep native-table speed;
views-over-parquet was rejected: sim's per-asset point queries would turn
minutes into hours), marks it in `ingested`, and ACKs it back (touch
/data/parquet/acks/<file>.ok — the fold disk-guard may only ever delete
ACKED parquet). The mirror itself is the durable warehouse layer Stage 1
(MotherDuck/ClickHouse) would consume.
Transport is the battle-tested flyctl sftp get (+ ssh console for acks);
no ingress on the recorder, by design. Quiet no-op when the box is
unreachable or another process holds the db write lock — files are
immutable and manifest-driven, so the next run catches up. Runs every 15
min via com.jaxperro.tape-sync and from daily.sh (old bulk ingest.py kept
as fallback for a fold-less recorder).
"""
import json
import os
import shutil
import subprocess
import sys
import time
HERE = os.path.dirname(os.path.abspath(__file__))
LIVE = os.path.join(HERE, "..", "live")
MIRROR = os.path.join(LIVE, "parquet")
LOCAL_MANIFEST = os.path.join(MIRROR, ".mirror_manifest.jsonl")
DB = os.path.join(LIVE, "rtds.duckdb")
APP, PQ = "wwf-recorder", "/data/parquet"
FLYCTL = shutil.which("flyctl") or "/opt/homebrew/bin/flyctl"
def box(cmd, timeout=120):
r = subprocess.run([FLYCTL, "ssh", "console", "-a", APP, "-C",
f"bash -c '{cmd}'"], capture_output=True, text=True,
timeout=timeout, stdin=subprocess.DEVNULL)
return r.stdout
def sftp_get(remote, local, timeout=240):
"""sftp first (a healthy multi-MB fetch takes <60s — the old 900s window
just burned the 15-min cadence when it hung), then the base64-over-
console fallback that carried ingest.py through the same flakiness
(2026-07-22: sftp timeouts left magic-byte-less partial parquets while
console commands stayed fast). Integrity is the caller's manifest
row-count verify either way; partial files are removed here."""
timed_out = False
try:
subprocess.run([FLYCTL, "ssh", "sftp", "get", remote, local, "-a", APP],
capture_output=True, timeout=timeout,
stdin=subprocess.DEVNULL)
except subprocess.TimeoutExpired:
timed_out = True # a timed-out get leaves a size>0 PARTIAL — must
# not be blessed, or the base64 fallback never
# runs and the file loops unreadable forever
# (2026-07-23: 8h of exactly that)
if not timed_out and os.path.exists(local) and os.path.getsize(local) > 0:
return True
try:
os.remove(local)
except OSError:
pass
try:
import base64
raw = box(f"base64 {remote}", timeout=600)
blob = base64.b64decode(raw)
if not blob:
return False
with open(local, "wb") as fh:
fh.write(blob)
print(f"[sync] {os.path.basename(remote)}: sftp failed — "
f"base64 fallback carried {len(blob)//1024}KB")
return True
except Exception as e:
print(f"[sync] {os.path.basename(remote)}: both transports failed "
f"({type(e).__name__})")
try:
os.remove(local)
except OSError:
pass
return False
def load_manifest(path):
out = {}
try:
with open(path) as fh:
for ln in fh:
try:
d = json.loads(ln)
out[d["segment"]] = d
except Exception:
pass
except FileNotFoundError:
pass
return out
def connect_rw(duckdb, tries=3, wait=10):
"""The 15-min cadence can collide with a long research read (one-writer-
OR-many-readers). Retry briefly, then yield to the next run."""
for i in range(tries):
try:
return duckdb.connect(DB)
except Exception as e:
if i == tries - 1:
print(f"[sync] db locked ({str(e)[:60]}) — yielding to next run")
return None
time.sleep(wait)
def main():
os.makedirs(MIRROR, exist_ok=True)
tmp_man = os.path.join(MIRROR, ".remote_manifest.tmp")
if not sftp_get(f"{PQ}/manifest.jsonl", tmp_man, timeout=120):
print("[sync] box unreachable or no manifest yet — nothing to do")
return 0
remote = load_manifest(tmp_man)
local = load_manifest(LOCAL_MANIFEST)
new = [d for s, d in sorted(remote.items()) if s not in local]
os.remove(tmp_man)
if not new:
print(f"[sync] mirror current ({len(local)} files)")
return 0
import duckdb
con = connect_rw(duckdb)
if con is None:
return 0
have = {r[0] for r in con.execute("SELECT segment FROM ingested").fetchall()}
got = 0
pending_acks = []
for d in new:
rel, seg = d["path"], d["segment"]
dst = os.path.join(MIRROR, rel)
os.makedirs(os.path.dirname(dst), exist_ok=True)
if not os.path.exists(dst) and not sftp_get(f"{PQ}/{rel}", dst):
print(f"[sync] {rel}: fetch failed — retry next run")
continue
try:
n, = con.execute(
f"SELECT count(*) FROM read_parquet('{dst}')").fetchone()
except Exception as e:
print(f"[sync] {rel}: unreadable ({e}) — dropped, retry next run")
try:
os.remove(dst)
except OSError:
pass
continue
if n != d["rows"]:
print(f"[sync] {rel}: rows {n} != manifest {d['rows']} — dropped")
os.remove(dst)
continue
# append into the native tables exactly once (segment-keyed), then
# mirror-manifest + ack. A crash between COMMIT and manifest write
# re-runs into the `have` guard — no double insert.
if seg not in have:
con.execute("BEGIN")
if d["family"] == "aux":
con.execute(f"""INSERT INTO aux
SELECT ts, topic, type, payload
FROM read_parquet('{dst}')""")
else:
con.execute(f"""INSERT INTO trades
SELECT ts, wallet, asset, cond, side, price, size, tx, title
FROM read_parquet('{dst}')""")
con.execute("INSERT INTO ingested VALUES (?,?,?)",
[seg, d["rows"], int(time.time())])
con.execute("COMMIT")
with open(LOCAL_MANIFEST, "a") as fh:
fh.write(json.dumps(d) + "\n")
# ACK IS DEFERRED AND NON-FATAL (2026-07-27): one ssh round-trip per
# file rides WireGuard, which travel networks throttle to the point
# of timing out — and a raised TimeoutExpired used to kill the whole
# sync mid-backlog (52 files in, 12 to go). The invariant is
# one-directional: the box may delete ONLY what the Mac acked, so a
# MISSING ack is always safe (box keeps the file; 25GB ≈ 3+ weeks).
# Batch them at the end, best-effort.
pending_acks.append(os.path.basename(rel))
got += 1
print(f"[sync] {rel}: {n} rows -> db + mirror (ack queued)")
# batched ack: ONE ssh round-trip for the whole run (was one per file).
# Never fatal — unacked files simply wait for a healthier network; the
# next run re-acks them (touch is idempotent, and `have` keeps the DB
# from re-ingesting).
if pending_acks:
try:
box("touch " + " ".join(f"{PQ}/acks/{a}.ok"
for a in pending_acks), timeout=180)
print(f"[sync] acked {len(pending_acks)} file(s) in one call")
except Exception as e:
print(f"[sync] ⚠ ack deferred ({type(e).__name__}) — box keeps "
f"{len(pending_acks)} file(s) until a healthier run; "
f"data already in db + mirror")
n, = con.execute("SELECT count(*) FROM trades").fetchone()
print(f"[sync] +{got}/{len(new)} files · rtds.duckdb now {n:,} trades")
return 0
if __name__ == "__main__":
sys.exit(main())