batchwatch 0.2.1
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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +176 -0
- data/lib/batchwatch/client.rb +670 -0
- data/lib/batchwatch/spool.rb +186 -0
- data/lib/batchwatch.rb +22 -0
- metadata +82 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: bfefddebcc183a5e63b03b570caa3d183103d4b661f90a617779241306201d6b
|
|
4
|
+
data.tar.gz: 02de063b1c8f22d8d2a5f93f2c3dda990b482af905ab811c5a630eb029289279
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: e06032ef12b72d4d8213ae27271723afcbbb87a17087304ecda1ea999c8fe65777fc87a39b773cfc58faed151e4d685e94127e4186c4792d59bfd08af553e6fc
|
|
7
|
+
data.tar.gz: 8134786ba93b55272cbdf87db36e02581b600d229aa5ef08d8275810c93c29344ae583193b6488d8036f14787dac812a39eb3b356bc61132a7c7fc700c728908
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Andreas Graae
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# batchwatch — Ruby client
|
|
2
|
+
|
|
3
|
+
Client for [batchwatch.dev](https://batchwatch.dev): crowdsourced measurement
|
|
4
|
+
of queue time on LLM batch APIs.
|
|
5
|
+
|
|
6
|
+
Batch endpoints cost 50% of the synchronous ones, but "completes within 24
|
|
7
|
+
hours" is impossible to plan around. batchwatch measures what the queue
|
|
8
|
+
actually does and answers one question: *should I use batch for this job?*
|
|
9
|
+
|
|
10
|
+
No dependencies. The standard library — `net/http`, `json`, `uri`, `socket`,
|
|
11
|
+
`tmpdir` — and nothing else.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
Point your `Gemfile` at the subdirectory:
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
gem "batchwatch", git: "https://github.com/batchwatch/client",
|
|
19
|
+
glob: "clients/ruby/*.gemspec"
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
or vendor the three files under `lib/` — they have no third-party imports. A
|
|
23
|
+
RubyGems release is on the way.
|
|
24
|
+
|
|
25
|
+
## Two lines
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
require "batchwatch"
|
|
29
|
+
|
|
30
|
+
bw = Batchwatch::Client.new(token: "bw_...") # token optional; falls back to $BATCHWATCH_TOKEN
|
|
31
|
+
|
|
32
|
+
# 1. before you submit — does this belong in the queue?
|
|
33
|
+
if bw.should_batch("gpt-5.6-sol", max_wait: "15m")
|
|
34
|
+
job = client.batches.create(...)
|
|
35
|
+
else
|
|
36
|
+
answer = client.chat.completions.create(...)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# 2. measure it, so the next person gets a better answer
|
|
40
|
+
bw.track("gpt-5.6-sol", input_tokens: 9720) do |t|
|
|
41
|
+
result = wait_for(job)
|
|
42
|
+
t.done(output_tokens: result.usage.completion_tokens)
|
|
43
|
+
end
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
With a block, `track` closes the measurement for you, including on the error
|
|
47
|
+
path: an exception raised inside your block is recorded as `failed` and
|
|
48
|
+
re-raised untouched. Without a block it returns the tracking handle and you
|
|
49
|
+
call `t.done(...)` yourself.
|
|
50
|
+
|
|
51
|
+
Get a key with no email and no card:
|
|
52
|
+
|
|
53
|
+
curl -X POST https://batchwatch.dev/v1/keys -d '{"label":"my pipeline"}'
|
|
54
|
+
|
|
55
|
+
## It fails open, always
|
|
56
|
+
|
|
57
|
+
If batchwatch is down, slow, or broken, your job must not notice. That is the
|
|
58
|
+
first requirement, ahead of collecting any data at all.
|
|
59
|
+
|
|
60
|
+
- Every submission runs on a background thread. `track()` and `t.done()` do no
|
|
61
|
+
network I/O on your thread.
|
|
62
|
+
- Two-second timeout by default (`BATCHWATCH_TIMEOUT`), applied to both the
|
|
63
|
+
connect and the read, so a server that accepts but never answers cannot hold
|
|
64
|
+
you.
|
|
65
|
+
- Every batchwatch error is swallowed and passed to the optional `logger` at
|
|
66
|
+
`debug` level. Nothing is printed unless you wire one up.
|
|
67
|
+
- `should_batch()` is the one synchronous call, because you are waiting for the
|
|
68
|
+
answer. If it cannot answer, you get your own `default` back — never a guess.
|
|
69
|
+
The default is `false`, "run it synchronously": being wrong that way costs
|
|
70
|
+
money, being wrong the other way blows a deadline.
|
|
71
|
+
- An exception raised inside your own `track` block is recorded as `failed` and
|
|
72
|
+
re-raised untouched. We swallow our errors, never yours.
|
|
73
|
+
|
|
74
|
+
`test/test_fail_open.rb` proves it against a port nothing listens on and
|
|
75
|
+
against a socket that accepts but never answers.
|
|
76
|
+
|
|
77
|
+
## It never sends your content
|
|
78
|
+
|
|
79
|
+
No prompts, no completions, no system prompts, no tool calls, no file names.
|
|
80
|
+
The request body is built from a fixed allowlist — provider, model, mode,
|
|
81
|
+
endpoint, request count, token counts, timestamps, status — and everything
|
|
82
|
+
else is dropped by `Batchwatch.sanitize` on the way out. There is no field to put
|
|
83
|
+
text in.
|
|
84
|
+
|
|
85
|
+
`test/test_no_content.rb` asserts it on the bytes a real HTTP server received,
|
|
86
|
+
and includes a positive control so the test cannot pass by the client simply
|
|
87
|
+
sending nothing.
|
|
88
|
+
|
|
89
|
+
## `output_tokens` defaults to `nil`, never `0`
|
|
90
|
+
|
|
91
|
+
You know your input tokens. You cannot know your output tokens before the model
|
|
92
|
+
has answered. So the default is absence (`nil`), not zero.
|
|
93
|
+
|
|
94
|
+
Zero is not a harmless placeholder here: output costs five to six times as much
|
|
95
|
+
as input, so a saving computed on zero output is systematically too low —
|
|
96
|
+
measured at 3.4x too low on a real model — and nothing in the response would
|
|
97
|
+
tell you. If you know a ceiling, pass `max_tokens` instead and the answer comes
|
|
98
|
+
back labelled as a ceiling.
|
|
99
|
+
|
|
100
|
+
Passing `output_tokens: 0` really does send `0`: zero is a measurement, absence
|
|
101
|
+
is not.
|
|
102
|
+
|
|
103
|
+
## Spooling
|
|
104
|
+
|
|
105
|
+
When a measurement cannot be delivered, the completed record is appended to a
|
|
106
|
+
JSONL file and replayed later through `POST /v1/calls/complete`. Losing
|
|
107
|
+
measurements exactly when the network is bad means losing them exactly when
|
|
108
|
+
they are most interesting.
|
|
109
|
+
|
|
110
|
+
- Default path: `$BATCHWATCH_SPOOL`, or `batchwatch-spool.jsonl` in the system
|
|
111
|
+
temp directory (`Dir.tmpdir`). Set `BATCHWATCH_SPOOL=""` or pass
|
|
112
|
+
`spool: nil` to turn it off.
|
|
113
|
+
- The spool is replayed automatically, at most once a minute, right after a
|
|
114
|
+
successful call — that is the moment we know the network is up. Call
|
|
115
|
+
`bw.flush_spool` yourself from a shutdown hook if you want it drained on
|
|
116
|
+
exit.
|
|
117
|
+
- **Spooling requires a token.** `/v1/calls/complete` takes your own
|
|
118
|
+
timestamps, so it is closed to anonymous callers; without a key a spool file
|
|
119
|
+
could never be sent, and writing one would just leak disk. `bw.spool` is
|
|
120
|
+
`nil` when no token is set.
|
|
121
|
+
- The file is capped at 5 MB. Beyond that, measurements are dropped rather than
|
|
122
|
+
filling your disk.
|
|
123
|
+
- A replayed measurement can arrive twice if the original `PATCH` reached the
|
|
124
|
+
server but the response did not. That is deliberate: a duplicate is visible
|
|
125
|
+
in the dataset, a lost measurement is not.
|
|
126
|
+
- The file format is identical across the Python, TypeScript, Go and Ruby
|
|
127
|
+
clients, so a spool written by one can be flushed by another.
|
|
128
|
+
- Threads are handled by a mutex. Two *processes* sharing one spool file may
|
|
129
|
+
send a record twice — give each process its own `BATCHWATCH_SPOOL` if that
|
|
130
|
+
matters.
|
|
131
|
+
|
|
132
|
+
## Configuration
|
|
133
|
+
|
|
134
|
+
| Argument | Environment | Default |
|
|
135
|
+
|---|---|---|
|
|
136
|
+
| `token` | `BATCHWATCH_TOKEN` | none (anonymous) |
|
|
137
|
+
| `base_url` | `BATCHWATCH_URL` | `https://batchwatch.dev` |
|
|
138
|
+
| `timeout` | `BATCHWATCH_TIMEOUT` | `2.0` seconds |
|
|
139
|
+
| `spool` | `BATCHWATCH_SPOOL` | `<tempdir>/batchwatch-spool.jsonl` |
|
|
140
|
+
| `enabled` | — | `true` |
|
|
141
|
+
| `logger` | — | `nil` (nothing logged) |
|
|
142
|
+
|
|
143
|
+
`enabled: false` turns every network call into a no-op, which is what you want
|
|
144
|
+
in CI.
|
|
145
|
+
|
|
146
|
+
## API
|
|
147
|
+
|
|
148
|
+
- `should_batch(model, max_wait: nil, default: false, **kw) -> true/false`
|
|
149
|
+
- `advice(model, max_wait: nil, provider: "openai", input_tokens: nil, output_tokens: nil, max_tokens: nil, risk: "p90") -> Hash | nil`
|
|
150
|
+
- `wait_now(model, provider: "openai", mode: "batch") -> Hash | nil`
|
|
151
|
+
- `track(model, provider: "openai", mode: "batch", requests: 1, input_tokens: nil, endpoint: nil) { |t| ... }` — with or without a block
|
|
152
|
+
- `t.done(output_tokens: nil, status: "completed", ttfb_ms: nil)`
|
|
153
|
+
- `t.failed(status: "failed")`
|
|
154
|
+
- `t.started(input_tokens: ...)` when the count is only known after submission
|
|
155
|
+
- `flush(timeout: 5.0) -> true/false` — wait for outstanding submissions before exit
|
|
156
|
+
- `flush_spool(timeout: nil) -> Integer` — send what is on disk, returns accepted
|
|
157
|
+
|
|
158
|
+
## Tests
|
|
159
|
+
|
|
160
|
+
rake test
|
|
161
|
+
# or a single suite:
|
|
162
|
+
ruby -Ilib -Itest test/test_fail_open.rb
|
|
163
|
+
|
|
164
|
+
26 tests, no network beyond loopback. They start real HTTP servers on
|
|
165
|
+
ephemeral ports (raw `TCPServer`, port 0) rather than stubbing `Net::HTTP`:
|
|
166
|
+
the thing under test is network behaviour, so the network should be in the
|
|
167
|
+
test. The allowlist and fail-open tests carry positive controls, so a client
|
|
168
|
+
that sent nothing at all would fail them rather than pass.
|
|
169
|
+
|
|
170
|
+
## Requirements
|
|
171
|
+
|
|
172
|
+
Requires Ruby 3.0 or newer. Tested on 3.1.
|
|
173
|
+
|
|
174
|
+
## Licence
|
|
175
|
+
|
|
176
|
+
MIT
|
|
@@ -0,0 +1,670 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# batchwatch client.
|
|
4
|
+
#
|
|
5
|
+
# Two rules shape this file.
|
|
6
|
+
#
|
|
7
|
+
# **It never fails upward.** If batchwatch is down, slow, or broken, the
|
|
8
|
+
# caller's batch job must not notice. Every network call runs on a background
|
|
9
|
+
# thread with a short timeout, and every error is swallowed and logged at
|
|
10
|
+
# DEBUG. The only exception that ever leaves this module is the caller's own.
|
|
11
|
+
#
|
|
12
|
+
# **It never sends content.** Prompts, completions, system prompts, tool
|
|
13
|
+
# calls, file names - none of it. The body is built from a fixed allowlist of
|
|
14
|
+
# fields: provider, model, mode, endpoint, request count, token counts and
|
|
15
|
+
# timestamps. There is no field to put text in, by construction.
|
|
16
|
+
#
|
|
17
|
+
# require "batchwatch"
|
|
18
|
+
#
|
|
19
|
+
# bw = Batchwatch::Client.new(token: "bw_...") # token is optional
|
|
20
|
+
#
|
|
21
|
+
# # 1) before you submit: does this belong in the queue?
|
|
22
|
+
# if bw.should_batch("gpt-5.6-sol", max_wait: "15m")
|
|
23
|
+
# job = client.batches.create(...)
|
|
24
|
+
# else
|
|
25
|
+
# answer = client.chat.completions.create(...)
|
|
26
|
+
# end
|
|
27
|
+
#
|
|
28
|
+
# # 2) measure it
|
|
29
|
+
# bw.track("gpt-5.6-sol", input_tokens: 9720) do |t|
|
|
30
|
+
# # ...
|
|
31
|
+
# t.done(output_tokens: 4519)
|
|
32
|
+
# end
|
|
33
|
+
require "digest"
|
|
34
|
+
require "json"
|
|
35
|
+
require "net/http"
|
|
36
|
+
require "uri"
|
|
37
|
+
require "tmpdir"
|
|
38
|
+
require "time"
|
|
39
|
+
|
|
40
|
+
require_relative "spool"
|
|
41
|
+
|
|
42
|
+
module Batchwatch
|
|
43
|
+
VERSION = "0.2.1"
|
|
44
|
+
|
|
45
|
+
# How often, at most, we try to drain the spool on our own.
|
|
46
|
+
SPOOL_INTERVAL_S = 60.0
|
|
47
|
+
|
|
48
|
+
# How long completion waits for the start call's id to land. Only the
|
|
49
|
+
# background thread waits - the caller is long gone.
|
|
50
|
+
ID_WAIT_S = 3.0
|
|
51
|
+
|
|
52
|
+
# Field names allowed to leave the machine. Everything else is not in the
|
|
53
|
+
# body. The test test_no_content.rb pins this list. Same fields as the
|
|
54
|
+
# Python, Go, TypeScript and .NET clients.
|
|
55
|
+
#
|
|
56
|
+
# The last four (acted_verdict, deadline_s, quoted_p50_s, quoted_p90_s) are
|
|
57
|
+
# the outcome measurement (#101): the advice we gave ourselves, attached to
|
|
58
|
+
# the later completion so the SERVER can compare its own measured duration
|
|
59
|
+
# with what we promised. All of it is numbers and a decision we made
|
|
60
|
+
# ourselves - no new PII.
|
|
61
|
+
ALLOWED_FIELDS = %w[
|
|
62
|
+
provider model mode endpoint requests
|
|
63
|
+
input_tokens output_tokens started_at ended_at
|
|
64
|
+
status ttfb_ms source
|
|
65
|
+
acted_verdict deadline_s quoted_p50_s quoted_p90_s
|
|
66
|
+
].freeze
|
|
67
|
+
|
|
68
|
+
def self.default_url
|
|
69
|
+
ENV["BATCHWATCH_URL"] || "https://batchwatch.dev"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def self.default_timeout
|
|
73
|
+
(ENV["BATCHWATCH_TIMEOUT"] || "2.0").to_f
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def self.default_spool
|
|
77
|
+
v = ENV["BATCHWATCH_SPOOL"]
|
|
78
|
+
unless v.nil?
|
|
79
|
+
# An empty string turns the spool off.
|
|
80
|
+
return v.empty? ? nil : v
|
|
81
|
+
end
|
|
82
|
+
File.join(Dir.tmpdir, "batchwatch-spool.jsonl")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Drop everything not on the allowlist.
|
|
86
|
+
#
|
|
87
|
+
# The last stop before the network. Even though no public method accepts free
|
|
88
|
+
# text, THIS function is the place you can point to when someone asks "how do
|
|
89
|
+
# you know a prompt cannot get out".
|
|
90
|
+
def self.sanitize(body)
|
|
91
|
+
body.each_with_object({}) do |(k, v), out|
|
|
92
|
+
out[k.to_s] = v if ALLOWED_FIELDS.include?(k.to_s)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def self.iso(time)
|
|
97
|
+
time.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# ---------------------------------------------------------- idempotency (#30)
|
|
101
|
+
#
|
|
102
|
+
# The die-and-reflush problem: the process dies with a completed measurement
|
|
103
|
+
# on disk, restarts, and replays it. If the first attempt already reached the
|
|
104
|
+
# server, the replay must NOT count the same job twice - a duplicate row drags
|
|
105
|
+
# the percentiles (the whole product) toward the slow tail, because the slow
|
|
106
|
+
# calls are exactly the ones that time out and get resent.
|
|
107
|
+
#
|
|
108
|
+
# The fix is an Idempotency-Key that the server dedupes on. The rule that
|
|
109
|
+
# makes it work: the key IS DERIVED FROM THE MEASUREMENT, deterministically,
|
|
110
|
+
# so the replay reconstructs exactly the same key as the first attempt. A
|
|
111
|
+
# fresh key at send time (a UUID per call) would give the replay a DIFFERENT
|
|
112
|
+
# key, and nothing would be deduped. The key is a pure function of the
|
|
113
|
+
# measurement's content - the record IS its own stored key material.
|
|
114
|
+
#
|
|
115
|
+
# The scheme follows backfill.py's send(): a readable, named string built from
|
|
116
|
+
# the identifying fields; printable ASCII with no control characters.
|
|
117
|
+
|
|
118
|
+
def self.idem_field(body, key)
|
|
119
|
+
v = body[key] || body[key.to_sym]
|
|
120
|
+
v.nil? ? "" : v.to_s
|
|
121
|
+
end
|
|
122
|
+
private_class_method :idem_field
|
|
123
|
+
|
|
124
|
+
# A short, stable content hash of a record. Hex SHA-256 (first 16 bytes) of
|
|
125
|
+
# the record's canonical JSON, serialised in the same insertion order the
|
|
126
|
+
# client sends on the wire - so the same record hashes to the same value
|
|
127
|
+
# every time, on every run, and a replay reconstructs it exactly. Printable
|
|
128
|
+
# ASCII, no control characters, well within the server's 255-char key limit.
|
|
129
|
+
def self.content_hash(body)
|
|
130
|
+
Digest::SHA256.hexdigest(JSON.generate(body))[0, 32]
|
|
131
|
+
end
|
|
132
|
+
private_class_method :content_hash
|
|
133
|
+
|
|
134
|
+
# Per-record key for POST /v1/calls (the start call).
|
|
135
|
+
#
|
|
136
|
+
# Keeps the readable provider/model prefix for logs, then a content hash of
|
|
137
|
+
# the full (sanitized) start body. started_at is second-resolution, so two
|
|
138
|
+
# DISTINCT measurements of the same provider+model within one second would
|
|
139
|
+
# otherwise share a key - and the server refuses a reused key that carries a
|
|
140
|
+
# different body (409 body_differs), silently dropping the second measurement.
|
|
141
|
+
# Hashing the whole body keeps distinct measurements apart; an identical body
|
|
142
|
+
# (a retry or a die-and-reflush replay) still hashes the same and dedupes, so
|
|
143
|
+
# #30 holds. This key is only ever computed live at the start POST, never
|
|
144
|
+
# reconstructed from a spool line, so idem_complete's cross-client
|
|
145
|
+
# spool-replay key format is untouched.
|
|
146
|
+
def self.idem_start(body)
|
|
147
|
+
"bw-start-#{idem_field(body, 'provider')}-#{idem_field(body, 'model')}-" \
|
|
148
|
+
"#{content_hash(body)}"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Per-request key for POST /v1/calls/complete. Derived from the group being
|
|
152
|
+
# sent, exactly like backfill.py: a single record is just a group of one.
|
|
153
|
+
# A replay of the SAME records chunks them identically (the spool preserves
|
|
154
|
+
# the order), so the same request carries the same key.
|
|
155
|
+
def self.idem_complete(group)
|
|
156
|
+
return nil if group.nil? || group.empty?
|
|
157
|
+
|
|
158
|
+
first = group.first
|
|
159
|
+
last = group.last
|
|
160
|
+
"bw-complete-#{idem_field(first, 'provider')}-#{idem_field(first, 'started_at')}-" \
|
|
161
|
+
"#{group.length}-#{idem_field(last, 'ended_at')}"
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Translate a max_wait into seconds, or nil if we cannot make sense of it.
|
|
165
|
+
#
|
|
166
|
+
# The caller typically writes "15m", "1h", "30s" or "2d" - the same language
|
|
167
|
+
# /v1/should-i-batch itself accepts. A bare number is read as seconds. If we
|
|
168
|
+
# cannot parse it, we send nil rather than a guess (rule #30).
|
|
169
|
+
def self.seconds(max_wait)
|
|
170
|
+
return nil if max_wait.nil?
|
|
171
|
+
return max_wait.to_f if max_wait.is_a?(Numeric)
|
|
172
|
+
|
|
173
|
+
s = max_wait.to_s.strip.downcase
|
|
174
|
+
return nil if s.empty?
|
|
175
|
+
|
|
176
|
+
factor = { "s" => 1.0, "m" => 60.0, "h" => 3600.0, "d" => 86_400.0 }
|
|
177
|
+
unit = s[-1]
|
|
178
|
+
if factor.key?(unit)
|
|
179
|
+
num = s[0..-2]
|
|
180
|
+
mult = factor[unit]
|
|
181
|
+
else
|
|
182
|
+
num = s
|
|
183
|
+
mult = 1.0
|
|
184
|
+
end
|
|
185
|
+
num = num.strip
|
|
186
|
+
return nil unless num.match?(/\A-?\d+(\.\d+)?\z/)
|
|
187
|
+
|
|
188
|
+
num.to_f * mult
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# The fields from a stored advice that may be attached to a completion (#101).
|
|
192
|
+
#
|
|
193
|
+
# Only non-nil values come along: if a percentile or a deadline is missing,
|
|
194
|
+
# the field is OMITTED entirely - we do not invent a zero (rule #30). With no
|
|
195
|
+
# advice at all the result is an empty hash, so nothing is attached.
|
|
196
|
+
def self.outcome_fields(advice)
|
|
197
|
+
return {} if advice.nil?
|
|
198
|
+
|
|
199
|
+
out = { "acted_verdict" => advice["acted_verdict"] }
|
|
200
|
+
%w[deadline_s quoted_p50_s quoted_p90_s].each do |name|
|
|
201
|
+
out[name] = advice[name] unless advice[name].nil?
|
|
202
|
+
end
|
|
203
|
+
out
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# A non-2xx response. Never reaches the caller of a public method - only the
|
|
207
|
+
# optional debug logger.
|
|
208
|
+
class HTTPError < StandardError
|
|
209
|
+
attr_reader :status, :body
|
|
210
|
+
|
|
211
|
+
def initialize(status, body)
|
|
212
|
+
@status = status
|
|
213
|
+
@body = body
|
|
214
|
+
super("batchwatch #{status}")
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# The client. Every submission is non-blocking and fails open.
|
|
219
|
+
#
|
|
220
|
+
# Args:
|
|
221
|
+
# token: API key. Falls back to $BATCHWATCH_TOKEN. Optional for
|
|
222
|
+
# measurement, required to replay a spool.
|
|
223
|
+
# base_url: Default $BATCHWATCH_URL or https://batchwatch.dev
|
|
224
|
+
# timeout: Seconds per HTTP call. Default $BATCHWATCH_TIMEOUT or 2.0.
|
|
225
|
+
# enabled: false turns every network call into a no-op.
|
|
226
|
+
# spool: Path to the spool file, nil to turn it off. Default
|
|
227
|
+
# $BATCHWATCH_SPOOL or a file in the temp directory. Spooling is inactive
|
|
228
|
+
# without a token, because the replay route requires one.
|
|
229
|
+
# logger: Optional logger. All swallowed errors go to logger.debug.
|
|
230
|
+
class Client
|
|
231
|
+
attr_reader :token, :base, :timeout, :enabled, :spool
|
|
232
|
+
|
|
233
|
+
UNSET = Object.new
|
|
234
|
+
private_constant :UNSET
|
|
235
|
+
|
|
236
|
+
def initialize(token: nil, base_url: nil, timeout: nil, enabled: true,
|
|
237
|
+
spool: UNSET, logger: nil)
|
|
238
|
+
@token = token || ENV["BATCHWATCH_TOKEN"]
|
|
239
|
+
@base = (base_url || Batchwatch.default_url).sub(%r{/+\z}, "")
|
|
240
|
+
@timeout = timeout.nil? ? Batchwatch.default_timeout : timeout
|
|
241
|
+
@enabled = enabled
|
|
242
|
+
@logger = logger
|
|
243
|
+
@threads = []
|
|
244
|
+
@threads_lock = Mutex.new
|
|
245
|
+
|
|
246
|
+
path = spool.equal?(UNSET) ? Batchwatch.default_spool : spool
|
|
247
|
+
# Without a key /v1/calls/complete cannot accept it, so a spool file could
|
|
248
|
+
# never be sent. So we do not write it.
|
|
249
|
+
@spool = (path && @token) ? Spool.new(path, logger: logger) : nil
|
|
250
|
+
if path && !@token
|
|
251
|
+
debug("batchwatch: no token - spool disabled " \
|
|
252
|
+
"(/v1/calls/complete requires a key)")
|
|
253
|
+
end
|
|
254
|
+
@spool_last = 0.0
|
|
255
|
+
@spool_lock = Mutex.new
|
|
256
|
+
# Last advice per model (#101). When should_batch answers, we store here
|
|
257
|
+
# what we recommended + the quoted percentiles, so the NEXT completion for
|
|
258
|
+
# the same model can attach them. The correlation is a documented
|
|
259
|
+
# approximation: latest-advice-per-model, not per-job.
|
|
260
|
+
@advice = {}
|
|
261
|
+
@advice_lock = Mutex.new
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# ----------------------------------------------------------------- core
|
|
265
|
+
|
|
266
|
+
# One HTTP call. Raises on error - only public methods swallow.
|
|
267
|
+
#
|
|
268
|
+
# +idem+ is an optional Idempotency-Key. On the write routes (/v1/calls and
|
|
269
|
+
# /v1/calls/complete) it lets the server dedupe a resend: the same body +
|
|
270
|
+
# the same key writes the measurement once, no matter how many times a
|
|
271
|
+
# die-and-reflush sends it (#30). The key is derived from the measurement,
|
|
272
|
+
# so the replay carries the same key as the first attempt. nil = no header.
|
|
273
|
+
def call(path, method: "GET", body: nil, timeout: nil, idem: nil)
|
|
274
|
+
uri = URI.parse(@base + path)
|
|
275
|
+
deadline = timeout || @timeout
|
|
276
|
+
|
|
277
|
+
req_class = case method
|
|
278
|
+
when "GET" then Net::HTTP::Get
|
|
279
|
+
when "POST" then Net::HTTP::Post
|
|
280
|
+
when "PATCH" then Net::HTTP::Patch
|
|
281
|
+
else raise ArgumentError, "unknown method #{method}"
|
|
282
|
+
end
|
|
283
|
+
req = req_class.new(uri)
|
|
284
|
+
req["user-agent"] = "batchwatch-ruby/#{VERSION}"
|
|
285
|
+
unless body.nil?
|
|
286
|
+
req["content-type"] = "application/json"
|
|
287
|
+
req.body = JSON.generate(body)
|
|
288
|
+
end
|
|
289
|
+
req["authorization"] = "Bearer #{@token}" if @token
|
|
290
|
+
req["idempotency-key"] = idem if idem && !idem.empty?
|
|
291
|
+
|
|
292
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
293
|
+
http.use_ssl = (uri.scheme == "https")
|
|
294
|
+
# Both the connect and the read timeout, so a server that accepts but
|
|
295
|
+
# never answers cannot hold us longer than the deadline.
|
|
296
|
+
http.open_timeout = deadline
|
|
297
|
+
http.read_timeout = deadline
|
|
298
|
+
|
|
299
|
+
res = http.request(req)
|
|
300
|
+
code = res.code.to_i
|
|
301
|
+
raw = res.body || ""
|
|
302
|
+
raise HTTPError.new(code, raw) if code < 200 || code >= 300
|
|
303
|
+
|
|
304
|
+
raw.strip.empty? ? nil : JSON.parse(raw)
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# Run on a background thread. Errors are swallowed - this must never take
|
|
308
|
+
# the caller's own call down with it.
|
|
309
|
+
def in_background(&block)
|
|
310
|
+
return nil unless @enabled
|
|
311
|
+
|
|
312
|
+
t = Thread.new do
|
|
313
|
+
begin
|
|
314
|
+
block.call
|
|
315
|
+
rescue HTTPError => e
|
|
316
|
+
debug("batchwatch #{e.status}: #{e.body}")
|
|
317
|
+
rescue StandardError => e
|
|
318
|
+
debug("batchwatch unavailable: #{e}")
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
# Clean up finished threads, so the list does not grow without bound in a
|
|
322
|
+
# long-running process.
|
|
323
|
+
@threads_lock.synchronize do
|
|
324
|
+
@threads.reject! { |x| !x.alive? }
|
|
325
|
+
@threads << t
|
|
326
|
+
end
|
|
327
|
+
t
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# Wait for outstanding submissions. Call it before the process exits.
|
|
331
|
+
def flush(timeout: 5.0)
|
|
332
|
+
deadline = monotonic + timeout
|
|
333
|
+
snapshot = @threads_lock.synchronize { @threads.dup }
|
|
334
|
+
snapshot.each do |t|
|
|
335
|
+
rest = deadline - monotonic
|
|
336
|
+
t.join(rest > 0 ? rest : 0)
|
|
337
|
+
end
|
|
338
|
+
@threads_lock.synchronize { @threads.reject! { |t| !t.alive? } }
|
|
339
|
+
@threads_lock.synchronize { @threads.empty? }
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# ---------------------------------------------------------- decision
|
|
343
|
+
|
|
344
|
+
# What the queue is doing right now, or nil if we cannot say.
|
|
345
|
+
def wait_now(model, provider: "openai", mode: "batch")
|
|
346
|
+
q = URI.encode_www_form(provider: provider, model: model, mode: mode)
|
|
347
|
+
r = call("/v1/wait?#{q}")
|
|
348
|
+
return nil if !r || r["verdict"] == "insufficient_data"
|
|
349
|
+
|
|
350
|
+
r
|
|
351
|
+
rescue StandardError => e
|
|
352
|
+
debug("batchwatch wait failed: #{e}")
|
|
353
|
+
nil
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# The whole verdict. Returns nil if we cannot answer.
|
|
357
|
+
#
|
|
358
|
+
# output_tokens is usually UNKNOWN at this point - the model decides them.
|
|
359
|
+
# The default is therefore nil, not zero. Sending zero would make the
|
|
360
|
+
# server compute the saving on zero output, and output costs five to six
|
|
361
|
+
# times as much as input: the answer would be systematically too low,
|
|
362
|
+
# without anyone seeing it. If you know a ceiling, pass max_tokens.
|
|
363
|
+
def advice(model, max_wait: nil, provider: "openai",
|
|
364
|
+
input_tokens: nil, output_tokens: nil, max_tokens: nil,
|
|
365
|
+
risk: "p90")
|
|
366
|
+
q = { "provider" => provider, "model" => model, "risk" => risk }
|
|
367
|
+
{ "input_tokens" => input_tokens, "output_tokens" => output_tokens,
|
|
368
|
+
"max_tokens" => max_tokens }.each do |name, value|
|
|
369
|
+
q[name] = value.to_i unless value.nil?
|
|
370
|
+
end
|
|
371
|
+
q["max_wait"] = max_wait unless max_wait.nil?
|
|
372
|
+
call("/v1/should-i-batch?#{URI.encode_www_form(q)}")
|
|
373
|
+
rescue StandardError => e
|
|
374
|
+
debug("batchwatch advice failed: #{e}")
|
|
375
|
+
nil
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
# true/false. On any doubt you get your default back.
|
|
379
|
+
#
|
|
380
|
+
# We never guess on the caller's behalf: if we cannot answer, the caller
|
|
381
|
+
# gets their own predetermined value. The default is false - "run it
|
|
382
|
+
# synchronously" - which is the safe way to be wrong, because a synchronous
|
|
383
|
+
# call just costs more, while an unexpected eight-hour queue can take a
|
|
384
|
+
# product down.
|
|
385
|
+
def should_batch(model, max_wait: nil, default: false, **kw)
|
|
386
|
+
r = advice(model, max_wait: max_wait, **kw)
|
|
387
|
+
return default unless r
|
|
388
|
+
|
|
389
|
+
answer = case r["verdict"]
|
|
390
|
+
when "run_batch" then true
|
|
391
|
+
when "run_sync", "batch_at" then false
|
|
392
|
+
else default
|
|
393
|
+
end
|
|
394
|
+
remember_advice(model, answer, max_wait, r)
|
|
395
|
+
answer
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
# Store the advice we just gave for THIS model (#101). Only numbers and our
|
|
399
|
+
# own decision end up here - no user data. The quoted percentiles are taken
|
|
400
|
+
# from the server's response; if one of them is missing, we store nil and
|
|
401
|
+
# simply do not attach it (rule #30: nothing invented as 0).
|
|
402
|
+
def remember_advice(model, acted_verdict, max_wait, answer)
|
|
403
|
+
num = ->(v) { v.is_a?(Numeric) ? v.to_f : nil }
|
|
404
|
+
advice = {
|
|
405
|
+
"acted_verdict" => acted_verdict,
|
|
406
|
+
"deadline_s" => Batchwatch.seconds(max_wait),
|
|
407
|
+
"quoted_p50_s" => num.call(answer["p50_s"]),
|
|
408
|
+
"quoted_p90_s" => num.call(answer["p90_s"])
|
|
409
|
+
}
|
|
410
|
+
@advice_lock.synchronize { @advice[model] = advice }
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
# The latest advice for a model, or nil. The approximation is
|
|
414
|
+
# latest-advice-per-model: the newest should_batch attaches to the next
|
|
415
|
+
# completion of the same model.
|
|
416
|
+
def get_advice(model)
|
|
417
|
+
@advice_lock.synchronize { @advice[model] }
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
# ------------------------------------------------------------- measurement
|
|
421
|
+
|
|
422
|
+
# Measure one call. The submission happens in the background.
|
|
423
|
+
#
|
|
424
|
+
# No exception from batchwatch ever reaches the caller. An exception from
|
|
425
|
+
# the caller's own block is recorded as "failed" and re-raised untouched.
|
|
426
|
+
#
|
|
427
|
+
# With a block it acts like the Python context manager: it closes the
|
|
428
|
+
# measurement for you, including on the error path. Without a block it
|
|
429
|
+
# returns the tracking handle and you call done() yourself.
|
|
430
|
+
def track(model, provider: "openai", mode: "batch", requests: 1,
|
|
431
|
+
input_tokens: nil, endpoint: nil)
|
|
432
|
+
t = Tracking.new(self, provider, model, mode, requests, input_tokens,
|
|
433
|
+
endpoint)
|
|
434
|
+
t.start
|
|
435
|
+
return t unless block_given?
|
|
436
|
+
|
|
437
|
+
begin
|
|
438
|
+
yield t
|
|
439
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
440
|
+
# Everything - including Interrupt/SignalException - is recorded as
|
|
441
|
+
# failed and re-raised untouched. We swallow our own errors, never the
|
|
442
|
+
# caller's.
|
|
443
|
+
t.done(status: "failed")
|
|
444
|
+
raise
|
|
445
|
+
else
|
|
446
|
+
t.done unless t.finished?
|
|
447
|
+
end
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
# --------------------------------------------------------------- spool
|
|
451
|
+
|
|
452
|
+
# Send everything waiting on disk. Returns the number accepted.
|
|
453
|
+
#
|
|
454
|
+
# Synchronous and safe to call from a shutdown hook. Never raises. Records
|
|
455
|
+
# the server rejects as invalid are dropped - they will never become valid
|
|
456
|
+
# - and the count is logged.
|
|
457
|
+
def flush_spool(timeout: nil)
|
|
458
|
+
return 0 if !@spool || !@enabled
|
|
459
|
+
|
|
460
|
+
records = @spool.take
|
|
461
|
+
return 0 if records.empty?
|
|
462
|
+
|
|
463
|
+
sent = 0
|
|
464
|
+
rest = records.dup
|
|
465
|
+
until rest.empty?
|
|
466
|
+
group = rest.shift(MAX_BATCH)
|
|
467
|
+
begin
|
|
468
|
+
# The key is derived from the group itself: a replay after a crash
|
|
469
|
+
# chunks the records identically (the spool preserves the order), so
|
|
470
|
+
# the key is the same and the server dedupes the double write (#30).
|
|
471
|
+
r = call("/v1/calls/complete", method: "POST", body: group,
|
|
472
|
+
timeout: timeout || [@timeout, 10.0].max,
|
|
473
|
+
idem: Batchwatch.idem_complete(group))
|
|
474
|
+
rescue StandardError => e
|
|
475
|
+
debug("batchwatch: spool could not be sent: #{e}")
|
|
476
|
+
# The group that failed stays put along with the rest.
|
|
477
|
+
@spool.keep(group + rest)
|
|
478
|
+
return sent
|
|
479
|
+
end
|
|
480
|
+
sent += (r || {}).fetch("accepted", 0)
|
|
481
|
+
rejected = (r || {}).fetch("rejected", 0)
|
|
482
|
+
if rejected && rejected > 0
|
|
483
|
+
debug("batchwatch: #{rejected} spooled measurements were rejected and dropped")
|
|
484
|
+
end
|
|
485
|
+
end
|
|
486
|
+
@spool.keep([])
|
|
487
|
+
debug("batchwatch: #{sent} spooled measurements sent")
|
|
488
|
+
sent
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
# Called from the background thread AFTER a successful call - so we know the
|
|
492
|
+
# network is up right now, and we avoid hammering a server that is not
|
|
493
|
+
# answering anyway.
|
|
494
|
+
def maybe_flush_spool
|
|
495
|
+
return unless @spool
|
|
496
|
+
|
|
497
|
+
now = monotonic
|
|
498
|
+
@spool_lock.synchronize do
|
|
499
|
+
return if now - @spool_last < SPOOL_INTERVAL_S
|
|
500
|
+
|
|
501
|
+
@spool_last = now
|
|
502
|
+
end
|
|
503
|
+
flush_spool
|
|
504
|
+
rescue StandardError => e
|
|
505
|
+
debug("batchwatch: spool drain failed: #{e}")
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
# Store a COMPLETED measurement that could not be delivered.
|
|
509
|
+
def spool_measurement(body)
|
|
510
|
+
if @spool
|
|
511
|
+
@spool.append(body)
|
|
512
|
+
else
|
|
513
|
+
debug("batchwatch: the measurement was lost (no spool)")
|
|
514
|
+
end
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
private
|
|
518
|
+
|
|
519
|
+
def monotonic
|
|
520
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
def debug(msg)
|
|
524
|
+
@logger&.debug(msg)
|
|
525
|
+
end
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
# Handle for one measurement in flight. Returned by track().
|
|
529
|
+
class Tracking
|
|
530
|
+
attr_reader :id
|
|
531
|
+
|
|
532
|
+
def initialize(bw, provider, model, mode, requests, input_tokens, endpoint)
|
|
533
|
+
@bw = bw
|
|
534
|
+
@body = { "provider" => provider, "model" => model, "mode" => mode,
|
|
535
|
+
"requests" => requests, "endpoint" => endpoint }
|
|
536
|
+
@input_tokens = input_tokens
|
|
537
|
+
@id = nil
|
|
538
|
+
@t0 = Time.now
|
|
539
|
+
@finished = false
|
|
540
|
+
@lock = Mutex.new
|
|
541
|
+
@id_ready = false
|
|
542
|
+
@id_cond = ConditionVariable.new
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
def finished?
|
|
546
|
+
@lock.synchronize { @finished }
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
def start
|
|
550
|
+
body = @body.dup
|
|
551
|
+
body["input_tokens"] = @input_tokens
|
|
552
|
+
body["started_at"] = Batchwatch.iso(@t0)
|
|
553
|
+
|
|
554
|
+
start_body = Batchwatch.sanitize(body)
|
|
555
|
+
@bw.in_background do
|
|
556
|
+
begin
|
|
557
|
+
r = @bw.call("/v1/calls", method: "POST", body: start_body,
|
|
558
|
+
idem: Batchwatch.idem_start(start_body))
|
|
559
|
+
@lock.synchronize do
|
|
560
|
+
@id = r && r["id"]
|
|
561
|
+
@id_ready = true
|
|
562
|
+
@id_cond.broadcast
|
|
563
|
+
end
|
|
564
|
+
@bw.maybe_flush_spool if r
|
|
565
|
+
rescue StandardError
|
|
566
|
+
@lock.synchronize do
|
|
567
|
+
@id_ready = true
|
|
568
|
+
@id_cond.broadcast
|
|
569
|
+
end
|
|
570
|
+
raise
|
|
571
|
+
end
|
|
572
|
+
end
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
# Update the token count when it is only known after submission.
|
|
576
|
+
def started(input_tokens: nil)
|
|
577
|
+
@input_tokens = input_tokens unless input_tokens.nil?
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
# Close the measurement.
|
|
581
|
+
#
|
|
582
|
+
# output_tokens stays nil when you do not know it. It is never defaulted to
|
|
583
|
+
# zero: zero is a measurement, absence is not, and the server prices them
|
|
584
|
+
# differently on purpose.
|
|
585
|
+
def done(output_tokens: nil, status: "completed", ttfb_ms: nil)
|
|
586
|
+
@lock.synchronize do
|
|
587
|
+
return if @finished
|
|
588
|
+
|
|
589
|
+
@finished = true
|
|
590
|
+
end
|
|
591
|
+
ended = Time.now
|
|
592
|
+
|
|
593
|
+
# The outcome measurement (#101): the latest advice for THIS model,
|
|
594
|
+
# attached to the completion. No advice -> empty hash -> the fields are
|
|
595
|
+
# omitted entirely.
|
|
596
|
+
advice = Batchwatch.outcome_fields(@bw.get_advice(@body["model"]))
|
|
597
|
+
|
|
598
|
+
@bw.in_background do
|
|
599
|
+
# Wait briefly for the id to land from the start call.
|
|
600
|
+
wait_for_id
|
|
601
|
+
id = @lock.synchronize { @id }
|
|
602
|
+
|
|
603
|
+
full = @body.dup
|
|
604
|
+
full.merge!("input_tokens" => @input_tokens,
|
|
605
|
+
"output_tokens" => output_tokens, "status" => status,
|
|
606
|
+
"started_at" => Batchwatch.iso(@t0),
|
|
607
|
+
"ended_at" => Batchwatch.iso(ended))
|
|
608
|
+
full["ttfb_ms"] = ttfb_ms unless ttfb_ms.nil?
|
|
609
|
+
full.merge!(advice)
|
|
610
|
+
|
|
611
|
+
if id
|
|
612
|
+
begin
|
|
613
|
+
@bw.call("/v1/calls/#{id}", method: "PATCH", body: Batchwatch.sanitize(
|
|
614
|
+
{ "status" => status, "output_tokens" => output_tokens,
|
|
615
|
+
"ttfb_ms" => ttfb_ms, "ended_at" => Batchwatch.iso(ended) }.merge(advice)
|
|
616
|
+
))
|
|
617
|
+
next
|
|
618
|
+
rescue StandardError
|
|
619
|
+
# The server already has the start. A spooled resend can therefore
|
|
620
|
+
# produce a duplicate if the PATCH reached the server anyway -
|
|
621
|
+
# chosen on purpose: a duplicate can be seen in the dataset, a lost
|
|
622
|
+
# measurement cannot.
|
|
623
|
+
@bw.spool_measurement(Batchwatch.sanitize(full))
|
|
624
|
+
next
|
|
625
|
+
end
|
|
626
|
+
end
|
|
627
|
+
|
|
628
|
+
# The start call never reached the server. Send the whole measurement
|
|
629
|
+
# in one go instead of losing it. The key is derived from the
|
|
630
|
+
# measurement, so a later spool replay of exactly this record carries
|
|
631
|
+
# the SAME key and is deduped (#30).
|
|
632
|
+
cleaned = Batchwatch.sanitize(full)
|
|
633
|
+
begin
|
|
634
|
+
@bw.call("/v1/calls/complete", method: "POST", body: cleaned,
|
|
635
|
+
idem: Batchwatch.idem_complete([cleaned]))
|
|
636
|
+
rescue StandardError
|
|
637
|
+
@bw.spool_measurement(cleaned)
|
|
638
|
+
end
|
|
639
|
+
end
|
|
640
|
+
end
|
|
641
|
+
|
|
642
|
+
# Record the job as not-completed. An unfinished wait is not a wait.
|
|
643
|
+
def failed(status: "failed")
|
|
644
|
+
done(status: status)
|
|
645
|
+
end
|
|
646
|
+
|
|
647
|
+
private
|
|
648
|
+
|
|
649
|
+
def wait_for_id
|
|
650
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + ID_WAIT_S
|
|
651
|
+
@lock.synchronize do
|
|
652
|
+
until @id_ready
|
|
653
|
+
rest = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
654
|
+
break if rest <= 0
|
|
655
|
+
|
|
656
|
+
@id_cond.wait(@lock, rest)
|
|
657
|
+
end
|
|
658
|
+
end
|
|
659
|
+
end
|
|
660
|
+
end
|
|
661
|
+
|
|
662
|
+
@default = nil
|
|
663
|
+
@default_lock = Mutex.new
|
|
664
|
+
|
|
665
|
+
# Shortcut that uses a shared default client, to get started quickly.
|
|
666
|
+
def self.track(model, **kw, &blk)
|
|
667
|
+
@default_lock.synchronize { @default ||= Client.new }
|
|
668
|
+
@default.track(model, **kw, &blk)
|
|
669
|
+
end
|
|
670
|
+
end
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# On-disk spool for measurements that could not be delivered.
|
|
4
|
+
#
|
|
5
|
+
# A measurement is worth the most exactly when the network is misbehaving, so
|
|
6
|
+
# that is the worst possible moment to lose one. Undeliverable *completed*
|
|
7
|
+
# measurements are therefore written to a JSONL file and replayed later through
|
|
8
|
+
# POST /v1/calls/complete.
|
|
9
|
+
#
|
|
10
|
+
# The file format is one JSON object per line, in exactly the form
|
|
11
|
+
# /v1/calls/complete accepts. It is the same format in the Python, Go,
|
|
12
|
+
# TypeScript and .NET clients, so a spool file written by one can be drained
|
|
13
|
+
# by another.
|
|
14
|
+
#
|
|
15
|
+
# Replay requires an API key: /v1/calls/complete takes the caller's own
|
|
16
|
+
# timestamps and is closed to anonymous callers for that reason. A client
|
|
17
|
+
# without a token therefore does not spool at all - a file no one can send is
|
|
18
|
+
# just a disk leak.
|
|
19
|
+
#
|
|
20
|
+
# Threads are handled with a mutex; separate processes sharing one spool file
|
|
21
|
+
# are not. Appending from multiple processes almost always goes fine, but two
|
|
22
|
+
# processes draining the same file at the same time may send the same
|
|
23
|
+
# measurement twice. Give each process its own BATCHWATCH_SPOOL if it matters.
|
|
24
|
+
require "json"
|
|
25
|
+
|
|
26
|
+
module Batchwatch
|
|
27
|
+
# The server's cap on one POST /v1/calls/complete.
|
|
28
|
+
MAX_BATCH = 500
|
|
29
|
+
|
|
30
|
+
# Cap on the spool file. If batchwatch is down for a week, a busy pipeline
|
|
31
|
+
# must not fill the user's disk. When the cap is reached, the measurement is
|
|
32
|
+
# dropped - and that is the right choice: his machine is not our storage.
|
|
33
|
+
MAX_BYTES = 5 * 1024 * 1024
|
|
34
|
+
|
|
35
|
+
# Append-only JSONL file of completed measurements waiting to be sent.
|
|
36
|
+
class Spool
|
|
37
|
+
attr_reader :path, :pending, :max_bytes
|
|
38
|
+
|
|
39
|
+
def initialize(path, max_bytes: MAX_BYTES, logger: nil)
|
|
40
|
+
@path = path
|
|
41
|
+
@pending = "#{path}.pending"
|
|
42
|
+
@max_bytes = max_bytes
|
|
43
|
+
@logger = logger
|
|
44
|
+
# The measurements come from background threads, one per completed call.
|
|
45
|
+
# Without the lock, two concurrent completions lose one of themselves:
|
|
46
|
+
# "find the end, write" is not atomic. Seen in a test with three at once:
|
|
47
|
+
# two lines made it.
|
|
48
|
+
@lock = Mutex.new
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# ------------------------------------------------------------------ write
|
|
52
|
+
|
|
53
|
+
# Store one completed measurement. Returns true if it was stored.
|
|
54
|
+
#
|
|
55
|
+
# Never raises: failing a spool must not be worse than the network error
|
|
56
|
+
# that triggered the spool.
|
|
57
|
+
def append(record)
|
|
58
|
+
@lock.synchronize { write_one(record) }
|
|
59
|
+
rescue StandardError => e
|
|
60
|
+
debug("batchwatch: could not spool: #{e}")
|
|
61
|
+
false
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# ------------------------------------------------------------------- read
|
|
65
|
+
|
|
66
|
+
# Move everything spooled into the pending file and return it.
|
|
67
|
+
#
|
|
68
|
+
# Returns a list of records. An empty list means there is nothing to send -
|
|
69
|
+
# also when the spool simply could not be read.
|
|
70
|
+
def take
|
|
71
|
+
@lock.synchronize { take_all }
|
|
72
|
+
rescue StandardError => e
|
|
73
|
+
debug("batchwatch: could not read spool: #{e}")
|
|
74
|
+
[]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Put records back after a partial or failed drain.
|
|
78
|
+
def keep(remaining)
|
|
79
|
+
@lock.synchronize do
|
|
80
|
+
if remaining && !remaining.empty?
|
|
81
|
+
write_all(@pending, remaining)
|
|
82
|
+
elsif File.exist?(@pending)
|
|
83
|
+
File.delete(@pending)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
rescue StandardError => e
|
|
87
|
+
debug("batchwatch: could not write spool back: #{e}")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Number of records waiting on disk. Best effort, never raises.
|
|
91
|
+
def size
|
|
92
|
+
@lock.synchronize { read_all(@pending).length + read_all(@path).length }
|
|
93
|
+
rescue StandardError => e
|
|
94
|
+
debug("batchwatch: could not count spool: #{e}")
|
|
95
|
+
0
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
def write_one(record)
|
|
101
|
+
if File.exist?(@path) && File.size(@path) >= @max_bytes
|
|
102
|
+
debug("batchwatch: spool is full (#{@path}) - the measurement is dropped")
|
|
103
|
+
return false
|
|
104
|
+
end
|
|
105
|
+
dir = File.dirname(@path)
|
|
106
|
+
mkdir_p(dir) if dir && !dir.empty? && dir != "."
|
|
107
|
+
|
|
108
|
+
line = JSON.generate(record)
|
|
109
|
+
# "r+b"/"a+b"-like behaviour: we need to be able to READ the last byte,
|
|
110
|
+
# and a pure append mode is write-only. If the file ends mid-line after a
|
|
111
|
+
# crash, the next measurement must not be glued onto it - otherwise we
|
|
112
|
+
# lose not just the half line, but the whole one too.
|
|
113
|
+
File.open(@path, File::WRONLY | File::CREAT, 0o644) do |f|
|
|
114
|
+
f.seek(0, IO::SEEK_END)
|
|
115
|
+
size = f.pos
|
|
116
|
+
prefix = ""
|
|
117
|
+
if size > 0
|
|
118
|
+
# Read the last byte through a separate handle - WRONLY cannot read.
|
|
119
|
+
# If the trailing newline is missing, we push one in front.
|
|
120
|
+
last = File.open(@path, "rb") { |r| r.seek(-1, IO::SEEK_END); r.read(1) }
|
|
121
|
+
prefix = "\n" if last != "\n"
|
|
122
|
+
end
|
|
123
|
+
# ONE write. If it is split, another writer can slip its line in
|
|
124
|
+
# between the two halves.
|
|
125
|
+
f.write("#{prefix}#{line}\n")
|
|
126
|
+
end
|
|
127
|
+
debug("batchwatch: measurement spooled to #{@path}")
|
|
128
|
+
true
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def take_all
|
|
132
|
+
records = read_all(@pending) + read_all(@path)
|
|
133
|
+
return [] if records.empty?
|
|
134
|
+
|
|
135
|
+
# The order is deliberate: write pending BEFORE the source is removed. A
|
|
136
|
+
# crash in between must produce duplicates, not loss - a duplicate can be
|
|
137
|
+
# seen and filtered out, a lost measurement does not exist.
|
|
138
|
+
write_all(@pending, records)
|
|
139
|
+
File.delete(@path) if File.exist?(@path)
|
|
140
|
+
records
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def read_all(path)
|
|
144
|
+
return [] unless File.exist?(path)
|
|
145
|
+
|
|
146
|
+
out = []
|
|
147
|
+
File.foreach(path) do |line|
|
|
148
|
+
line = line.strip
|
|
149
|
+
next if line.empty?
|
|
150
|
+
|
|
151
|
+
begin
|
|
152
|
+
out << JSON.parse(line)
|
|
153
|
+
rescue JSON::ParserError
|
|
154
|
+
# A half-written line after a crash. Skip it instead of losing the
|
|
155
|
+
# rest of the file.
|
|
156
|
+
debug("batchwatch: unusable line in spool skipped")
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
out
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def write_all(path, records)
|
|
163
|
+
File.open(path, "w") do |f|
|
|
164
|
+
records.each { |p| f.write("#{JSON.generate(p)}\n") }
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# We avoid a dependency on 'fileutils' in the library by rolling a small
|
|
169
|
+
# recursive mkdir ourselves - the standard library can do all of it.
|
|
170
|
+
def mkdir_p(dir)
|
|
171
|
+
return if File.directory?(dir)
|
|
172
|
+
|
|
173
|
+
parent = File.dirname(dir)
|
|
174
|
+
mkdir_p(parent) if parent != dir && !File.directory?(parent)
|
|
175
|
+
begin
|
|
176
|
+
Dir.mkdir(dir)
|
|
177
|
+
rescue Errno::EEXIST
|
|
178
|
+
# Another thread got to make it. Fine.
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def debug(msg)
|
|
183
|
+
@logger&.debug(msg)
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
data/lib/batchwatch.rb
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# batchwatch - Ruby client for batchwatch.dev.
|
|
4
|
+
#
|
|
5
|
+
# Crowdsourced measurement of queue time on LLM batch APIs. Batch endpoints
|
|
6
|
+
# cost 50% of the synchronous ones, but "completes within 24 hours" is
|
|
7
|
+
# impossible to plan around. batchwatch measures what the queue actually does
|
|
8
|
+
# and answers one question: *should I use batch for this job?*
|
|
9
|
+
#
|
|
10
|
+
# Standard library only. No dependencies, and none planned.
|
|
11
|
+
#
|
|
12
|
+
# require "batchwatch"
|
|
13
|
+
#
|
|
14
|
+
# bw = Batchwatch::Client.new(token: "bw_...")
|
|
15
|
+
# if bw.should_batch("gpt-5.6-sol", max_wait: "15m")
|
|
16
|
+
# # ... send batch ...
|
|
17
|
+
# end
|
|
18
|
+
# bw.track("gpt-5.6-sol", input_tokens: 9720) do |t|
|
|
19
|
+
# t.done(output_tokens: 4519)
|
|
20
|
+
# end
|
|
21
|
+
require_relative "batchwatch/spool"
|
|
22
|
+
require_relative "batchwatch/client"
|
metadata
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: batchwatch
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.2.1
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Andreas Graae
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-08-29 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: minitest
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '5.15'
|
|
20
|
+
type: :development
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '5.15'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: rake
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '13.0'
|
|
34
|
+
type: :development
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '13.0'
|
|
41
|
+
description: Crowdsourced measurement of queue time on LLM batch APIs. Fails open
|
|
42
|
+
(a batchwatch outage never touches your job) and never sends content - only a fixed
|
|
43
|
+
allowlist of provider, model, mode, endpoint, request count, token counts and timestamps.
|
|
44
|
+
Standard library only, no dependencies.
|
|
45
|
+
email:
|
|
46
|
+
executables: []
|
|
47
|
+
extensions: []
|
|
48
|
+
extra_rdoc_files: []
|
|
49
|
+
files:
|
|
50
|
+
- LICENSE
|
|
51
|
+
- README.md
|
|
52
|
+
- lib/batchwatch.rb
|
|
53
|
+
- lib/batchwatch/client.rb
|
|
54
|
+
- lib/batchwatch/spool.rb
|
|
55
|
+
homepage: https://batchwatch.dev
|
|
56
|
+
licenses:
|
|
57
|
+
- MIT
|
|
58
|
+
metadata:
|
|
59
|
+
homepage_uri: https://batchwatch.dev
|
|
60
|
+
source_code_uri: https://github.com/batchwatch/client
|
|
61
|
+
rubygems_mfa_required: 'true'
|
|
62
|
+
post_install_message:
|
|
63
|
+
rdoc_options: []
|
|
64
|
+
require_paths:
|
|
65
|
+
- lib
|
|
66
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
67
|
+
requirements:
|
|
68
|
+
- - ">="
|
|
69
|
+
- !ruby/object:Gem::Version
|
|
70
|
+
version: '3.0'
|
|
71
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
72
|
+
requirements:
|
|
73
|
+
- - ">="
|
|
74
|
+
- !ruby/object:Gem::Version
|
|
75
|
+
version: '0'
|
|
76
|
+
requirements: []
|
|
77
|
+
rubygems_version: 3.3.27
|
|
78
|
+
signing_key:
|
|
79
|
+
specification_version: 4
|
|
80
|
+
summary: Client for batchwatch.dev - measure queue time on LLM batch APIs without
|
|
81
|
+
ever blocking your job
|
|
82
|
+
test_files: []
|