Files
winning-wallet-finder_github/recorder/sync_tape.py
T
jaxperro 5c8ee5961b stage-0 warehouse: recorder folds its own tape to parquet; Mac mirrors every 15 min
The nightly Mac-coupled bulk ingest was the fragile link (today: Mac slept
through 08:00, the pipeline started 09:31, and the sftp bulk pull wedged in
timeout cascades holding the db lock — digest blocked behind it). Stage 0
moves the fold onto the box and reduces the Mac to an incremental mirror:

- recorder/fold.py (sidecar, capture stays PID 1): closed gz segments ->
  zstd parquet /data/parquet/<fam>/date=*/segment.parquet, row-parity
  verified, manifest-logged, THEN gz deleted. Deletion invariant STRONGER:
  raw needs a verified parquet; parquet needs a Mac ACK before the disk
  guard may prune it. duckdb capped 384MB; VM 256MB -> 1GB.
- recorder/sync_tape.py (com.jaxperro.tape-sync every 15 min + daily.sh):
  manifest-driven incremental sftp pull, per-file row verify, append into
  rtds.duckdb NATIVE tables (views-over-parquet rejected: sim's per-asset
  point queries would crawl), segment-keyed idempotence shared with the
  legacy ingest.py (kept as fallback), ack back to the box.
- recorder/bootstrap_parquet.py: pre-fold history exported to the mirror,
  parity OK (13.84M trades + 3.07M aux). live/parquet/ is now the complete
  durable layer Stage 1 (MotherDuck/ClickHouse) would consume.
- research/tape.py connect(): brief retry — the 15-min sync holds the
  write lock for seconds.

First run: backlog folded in <60s on the box, 36/36 files mirrored+
verified, rtds.duckdb 13.8M -> 18.2M trades, tape age 24h+ -> ~15-45 min.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 12:46:54 -04:00

147 lines
5.5 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=900):
subprocess.run([FLYCTL, "ssh", "sftp", "get", remote, local, "-a", APP],
capture_output=True, timeout=timeout,
stdin=subprocess.DEVNULL)
return os.path.exists(local) and os.path.getsize(local) > 0
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
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")
box(f"touch {PQ}/acks/{os.path.basename(rel)}.ok")
got += 1
print(f"[sync] {rel}: {n} rows -> db + mirror + ack")
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())