Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
---
name: cf-adahandle-resolver-mainnet-integration-test-full-sync
description: QA Automation Engineer — drives a full mainnet integration test of cf-adahandle-resolver on a remote host via SSH. Provisions a fresh Postgres + API stack with docker compose, syncing the chain from the first ADA Handle mint (slot 47931310) to tip, monitors window-relative progress from the DB cursor, then asserts the run is clean (zero block-parse errors through the Conway era / Van Rossem boundary) and that the REST API resolves handles consistently. Use when the user wants to validate a branch end-to-end against mainnet on a remote box (typed `/cf-adahandle-resolver-mainnet-integration-test-full-sync` or asked for a "mainnet full-sync regression run" / "hard-fork readiness sync test").
---

# Mainnet Full-Sync Integration Test (Remote, via SSH)

You are acting as a **QA Automation Engineer**. The user has a remote Linux box reachable over SSH that will:

1. Pull a fresh checkout of `cf-adahandle-resolver` on a target branch.
2. Spin up a clean Postgres + API stack via the project's `docker-compose.yml`, syncing mainnet from the first ADA Handle minting block (**slot 47931310**) up to tip.
3. As the indexer advances, it parses every block on the way to tip — **including the Conway era and the Van Rossem (protocol v11) boundary**. Parsing the whole window without error *is* the core regression: this is what proves the project's yaci-store/yaci-core versions are hard-fork ready.

Your job is to drive this remotely. You forward the remote API (port 9095) and Postgres (port 5432) to localhost via SSH so the regression spot-checks run on your local machine against the live remote stack.

## What "pass" means here

Unlike a fixture-based API test, the regression signal is the **full sync itself**:

1. **Sync reaches tip** — the DB cursor catches up to the live mainnet tip (window progress ≈ 100%), with the cursor era at **Conway** (Van Rossem is intra-Conway).
2. **Zero genuine block-parse errors** across the whole run — no `BlockParseRuntimeException` / "Block parsing error" whose root cause is a decode failure. (Errors whose root cause is "Database is already closed" / "context closed" are teardown/shutdown noise, not parse failures — see below.)
3. **REST spot-checks pass** — two complementary checks:
- **Golden handles** (`golden-handles.txt`): a fixed set of commonly-known mainnet handles (`cardano`, `hosky`, `minswap`, `bigpey`, `charles`, …) must all resolve and round-trip. Deterministic and reviewable.
- **Random DB sample**: for a sample of handles drawn from the synced DB, the API's forward lookup matches the stored row and the reverse lookup round-trips.

## Critical rules

- **Never blow away an existing instance silently.** If the remote already has a stack running, the Postgres volume populated, or the target ports occupied, **STOP** and report. Ask whether to clean up or abort. Don't `docker compose down -v` on your own.
- **Fresh sync means empty data.** A clean run requires the `adahandle-db` Postgres volume to be empty. If it already has data, surface it and let the user decide (wipe vs. resume).
- **Don't run the REST spot-checks too early.** They need the `ada_handle` table populated and the sync at (or near) tip. Running them mid-sync produces meaningless results.
- **Full sync is long.** From slot 47931310 to tip is multiple hours on a typical box. Set expectations before kicking off, and prefer long fallback waits (1200–1800s) when polling progress.
- **The build under test is THIS branch's source.** `start-remote.sh` builds the API image from the checked-out Dockerfile — not a published `:latest`.

## Required configuration

Load config from (in priority order):

1. CLI args passed to the skill: `--ssh-host`, `--ssh-user`, `--remote-dir`, `--branch`, `--env-file`.
2. `./.claude/skills/cf-adahandle-resolver-mainnet-integration-test-full-sync/config.env` (gitignored — not committed).
3. Defaults.

| Variable | Default | Required? |
|---|---|---|
| `REMOTE_SSH_HOST` | none | **yes** (host or ssh_config alias) |
| `REMOTE_SSH_USER` | none | no — leave empty when `REMOTE_SSH_HOST` is an ssh_config alias whose `User` directive already sets the login |
| `REMOTE_PROJECT_DIR` | `$HOME/git/cf-adahandle-resolver` | no |
| `BRANCH` | `main` | no |
| `GIT_REPO_URL` | `https://github.com/cardano-foundation/cf-adahandle-resolver.git` | no |
| `ENV_FILE` | `.env` (mainnet) | no |
| `LOCAL_API_PORT` | `9095` | no |
| `LOCAL_DB_PORT` | `5432` | no |
| `SAMPLE_N` | `10` | no — number of handles sampled for REST spot-checks |

If `REMOTE_SSH_HOST` is missing, refuse to start and tell the user to either supply it as an arg or fill in `config.env`. **Never invent or default a hostname.**

A template lives at `config.env.example`. Tell the user to `cp config.env.example config.env` and fill it in; the real `config.env` is gitignored.

## Procedure

You orchestrate via the helper scripts under `./scripts/`. They are intentionally small and atomic so each step is auditable. Source `scripts/load-config.sh` first to populate environment variables, then call the steps in order. All scripts are idempotent and safe to re-run.

### Step 0 — Confirm intent with the user

Before any remote action, echo back the resolved config (host, user, remote dir, branch, env file) and ask the user to confirm. A fresh sync is destructive to any existing data on the remote.

### Step 1 — Preflight check (read-only on remote)

Run `scripts/preflight-remote.sh`. It SSHs in and checks: a running compose stack in `$REMOTE_PROJECT_DIR`; whether ports 9095/5432 are already bound; that the Docker daemon is reachable; and whether `$REMOTE_PROJECT_DIR/.git` exists (informational). If anything is occupied → **STOP**, print findings, and ask:

> "Found an existing instance / occupied ports on `$REMOTE_SSH_HOST`. Options: (a) abort, (b) stop & wipe the existing stack (`docker compose down -v` in `$REMOTE_PROJECT_DIR`) then continue. What do you want me to do?"

Only proceed with explicit approval.

### Step 2 — Prepare repo on remote

Run `scripts/prepare-remote.sh`. Clones or `git fetch` + `reset --hard` + `clean -fdx` + `checkout $BRANCH` + `pull --ff-only`. Prints the resolved commit SHA so the user can verify what's under test.

### Step 3 — Start the docker compose stack

Run `scripts/start-remote.sh`. Confirms `$ENV_FILE` is present, builds the `api` image from source (`--pull` to refresh the base image), `docker compose up -d`, and prints the running containers. If the `adahandle-db` volume already has data, it does **not** auto-wipe — it surfaces this and asks.

### Step 4 — Open SSH tunnels

Run `scripts/open-tunnels.sh`: backgrounds an SSH connection with `-L $LOCAL_API_PORT:localhost:9095` and `-L $LOCAL_DB_PORT:localhost:5432`, stores the PID in `tunnels.pid`, and sanity-checks `http://localhost:$LOCAL_API_PORT/actuator/health`.

### Step 5 — Monitor sync to tip

**Preferred:** run `scripts/monitor-sync-progress.sh 1200 --until-tip` (or background it under a `Monitor`). It reads the chain cursor straight from Postgres and compares against the live mainnet tip from Koios — **window-relative** progress (relative to the slot-47931310 start), correct throughout the run. It exits 0 once window progress ≥ 99.95%. For a one-shot spot check use `scripts/check-sync-progress.sh`.

**Do NOT trust the API's `syncPercentage`** if you look at `/actuator/health` — yaci-store computes it from Byron genesis, so with a mid-chain start slot it reads misleadingly high from the start. The DB-cursor metric (window-relative) is the source of truth.

From slot 47931310 to tip is several hours. Use `ScheduleWakeup` with a long delay (1200–3600s) while waiting, and warn the user the loop runs for a long time. Print progress + ETA periodically.

### Step 6 — Check for parse errors (the hard-fork regression)

Once at tip, run `scripts/check-parse-errors.sh`. It scans the `api` container logs for genuine block-parse failures, **excluding** the benign shutdown-cascade lines (root cause "Database is already closed" / "context closed already" / "Scheduled to stop"). It also reports the cursor era.

- **0 genuine parse errors + cursor era = Conway at tip** → the build parsed the full window including the Van Rossem boundary. This is the headline pass.
- Any genuine parse error → **report it immediately** with the offending block number/slot and the root-cause line; this is a real hard-fork-readiness failure.

### Step 7 — REST spot-checks

Two scripts, both against the tunneled API:

**`scripts/run-golden-handles.sh`** — the deterministic golden check. Reads commonly-known mainnet handles from `golden-handles.txt` and for each asserts:
- **Forward**: `GET /api/v1/addresses/by-ada-handle/{handle}` → HTTP 200, `paymentAddress` well-formed (`addr1…`), `stakeAddress` absent (enterprise address) or well-formed (`stake1…`).
- **Reverse**: `GET /api/v1/ada-handles/by-payment-address/{paymentAddress}` → the handle is in the returned list. (Reverse uses the payment address — always present — so it works for enterprise-address handles too.)

Golden handles are **names only** (no hardcoded addresses): handles are transferable NFTs, so pinning addresses would rot. The check is therefore drift-proof — it asserts these famous handles resolve and round-trip after a full sync. Run at tip (a handle minted after a mid-sync cursor won't exist yet).

**`scripts/run-rest-spotchecks.sh`** — randomized coverage. Samples `SAMPLE_N` handles from the synced `ada_handle` table (JSON-encoded so arbitrary UTF-8 names parse safely; empty names skipped), then asserts forward lookup equals the stored row and reverse-by-stake-address round-trips. A DB-vs-API consistency check, so no false failures from drift. (For known-address assertions, set `EXPECTED_HANDLES` in `config.env` — optional.)

If any golden handle fails after a clean full sync, that's a real regression — report it.

### Step 8 — Report

Summarize: branch + commit tested; time spent (sync, total); final cursor (slot/block/era) vs tip; parse-error count (and first offenders if any); spot-check pass/fail counts (and first failures). Remind the user the stack is **still running** on the remote and ask whether to `docker compose down -v` + close tunnels, or leave it up for inspection.

## Handling failures

- **SSH connection lost mid-run.** Re-open tunnels and resume monitoring. Don't restart the stack.
- **Stack restarts mid-sync.** `STORE_SYNCAUTOSTART=true`, so the indexer resumes from the persisted cursor automatically. Keep monitoring.
- **Stuck health/no progress.** Pull `docker compose logs --tail=200 api` and `... db` remotely; surface the first ERROR line.

### Known issue: sync wedges on a dead relay IP ("Scheduled to stop")

Same root cause as filed upstream for yaci/yaci-store: the Cardano node hostname fronts a load-balanced pool of A-records; yaci-core's TCP client resolves to a **single** IP with no failover, and if it pins a now-unreachable member it loops forever.

**Symptom:** cursor stops advancing (`check-sync-progress.sh` shows no movement); API logs spam `io.netty.channel.ConnectTimeoutException: connection timed out ... :3001`; `/actuator/health` shows the connection out of service / `Scheduled to stop`.

**Mitigation (already configured):** `.env` sets `JAVA_TOOL_OPTIONS=... -Dnetworkaddress.cache.ttl=0 ...` so the JVM re-resolves DNS on every attempt and tends to pick a healthy IP.

**Recovery if it still wedges:** `ssh <host> 'cd <remote-dir> && docker compose --env-file <env> restart api'`. A fresh JVM re-resolves DNS and usually picks a healthy IP, then catches up the gap quickly. The Postgres cursor persists across the restart, so no re-sync.

## Files this skill owns

```
.claude/skills/cf-adahandle-resolver-mainnet-integration-test-full-sync/
├── SKILL.md # this file
├── config.env.example # template, committed
├── config.env # actual config, gitignored
├── golden-handles.txt # commonly-known mainnet handles for the golden check
├── tunnels.pid # PID of background SSH tunnel (runtime, gitignored)
└── scripts/
├── load-config.sh # config loader (source first)
├── preflight-remote.sh # remote occupancy check (read-only)
├── prepare-remote.sh # clone / reset / checkout $BRANCH
├── start-remote.sh # docker compose build + up (image from source)
├── open-tunnels.sh # SSH port-forward 9095 + 5432
├── close-tunnels.sh # kill background SSH tunnel
├── check-sync-progress.sh # one-shot window-relative progress (DB cursor vs Koios tip)
├── monitor-sync-progress.sh # looped progress + ETA; --until-tip exits at ~100%
├── check-parse-errors.sh # scan api logs for genuine block-parse failures + report era
├── run-golden-handles.sh # golden check: known handles resolve + round-trip (drift-proof)
└── run-rest-spotchecks.sh # DB-vs-API consistency + reverse round-trip on sampled handles
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Copy this to config.env and fill in the SSH details.
# config.env is gitignored — never commit real hostnames or usernames.
#
# Required:
REMOTE_SSH_HOST=

# Optional. Leave empty if REMOTE_SSH_HOST is an ssh_config alias whose
# `User` directive already specifies the remote login (recommended).
# Only set this to force user@host on the SSH command line.
REMOTE_SSH_USER=

# Optional (defaults shown):
# NOTE: single-quote $HOME so it's expanded on the REMOTE shell, not locally.
# Canonical layout: $HOME/git/<repo>.
REMOTE_PROJECT_DIR='$HOME/git/cf-adahandle-resolver'
BRANCH="main"
# HTTPS is the default — public repo, no auth needed on the remote box.
GIT_REPO_URL="https://github.com/cardano-foundation/cf-adahandle-resolver.git"

# Which env file the remote docker compose should use (mainnet by default).
ENV_FILE=".env"

# Local ports the SSH tunnel will bind. The remote exposes the API on 9095
# and Postgres on 5432 (per docker-compose.yml + .env).
LOCAL_API_PORT="9095"
LOCAL_DB_PORT="5432"

# How many handles to sample from the synced DB for REST spot-checks.
SAMPLE_N="10"

# Optional: known handle -> expected addresses, for hard assertions in addition
# to the DB-vs-API round-trip. Format: "handle1=stake1...;handle2=stake1...".
# Leave empty to rely solely on DB-vs-API consistency (recommended default).
EXPECTED_HANDLES=""
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Golden ADA handles — commonly-known mainnet handles used as a deterministic
# regression check by run-golden-handles.sh. One handle per line (no leading $);
# blank lines and # comments ignored.
#
# WHY NAMES ONLY (no expected addresses): ADA handles are transferable NFTs, so
# the stake/payment address behind a handle can change at any time. Pinning exact
# addresses would produce false failures from on-chain drift. The golden test
# therefore asserts that each handle RESOLVES and ROUND-TRIPS (forward lookup
# returns a well-formed addr1...; reverse-by-payment-address contains the handle),
# which is drift-proof and still a meaningful regression: after a full sync these
# famous handles must be present and consistent.
#
# Every entry below was confirmed present on mainnet via the synced DB.
# Run at tip (a handle minted after a mid-sync cursor won't be present yet).

# Project / ecosystem names
cardano
ada
sundae
minswap
snek
nmkr
jpgstore
wmt
hosky

# People / brand names
bigpey
charles
hoskinson
ledger

# Eras / generic
shelley
vasil
king
money
whale
bitcoin
ethereum

# Short / numeric
ada1
btc

# Enterprise-address handles (payment only, no stake) — exercise the no-stake path
eth
satoshi
crypto
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
#!/usr/bin/env bash
Comment thread
matiwinnetou marked this conversation as resolved.
# The core hard-fork regression check. Scans the api container logs for genuine
# block-parse failures and reports the cursor era.
#
# A "genuine" parse error is a BlockParseRuntimeException / "Block parsing error"
# whose root cause is a decode failure. We EXCLUDE the benign shutdown cascade
# (root cause "Database is already closed" / "context closed already" /
# "Scheduled to stop" / "Fetcher has already been stopped") — those are
# teardown/relay-wedge artifacts, not block-format incompatibilities.
#
# Exits 0 if there are zero genuine parse errors, 1 otherwise.
# Grepping happens on the remote so a multi-hour log isn't transferred whole.

set -euo pipefail
# shellcheck disable=SC1091
source "$(dirname "${BASH_SOURCE[0]}")/load-config.sh"

ssh_exec() { ssh -o BatchMode=yes "$REMOTE" "$@"; }

SIG='BlockParseRuntimeException|Block parsing error|Error in parsing|DeserializationException|CborException|CborRuntimeException'
BENIGN='already closed|context closed|Scheduled to stop|Fetcher has already been stopped'

echo "[parse-check] scanning api logs on $REMOTE ..."
# Pull only candidate error lines (matching SIG) from the remote.
candidates=$(ssh_exec "cd $REMOTE_PROJECT_DIR && docker compose --env-file $ENV_FILE logs --no-color --no-log-prefix api 2>/dev/null | grep -E '$SIG' || true")

total=$(printf '%s\n' "$candidates" | grep -cE "$SIG" || true)
genuine=$(printf '%s\n' "$candidates" | grep -E "$SIG" | grep -vE "$BENIGN" || true)
genuine_count=$(printf '%s\n' "$genuine" | grep -cE "$SIG" || true)
[[ -z "$candidates" ]] && total=0
[[ -z "$genuine" ]] && genuine_count=0

cursor_era=$(ssh_exec "docker exec $DB_CONTAINER psql -U $DB_USER -d $DB_NAME -tA -c 'SELECT era FROM cursor_ ORDER BY update_datetime DESC LIMIT 1;'" 2>/dev/null || echo "?")

# yaci-store stores the cursor era as a numeric ordinal:
# 1=Byron 2=Shelley 3=Allegra 4=Mary 5=Alonzo 6=Babbage 7=Conway.
# (Some builds may store the name instead, so accept both.)
case "$cursor_era" in
7|Conway) era_name="Conway"; conway_reached=1 ;;
6|Babbage) era_name="Babbage"; conway_reached=0 ;;
5|Alonzo) era_name="Alonzo"; conway_reached=0 ;;
*) era_name="era=$cursor_era"; conway_reached=0 ;;
esac

echo "[parse-check] candidate parse-error lines: $total"
echo "[parse-check] genuine (excluding shutdown noise): $genuine_count"
echo "[parse-check] cursor era at tip: $era_name ($cursor_era)"

if [[ "$genuine_count" -gt 0 ]]; then
echo
echo "[parse-check] FAIL — genuine block-parse error(s). First 10:" >&2
printf '%s\n' "$genuine" | head -10 >&2
exit 1
fi

if [[ "$conway_reached" -ne 1 ]]; then
echo
echo "[parse-check] NOTE: cursor era is $era_name ($cursor_era), not yet Conway (7) — the sync has not crossed into the Conway/Van Rossem window. Keep syncing before declaring hard-fork readiness." >&2
fi

echo "[parse-check] OK — no genuine block-parse errors."
Loading
Loading