runboard 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- runboard/__init__.py +39 -0
- runboard/cli.py +178 -0
- runboard/client.py +385 -0
- runboard/config.py +36 -0
- runboard/server.py +163 -0
- runboard/static/app.js +506 -0
- runboard/static/index.html +60 -0
- runboard/static/style.css +177 -0
- runboard/static/uPlot.iife.min.js +2 -0
- runboard/static/uPlot.min.css +1 -0
- runboard/storage.py +193 -0
- runboard/tunnel.py +91 -0
- runboard-0.2.0.dist-info/METADATA +362 -0
- runboard-0.2.0.dist-info/RECORD +18 -0
- runboard-0.2.0.dist-info/WHEEL +5 -0
- runboard-0.2.0.dist-info/entry_points.txt +2 -0
- runboard-0.2.0.dist-info/licenses/LICENSE +21 -0
- runboard-0.2.0.dist-info/top_level.txt +1 -0
runboard/__init__.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
|
|
3
|
+
from .client import Run, sync
|
|
4
|
+
|
|
5
|
+
__all__ = ["init", "log", "finish", "Run", "sync", "run"]
|
|
6
|
+
__version__ = "0.2.0"
|
|
7
|
+
|
|
8
|
+
run = None
|
|
9
|
+
_orig_excepthook = sys.excepthook
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _excepthook(exc_type, exc, tb):
|
|
13
|
+
if run is not None and not issubclass(exc_type, KeyboardInterrupt):
|
|
14
|
+
run.finish("crashed")
|
|
15
|
+
elif run is not None:
|
|
16
|
+
run.finish("killed")
|
|
17
|
+
_orig_excepthook(exc_type, exc, tb)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def init(project="default", name=None, config=None, **kwargs):
|
|
21
|
+
global run
|
|
22
|
+
if run is not None:
|
|
23
|
+
run.finish()
|
|
24
|
+
run = Run(project=project, name=name, config=config, **kwargs)
|
|
25
|
+
sys.excepthook = _excepthook
|
|
26
|
+
return run
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def log(data, step=None):
|
|
30
|
+
if run is None:
|
|
31
|
+
raise RuntimeError("call runboard.init() first")
|
|
32
|
+
run.log(data, step=step)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def finish(status="finished"):
|
|
36
|
+
global run
|
|
37
|
+
if run is not None:
|
|
38
|
+
run.finish(status)
|
|
39
|
+
run = None
|
runboard/cli.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import getpass
|
|
3
|
+
import signal
|
|
4
|
+
import socket
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
import urllib.error
|
|
8
|
+
import urllib.request
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from urllib.parse import quote
|
|
11
|
+
|
|
12
|
+
from . import __version__, config
|
|
13
|
+
from .client import sync
|
|
14
|
+
from .server import Server
|
|
15
|
+
from .storage import Storage
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _notify(url, text):
|
|
19
|
+
try:
|
|
20
|
+
urllib.request.urlopen(urllib.request.Request(url, data=text.encode(), method="POST"), timeout=10).close()
|
|
21
|
+
except OSError as e:
|
|
22
|
+
print(f"[runboard] notify failed: {e}", file=sys.stderr)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _reachable_host():
|
|
26
|
+
name = socket.gethostname()
|
|
27
|
+
try:
|
|
28
|
+
socket.getaddrinfo(name, None)
|
|
29
|
+
return name
|
|
30
|
+
except OSError:
|
|
31
|
+
pass
|
|
32
|
+
try:
|
|
33
|
+
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
|
34
|
+
s.connect(("10.255.255.255", 1))
|
|
35
|
+
return s.getsockname()[0]
|
|
36
|
+
except OSError:
|
|
37
|
+
return "127.0.0.1"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def cmd_serve(args):
|
|
41
|
+
signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
|
|
42
|
+
token = config.get_or_create_token()
|
|
43
|
+
try:
|
|
44
|
+
srv = Server(args.dir, token, host=args.host, port=args.port)
|
|
45
|
+
except OSError as e:
|
|
46
|
+
if args.port_explicit:
|
|
47
|
+
raise SystemExit(f"runboard: cannot bind port {args.port}: {e}")
|
|
48
|
+
srv = Server(args.dir, token, host=args.host, port=0)
|
|
49
|
+
print(f"[runboard] port {args.port} busy; using {srv.port}", file=sys.stderr)
|
|
50
|
+
srv.start()
|
|
51
|
+
advertise = args.advertise or f"http://{_reachable_host()}:{srv.port}"
|
|
52
|
+
config.write_server_info({"url": advertise, "token": token, "dir": str(srv.storage.root)})
|
|
53
|
+
print(f"runboard serving {srv.storage.root}")
|
|
54
|
+
print(f" local: http://127.0.0.1:{srv.port}/?token={token}")
|
|
55
|
+
print(f" cluster: {advertise}/?token={token}")
|
|
56
|
+
print(f" (training jobs pick up the server from {config.home() / 'server.json'})")
|
|
57
|
+
sys.stdout.flush()
|
|
58
|
+
|
|
59
|
+
tunnel = None
|
|
60
|
+
|
|
61
|
+
def open_tunnel():
|
|
62
|
+
from .tunnel import Tunnel
|
|
63
|
+
|
|
64
|
+
t = Tunnel(srv.port)
|
|
65
|
+
url = t.start()
|
|
66
|
+
public = f"{url}/?token={token}"
|
|
67
|
+
print(f" public: {public}", flush=True)
|
|
68
|
+
print(" (the public URL can take ~30s to start resolving)", flush=True)
|
|
69
|
+
info = config.read_server_info()
|
|
70
|
+
info["public_url"] = public
|
|
71
|
+
config.write_server_info(info)
|
|
72
|
+
if args.notify:
|
|
73
|
+
_notify(args.notify, f"runboard dashboard: {public}")
|
|
74
|
+
return t
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
if args.tunnel:
|
|
78
|
+
tunnel = open_tunnel()
|
|
79
|
+
while True:
|
|
80
|
+
time.sleep(5)
|
|
81
|
+
if tunnel and tunnel.proc.poll() is not None:
|
|
82
|
+
print("[runboard] tunnel exited; restarting", file=sys.stderr, flush=True)
|
|
83
|
+
try:
|
|
84
|
+
tunnel = open_tunnel()
|
|
85
|
+
except RuntimeError as e:
|
|
86
|
+
print(f"[runboard] {e}; retrying in 30s", file=sys.stderr, flush=True)
|
|
87
|
+
time.sleep(30)
|
|
88
|
+
except KeyboardInterrupt:
|
|
89
|
+
pass
|
|
90
|
+
finally:
|
|
91
|
+
if tunnel:
|
|
92
|
+
tunnel.stop()
|
|
93
|
+
srv.stop()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def cmd_ls(args):
|
|
97
|
+
runs = Storage(args.dir).list_runs()
|
|
98
|
+
for r in runs:
|
|
99
|
+
upd = datetime.fromtimestamp(r["updated"]).strftime("%Y-%m-%d %H:%M") if r.get("updated") else "-"
|
|
100
|
+
print(f"{r['project']:20} {r['run_id']:28} {str(r.get('status', '')):10} {upd} {r.get('name', '')}")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def cmd_sync(args):
|
|
104
|
+
n = sync(server=args.server, token=args.token, paths=args.files or None)
|
|
105
|
+
print(f"uploaded {n} rows")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def cmd_url(args):
|
|
109
|
+
info = config.read_server_info()
|
|
110
|
+
token = info.get("token") or config.get_or_create_token()
|
|
111
|
+
print(info.get("public_url") or f"{info.get('url', 'http://127.0.0.1:8080')}/?token={token}")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def cmd_configure(args):
|
|
115
|
+
server = args.server.rstrip("/")
|
|
116
|
+
token = args.token or getpass.getpass("Runboard access token: ").strip()
|
|
117
|
+
if not token:
|
|
118
|
+
raise SystemExit("runboard: access token cannot be empty")
|
|
119
|
+
request = urllib.request.Request(
|
|
120
|
+
f"{server}/api/health",
|
|
121
|
+
headers={"Authorization": f"Bearer {token}", "User-Agent": "runboard/0.2"},
|
|
122
|
+
)
|
|
123
|
+
try:
|
|
124
|
+
with urllib.request.urlopen(request, timeout=10) as response:
|
|
125
|
+
if response.status != 200:
|
|
126
|
+
raise OSError(f"HTTP {response.status}")
|
|
127
|
+
except urllib.error.HTTPError as e:
|
|
128
|
+
if e.code == 401:
|
|
129
|
+
raise SystemExit("runboard: the server rejected that token") from e
|
|
130
|
+
raise SystemExit(f"runboard: could not validate the server (HTTP {e.code})") from e
|
|
131
|
+
except OSError as e:
|
|
132
|
+
raise SystemExit(f"runboard: could not reach {server}: {e}") from e
|
|
133
|
+
config.write_server_info({"url": server, "token": token})
|
|
134
|
+
print(f"configured {server}")
|
|
135
|
+
print(f"dashboard: {server}/?token={quote(token, safe='')}")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def main(argv=None):
|
|
139
|
+
p = argparse.ArgumentParser(prog="runboard", description="Live training dashboards for any cluster.")
|
|
140
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
141
|
+
|
|
142
|
+
s = sub.add_parser("serve", help="run the dashboard server")
|
|
143
|
+
s.add_argument("--dir", default="./runboard-runs", help="directory where runs are stored")
|
|
144
|
+
s.add_argument("--host", default="0.0.0.0")
|
|
145
|
+
s.add_argument("--port", type=int, default=None)
|
|
146
|
+
s.add_argument("--tunnel", action="store_true", help="expose publicly via a Cloudflare quick tunnel")
|
|
147
|
+
s.add_argument("--advertise", help="URL training jobs use to reach this server")
|
|
148
|
+
s.add_argument("--notify", help="POST the public URL to this webhook (e.g. https://ntfy.sh/<topic>)")
|
|
149
|
+
s.set_defaults(func=cmd_serve)
|
|
150
|
+
|
|
151
|
+
s = sub.add_parser("ls", help="list runs")
|
|
152
|
+
s.add_argument("--dir", default="./runboard-runs")
|
|
153
|
+
s.set_defaults(func=cmd_ls)
|
|
154
|
+
|
|
155
|
+
s = sub.add_parser("sync", help="upload spooled offline metrics")
|
|
156
|
+
s.add_argument("files", nargs="*")
|
|
157
|
+
s.add_argument("--server")
|
|
158
|
+
s.add_argument("--token")
|
|
159
|
+
s.set_defaults(func=cmd_sync)
|
|
160
|
+
|
|
161
|
+
s = sub.add_parser("url", help="print the dashboard URL")
|
|
162
|
+
s.set_defaults(func=cmd_url)
|
|
163
|
+
|
|
164
|
+
s = sub.add_parser("configure", help="save and verify a hosted Runboard server")
|
|
165
|
+
s.add_argument("server", help="Runboard URL, such as https://runboard.<account>.workers.dev")
|
|
166
|
+
s.add_argument("--token", help="access token; omitted values are read securely from the terminal")
|
|
167
|
+
s.set_defaults(func=cmd_configure)
|
|
168
|
+
|
|
169
|
+
p.add_argument("--version", action="version", version=f"runboard {__version__}")
|
|
170
|
+
args = p.parse_args(argv)
|
|
171
|
+
if getattr(args, "cmd", None) == "serve":
|
|
172
|
+
args.port_explicit = args.port is not None
|
|
173
|
+
args.port = args.port or 8080
|
|
174
|
+
args.func(args)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
if __name__ == "__main__":
|
|
178
|
+
main()
|
runboard/client.py
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
import atexit
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import secrets
|
|
5
|
+
import socket
|
|
6
|
+
import sys
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
import urllib.error
|
|
10
|
+
import urllib.request
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from urllib.parse import quote
|
|
13
|
+
|
|
14
|
+
from . import config as _config
|
|
15
|
+
from .storage import Storage, clean_row, valid_name
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _warn(msg):
|
|
19
|
+
print(f"[runboard] {msg}", file=sys.stderr, flush=True)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _sanitize(name):
|
|
23
|
+
s = "".join(c if c.isalnum() or c in "._-" else "-" for c in str(name))[:128].strip(".")
|
|
24
|
+
return s or "default"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def post_json(server, token, project, run_id, payload, timeout=10):
|
|
28
|
+
url = f"{server.rstrip('/')}/api/runs/{quote(project)}/{quote(run_id)}"
|
|
29
|
+
req = urllib.request.Request(
|
|
30
|
+
url,
|
|
31
|
+
data=json.dumps(payload, default=str).encode(),
|
|
32
|
+
headers={"Content-Type": "application/json", "Authorization": f"Bearer {token}", "User-Agent": "runboard/0.2"},
|
|
33
|
+
method="POST",
|
|
34
|
+
)
|
|
35
|
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
36
|
+
return json.loads(r.read() or b"{}")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class _HttpSink:
|
|
40
|
+
def __init__(self, server, token):
|
|
41
|
+
self.server = server
|
|
42
|
+
self.token = token
|
|
43
|
+
|
|
44
|
+
def send(self, project, run_id, meta, rows):
|
|
45
|
+
pending = [(meta, rows)]
|
|
46
|
+
while pending:
|
|
47
|
+
batch_meta, batch = pending.pop()
|
|
48
|
+
payload = {"rows": batch}
|
|
49
|
+
if batch_meta:
|
|
50
|
+
payload["meta"] = batch_meta
|
|
51
|
+
try:
|
|
52
|
+
post_json(self.server, self.token, project, run_id, payload)
|
|
53
|
+
except urllib.error.HTTPError as e:
|
|
54
|
+
if e.code != 413 or len(batch) < 2:
|
|
55
|
+
raise
|
|
56
|
+
middle = len(batch) // 2
|
|
57
|
+
pending.append(({}, batch[middle:]))
|
|
58
|
+
pending.append((batch_meta, batch[:middle]))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class _FileSink:
|
|
62
|
+
def __init__(self, root):
|
|
63
|
+
self.storage = Storage(root)
|
|
64
|
+
|
|
65
|
+
def send(self, project, run_id, meta, rows):
|
|
66
|
+
if meta:
|
|
67
|
+
self.storage.update_meta(project, run_id, meta)
|
|
68
|
+
if rows:
|
|
69
|
+
self.storage.append_rows(project, run_id, rows)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class _NullSink:
|
|
73
|
+
def send(self, project, run_id, meta, rows):
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _is_nonzero_rank():
|
|
78
|
+
for var in ("RANK", "LOCAL_RANK"):
|
|
79
|
+
v = os.environ.get(var)
|
|
80
|
+
if v not in (None, "", "0"):
|
|
81
|
+
return True
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def spool_dir():
|
|
86
|
+
return _config.home() / "spool"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class Run:
|
|
90
|
+
def __init__(
|
|
91
|
+
self,
|
|
92
|
+
project="default",
|
|
93
|
+
name=None,
|
|
94
|
+
config=None,
|
|
95
|
+
run_id=None,
|
|
96
|
+
server=None,
|
|
97
|
+
token=None,
|
|
98
|
+
dir=None,
|
|
99
|
+
mode=None,
|
|
100
|
+
tags=None,
|
|
101
|
+
flush_interval=1.0,
|
|
102
|
+
max_buffer=1_000_000,
|
|
103
|
+
chunk_size=5000,
|
|
104
|
+
all_ranks=False,
|
|
105
|
+
):
|
|
106
|
+
self.project = _sanitize(project)
|
|
107
|
+
self.run_id = run_id or time.strftime("%Y%m%d-%H%M%S-") + secrets.token_hex(3)
|
|
108
|
+
if not valid_name(self.run_id):
|
|
109
|
+
raise ValueError(f"invalid run_id {self.run_id!r}")
|
|
110
|
+
self.name = name or self.run_id
|
|
111
|
+
self.config = dict(config or {})
|
|
112
|
+
self.flush_interval = flush_interval
|
|
113
|
+
self.max_buffer = max_buffer
|
|
114
|
+
self.chunk_size = chunk_size
|
|
115
|
+
self._send_lock = threading.Lock()
|
|
116
|
+
self._step = 0
|
|
117
|
+
self._seq = 0
|
|
118
|
+
self._sid = secrets.token_hex(8)
|
|
119
|
+
self._rows = []
|
|
120
|
+
self._overflow = []
|
|
121
|
+
self._pending_spools = []
|
|
122
|
+
self._meta = {}
|
|
123
|
+
self._lock = threading.Lock()
|
|
124
|
+
self._stop = threading.Event()
|
|
125
|
+
self._finished = False
|
|
126
|
+
self._warned = False
|
|
127
|
+
if _is_nonzero_rank() and not all_ranks:
|
|
128
|
+
self._sink, self.mode = _NullSink(), "disabled"
|
|
129
|
+
else:
|
|
130
|
+
self._sink, self.mode = self._resolve_sink(server, token, dir, mode)
|
|
131
|
+
self._set_meta(
|
|
132
|
+
name=self.name,
|
|
133
|
+
config=self.config,
|
|
134
|
+
tags=list(tags or []),
|
|
135
|
+
status="running",
|
|
136
|
+
created=time.time(),
|
|
137
|
+
host=socket.gethostname(),
|
|
138
|
+
argv=sys.argv,
|
|
139
|
+
slurm_job_id=os.environ.get("SLURM_JOB_ID"),
|
|
140
|
+
mode=self.mode,
|
|
141
|
+
)
|
|
142
|
+
self._thread = threading.Thread(target=self._loop, name="runboard-sender", daemon=True)
|
|
143
|
+
self._thread.start()
|
|
144
|
+
atexit.register(self.finish)
|
|
145
|
+
|
|
146
|
+
def _resolve_sink(self, server, token, dir, mode):
|
|
147
|
+
dir = dir or os.environ.get("RUNBOARD_DIR")
|
|
148
|
+
if mode == "file" or (mode is None and dir):
|
|
149
|
+
root = dir or "./runboard-runs"
|
|
150
|
+
return _FileSink(root), "file"
|
|
151
|
+
info = _config.read_server_info()
|
|
152
|
+
server = server or os.environ.get("RUNBOARD_SERVER") or info.get("url")
|
|
153
|
+
token = token or os.environ.get("RUNBOARD_TOKEN") or info.get("token")
|
|
154
|
+
if server and token:
|
|
155
|
+
try:
|
|
156
|
+
urllib.request.urlopen(
|
|
157
|
+
urllib.request.Request(
|
|
158
|
+
f"{server.rstrip('/')}/api/health",
|
|
159
|
+
headers={"Authorization": f"Bearer {token}", "User-Agent": "runboard/0.2"},
|
|
160
|
+
),
|
|
161
|
+
timeout=3,
|
|
162
|
+
).close()
|
|
163
|
+
except urllib.error.HTTPError as e:
|
|
164
|
+
if e.code == 401:
|
|
165
|
+
_warn(f"server {server} rejected the token; metrics will be buffered (check RUNBOARD_TOKEN)")
|
|
166
|
+
except OSError as e:
|
|
167
|
+
_warn(f"server {server} not reachable yet ({e}); metrics will be buffered and retried")
|
|
168
|
+
return _HttpSink(server, token), "http"
|
|
169
|
+
if mode == "http":
|
|
170
|
+
raise ValueError("http mode needs a server and token (args, RUNBOARD_SERVER/RUNBOARD_TOKEN, or `runboard serve`)")
|
|
171
|
+
_warn("no server found; writing to ./runboard-runs (run `runboard serve --dir ./runboard-runs` to view)")
|
|
172
|
+
return _FileSink("./runboard-runs"), "file"
|
|
173
|
+
|
|
174
|
+
def _set_meta(self, **kw):
|
|
175
|
+
with self._lock:
|
|
176
|
+
self._meta.update(kw)
|
|
177
|
+
|
|
178
|
+
def log(self, data, step=None):
|
|
179
|
+
if self._finished:
|
|
180
|
+
return
|
|
181
|
+
if step is None:
|
|
182
|
+
step = self._step
|
|
183
|
+
self._step = max(self._step, int(step) + 1)
|
|
184
|
+
row = clean_row(dict(data))
|
|
185
|
+
row["_step"] = int(step)
|
|
186
|
+
row["_time"] = time.time()
|
|
187
|
+
with self._lock:
|
|
188
|
+
row["_seq"] = self._seq
|
|
189
|
+
row["_sid"] = self._sid
|
|
190
|
+
self._seq += 1
|
|
191
|
+
self._rows.append(row)
|
|
192
|
+
if len(self._rows) > self.max_buffer:
|
|
193
|
+
overflow = self._rows[: len(self._rows) - self.max_buffer]
|
|
194
|
+
del self._rows[: len(overflow)]
|
|
195
|
+
self._overflow.extend(overflow)
|
|
196
|
+
|
|
197
|
+
def _take(self):
|
|
198
|
+
with self._lock:
|
|
199
|
+
rows, meta = self._overflow, self._meta
|
|
200
|
+
rows.extend(self._rows)
|
|
201
|
+
self._rows, self._meta = [], {}
|
|
202
|
+
self._overflow = []
|
|
203
|
+
return meta, rows
|
|
204
|
+
|
|
205
|
+
def _restore(self, meta, rows):
|
|
206
|
+
with self._lock:
|
|
207
|
+
self._overflow[:0] = rows
|
|
208
|
+
self._meta = {**meta, **self._meta}
|
|
209
|
+
|
|
210
|
+
def _is_empty(self):
|
|
211
|
+
with self._lock:
|
|
212
|
+
return not (self._pending_spools or self._overflow or self._rows or self._meta)
|
|
213
|
+
|
|
214
|
+
def _flush_once(self):
|
|
215
|
+
with self._send_lock:
|
|
216
|
+
with self._lock:
|
|
217
|
+
pending_spool = self._pending_spools[0] if self._pending_spools else None
|
|
218
|
+
if pending_spool is not None:
|
|
219
|
+
return self._flush_spool(pending_spool)
|
|
220
|
+
meta, rows = self._take()
|
|
221
|
+
if not meta and not rows:
|
|
222
|
+
return True
|
|
223
|
+
sent = 0
|
|
224
|
+
try:
|
|
225
|
+
while True:
|
|
226
|
+
chunk = rows[sent : sent + self.chunk_size]
|
|
227
|
+
try:
|
|
228
|
+
self._sink.send(self.project, self.run_id, meta, chunk)
|
|
229
|
+
except urllib.error.HTTPError as e:
|
|
230
|
+
if 400 <= e.code < 500 and e.code not in (401, 408, 429):
|
|
231
|
+
_warn(f"server rejected {len(chunk)} rows ({e.code}: {e.reason}); dropping them")
|
|
232
|
+
else:
|
|
233
|
+
raise
|
|
234
|
+
meta = {}
|
|
235
|
+
sent += len(chunk)
|
|
236
|
+
if sent >= len(rows):
|
|
237
|
+
break
|
|
238
|
+
if self._warned:
|
|
239
|
+
_warn("connection restored")
|
|
240
|
+
self._warned = False
|
|
241
|
+
return True
|
|
242
|
+
except Exception as e:
|
|
243
|
+
self._restore(meta, rows[sent:])
|
|
244
|
+
if not self._warned:
|
|
245
|
+
_warn(f"could not send metrics ({e}); buffering and retrying")
|
|
246
|
+
self._warned = True
|
|
247
|
+
return False
|
|
248
|
+
|
|
249
|
+
def _flush_spool(self, path):
|
|
250
|
+
try:
|
|
251
|
+
rec = json.loads(path.read_text())
|
|
252
|
+
rows = rec.get("rows", [])
|
|
253
|
+
meta = rec.get("meta") or {}
|
|
254
|
+
for i in range(0, max(len(rows), 1), self.chunk_size):
|
|
255
|
+
self._sink.send(self.project, self.run_id, meta, rows[i : i + self.chunk_size])
|
|
256
|
+
meta = {}
|
|
257
|
+
path.unlink()
|
|
258
|
+
with self._lock:
|
|
259
|
+
self._pending_spools.remove(path)
|
|
260
|
+
if self._warned:
|
|
261
|
+
_warn("connection restored")
|
|
262
|
+
self._warned = False
|
|
263
|
+
return True
|
|
264
|
+
except urllib.error.HTTPError as e:
|
|
265
|
+
if 400 <= e.code < 500 and e.code not in (401, 408, 429):
|
|
266
|
+
_warn(f"server rejected spooled metrics ({e.code}: {e.reason}); dropping {path}")
|
|
267
|
+
try:
|
|
268
|
+
path.unlink(missing_ok=True)
|
|
269
|
+
except OSError:
|
|
270
|
+
pass
|
|
271
|
+
with self._lock:
|
|
272
|
+
if path in self._pending_spools:
|
|
273
|
+
self._pending_spools.remove(path)
|
|
274
|
+
return True
|
|
275
|
+
if not self._warned:
|
|
276
|
+
_warn(f"could not send metrics ({e}); buffering and retrying")
|
|
277
|
+
self._warned = True
|
|
278
|
+
return False
|
|
279
|
+
except Exception as e:
|
|
280
|
+
if not self._warned:
|
|
281
|
+
_warn(f"could not send metrics ({e}); buffering and retrying")
|
|
282
|
+
self._warned = True
|
|
283
|
+
return False
|
|
284
|
+
|
|
285
|
+
def _spill_overflow(self):
|
|
286
|
+
with self._lock:
|
|
287
|
+
rows, self._overflow = self._overflow, []
|
|
288
|
+
if not rows:
|
|
289
|
+
return
|
|
290
|
+
try:
|
|
291
|
+
paths = [self._spool({}, rows[i : i + self.chunk_size * 10]) for i in range(0, len(rows), self.chunk_size * 10)]
|
|
292
|
+
except OSError as e:
|
|
293
|
+
with self._lock:
|
|
294
|
+
self._overflow[:0] = rows
|
|
295
|
+
if not self._warned:
|
|
296
|
+
_warn(f"could not spool metrics ({e}); retaining them in memory")
|
|
297
|
+
self._warned = True
|
|
298
|
+
return
|
|
299
|
+
with self._lock:
|
|
300
|
+
self._pending_spools.extend(paths)
|
|
301
|
+
|
|
302
|
+
def _loop(self):
|
|
303
|
+
delay = self.flush_interval
|
|
304
|
+
while not self._stop.wait(delay):
|
|
305
|
+
try:
|
|
306
|
+
self._spill_overflow()
|
|
307
|
+
ok = self._flush_once()
|
|
308
|
+
except Exception:
|
|
309
|
+
ok = False
|
|
310
|
+
delay = self.flush_interval if ok else min(delay * 2, 30.0)
|
|
311
|
+
|
|
312
|
+
def _spool(self, meta, rows):
|
|
313
|
+
d = spool_dir()
|
|
314
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
315
|
+
first_seq = rows[0].get("_seq", 0) if rows else 0
|
|
316
|
+
p = d / f"{self.project[:48]}__{self.run_id[:48]}__{self._sid}__{first_seq:020d}.jsonl"
|
|
317
|
+
tmp = p.with_suffix(".jsonl.tmp")
|
|
318
|
+
tmp.write_text(json.dumps({"project": self.project, "run_id": self.run_id, "meta": meta, "rows": rows}, default=str) + "\n")
|
|
319
|
+
with open(tmp, "rb") as f:
|
|
320
|
+
os.fsync(f.fileno())
|
|
321
|
+
os.replace(tmp, p)
|
|
322
|
+
fd = os.open(d, os.O_RDONLY)
|
|
323
|
+
try:
|
|
324
|
+
os.fsync(fd)
|
|
325
|
+
finally:
|
|
326
|
+
os.close(fd)
|
|
327
|
+
return p
|
|
328
|
+
|
|
329
|
+
def finish(self, status="finished", timeout=10.0):
|
|
330
|
+
if self._finished:
|
|
331
|
+
return
|
|
332
|
+
self._finished = True
|
|
333
|
+
self._stop.set()
|
|
334
|
+
self._thread.join(timeout=5)
|
|
335
|
+
self._set_meta(status=status, finished=time.time())
|
|
336
|
+
deadline = time.time() + timeout
|
|
337
|
+
while True:
|
|
338
|
+
if self._flush_once() and self._is_empty():
|
|
339
|
+
return
|
|
340
|
+
if time.time() >= deadline:
|
|
341
|
+
break
|
|
342
|
+
time.sleep(1.0)
|
|
343
|
+
with self._send_lock:
|
|
344
|
+
meta, rows = self._take()
|
|
345
|
+
if rows or meta:
|
|
346
|
+
try:
|
|
347
|
+
p = self._spool(meta, rows)
|
|
348
|
+
_warn(f"server unreachable; {len(rows)} rows saved to {p}. Upload later with `runboard sync`.")
|
|
349
|
+
except OSError as e:
|
|
350
|
+
self._restore(meta, rows)
|
|
351
|
+
_warn(f"server unreachable and could not write spool file ({e}); metrics remain in memory")
|
|
352
|
+
with self._lock:
|
|
353
|
+
pending_spools = list(self._pending_spools)
|
|
354
|
+
if pending_spools:
|
|
355
|
+
_warn(f"server unreachable; {len(pending_spools)} metric batches saved to {spool_dir()}. Upload later with `runboard sync`.")
|
|
356
|
+
|
|
357
|
+
def __enter__(self):
|
|
358
|
+
return self
|
|
359
|
+
|
|
360
|
+
def __exit__(self, exc_type, exc, tb):
|
|
361
|
+
self.finish("crashed" if exc_type else "finished")
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def sync(server=None, token=None, paths=None):
|
|
365
|
+
info = _config.read_server_info()
|
|
366
|
+
server = server or os.environ.get("RUNBOARD_SERVER") or info.get("url")
|
|
367
|
+
token = token or os.environ.get("RUNBOARD_TOKEN") or info.get("token")
|
|
368
|
+
if not (server and token):
|
|
369
|
+
raise ValueError("no server/token configured")
|
|
370
|
+
sink = _HttpSink(server, token)
|
|
371
|
+
files = [Path(p) for p in paths] if paths else sorted(spool_dir().glob("*.jsonl"))
|
|
372
|
+
total = 0
|
|
373
|
+
for p in files:
|
|
374
|
+
for line in p.read_text().splitlines():
|
|
375
|
+
if not line.strip():
|
|
376
|
+
continue
|
|
377
|
+
rec = json.loads(line)
|
|
378
|
+
rows = rec["rows"]
|
|
379
|
+
meta = rec.get("meta") or None
|
|
380
|
+
for i in range(0, max(len(rows), 1), 5000):
|
|
381
|
+
sink.send(rec["project"], rec["run_id"], meta, rows[i : i + 5000])
|
|
382
|
+
meta = None
|
|
383
|
+
total += len(rows)
|
|
384
|
+
p.unlink()
|
|
385
|
+
return total
|
runboard/config.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import secrets
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def home():
|
|
8
|
+
return Path(os.environ.get("RUNBOARD_HOME", "~/.runboard")).expanduser()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_or_create_token():
|
|
12
|
+
p = home() / "token"
|
|
13
|
+
if p.exists():
|
|
14
|
+
t = p.read_text().strip()
|
|
15
|
+
if t:
|
|
16
|
+
return t
|
|
17
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
18
|
+
t = secrets.token_urlsafe(24)
|
|
19
|
+
p.write_text(t)
|
|
20
|
+
os.chmod(p, 0o600)
|
|
21
|
+
return t
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def write_server_info(info):
|
|
25
|
+
p = home() / "server.json"
|
|
26
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
p.write_text(json.dumps(info, indent=2))
|
|
28
|
+
os.chmod(p, 0o600)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def read_server_info():
|
|
32
|
+
p = home() / "server.json"
|
|
33
|
+
try:
|
|
34
|
+
return json.loads(p.read_text())
|
|
35
|
+
except (OSError, ValueError):
|
|
36
|
+
return {}
|