Merge pull request 'agent: stop downloader pool stampeding a slow curator (congestion collapse)' (#177) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
Build images / build-ml (push) Successful in 8s
Build images / build-agent (push) Successful in 8s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 35s
CI / integration (push) Successful in 3m22s

This commit was merged in pull request #177.
This commit is contained in:
2026-07-01 10:45:48 -04:00
3 changed files with 34 additions and 8 deletions
+1 -1
View File
@@ -17,7 +17,7 @@ from .worker import Worker
# Bump on every agent change. The page embeds this and /status reports it; the UI
# warns to reload when they differ — so a stale browser-cached page can't be
# mistaken for "the new image didn't deploy". (Belt-and-braces with no-store.)
VERSION = "2026-07-01.3 · video frame dedup"
VERSION = "2026-07-01.4 · gentler downloads, failure-aware scaling"
logbuf.install()
cfg = Config.from_env()
+5 -1
View File
@@ -113,7 +113,11 @@ class FcClient:
def fetch_image(self, image_url: str) -> bytes:
# image_url is a server-relative path ("/images/...").
r = self.s.get(f"{self.base}{image_url}", timeout=180)
# timeout=(connect, read): the read timeout is BETWEEN-BYTES, not total,
# so a large-but-flowing download still completes — but a stuck/dead
# connection (curator overloaded) fails in 60s instead of hanging a
# downloader for 180s and piling up concurrent stuck requests on curator.
r = self.s.get(f"{self.base}{image_url}", timeout=(10, 60))
r.raise_for_status()
return r.content
+28 -6
View File
@@ -61,10 +61,14 @@ def _is_transient(exc: requests.RequestException) -> bool:
return resp.status_code >= 500 or resp.status_code in (401, 403, 408, 409, 429)
# Pipeline sizing. Downloaders are I/O-bound so the ceiling is generous; consumers
# are GPU-bound so a couple saturate the card. The buffer is small on purpose —
# each slot can hold many decoded video frames, so it bounds RAM, not just depth.
DL_MAX = 24 # max downloader threads
# Pipeline sizing. Downloaders are I/O-bound, but every download streams a full
# original (large videos included) THROUGH curator's single Python file-serving
# path — so the ceiling is deliberately modest: too many concurrent large-file
# GETs saturate curator's web workers + NFS and slow everything (including the
# browser). 8 keeps a fast GPU fed without stampeding curator. Consumers are
# GPU-bound so a couple saturate the card. The buffer is small on purpose — each
# slot can hold many decoded video frames, so it bounds RAM, not just depth.
DL_MAX = 8 # max downloader threads
CONSUMER_MAX = 2 # max GPU consumer threads
BUFFER_MAX = 12 # bounded decoded-frame buffer (backpressure + RAM cap)
@@ -712,13 +716,22 @@ class Worker:
(the GPU is the bottleneck → add a 2nd consumer if it has headroom and the
add lifts throughput, else trim a downloader). Occupancy, util and
throughput are EWMA-smoothed and decisions spaced so moves ride averaged
signals, not tick-to-tick noise. VRAM pressure sheds a consumer at once."""
signals, not tick-to-tick noise. VRAM pressure sheds a consumer at once.
Failure guard (critical): an empty buffer can mean the GPU is starving OR
that downloads are FAILING (curator slow/unreachable). In the latter case
adding downloaders piles more concurrent large-file requests onto a
struggling curator — a congestion collapse that slows curator (and its
browser) further and never recovers. So if transient download failures
rose since the last decision, SHRINK toward the floor instead of growing,
and let the pool ramp back up only once downloads succeed again."""
from . import gpu as gpumod
occ_ewma: float | None = None
util_ewma: float | None = None
tput_ewma: float | None = None
prev_p, prev_t = self.processed, time.monotonic()
prev_fail = self.transient
tick = 0
con_grew = False # did the previous decision add a consumer?
tput_before = 0.0 # smoothed jobs/s before that consumer add
@@ -726,6 +739,7 @@ class Worker:
if not (self._running and self._auto):
occ_ewma = util_ewma = tput_ewma = None
prev_p, prev_t = self.processed, time.monotonic()
prev_fail = self.transient
tick = 0
con_grew = False
self._util_smooth = None
@@ -764,9 +778,17 @@ class Worker:
tput_ewma = inst if tput_ewma is None else (
TPUT_ALPHA * inst + (1 - TPUT_ALPHA) * tput_ewma
)
fail_delta = self.transient - prev_fail
prev_fail = self.transient
d0, c0 = self._dl_target, self._consumer_target
if occ_ewma < OCC_LOW:
if fail_delta > 0:
# Downloads are FAILING (curator slow/unreachable), so the empty
# buffer is NOT the GPU starving — growing would stampede a
# struggling curator. Back off toward the floor and let it recover.
self._apply_downloaders(-1)
con_grew = False
elif occ_ewma < OCC_LOW:
# Buffer starving → GPU idle waiting on downloads → add a feeder.
self._apply_downloaders(+1)
con_grew = False