Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5dead8172c |
18
README.md
18
README.md
@@ -2,7 +2,23 @@
|
|||||||
|
|
||||||
This repository is packaged for the ModelHub XC agent platform.
|
This repository is packaged for the ModelHub XC agent platform.
|
||||||
|
|
||||||
## Capacity refresh fix 2026.10.27.1
|
## Capacity pagination verification 2026.10.28.1
|
||||||
|
|
||||||
|
Supersedes the 2026.10.27.1 policy of using the larger reported total.
|
||||||
|
The platform can return inflated totals, incorrect page counts, and short pages.
|
||||||
|
Capacity now reads waiting/running pages through an explicit empty page, ignores
|
||||||
|
total/pages for termination, and deduplicates task IDs. It repeats the scan with
|
||||||
|
page sizes 100 and 50; only matching active ID sets confirm occupancy. Reported
|
||||||
|
totals are diagnostic only. Repeated pages, malformed records, request failures,
|
||||||
|
changing snapshots or the 100-page-per-status budget fail closed for that account.
|
||||||
|
This costs additional read requests but scans active tasks, not full history.
|
||||||
|
Two agreeing reads are not an atomic snapshot or a guarantee against a platform
|
||||||
|
omission shared by both queries; server submission limits remain authoritative.
|
||||||
|
Logs report `method=dual_page_scan`, `confirmed_active`, and safe failure reasons.
|
||||||
|
No age cleanup, routing, success-rate gates, recent-five-slot reserve, or weekly
|
||||||
|
capacity exploration policy changes. Existing tasks are not cancelled.
|
||||||
|
|
||||||
|
## Previous capacity refresh fix 2026.10.27.1
|
||||||
|
|
||||||
All task-page requests are clamped to the platform's 100-record limit, including
|
All task-page requests are clamped to the platform's 100-record limit, including
|
||||||
community sampling. Normal and weekly capacity refreshes now use the same
|
community sampling. Normal and weekly capacity refreshes now use the same
|
||||||
|
|||||||
@@ -325,47 +325,63 @@ class ModelHubClient:
|
|||||||
return active_count
|
return active_count
|
||||||
|
|
||||||
def count_active_tasks_exact(self) -> int:
|
def count_active_tasks_exact(self) -> int:
|
||||||
"""Conservative active occupancy from filtered, validated pages.
|
"""Confirm active IDs with two independently paginated filtered reads.
|
||||||
|
|
||||||
Totals can disagree with records. Count unique IDs, but retain the
|
The platform's total/pages fields can describe a different population.
|
||||||
larger reported total rather than inventing extra free slots.
|
Only an explicit empty page terminates a scan, never those fields or a
|
||||||
|
short page. Two page sizes must agree before advertising any free slots.
|
||||||
|
This is not an atomic snapshot: concurrent queue changes fail closed.
|
||||||
"""
|
"""
|
||||||
total = 0
|
def invalid(reason: str) -> ModelHubAPIError:
|
||||||
seen: set[str] = set()
|
error = ModelHubAPIError(reason)
|
||||||
|
error.capacity_reason = reason
|
||||||
|
return error
|
||||||
|
|
||||||
|
def scan(page_size: int) -> tuple[set[str], list[dict[str, Any]]]:
|
||||||
|
active: set[str] = set()
|
||||||
details = []
|
details = []
|
||||||
for status in ("waiting", "running"):
|
for status in ("waiting", "running"):
|
||||||
current, reported = 1, 0
|
ids: set[str] = set()
|
||||||
status_ids: set[str] = set()
|
reported = None
|
||||||
while True:
|
for current in range(1, 101):
|
||||||
data = self.list_tasks_page(current=current, page_size=100, only_mine=True, status=status).get("data")
|
response = self.list_tasks_page(
|
||||||
if not isinstance(data, dict) or "total" not in data or not isinstance(data.get("records"), list):
|
current=current, page_size=page_size, only_mine=True, status=status,
|
||||||
raise ModelHubAPIError(f"Incomplete active-task response for status={status}")
|
)
|
||||||
count = int(data["total"])
|
data = response.get("data") if isinstance(response, dict) else None
|
||||||
if count < 0:
|
if not isinstance(data, dict) or not isinstance(data.get("records"), list):
|
||||||
raise ModelHubAPIError("Negative active-task total")
|
raise invalid("invalid_active_response")
|
||||||
reported = max(reported, count)
|
try:
|
||||||
pages = max(1, int(data.get("pages") or 0), (count + 99) // 100)
|
value = int(data["total"])
|
||||||
|
if value >= 0:
|
||||||
|
reported = max(reported or 0, value)
|
||||||
|
except (KeyError, ValueError, TypeError):
|
||||||
|
pass # Metadata is diagnostic, not an occupancy source.
|
||||||
rows = data["records"]
|
rows = data["records"]
|
||||||
prior = len(status_ids)
|
if not rows:
|
||||||
|
break
|
||||||
|
if len(rows) > page_size:
|
||||||
|
raise invalid("page_size_not_respected")
|
||||||
|
previous = len(ids)
|
||||||
for row in rows:
|
for row in rows:
|
||||||
if not isinstance(row, dict) or not row.get("taskId") or row.get("status") != status:
|
if not isinstance(row, dict) or not row.get("taskId") or row.get("status") != status:
|
||||||
raise ModelHubAPIError(f"Invalid filtered active-task record for status={status}")
|
raise invalid("invalid_active_record")
|
||||||
status_ids.add(str(row["taskId"]))
|
ids.add(str(row["taskId"]))
|
||||||
if current >= pages:
|
if len(ids) == previous:
|
||||||
if current > 1 and len(status_ids) == prior:
|
raise invalid("active_pagination_no_progress")
|
||||||
raise ModelHubAPIError("Active-task pagination made no progress")
|
else:
|
||||||
break
|
raise invalid("active_pagination_budget_exhausted")
|
||||||
if not rows or len(status_ids) == prior or current >= 1000:
|
active.update(ids)
|
||||||
raise ModelHubAPIError("Incomplete active-task pagination")
|
details.append({"status": status, "reported": reported, "unique": len(ids),
|
||||||
current += 1
|
"mismatch": reported != len(ids), "pageSize": page_size})
|
||||||
# An ID can move from waiting to running between requests.
|
return active, details
|
||||||
overlap = len(status_ids & seen)
|
|
||||||
total += max(reported, len(status_ids)) - overlap
|
self.active_count_details = []
|
||||||
seen.update(status_ids)
|
first, details = scan(100)
|
||||||
details.append({"status": status, "reported": reported, "unique": len(status_ids),
|
confirmed, confirmation_details = scan(50)
|
||||||
"mismatch": reported != len(status_ids)})
|
if first != confirmed:
|
||||||
self.active_count_details = details
|
raise invalid("active_snapshot_changed")
|
||||||
return total
|
self.active_count_details = details + confirmation_details
|
||||||
|
return len(confirmed)
|
||||||
|
|
||||||
def _request(
|
def _request(
|
||||||
self,
|
self,
|
||||||
@@ -742,7 +758,8 @@ class ModelHubClientPool:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
# Never log request URLs/headers or credentials.
|
# Never log request URLs/headers or credentials.
|
||||||
code = getattr(exc, "code", None)
|
code = getattr(exc, "code", None)
|
||||||
print(f"[capacity] refresh_failed error={type(exc).__name__} code={code} count=unknown", flush=True)
|
reason = getattr(exc, "capacity_reason", "request_failed")
|
||||||
|
print(f"[capacity] refresh_failed error={type(exc).__name__} code={code} reason={reason} count=unknown", flush=True)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _safe_list_tasks(self, client: ModelHubClient, kwargs: dict[str, Any]) -> list[dict[str, Any]]:
|
def _safe_list_tasks(self, client: ModelHubClient, kwargs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
@@ -819,7 +836,7 @@ class ModelHubClientPool:
|
|||||||
details = getattr(self.clients[index], "active_count_details", [])
|
details = getattr(self.clients[index], "active_count_details", [])
|
||||||
if any(item.get("mismatch") for item in details):
|
if any(item.get("mismatch") for item in details):
|
||||||
print(f"[capacity] account={index + 1:02d} total_records_mismatch=true "
|
print(f"[capacity] account={index + 1:02d} total_records_mismatch=true "
|
||||||
f"conservative_active={new_remote_count} details={details}", flush=True)
|
f"confirmed_active={new_remote_count} method=dual_page_scan details={details}", flush=True)
|
||||||
if new_remote_count > self._account_caps[index]:
|
if new_remote_count > self._account_caps[index]:
|
||||||
self._account_caps[index] = new_remote_count
|
self._account_caps[index] = new_remote_count
|
||||||
caps_changed = True
|
caps_changed = True
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
AGENT_VERSION = "2026.10.27.1"
|
AGENT_VERSION = "2026.10.28.1"
|
||||||
|
|||||||
@@ -15,10 +15,11 @@ class CapacityRefreshTests(unittest.TestCase):
|
|||||||
|
|
||||||
def client(self, total=97, observed=94):
|
def client(self, total=97, observed=94):
|
||||||
client = ModelHubClient(token="test")
|
client = ModelHubClient(token="test")
|
||||||
def page(*, status, page_size, **kwargs):
|
def page(*, status, page_size, current=1, **kwargs):
|
||||||
self.assertLessEqual(page_size, 100)
|
self.assertLessEqual(page_size, 100)
|
||||||
return {"data": {"total": total if status == "waiting" else 0, "records": [
|
return {"data": {"total": total if status == "waiting" else 0, "records": [
|
||||||
{"taskId": str(i), "status": status} for i in range(observed if status == "waiting" else 0)]}}
|
{"taskId": str(i), "status": status} for i in range(
|
||||||
|
(current - 1) * page_size, min(current * page_size, observed if status == "waiting" else 0))]}}
|
||||||
client.list_tasks_page = page
|
client.list_tasks_page = page
|
||||||
return client
|
return client
|
||||||
|
|
||||||
@@ -26,8 +27,8 @@ class CapacityRefreshTests(unittest.TestCase):
|
|||||||
client = self.client()
|
client = self.client()
|
||||||
pool = ModelHubClientPool([client], active_task_cap=100)
|
pool = ModelHubClientPool([client], active_task_cap=100)
|
||||||
pool._remote_counts[0] = 100
|
pool._remote_counts[0] = 100
|
||||||
self.assertEqual([97], pool.refresh_active_counts())
|
self.assertEqual([94], pool.refresh_active_counts())
|
||||||
self.assertEqual(3, pool.available_submit_slots())
|
self.assertEqual(6, pool.available_submit_slots())
|
||||||
self.assertTrue(client.active_count_details[0]["mismatch"])
|
self.assertTrue(client.active_count_details[0]["mismatch"])
|
||||||
|
|
||||||
def test_error_is_unknown_and_recovery_reopens_slots(self):
|
def test_error_is_unknown_and_recovery_reopens_slots(self):
|
||||||
@@ -40,22 +41,82 @@ class CapacityRefreshTests(unittest.TestCase):
|
|||||||
self.assertIsNone(pool.active_count_status()[0]["active"])
|
self.assertIsNone(pool.active_count_status()[0]["active"])
|
||||||
self.assertIsNone(pool._reserve_account(set()))
|
self.assertIsNone(pool._reserve_account(set()))
|
||||||
pool.refresh_active_counts()
|
pool.refresh_active_counts()
|
||||||
self.assertEqual(3, pool.available_submit_slots())
|
self.assertEqual(6, pool.available_submit_slots())
|
||||||
|
|
||||||
def test_incomplete_or_repeated_pages_do_not_open_slots(self):
|
def test_incomplete_or_repeated_pages_do_not_open_slots(self):
|
||||||
client = self.client(total=200, observed=100)
|
client = self.client(total=200, observed=100)
|
||||||
|
client.list_tasks_page = lambda **kwargs: {"data": {"records": [
|
||||||
|
{"taskId": "same", "status": kwargs["status"]}]}}
|
||||||
with self.assertRaises(ModelHubAPIError):
|
with self.assertRaises(ModelHubAPIError):
|
||||||
client.count_active_tasks_exact()
|
client.count_active_tasks_exact()
|
||||||
client.list_tasks_page = lambda **kwargs: {"data": {"records": []}}
|
client.list_tasks_page = lambda **kwargs: {"data": {}}
|
||||||
with self.assertRaises(ModelHubAPIError):
|
with self.assertRaises(ModelHubAPIError):
|
||||||
client.count_active_tasks_exact()
|
client.count_active_tasks_exact()
|
||||||
|
|
||||||
def test_duplicate_ids_and_transition_are_counted_once(self):
|
def test_duplicate_ids_and_transition_are_counted_once(self):
|
||||||
client = ModelHubClient(token="test")
|
client = ModelHubClient(token="test")
|
||||||
client.list_tasks_page = lambda **kwargs: {"data": {"total": 1, "records": [
|
client.list_tasks_page = lambda **kwargs: {"data": {"total": 1, "records": [] if kwargs['current'] > 1 else [
|
||||||
{"taskId": "same", "status": kwargs["status"]},
|
{"taskId": "same", "status": kwargs["status"]},
|
||||||
{"taskId": "same", "status": kwargs["status"]}]}}
|
{"taskId": "same", "status": kwargs["status"]}]}}
|
||||||
self.assertEqual(1, client.count_active_tasks_exact())
|
self.assertEqual(1, client.count_active_tasks_exact())
|
||||||
|
|
||||||
def test_total_lower_than_observed_uses_larger_count(self):
|
def test_total_lower_than_observed_uses_larger_count(self):
|
||||||
self.assertEqual(99, self.client(total=97, observed=99).count_active_tasks_exact())
|
self.assertEqual(99, self.client(total=97, observed=99).count_active_tasks_exact())
|
||||||
|
|
||||||
|
def test_inflated_total_does_not_hide_capacity(self):
|
||||||
|
self.assertEqual(100, self.client(total=773, observed=100).count_active_tasks_exact())
|
||||||
|
|
||||||
|
def test_underreported_pages_do_not_truncate_scan(self):
|
||||||
|
client = self.client(total=58, observed=175)
|
||||||
|
original = client.list_tasks_page
|
||||||
|
def page(**kwargs):
|
||||||
|
result = original(**kwargs)
|
||||||
|
result['data']['pages'] = 1
|
||||||
|
return result
|
||||||
|
client.list_tasks_page = page
|
||||||
|
self.assertEqual(175, client.count_active_tasks_exact())
|
||||||
|
|
||||||
|
def test_different_confirmation_ids_fail_closed_even_with_equal_counts(self):
|
||||||
|
client = self.client(total=1, observed=1)
|
||||||
|
original = client.list_tasks_page
|
||||||
|
def page(**kwargs):
|
||||||
|
result = original(**kwargs)
|
||||||
|
if kwargs['page_size'] == 50:
|
||||||
|
for row in result['data']['records']:
|
||||||
|
row['taskId'] = 'replacement'
|
||||||
|
return result
|
||||||
|
client.list_tasks_page = page
|
||||||
|
with self.assertRaisesRegex(ModelHubAPIError, 'active_snapshot_changed'):
|
||||||
|
client.count_active_tasks_exact()
|
||||||
|
|
||||||
|
def test_confirmation_error_does_not_open_slots(self):
|
||||||
|
client = self.client(total=773, observed=94)
|
||||||
|
original = client.list_tasks_page
|
||||||
|
def page(**kwargs):
|
||||||
|
if kwargs['page_size'] == 50:
|
||||||
|
raise ModelHubAPIError('temporary failure')
|
||||||
|
return original(**kwargs)
|
||||||
|
client.list_tasks_page = page
|
||||||
|
pool = ModelHubClientPool([client], active_task_cap=100)
|
||||||
|
pool.refresh_active_counts()
|
||||||
|
self.assertEqual(0, pool.available_submit_slots())
|
||||||
|
self.assertFalse(pool.active_count_status()[0]['known'])
|
||||||
|
|
||||||
|
def test_empty_queue_confirmed_despite_stale_total(self):
|
||||||
|
self.assertEqual(0, self.client(total=773, observed=0).count_active_tasks_exact())
|
||||||
|
|
||||||
|
def test_short_page_is_not_end_of_scan(self):
|
||||||
|
client = ModelHubClient(token='test')
|
||||||
|
def page(**kwargs):
|
||||||
|
rows = [{'taskId': str(kwargs['current']), 'status': 'waiting'}] if (
|
||||||
|
kwargs['status'] == 'waiting' and kwargs['current'] <= 3) else []
|
||||||
|
return {'data': {'records': rows}}
|
||||||
|
client.list_tasks_page = page
|
||||||
|
self.assertEqual(3, client.count_active_tasks_exact())
|
||||||
|
|
||||||
|
def test_scan_budget_is_bounded(self):
|
||||||
|
client = ModelHubClient(token='test')
|
||||||
|
client.list_tasks_page = lambda **kw: {'data': {'records': [
|
||||||
|
{'taskId': str(kw['current']), 'status': kw['status']}]}}
|
||||||
|
with self.assertRaisesRegex(ModelHubAPIError, 'active_pagination_budget_exhausted'):
|
||||||
|
client.count_active_tasks_exact()
|
||||||
|
|||||||
@@ -290,16 +290,17 @@ class ClientPoolConcurrencyTests(unittest.TestCase):
|
|||||||
client = ModelHubClient(token="test-token")
|
client = ModelHubClient(token="test-token")
|
||||||
calls: list[str] = []
|
calls: list[str] = []
|
||||||
|
|
||||||
def page(*, status: str, current: int = 1, **_kwargs) -> dict: # noqa: ANN003
|
def page(*, status: str, current: int = 1, page_size: int = 100, **_kwargs) -> dict: # noqa: ANN003
|
||||||
calls.append(status)
|
calls.append(status)
|
||||||
total = {"waiting": 175, "running": 25}[status]
|
total = {"waiting": 175, "running": 25}[status]
|
||||||
return {"data": {"total": total, "records": [
|
return {"data": {"total": total, "records": [
|
||||||
{"taskId": f"{status}-{i}", "status": status}
|
{"taskId": f"{status}-{i}", "status": status}
|
||||||
for i in range((current - 1) * 100, min(current * 100, total))]}}
|
for i in range((current - 1) * page_size, min(current * page_size, total))]}}
|
||||||
|
|
||||||
client.list_tasks_page = page # type: ignore[method-assign]
|
client.list_tasks_page = page # type: ignore[method-assign]
|
||||||
self.assertEqual(200, client.count_active_tasks_exact())
|
self.assertEqual(200, client.count_active_tasks_exact())
|
||||||
self.assertEqual(["waiting", "waiting", "running"], calls)
|
self.assertEqual(8, calls.count("waiting"))
|
||||||
|
self.assertEqual(4, calls.count("running"))
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def monday_calibration_time(hour: int = 10) -> datetime:
|
def monday_calibration_time(hour: int = 10) -> datetime:
|
||||||
|
|||||||
Reference in New Issue
Block a user