A New SJ Satellite Is Coming — Early Joiners Get More!

Hi @mattventura, here is stats for these satellites, https://stats.storkspace.net/, it will update once a day (retry several times a day if somehow fail).

storj_stats.py
#!/usr/bin/env python3
"""Generate data.json, data-public.json and nodes.json (stats.storjshare.io style)
from satellite databases running on CockroachDB.

Every query reads historical data with AS OF SYSTEM TIME '-10m' (configurable
with --as-of) to avoid contention with live writes. The "last 30 days" and
"4 hour online" windows are relative to now(), which differs from the read
time by only ~10 minutes.

Usage:
    pip install "psycopg[binary]"
    python3 storj_stats.py --config config.toml --out ./out
    python3 storj_stats.py --config config.toml --as-of -30s
    python3 storj_stats.py --print-sql            # show the queries, no DB needed

Requires Python 3.11+ (tomllib).
"""
from __future__ import annotations

import argparse
import json
import os
import re
import sys
import tempfile
import tomllib
from decimal import Decimal
from pathlib import Path

# --------------------------------------------------------------------------
# Queries. {asof} is replaced by the --as-of value (validated, e.g. -10m).
#   pb.PieceAction: PUT = 1 (upload), GET = 2 (download)
#   placement 0 / NULL = every country = public placement
# --------------------------------------------------------------------------
AOST = "AS OF SYSTEM TIME '{asof}'"

Q_STORAGE = f"""
SELECT
    COALESCE(SUM(total_bytes), 0)           AS storage_total_bytes,
    COALESCE(SUM(object_count), 0)          AS storage_total_objects,
    COALESCE(SUM(remote_segments_count), 0) AS storage_total_segments
FROM bucket_storage_tallies {AOST}
WHERE interval_start = (SELECT MAX(interval_start) FROM bucket_storage_tallies)
"""

Q_BANDWIDTH = f"""
SELECT
    COALESCE(SUM(settled + inline) FILTER (WHERE action = 2), 0) AS bandwidth_bytes_downloaded,
    COALESCE(SUM(settled + inline) FILTER (WHERE action = 1), 0) AS bandwidth_bytes_uploaded
FROM bucket_bandwidth_rollups {AOST}
WHERE interval_start >  now() - INTERVAL '30 days'
  AND action IN (1, 2)
"""

# Run on the METABASE database. redundancy is a little-endian packed int8:
# byte4 = required shares, byte7 = total shares.
Q_EXPANSION = f"""
SELECT
    COALESCE(SUM(
        encrypted_size::numeric
        * ((redundancy >> 56) & 255)
        / NULLIF((redundancy >> 32) & 255, 0)
    ), 0)::bigint AS storage_total_bytes_after_expansion
FROM segments {AOST}
WHERE remote_alias_pieces IS NOT NULL
  AND (expires_at IS NULL OR expires_at > now())
"""

Q_STORAGE_PUBLIC = f"""
SELECT
    COALESCE(SUM(t.total_bytes), 0)           AS storage_total_bytes,
    COALESCE(SUM(t.object_count), 0)          AS storage_total_objects,
    COALESCE(SUM(t.remote_segments_count), 0) AS storage_total_segments
FROM bucket_storage_tallies t
JOIN bucket_metainfos b
  ON b.project_id = t.project_id AND b.name = t.bucket_name
{AOST}
WHERE t.interval_start = (SELECT MAX(interval_start) FROM bucket_storage_tallies)
  AND COALESCE(b.placement, 0) = 0
"""

Q_BANDWIDTH_PUBLIC = f"""
SELECT
    COALESCE(SUM(r.settled + r.inline) FILTER (WHERE r.action = 2), 0) AS bandwidth_bytes_downloaded,
    COALESCE(SUM(r.settled + r.inline) FILTER (WHERE r.action = 1), 0) AS bandwidth_bytes_uploaded
FROM bucket_bandwidth_rollups r
JOIN bucket_metainfos b
  ON b.project_id = r.project_id AND b.name = r.bucket_name
{AOST}
WHERE r.interval_start >  now() - INTERVAL '30 days'
  AND r.action IN (1, 2)
  AND COALESCE(b.placement, 0) = 0
"""

Q_NODES = f"""
WITH n AS (
    SELECT
        *,
        (disqualified IS NOT NULL)                              AS is_dq,
        (disqualified IS NULL AND exit_finished_at IS NOT NULL) AS is_exited,
        (disqualified IS NULL AND exit_finished_at IS NULL)     AS is_live
    FROM nodes
)
SELECT
    COUNT(*) FILTER (WHERE is_live AND last_contact_success >  now() - INTERVAL '4 hours') AS active_nodes,
    COUNT(*) FILTER (WHERE is_dq)                                                                          AS disqualified_nodes,
    COUNT(*) FILTER (WHERE is_exited)                                                                      AS exited_nodes,
    COUNT(*) FILTER (WHERE is_live AND last_contact_success <= now() - INTERVAL '4 hours') AS offline_nodes,
    COUNT(*) FILTER (WHERE is_live AND (unknown_audit_suspended IS NOT NULL
                                        OR offline_suspended IS NOT NULL))                                 AS suspended_nodes,
    COUNT(*)                                                                                               AS total_nodes,
    COUNT(*) FILTER (WHERE is_live AND vetted_at IS NOT NULL)                                              AS vetted_nodes,
    COUNT(*) FILTER (WHERE is_live AND free_disk >= 0 AND free_disk < 5000000000)                          AS full_nodes,
    COUNT(DISTINCT wallet) FILTER (WHERE is_live
                                   AND last_contact_success > now() - INTERVAL '4 hours'
                                   AND wallet <> '')                                                       AS active_wallets
FROM n {AOST}
"""


def sql(template: str, asof: str) -> str:
    return template.format(asof=asof).strip()


def validate_asof(value: str) -> str:
    if not re.fullmatch(r"-\d+(ms|s|m|h)", value):
        raise argparse.ArgumentTypeError("use a negative interval such as -10m, -30s or -1h")
    return value


def to_jsonable(v):
    if v is None:
        return 0
    if isinstance(v, Decimal):
        return int(v)
    return v


def fetch_row(conn, query: str) -> dict:
    with conn.cursor() as cur:
        cur.execute(query)
        cols = [c.name for c in cur.description]
        row = cur.fetchone()
    return {c: to_jsonable(v) for c, v in zip(cols, row)}


def collect(sat: dict, asof: str, skip_expansion: bool) -> dict:
    import psycopg  # imported here so --print-sql works without the driver

    data, data_public, nodes = {}, {}, {}

    with psycopg.connect(sat["satellitedb"], autocommit=True) as conn:
        data.update(fetch_row(conn, sql(Q_STORAGE, asof)))
        data.update(fetch_row(conn, sql(Q_BANDWIDTH, asof)))
        data_public.update(fetch_row(conn, sql(Q_STORAGE_PUBLIC, asof)))
        data_public.update(fetch_row(conn, sql(Q_BANDWIDTH_PUBLIC, asof)))
        nodes.update(fetch_row(conn, sql(Q_NODES, asof)))

    if sat.get("metabase") and not skip_expansion:
        with psycopg.connect(sat["metabase"], autocommit=True) as conn:
            data.update(fetch_row(conn, sql(Q_EXPANSION, asof)))
    else:
        data["storage_total_bytes_after_expansion"] = None

    # keep the same field order as the published page
    order = [
        "bandwidth_bytes_downloaded", "bandwidth_bytes_uploaded",
        "storage_total_bytes", "storage_total_bytes_after_expansion",
        "storage_total_objects", "storage_total_segments",
    ]
    data = {k: data[k] for k in order}
    return {"data": data, "data_public": data_public, "nodes": nodes}


def write_json_atomic(path: Path, obj) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=path.name, suffix=".tmp")
    try:
        with os.fdopen(fd, "w") as f:
            json.dump(obj, f, indent=2)
            f.write("\n")
        os.replace(tmp, path)
    except BaseException:
        os.unlink(tmp)
        raise


def print_sql(asof: str) -> None:
    sections = [
        ("data.json: storage [satellitedb]", Q_STORAGE),
        ("data.json: bandwidth [satellitedb]", Q_BANDWIDTH),
        ("data.json: storage_total_bytes_after_expansion [metabase]", Q_EXPANSION),
        ("data-public.json: storage [satellitedb]", Q_STORAGE_PUBLIC),
        ("data-public.json: bandwidth [satellitedb]", Q_BANDWIDTH_PUBLIC),
        ("nodes.json [satellitedb]", Q_NODES),
    ]
    print(f"-- Every query reads AS OF SYSTEM TIME '{asof}'\n")
    for title, q in sections:
        print(f"-- {title}\n{sql(q, asof)};\n")


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("--config", default="config.toml", help="TOML file listing satellites (default: config.toml)")
    ap.add_argument("--out", default="out", help="output directory (default: ./out)")
    ap.add_argument("--as-of", dest="asof", type=validate_asof, default="-10m",
                    help="AS OF SYSTEM TIME offset (default: -10m)")
    ap.add_argument("--skip-expansion", action="store_true",
                    help="skip the slow metabase scan; storage_total_bytes_after_expansion becomes null")
    ap.add_argument("--print-sql", action="store_true", help="print the queries and exit")
    args = ap.parse_args()

    asof = args.asof

    if args.print_sql:
        print_sql(asof)
        return 0

    with open(args.config, "rb") as f:
        cfg = tomllib.load(f)
    sats = cfg.get("satellite", [])
    if not sats:
        print("error: no [[satellite]] entries in config", file=sys.stderr)
        return 2

    print(f"reading AS OF SYSTEM TIME '{asof}'", file=sys.stderr)

    out = {"data": {}, "data_public": {}, "nodes": {}}
    failed = False
    for sat in sats:
        url = sat["url"]
        print(f"[{url}] querying...", file=sys.stderr)
        try:
            res = collect(sat, asof, args.skip_expansion)
        except Exception as e:  # report every satellite, then fail as a whole
            failed = True
            print(f"[{url}] FAILED: {e}", file=sys.stderr)
            continue
        for k in out:
            out[k][url] = res[k]

    if failed:
        print("not writing any file because at least one satellite failed", file=sys.stderr)
        return 1

    outdir = Path(args.out)
    write_json_atomic(outdir / "data.json", out["data"])
    write_json_atomic(outdir / "data-public.json", out["data_public"])
    write_json_atomic(outdir / "nodes.json", out["nodes"])
    print(f"wrote data.json, data-public.json, nodes.json to {outdir}/", file=sys.stderr)
    return 0


if __name__ == "__main__":
    sys.exit(main())