feat(agent): global bandwidth cap — the agent can't saturate the desktop's network
One shared TokenBucket (default 8 MB/s; BANDWIDTH_LIMIT_MB_S, 0 = unlimited; live MB/s dial + net readout in the control UI) is charged by every still download (streamed chunk reads) and every ffmpeg video stream (metered from outside via /proc/<pid>/io and SIGSTOP/SIGCONTed into budget). Why: D1 re-measurement 2026-07-02 — the idle link moves ~38 MB/s, but 8 unthrottled downloaders bufferbloated it to ~1-1.5 MB/s PER STREAM (operator's browser included). Capping the aggregate keeps the desktop usable and still beats the collapsed sweep throughput it replaces. Agent build 2026-07-02.4. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM
This commit is contained in:
@@ -42,6 +42,7 @@ from .client import FcClient
|
||||
from .config import Config
|
||||
from .crops import crop_region
|
||||
from .detectors import dedupe_crops
|
||||
from .throttle import TokenBucket
|
||||
|
||||
# Cap on the lease-retry backoff: when curator is unreachable (e.g. you redeploy
|
||||
# it while away), a downloader retries leasing with exponential backoff up to this
|
||||
@@ -181,6 +182,10 @@ class Worker:
|
||||
f"Authorization: Bearer {cfg.token}\r\n" if cfg.token else ""
|
||||
)
|
||||
self._lock = threading.Lock()
|
||||
# ONE bandwidth budget for everything the agent pulls (still downloads
|
||||
# and ffmpeg video streams): the agent shares a desktop's network, so
|
||||
# the polite bound is on the aggregate — see throttle.py for why.
|
||||
self.throttle = TokenBucket(cfg.bandwidth_limit_mb_s * 1_048_576)
|
||||
self._running = False
|
||||
# The lifecycle state the UI shows (see the STOPPED/STARTING/... consts).
|
||||
# It stays STOPPING — a truthful "winding down" — from the Stop press until
|
||||
@@ -219,6 +224,7 @@ class Worker:
|
||||
# often the browser polls (see RATE_INTERVAL). Decay to 0 when work stops.
|
||||
self._jpm = 0.0
|
||||
self._dpm = 0.0
|
||||
self._net_mb_s = 0.0 # smoothed aggregate download rate (UI readout)
|
||||
self._util_smooth: float | None = None # EWMA GPU util (set by control loop)
|
||||
# Curator queue snapshot, refreshed by a background poller so the UI
|
||||
# /status read is instant — never an inline curator HTTP call (which
|
||||
@@ -382,6 +388,7 @@ class Worker:
|
||||
this here (not in the browser) makes the numbers independent of the poll
|
||||
rate, so a throttled/unfocused tab still shows a real rate."""
|
||||
prev_p, prev_d, prev_t = self.processed, self.downloaded, time.monotonic()
|
||||
prev_b = self.throttle.consumed
|
||||
while True:
|
||||
time.sleep(RATE_INTERVAL)
|
||||
now = time.monotonic()
|
||||
@@ -389,9 +396,12 @@ class Worker:
|
||||
if dt > 0:
|
||||
jp = max(0.0, 60.0 * (self.processed - prev_p) / dt)
|
||||
dp = max(0.0, 60.0 * (self.downloaded - prev_d) / dt)
|
||||
nb = max(0.0, (self.throttle.consumed - prev_b) / dt / 1_048_576)
|
||||
self._jpm = RATE_ALPHA * jp + (1 - RATE_ALPHA) * self._jpm
|
||||
self._dpm = RATE_ALPHA * dp + (1 - RATE_ALPHA) * self._dpm
|
||||
self._net_mb_s = RATE_ALPHA * nb + (1 - RATE_ALPHA) * self._net_mb_s
|
||||
prev_p, prev_d, prev_t = self.processed, self.downloaded, now
|
||||
prev_b = self.throttle.consumed
|
||||
|
||||
# --- control -----------------------------------------------------------
|
||||
def start(self):
|
||||
@@ -494,6 +504,14 @@ class Worker:
|
||||
with self._lock:
|
||||
self._auto = bool(on)
|
||||
|
||||
def set_bandwidth(self, mb_s: float):
|
||||
# Live-retunes the shared bucket; a downloader blocked mid-wait re-checks
|
||||
# immediately (set_rate notifies), so raising the cap takes effect now.
|
||||
self.throttle.set_rate(max(0.0, float(mb_s)) * 1_048_576)
|
||||
log.info("bandwidth cap set to %s",
|
||||
"unlimited" if self.throttle.rate <= 0
|
||||
else f"{self.throttle.rate / 1_048_576:g} MB/s")
|
||||
|
||||
def set_concurrency(self, n: int):
|
||||
# The UI dial tunes the DOWNLOADER count. A manual set is an override →
|
||||
# leave Auto so the autoscaler stops fighting the operator.
|
||||
@@ -564,6 +582,8 @@ class Worker:
|
||||
"downloads_per_min": round(self._dpm, 1),
|
||||
"errors": self.errors,
|
||||
"transient": self.transient,
|
||||
"bandwidth_limit_mb_s": round(self.throttle.rate / 1_048_576, 1),
|
||||
"net_mb_s": round(self._net_mb_s, 1), # observed aggregate rate
|
||||
}
|
||||
|
||||
def _bump(self, *, processed=0, downloaded=0, errors=0, active=0, transient=0):
|
||||
@@ -682,6 +702,7 @@ class Worker:
|
||||
job.get("max_frames", 64),
|
||||
headers=self._auth_header, timeout=self.cfg.ffmpeg_timeout,
|
||||
should_stop=lambda: self._stopped(stop_evt),
|
||||
governor=self.throttle,
|
||||
)
|
||||
if not frames:
|
||||
# Stop killed ffmpeg → NOT the job's fault; raise transient so the
|
||||
@@ -709,7 +730,7 @@ class Worker:
|
||||
return frames
|
||||
# Stills: download the bytes and decode.
|
||||
with self._timed("download"):
|
||||
data = self.client.fetch_image(job["image_url"])
|
||||
data = self.client.fetch_image(job["image_url"], throttle=self.throttle)
|
||||
with self._timed("decode"):
|
||||
frames = [(None, media.load_image(data))]
|
||||
return frames
|
||||
|
||||
Reference in New Issue
Block a user