fix: prevent stale capacity poisoning and deepen recent-only refill scans
Keep ordinary capacity races from lowering persistent per-account limits. Deepen recent-only discovery within the existing age and quality gates. Add offline regressions for recovery and reserved-slot refill.
This commit is contained in:
10
README.md
10
README.md
@@ -301,6 +301,16 @@ model/GPU combinations, the same run progressively expands discovery to the last
|
|||||||
7 days, the last 30 days, and finally older history (up to 3,000 models). Scanning
|
7 days, the last 30 days, and finally older history (up to 3,000 models). Scanning
|
||||||
stops as soon as enough replacement candidates have been found.
|
stops as soon as enough replacement candidates have been found.
|
||||||
|
|
||||||
|
When only recent-model reserved positions remain, discovery also deepens the
|
||||||
|
same recent window up to the configured scan cap (3,000 by default). It never
|
||||||
|
admits older models into those positions or lowers the quality gates. This avoids
|
||||||
|
rechecking only an exhausted short prefix on every poll.
|
||||||
|
|
||||||
|
An ordinary account-capacity rejection temporarily marks that account full and
|
||||||
|
tries the next account. It does not lower the persisted capacity using a cached
|
||||||
|
active count: another worker may have filled the queue since that count was
|
||||||
|
read. Downward capacity learning remains confined to weekly calibration.
|
||||||
|
|
||||||
Model verification results are reused for 15 minutes across poll cycles, and a
|
Model verification results are reused for 15 minutes across poll cycles, and a
|
||||||
locally failed model/GPU pair cools down for 24 hours instead of being excluded
|
locally failed model/GPU pair cools down for 24 hours instead of being excluded
|
||||||
forever. The `[scan]` lines show every expansion stage, while `[daily] wave_done`
|
forever. The `[scan]` lines show every expansion stage, while `[daily] wave_done`
|
||||||
|
|||||||
@@ -822,6 +822,17 @@ def build_adaptive_scan_stages(
|
|||||||
"limit": max_models,
|
"limit": max_models,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
if not allow_older_than_recent_window:
|
||||||
|
# Reserved positions still need a deep search of eligible recent
|
||||||
|
# models. Widening the date window alone repeatedly scans the same
|
||||||
|
# short prefix (often already processed) on every poll cycle.
|
||||||
|
stages.append(
|
||||||
|
{
|
||||||
|
"name": "recent_window_full",
|
||||||
|
"updatedAfter": recent_cutoff,
|
||||||
|
"limit": max_models,
|
||||||
|
}
|
||||||
|
)
|
||||||
deduped: list[dict[str, Any]] = []
|
deduped: list[dict[str, Any]] = []
|
||||||
seen: set[tuple[str | None, int, str]] = set()
|
seen: set[tuple[str | None, int, str]] = set()
|
||||||
for stage in stages:
|
for stage in stages:
|
||||||
|
|||||||
@@ -1029,7 +1029,12 @@ class ModelHubClientPool:
|
|||||||
self._capacity_probe_cooldown_until[index] = (
|
self._capacity_probe_cooldown_until[index] = (
|
||||||
self._capacity_probe_cycle + self._capacity_probe_cooldown_cycles
|
self._capacity_probe_cycle + self._capacity_probe_cooldown_cycles
|
||||||
)
|
)
|
||||||
else:
|
elif self._weekly_target_locked() == index:
|
||||||
|
# Only the scheduled calibration may lower a learned cap.
|
||||||
|
# Ordinary rejection can race with another agent filling this
|
||||||
|
# account; our cached count is not evidence of its capacity.
|
||||||
|
# Persisting it (even zero -> one) strands otherwise usable
|
||||||
|
# positions until the next calibration.
|
||||||
observed_capacity = max(1, self._effective_count_locked(index))
|
observed_capacity = max(1, self._effective_count_locked(index))
|
||||||
if observed_capacity < self._account_caps[index]:
|
if observed_capacity < self._account_caps[index]:
|
||||||
self._account_caps[index] = observed_capacity
|
self._account_caps[index] = observed_capacity
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import tempfile
|
|||||||
import threading
|
import threading
|
||||||
import unittest
|
import unittest
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from dataclasses import replace
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
@@ -216,6 +217,38 @@ def make_candidate(index: int) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
class ClientPoolConcurrencyTests(unittest.TestCase):
|
class ClientPoolConcurrencyTests(unittest.TestCase):
|
||||||
|
def test_stale_capacity_rejection_does_not_poison_learned_limit(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temporary_dir:
|
||||||
|
state_path = Path(temporary_dir) / "capacity.json"
|
||||||
|
client = FakeClient(active_count=0, reject_capacity=True)
|
||||||
|
pool = ModelHubClientPool(
|
||||||
|
[client], # type: ignore[list-item]
|
||||||
|
active_task_cap=100,
|
||||||
|
recent_model_reserve_slots=5,
|
||||||
|
capacity_probe_interval_cycles=0,
|
||||||
|
capacity_state_path=state_path,
|
||||||
|
)
|
||||||
|
self.assertEqual(100, pool.available_submit_slots())
|
||||||
|
with self.assertRaises(ModelHubAPIError):
|
||||||
|
pool.add_task({"model": "rejected-during-race"})
|
||||||
|
|
||||||
|
self.assertEqual([100], pool.account_capacity_limits())
|
||||||
|
client.reject_capacity = False
|
||||||
|
pool.refresh_active_counts()
|
||||||
|
self.assertEqual(95, pool.old_model_submit_slots())
|
||||||
|
pool.add_task_for_model(
|
||||||
|
{"model": "eligible-old-model"},
|
||||||
|
model_last_modified="2025-01-01T00:00:00Z",
|
||||||
|
submitted_at="2026-10-02T00:00:00Z",
|
||||||
|
)
|
||||||
|
self.assertEqual(1, len(client.submitted))
|
||||||
|
restarted = ModelHubClientPool(
|
||||||
|
[client], # type: ignore[list-item]
|
||||||
|
active_task_cap=100,
|
||||||
|
capacity_state_path=state_path,
|
||||||
|
)
|
||||||
|
self.assertEqual([100], restarted.account_capacity_limits())
|
||||||
|
|
||||||
def test_exact_active_count_uses_filtered_platform_totals(self) -> None:
|
def test_exact_active_count_uses_filtered_platform_totals(self) -> None:
|
||||||
client = ModelHubClient(token="test-token")
|
client = ModelHubClient(token="test-token")
|
||||||
calls: list[str] = []
|
calls: list[str] = []
|
||||||
@@ -481,8 +514,37 @@ class ClientPoolConcurrencyTests(unittest.TestCase):
|
|||||||
recent_model_days=7,
|
recent_model_days=7,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(["configured_window", "last_7_days"], [stage["name"] for stage in stages])
|
self.assertEqual(
|
||||||
|
["configured_window", "last_7_days", "recent_window_full"],
|
||||||
|
[stage["name"] for stage in stages],
|
||||||
|
)
|
||||||
self.assertTrue(all(stage["updatedAfter"] >= now - timedelta(days=7) for stage in stages))
|
self.assertTrue(all(stage["updatedAfter"] >= now - timedelta(days=7) for stage in stages))
|
||||||
|
self.assertEqual(3000, stages[-1]["limit"])
|
||||||
|
|
||||||
|
def test_recent_only_scan_deepens_even_when_date_window_cannot_expand(self) -> None:
|
||||||
|
now = datetime(2026, 8, 11, tzinfo=timezone.utc)
|
||||||
|
for initial_cutoff in (None, now - timedelta(days=7), now - timedelta(days=30)):
|
||||||
|
with self.subTest(initial_cutoff=initial_cutoff):
|
||||||
|
stages = build_adaptive_scan_stages(
|
||||||
|
now=now,
|
||||||
|
initial_updated_after=initial_cutoff,
|
||||||
|
initial_limit=20,
|
||||||
|
max_models=800,
|
||||||
|
allow_older_than_recent_window=False,
|
||||||
|
)
|
||||||
|
self.assertEqual([20, 800], [stage["limit"] for stage in stages])
|
||||||
|
self.assertTrue(all(stage["updatedAfter"] == now - timedelta(days=7) for stage in stages))
|
||||||
|
|
||||||
|
def test_recent_only_scan_deduplicates_an_already_full_window(self) -> None:
|
||||||
|
now = datetime(2026, 8, 11, tzinfo=timezone.utc)
|
||||||
|
stages = build_adaptive_scan_stages(
|
||||||
|
now=now,
|
||||||
|
initial_updated_after=now - timedelta(days=7),
|
||||||
|
initial_limit=100,
|
||||||
|
max_models=100,
|
||||||
|
allow_older_than_recent_window=False,
|
||||||
|
)
|
||||||
|
self.assertEqual(1, len(stages))
|
||||||
|
|
||||||
def test_submit_candidate_reports_old_model_policy_skip_at_dynamic_threshold(self) -> None:
|
def test_submit_candidate_reports_old_model_policy_skip_at_dynamic_threshold(self) -> None:
|
||||||
client = FakeClient(active_count=95)
|
client = FakeClient(active_count=95)
|
||||||
@@ -738,6 +800,54 @@ class ClientPoolConcurrencyTests(unittest.TestCase):
|
|||||||
self.assertEqual("last_modified", summary["scanStages"][-1]["sort"])
|
self.assertEqual("last_modified", summary["scanStages"][-1]["sort"])
|
||||||
self.assertIsNone(discovery.calls[-1])
|
self.assertIsNone(discovery.calls[-1])
|
||||||
|
|
||||||
|
def test_reserved_slots_find_recent_candidates_beyond_first_scan_prefix(self) -> None:
|
||||||
|
class RecentOnlyClient(ExhaustedRecentClient):
|
||||||
|
def old_model_submit_slots(self) -> int:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
class PagedRecentDiscovery(FakeDiscovery):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__(4)
|
||||||
|
self.models[0] = replace(self.models[0], repo_id="owner/recent-exhausted")
|
||||||
|
self.limits: list[int] = []
|
||||||
|
|
||||||
|
def list_recent_models(self, *, limit, **_kwargs): # noqa: ANN003
|
||||||
|
self.limits.append(limit)
|
||||||
|
return self.models[:limit]
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as temporary_dir:
|
||||||
|
root = Path(temporary_dir)
|
||||||
|
args = build_parser().parse_args([
|
||||||
|
"--task-types", "text-generation", "--since-hours", "168",
|
||||||
|
"--limit", "1", "--max-scan-models", "4",
|
||||||
|
"--skip-outcome-sync", "--skip-history-archive",
|
||||||
|
"--disable-gpu-strategy",
|
||||||
|
])
|
||||||
|
for field, filename in (
|
||||||
|
("runs_dir", "runs"), ("ledger_path", "ledger.jsonl"),
|
||||||
|
("outcomes_path", "outcomes.jsonl"), ("claims_path", "claims.jsonl"),
|
||||||
|
("history_archive_path", "history.jsonl"),
|
||||||
|
("submission_exclusions_path", "exclusions.jsonl"),
|
||||||
|
):
|
||||||
|
setattr(args, field, str(root / filename))
|
||||||
|
selector = TemplateSelector()
|
||||||
|
discovery = PagedRecentDiscovery()
|
||||||
|
client = RecentOnlyClient(
|
||||||
|
available=2,
|
||||||
|
processed_gpus=set(selector.supported_target_gpus("text-generation", auto_only=True)),
|
||||||
|
)
|
||||||
|
summary = run_submission(
|
||||||
|
args,
|
||||||
|
now=datetime(2026, 1, 2, 12, tzinfo=timezone.utc),
|
||||||
|
hf_discovery=discovery, # type: ignore[arg-type]
|
||||||
|
modelhub_client=client, # type: ignore[arg-type]
|
||||||
|
template_selector=selector,
|
||||||
|
)
|
||||||
|
self.assertEqual([1, 4], discovery.limits)
|
||||||
|
self.assertEqual(2, summary["submittedCount"])
|
||||||
|
self.assertEqual("recent_window_full", summary["scanStages"][-1]["name"])
|
||||||
|
self.assertEqual(4, summary["scannedModels"])
|
||||||
|
|
||||||
def test_adaptive_strategy_counts_only_platform_accepted_tasks(self) -> None:
|
def test_adaptive_strategy_counts_only_platform_accepted_tasks(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as temporary_dir:
|
with tempfile.TemporaryDirectory() as temporary_dir:
|
||||||
root = Path(temporary_dir)
|
root = Path(temporary_dir)
|
||||||
|
|||||||
Reference in New Issue
Block a user