gipper 0.3.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.
gipper/history.py ADDED
@@ -0,0 +1,232 @@
1
+ """What gipper has learned from its own rentals, read from the ledger (nothing else
2
+ is stored): how often a provider's or a machine's hosts turn out bad, how long they
3
+ take to be ready, how fast transfers really go from here, how long a workload's
4
+ compute takes per GPU, and how a provider's bill compares with the rate x lifetime
5
+ gipper computes.
6
+
7
+ A rental (one machine) is *good* once its job started on it, and *bad* if the
8
+ host was broken: a busy GPU, CUDA that won't initialise, ssh that never came up,
9
+ a failed probe, or a connection lost before or during the job. A host rejected
10
+ because it doesn't suit this job (`run.Unsuitable`: compute capability, a
11
+ client's runtime check) is neither. Nor are errors on this side (a local file
12
+ that won't upload), errors before any machine existed (no stock), or a job that
13
+ fails on a working host.
14
+
15
+ Compute time is learnt only from jobs that succeeded; transfer speed only from
16
+ real transfers of at least MIN_TRANSFER_BYTES. Small probes mostly measure ssh
17
+ start-up, so they're never used for speed.
18
+
19
+ Rates are smoothed so one bad night doesn't condemn a provider and one good run
20
+ doesn't make a stranger's machine trusted: a machine starts from its provider's
21
+ rate, a provider from PRIOR_FAIL.
22
+ """
23
+ import re
24
+ import statistics
25
+ import time
26
+ from collections import defaultdict
27
+ from dataclasses import dataclass, field
28
+ from datetime import datetime
29
+
30
+ from gipper import ledger
31
+ from gipper.config import settings
32
+
33
+ PRIOR_FAIL = 0.10 # a provider gipper has never used
34
+ PROVIDER_WEIGHT = 4 # rentals' worth of belief in the prior
35
+ MACHINE_WEIGHT = 2 # rentals' worth of belief in the provider's rate, for one machine
36
+ MIN_TRANSFER_BYTES = 20e6 # smaller transfers say more about ssh start-up than bandwidth
37
+ MIN_BILLED_S = 600 # shorter rentals mostly measure a provider's rounding, not its rate
38
+ LOST = re.compile(r"ssh|connection|timed out|timeout|broken pipe|reset by peer|no route|unreachable", re.I)
39
+ # host.reject reasons written before rejects carried a "kind": the host's fault or not
40
+ BROKEN = re.compile(r"busy|nvidia-smi|cuinit|cuda initiali|device enumeration|libcuda|ssh|never came up|no ssh endpoint|"
41
+ r"not active|is (exited|unknown|offline)|probe|below configured minimum|timeout|connection", re.I)
42
+
43
+
44
+ @dataclass
45
+ class Rental:
46
+ provider: str
47
+ pod: str
48
+ job: str | None
49
+ ts: float
50
+ machine: str | None = None
51
+ gpu: str | None = None
52
+ country: str | None = None
53
+ ready_s: float | None = None
54
+ started: float | None = None # run.start time
55
+ ended: float | None = None # job exit (or collect, for older receipts)
56
+ compute_s: float | None = None # only from a job that succeeded
57
+ succeeded: bool = False
58
+ outcome: str | None = None # good | bad
59
+ why: str | None = None
60
+ up_mbps: list = field(default_factory=list) # real transfers (inputs up, outputs down)
61
+ down_mbps: list = field(default_factory=list)
62
+ computed_usd: float = 0.0
63
+ computed_s: float = 0.0
64
+ billed_usd: float | None = None
65
+ cleanup_pending: bool = False
66
+
67
+
68
+ def _ts(r):
69
+ return datetime.fromisoformat(r["ts"]).timestamp()
70
+
71
+
72
+ def _median(xs):
73
+ xs = [x for x in xs if x is not None]
74
+ return statistics.median(xs) if xs else None
75
+
76
+
77
+ class History:
78
+ def __init__(self, rentals, workloads, rtts=()):
79
+ self.rentals = rentals # [Rental]
80
+ self.workloads = workloads # job id -> workload
81
+ self.rtts = list(rtts) # [(provider, machine, country, origin, ms)]
82
+ self._by = defaultdict(list)
83
+ for r in rentals:
84
+ self._by[r.provider].append(r)
85
+ if r.machine:
86
+ self._by[(r.provider, r.machine)].append(r)
87
+
88
+ @classmethod
89
+ def load(cls, days=None, records=None):
90
+ days = settings()["history_days"] if days is None else days
91
+ recs = records if records is not None else ledger.read(since=time.time() - days * 86400)
92
+ rentals, workloads, current, billed, rtts = {}, {}, {}, {}, []
93
+ for r in recs:
94
+ p, kind, job = r["payload"], r["kind"], r.get("job")
95
+ if (r.get("refs") or {}).get("workload") and job:
96
+ workloads[job] = r["refs"]["workload"]
97
+ key = (p.get("provider") or "runpod", str(p.get("pod"))) if p.get("pod") is not None else None
98
+ if kind == "pod.create" and key:
99
+ rentals[key] = Rental(key[0], key[1], job, _ts(r), machine=_s(p.get("machine")), gpu=p.get("gpu"),
100
+ country=p.get("country"))
101
+ current[job] = key
102
+ continue
103
+ if kind == "market.rtt":
104
+ rtts += [(x.get("provider"), _s(x.get("machine")), x.get("country"), p.get("origin"), x["ms"])
105
+ for x in p.get("samples") or [] if x.get("ms") is not None]
106
+ continue
107
+ if kind == "error" and job in current:
108
+ rent, err = rentals.get(current[job]), p.get("error", "")
109
+ if rent and not rent.ended and LOST.search(err) and (rent.outcome is None or rent.started):
110
+ rent.outcome, rent.why = "bad", ("lost: " if rent.started else "") + err[:200]
111
+ continue
112
+ if kind == "run.end" and job in current:
113
+ rent = rentals.get(current[job])
114
+ if rent and rent.started:
115
+ rent.ended = rent.ended or _ts(r)
116
+ rent.succeeded = p.get("outcome") == "succeeded"
117
+ rent.compute_s = p.get("compute_s") or rent.compute_s
118
+ continue
119
+ if kind == "spend.billed" and key:
120
+ billed[key] = p.get("usd", 0.0) # the latest bill for the machine counts
121
+ rent = rentals.get(key) if key else None
122
+ if rent is None:
123
+ continue
124
+ if kind == "pod.ready":
125
+ rent.ready_s = p.get("after_s")
126
+ rent.machine = rent.machine or _s(p.get("machine"))
127
+ rent.gpu = p.get("gpu") or rent.gpu
128
+ elif kind == "run.start":
129
+ rent.started, rent.outcome = _ts(r), "good"
130
+ elif kind == "host.reject":
131
+ why = p.get("why", "")
132
+ broken = p.get("kind") == "broken" or (p.get("kind") is None and BROKEN.search(why))
133
+ rent.outcome, rent.why = ("bad" if broken else None), why[:200]
134
+ rent.machine = rent.machine or _s(p.get("machine"))
135
+ elif kind == "transfer" and p.get("seconds") and p.get("bytes", 0) >= MIN_TRANSFER_BYTES:
136
+ mbps = p["bytes"] * 8 / 1e6 / p["seconds"]
137
+ (rent.up_mbps if p.get("direction") == "up" else rent.down_mbps).append(mbps)
138
+ elif kind == "collect" and rent.started and not rent.ended:
139
+ rent.ended = _ts(r) # older receipts: compute ~ run.start .. collect
140
+ elif kind == "spend":
141
+ rent.computed_usd += p.get("usd", 0.0)
142
+ rent.computed_s += p.get("seconds", 0)
143
+ elif kind == "pod.termination_pending":
144
+ rent.cleanup_pending = True
145
+ elif kind == "pod.closure_verified":
146
+ rent.cleanup_pending = False
147
+ elif kind == "host.rtt" and p.get("ms") is not None:
148
+ rtts.append((rent.provider, rent.machine or _s(p.get("machine")), rent.country or p.get("country"),
149
+ p.get("origin"), p["ms"]))
150
+ for key, usd in billed.items():
151
+ if key in rentals:
152
+ rentals[key].billed_usd = usd
153
+ for rent in rentals.values():
154
+ if not rent.succeeded:
155
+ rent.compute_s = None # a capped, failed or interrupted run says nothing about how long it takes
156
+ elif rent.compute_s is None and rent.started and rent.ended:
157
+ rent.compute_s = rent.ended - rent.started
158
+ return cls(list(rentals.values()), workloads, rtts)
159
+
160
+ # ---------------------------------------------------------------- what it knows
161
+ def rentals_of(self, provider, machine=None):
162
+ return self._by[(provider, str(machine))] if machine is not None else self._by[provider]
163
+
164
+ def failure(self, provider, machine=None):
165
+ """Chance a fresh rental turns out bad: the machine's record if it has one,
166
+ pulled towards the provider's, pulled towards PRIOR_FAIL."""
167
+ rs = [r for r in self.rentals_of(provider) if r.outcome]
168
+ bad = sum(r.outcome == "bad" for r in rs)
169
+ p = (bad + PRIOR_FAIL * PROVIDER_WEIGHT) / (len(rs) + PROVIDER_WEIGHT)
170
+ if machine is None:
171
+ return p
172
+ ms = [r for r in self.rentals_of(provider, machine) if r.outcome]
173
+ return (sum(r.outcome == "bad" for r in ms) + p * MACHINE_WEIGHT) / (len(ms) + MACHINE_WEIGHT)
174
+
175
+ def ready_s(self, provider, machine=None):
176
+ return (_median([r.ready_s for r in self.rentals_of(provider, machine)]) if machine is not None else None) \
177
+ or _median([r.ready_s for r in self.rentals_of(provider)])
178
+
179
+ def mbps(self, provider, machine, direction, country=None):
180
+ """Measured transfer speed from here (real transfers): this machine, then the
181
+ provider's machines in the same country (far hosts upload slower), then the
182
+ provider; None if gipper hasn't timed one."""
183
+ pick = (lambda r: r.up_mbps) if direction == "up" else (lambda r: r.down_mbps)
184
+ scopes = ((self.rentals_of(provider, machine),) if machine is not None else ()) + \
185
+ (([r for r in self.rentals_of(provider) if r.country == country],) if country else ()) + \
186
+ (self.rentals_of(provider),)
187
+ for rs in scopes:
188
+ v = _median([x for r in rs for x in pick(r)])
189
+ if v:
190
+ return v
191
+ return None
192
+
193
+ def rtt(self, provider, machine, origin):
194
+ """Median round trip from `origin` to this machine, from pings and rentals."""
195
+ if machine is None:
196
+ return None
197
+ return _median([ms for p, m, c, o, ms in self.rtts if p == provider and m == str(machine) and o == origin])
198
+
199
+ def country_rtt(self, country, origin):
200
+ """Median round trip from `origin` to any host in `country`."""
201
+ if not country:
202
+ return None
203
+ return _median([ms for p, m, c, o, ms in self.rtts if c == country and o == origin])
204
+
205
+ def compute_s(self, workload, gpu):
206
+ """Median compute seconds this workload took on this GPU before."""
207
+ from gipper.market import matches
208
+ return _median([r.compute_s for r in self.rentals if r.compute_s and r.gpu and matches(gpu, r.gpu)
209
+ and self.workloads.get(r.job) == workload])
210
+
211
+ def billing_factor(self, provider):
212
+ """Billed / computed over rentals of MIN_BILLED_S or more, as one ratio of
213
+ totals: how much a provider's bill exceeds rate x lifetime for real work.
214
+ (Short rentals mostly measure rounding: a 47 s Vast run billed 5x.)"""
215
+ rs = [r for r in self.rentals_of(provider)
216
+ if r.billed_usd is not None and r.computed_usd > 0 and r.computed_s >= MIN_BILLED_S]
217
+ computed = sum(r.computed_usd for r in rs)
218
+ return sum(r.billed_usd for r in rs) / computed if computed > 0.05 else None
219
+
220
+ def summary(self, provider):
221
+ rs = self.rentals_of(provider)
222
+ judged = [r for r in rs if r.outcome]
223
+ return {"rentals": len(rs), "good": sum(r.outcome == "good" for r in judged),
224
+ "bad": sum(r.outcome == "bad" for r in judged), "failure": self.failure(provider),
225
+ "ready_s": self.ready_s(provider), "up_mbps": self.mbps(provider, None, "up"),
226
+ "down_mbps": self.mbps(provider, None, "down"), "billing_factor": self.billing_factor(provider),
227
+ "cleanup_pending": sum(r.cleanup_pending for r in rs),
228
+ "reasons": [r.why for r in judged if r.outcome == "bad"][-5:]}
229
+
230
+
231
+ def _s(v):
232
+ return str(v) if v is not None else None
gipper/http.py ADDED
@@ -0,0 +1,40 @@
1
+ """One small JSON-over-HTTPS client for every provider: stdlib only, retries on
2
+ throttling and 5xx, and the key only ever in a header and scrubbed from errors."""
3
+ import json
4
+ import time
5
+ import urllib.error
6
+ import urllib.parse
7
+ import urllib.request
8
+
9
+ from gipper.config import redact
10
+
11
+ # Cloudflare in front of Runpod's API rejects Python's default agent (error 1010)
12
+ AGENT = "gipper/0.1 (+https://github.com/lavallee/gipper)"
13
+
14
+
15
+ class ProviderError(RuntimeError):
16
+ pass
17
+
18
+
19
+ def request(method, url, body=None, headers=None, params=None, retries=3, timeout=60):
20
+ if params:
21
+ url += "?" + urllib.parse.urlencode({k: json.dumps(v) if isinstance(v, (dict, list)) else v for k, v in params.items()})
22
+ data = json.dumps(body).encode() if body is not None else None
23
+ hdrs = {"Content-Type": "application/json", "Accept": "application/json", "User-Agent": AGENT, **(headers or {})}
24
+ for attempt in range(retries):
25
+ req = urllib.request.Request(url, data=data, method=method, headers=hdrs)
26
+ try:
27
+ with urllib.request.urlopen(req, timeout=timeout) as r:
28
+ raw = r.read()
29
+ return json.loads(raw) if raw.strip() else {}
30
+ except urllib.error.HTTPError as e:
31
+ msg = redact(e.read().decode(errors="replace")[:500])
32
+ if e.code in (429, 500, 502, 503, 504) and attempt < retries - 1:
33
+ time.sleep(2 * (attempt + 1))
34
+ continue
35
+ raise ProviderError(f"{method} {url.split('?')[0]}: HTTP {e.code}: {msg}") from None
36
+ except (urllib.error.URLError, TimeoutError) as e:
37
+ if attempt < retries - 1:
38
+ time.sleep(2 * (attempt + 1))
39
+ continue
40
+ raise ProviderError(f"{method} {url.split('?')[0]}: {getattr(e, 'reason', e)}") from None
gipper/job.py ADDED
@@ -0,0 +1,153 @@
1
+ """The job spec: one TOML file, shared by every executor (rented GPUs from Runpod,
2
+ Vast and Shadeform now; owned machines planned).
3
+
4
+ name = "batch-inference"
5
+ workload = "example-workload" # what this is, for throughput history
6
+ units = { name = "items", count = 200 }
7
+ sensitivity = "cloud-ok" # "owned-only" refuses rented GPUs
8
+
9
+ [gpu]
10
+ min_mem_gb = 16
11
+ prefer = ["RTX A5000", "RTX 4090"] # optional order; names match across providers
12
+ regions = ["US", "CA"] # ISO country codes it may run in; default anywhere
13
+ providers = ["vast", "runpod"] # markets to shop; default all with a key
14
+ cloud = "COMMUNITY" # Runpod: COMMUNITY (then SECURE) or SECURE
15
+ max_usd_per_h = 0.6 # optional hourly ceiling; no ceiling by default
16
+
17
+ [pod] # optional overrides
18
+ image = "runpod/base:1.4.1-cuda1300-ubuntu2404" # default: each provider's own
19
+ container_disk_gb = 40
20
+ volume = "gipper-models" # Runpod network volume name (pins the data centre)
21
+ ports = ["5004/udp", "8080/tcp"] # published; the job sees GIPPER_PUBLIC_IP and
22
+ # GIPPER_PORT_5004_UDP (the external port). UDP
23
+ # rules out Runpod.
24
+
25
+ [env] # non-secret environment for the job
26
+ PYTHONUNBUFFERED = "1"
27
+
28
+ [[inputs]] # local -> pod, under /root/job/<dst>
29
+ src = "~/gpu-project"
30
+ dst = "project"
31
+ exclude = ["runs", ".git", "__pycache__"]
32
+
33
+ [[outputs]] # pod /root/job/<src> -> local <dst>
34
+ src = "out"
35
+ dst = "~/gpu-project/runs/x/out"
36
+
37
+ [run]
38
+ setup = "bash project/setup.sh"
39
+ command = "python project/main.py"
40
+ checkpoint = { src = "out", every_s = 300 } # synced down while running
41
+
42
+ [budget]
43
+ max_usd = 1.0
44
+ max_hours = 2.0
45
+
46
+ [placement] # optional: how to judge offers on the whole run
47
+ compute_s = 900 # (all fields in gipper/placement.py)
48
+ setup_download_gb = 5
49
+ """
50
+ import tomllib
51
+ from dataclasses import dataclass, field
52
+ from pathlib import Path
53
+
54
+ from gipper.config import settings
55
+ from gipper.placement import Placement, load_placement
56
+
57
+
58
+ class JobError(ValueError):
59
+ pass
60
+
61
+
62
+ @dataclass
63
+ class Transfer:
64
+ src: str
65
+ dst: str
66
+ exclude: list = field(default_factory=list)
67
+
68
+
69
+ @dataclass
70
+ class Job:
71
+ name: str
72
+ workload: str
73
+ command: str
74
+ setup: str = ""
75
+ units_name: str = "units"
76
+ units_count: float | None = None
77
+ sensitivity: str = "cloud-ok"
78
+ min_mem_gb: float = 8
79
+ prefer: list = field(default_factory=list)
80
+ regions: list = field(default_factory=list)
81
+ providers: list = field(default_factory=list)
82
+ cloud: str = "COMMUNITY"
83
+ max_usd_per_h: float | None = None
84
+ image: str = ""
85
+ container_disk_gb: int = 40
86
+ volume: str | None = None
87
+ ports: list = field(default_factory=list)
88
+ env: dict = field(default_factory=dict)
89
+ inputs: list = field(default_factory=list)
90
+ outputs: list = field(default_factory=list)
91
+ checkpoint_src: str | None = None
92
+ checkpoint_every_s: int = 300
93
+ max_usd: float = 2.0
94
+ max_hours: float = 3.0
95
+ path: Path | None = None
96
+ placement: Placement = field(default_factory=Placement)
97
+
98
+
99
+ def load(path):
100
+ path = Path(path).expanduser().resolve()
101
+ d = tomllib.loads(path.read_text())
102
+ s = settings()
103
+ for k in ("name", "workload"):
104
+ if not d.get(k):
105
+ raise JobError(f"{path.name}: `{k}` is required")
106
+ run = d.get("run", {})
107
+ if not run.get("command"):
108
+ raise JobError(f"{path.name}: [run] command is required")
109
+ gpu, pod, budget = d.get("gpu", {}), d.get("pod", {}), d.get("budget", {})
110
+ units = d.get("units", {})
111
+ ck = run.get("checkpoint") or {}
112
+ sens = d.get("sensitivity", "cloud-ok")
113
+ if sens not in ("cloud-ok", "owned-only"):
114
+ raise JobError(f"{path.name}: sensitivity must be cloud-ok or owned-only")
115
+ base = path.parent
116
+
117
+ def tr(items, local_key):
118
+ out = []
119
+ for it in items:
120
+ if not it.get("src") or not it.get("dst"):
121
+ raise JobError(f"{path.name}: inputs/outputs need src and dst")
122
+ t = Transfer(it["src"], it["dst"], list(it.get("exclude", [])))
123
+ loc = Path(getattr(t, local_key)).expanduser()
124
+ setattr(t, local_key, str(loc if loc.is_absolute() else (base / loc).resolve()))
125
+ out.append(t)
126
+ return out
127
+
128
+ job = Job(
129
+ name=d["name"], workload=d["workload"], command=run["command"], setup=run.get("setup", ""),
130
+ units_name=units.get("name", "units"), units_count=units.get("count"), sensitivity=sens,
131
+ min_mem_gb=float(gpu.get("min_mem_gb", 8)), prefer=list(gpu.get("prefer", [])),
132
+ regions=[r.upper() for r in gpu.get("regions", s["regions"])], providers=list(gpu.get("providers", [])),
133
+ cloud=gpu.get("cloud", s["cloud"]), max_usd_per_h=gpu.get("max_usd_per_h"), image=pod.get("image", ""),
134
+ container_disk_gb=int(pod.get("container_disk_gb", s["container_disk_gb"])), volume=pod.get("volume"),
135
+ ports=[str(p) for p in pod.get("ports", [])],
136
+ env={k: str(v) for k, v in d.get("env", {}).items()},
137
+ inputs=tr(d.get("inputs", []), "src"), outputs=tr(d.get("outputs", []), "dst"),
138
+ checkpoint_src=ck.get("src"), checkpoint_every_s=int(ck.get("every_s", s["checkpoint_every_s"])),
139
+ max_usd=float(budget.get("max_usd", s["default_job_cap_usd"])),
140
+ max_hours=float(budget.get("max_hours", s["default_max_hours"])), path=path,
141
+ placement=load_placement(d.get("placement"), error=JobError))
142
+ for p in job.ports:
143
+ num, _, proto = p.partition("/")
144
+ if not num.isdigit() or proto.lower() not in ("", "tcp", "udp", "http"):
145
+ raise JobError(f"{path.name}: ports look like \"5004/udp\" or \"8080/tcp\", got {p!r}")
146
+ if job.volume and job.providers and job.providers != ["runpod"]:
147
+ raise JobError(f"{path.name}: a network volume is a Runpod thing; set providers = [\"runpod\"]")
148
+ for t in job.inputs:
149
+ if not Path(t.src).exists():
150
+ raise JobError(f"{path.name}: input {t.src} does not exist")
151
+ if any(k.upper().endswith(("KEY", "TOKEN", "SECRET")) for k in job.env):
152
+ raise JobError(f"{path.name}: secrets do not belong in [env]")
153
+ return job
gipper/latency.py ADDED
@@ -0,0 +1,50 @@
1
+ """Round trips from this machine: to a host's public IP before renting (system
2
+ `ping`; about 60% of Vast hosts answer), and to a rented machine's ssh port after
3
+ (TCP connect, no login). Both measure from *here*; for a job whose user is
4
+ elsewhere (`latency_target` names where its own measurements came from), they're
5
+ a proxy, used to rank but never to pass `max_rtt_ms`, unless the target is "here".
6
+
7
+ Vast's location labels can be wrong (on 2026-10-28 a "China" host answered in
8
+ 80 ms from the IP an "United States" host had used), so a measured round trip
9
+ outranks the country it claims.
10
+ """
11
+ import re
12
+ import socket
13
+ import subprocess
14
+ import time
15
+ from concurrent.futures import ThreadPoolExecutor
16
+
17
+ HERE = socket.gethostname()
18
+ _RTT = re.compile(r"= [\d.]+/([\d.]+)/") # ping summary: min/avg/max/mdev -> avg
19
+
20
+
21
+ def ping_ms(ip, count=3, timeout_s=2):
22
+ """Average ICMP round trip in ms, or None if the host doesn't answer."""
23
+ try:
24
+ r = subprocess.run(["ping", "-n", "-q", "-c", str(count), "-W", str(timeout_s), ip],
25
+ capture_output=True, text=True, timeout=count * timeout_s + 3)
26
+ except (OSError, subprocess.SubprocessError):
27
+ return None
28
+ m = _RTT.search(r.stdout)
29
+ return float(m.group(1)) if r.returncode == 0 and m else None
30
+
31
+
32
+ def ping_all(ips, workers=16):
33
+ """{ip: ms or None}, pinged in parallel (about 6 s for any number up to `workers`)."""
34
+ ips = sorted({ip for ip in ips if ip})
35
+ with ThreadPoolExecutor(max_workers=workers) as pool:
36
+ return dict(zip(ips, pool.map(ping_ms, ips)))
37
+
38
+
39
+ def connect_ms(host, port, tries=5, timeout_s=3):
40
+ """Fastest TCP connect to host:port in ms (a round trip, without logging in)."""
41
+ best = None
42
+ for _ in range(tries):
43
+ t = time.perf_counter()
44
+ try:
45
+ with socket.create_connection((host, int(port)), timeout=timeout_s):
46
+ ms = (time.perf_counter() - t) * 1000
47
+ except OSError:
48
+ continue
49
+ best = ms if best is None else min(best, ms)
50
+ return round(best, 1) if best is not None else None
gipper/ledger.py ADDED
@@ -0,0 +1,68 @@
1
+ """The receipt ledger: append-only JSON lines, one file per month.
2
+
3
+ Every record is `gipper.receipt/v1`: {schema, id, ts, kind, job, refs, payload}.
4
+ Kinds so far: estimate, pod.create, pod.ready, run.start, run.end, spend,
5
+ checkpoint, collect, pod.terminate, cap, reap, error. External readers and replay
6
+ read these; nothing rewrites them.
7
+ """
8
+ import json
9
+ import os
10
+ import time
11
+ import uuid
12
+ from datetime import datetime, timezone
13
+
14
+ from gipper.config import DATA, redact
15
+
16
+ SCHEMA = "gipper.receipt/v1"
17
+
18
+
19
+ def _dir():
20
+ d = DATA / "receipts"
21
+ d.mkdir(parents=True, exist_ok=True)
22
+ return d
23
+
24
+
25
+ def new_id(prefix="r"):
26
+ return f"{prefix}_{uuid.uuid4().hex[:12]}"
27
+
28
+
29
+ def write(kind, payload=None, *, job=None, refs=None):
30
+ now = datetime.now(timezone.utc)
31
+ rec = {"schema": SCHEMA, "id": new_id(), "ts": now.isoformat(timespec="seconds"), "kind": kind,
32
+ "job": job, "refs": refs or {}, "payload": payload or {}}
33
+ line = redact(json.dumps(rec, default=str))
34
+ path = _dir() / f"{now:%Y-%m}.jsonl"
35
+ with open(path, "a") as f:
36
+ f.write(line + "\n")
37
+ f.flush()
38
+ os.fsync(f.fileno())
39
+ return rec
40
+
41
+
42
+ def read(since=None, kinds=None, job=None):
43
+ """Yield receipts (oldest first); since = epoch seconds or ISO date."""
44
+ if isinstance(since, str):
45
+ since = datetime.fromisoformat(since).replace(tzinfo=timezone.utc).timestamp()
46
+ for path in sorted(_dir().glob("*.jsonl")):
47
+ for line in path.read_text().splitlines():
48
+ if not line.strip():
49
+ continue
50
+ rec = json.loads(line)
51
+ if since and datetime.fromisoformat(rec["ts"]).timestamp() < since:
52
+ continue
53
+ if kinds and rec["kind"] not in kinds:
54
+ continue
55
+ if job and rec.get("job") != job:
56
+ continue
57
+ yield rec
58
+
59
+
60
+ def spend_since(since):
61
+ """Dollars spent since `since`: providers' bills where reconciled, else gipper's own."""
62
+ from gipper.billing import spend_since as billed_or_computed
63
+ return billed_or_computed(since)
64
+
65
+
66
+ def today_start():
67
+ t = time.localtime()
68
+ return time.mktime((t.tm_year, t.tm_mon, t.tm_mday, 0, 0, 0, 0, 0, -1))
gipper/lifecycle.py ADDED
@@ -0,0 +1,60 @@
1
+ """Ending a rental, and knowing it ended: a provider accepting the delete is not
2
+ the machine being gone. `terminate()` asks for the delete (retrying), then reads
3
+ the provider's list until the machine is absent. Only then does it write
4
+ `pod.closure_verified`, `pod.terminate` and the computed `spend`; until then the
5
+ machine stays `pod.termination_pending`, which the reaper picks up first, and
6
+ gipper rents no replacement while a previous machine may still be billing.
7
+ """
8
+ import time
9
+
10
+ from gipper import ledger
11
+
12
+ DELETE_TRIES, LIST_TRIES, PAUSE_S = 5, 4, 2
13
+
14
+
15
+ def terminate(provider, iid, *, job_id=None, started=None, usd_h=None, refs=None, delete_accepted=False):
16
+ """True once the provider no longer lists the machine."""
17
+ iid = str(iid)
18
+ base = {"provider": provider.name, "pod": iid}
19
+ ledger.write("pod.termination_pending", {**base, "delete_accepted": delete_accepted,
20
+ "reason": "termination requested; not yet confirmed gone"}, job=job_id)
21
+ accepted, error = delete_accepted, None
22
+ for attempt in range(0 if accepted else DELETE_TRIES):
23
+ try:
24
+ provider.terminate(iid)
25
+ accepted = True
26
+ break
27
+ except Exception as e:
28
+ error = f"{type(e).__name__}: {e}"
29
+ if attempt < DELETE_TRIES - 1:
30
+ time.sleep(PAUSE_S)
31
+ gone = False
32
+ if accepted:
33
+ for attempt in range(LIST_TRIES):
34
+ try:
35
+ listed = provider.list_ours()
36
+ if not isinstance(listed, list) or not all(isinstance(m, dict) and "id" in m for m in listed):
37
+ raise ValueError("provider returned an unreadable machine list")
38
+ gone = not any(str(m["id"]) == iid for m in listed)
39
+ error = None if gone else "provider still lists the machine"
40
+ if gone:
41
+ break
42
+ except Exception as e: # an unreadable list is not proof of absence
43
+ error = f"{type(e).__name__}: {e}"
44
+ if attempt < LIST_TRIES - 1:
45
+ time.sleep(PAUSE_S)
46
+ if not gone:
47
+ ledger.write("pod.termination_pending", {**base, "delete_accepted": accepted,
48
+ "reason": error or "not confirmed gone"}, job=job_id)
49
+ print(f"{provider.name} {iid}: termination pending ({error or 'not confirmed gone'})", flush=True)
50
+ return False
51
+ ledger.write("pod.closure_verified", {**base, "basis": "delete accepted, then absent from the provider's list"},
52
+ job=job_id)
53
+ ledger.write("pod.terminate", base, job=job_id)
54
+ if started is not None and usd_h is not None:
55
+ secs = max(0, time.time() - started)
56
+ ledger.write("spend", {**base, "seconds": round(secs), "usd_per_h": usd_h, "usd": round(secs * usd_h / 3600, 4),
57
+ "basis": "rate x lifetime to confirmed closure", "accuracy": "estimated"},
58
+ job=job_id, refs=refs)
59
+ print(f"{provider.name} {iid} terminated after {secs / 60:.1f} min: ~${secs * usd_h / 3600:.3f}", flush=True)
60
+ return True