test_capacity_quotas.py 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213
  1. import asyncio
  2. from uuid import uuid4
  3. import pytest
  4. from pydantic import ValidationError
  5. from sqlalchemy import select
  6. from test_after_sales import scenario # noqa: F401
  7. from zhifu.modules.after_sales.models import BusinessEvent
  8. from zhifu.modules.agent_runtime.capacity import acquire, keys, release
  9. from zhifu.modules.agent_runtime.config import AgentSettings
  10. from zhifu.modules.agent_runtime.worker import claim
  11. from zhifu.modules.memory.worker import claim as claim_memory
  12. from zhifu.operations.tenant_quota import QuotaChange, configure
  13. pytestmark = pytest.mark.integration
  14. @pytest.mark.parametrize("first,maximum", [("interactive", 3), ("background", 2)])
  15. async def test_reserved_capacity_survives_other_class_saturation(scenario, first, maximum): # noqa: F811
  16. state = scenario["app"].state.state
  17. settings = AgentSettings()
  18. prefix = "verify:capacity:" + uuid4().hex
  19. other = "background" if first == "interactive" else "interactive"
  20. identifiers = [uuid4().hex for _ in range(16)]
  21. try:
  22. results = await asyncio.gather(
  23. *(
  24. acquire(state, settings, first, identifier, prefix=prefix)
  25. for identifier in identifiers
  26. )
  27. )
  28. assert sum(results) == maximum
  29. others = await asyncio.gather(
  30. *(acquire(state, settings, other, uuid4().hex, prefix=prefix) for _ in range(8))
  31. )
  32. assert sum(others) == settings.global_concurrency - maximum
  33. assert await state.zcard(prefix) == settings.global_concurrency
  34. selected = identifiers[results.index(True)]
  35. await release(state, selected, prefix=prefix)
  36. await release(state, selected, prefix=prefix)
  37. assert await state.zcard(prefix) == settings.global_concurrency - 1
  38. assert await acquire(state, settings, first, uuid4().hex, prefix=prefix)
  39. # 使用 Redis 的真实过期分数模拟失联租约到期,下一次领取必须清理全部分类集合。
  40. for key in keys(prefix)[:3]:
  41. members = await state.zrange(key, 0, -1)
  42. await state.zadd(key, dict.fromkeys(members, 0))
  43. assert await acquire(state, settings, other, uuid4().hex, prefix=prefix)
  44. assert await state.zcard(prefix) == 1
  45. finally:
  46. await state.delete(*keys(prefix))
  47. async def test_quota_update_conflict_audit_and_running_tasks(scenario): # noqa: F811
  48. sessions = scenario["app"].state.sessions
  49. tenant_id = scenario["tenant"].bytes
  50. before = await configure(sessions, tenant_id)
  51. assert before["version"] == 0
  52. change = QuotaChange(
  53. expected_version=0,
  54. interactive_limit=1,
  55. background_limit=0,
  56. operator="integration",
  57. reason="验证租户额度调整",
  58. )
  59. results = await asyncio.gather(
  60. configure(sessions, tenant_id, change),
  61. configure(sessions, tenant_id, change),
  62. return_exceptions=True,
  63. )
  64. assert sum(isinstance(result, ValueError) for result in results) == 1
  65. assert (await configure(sessions, tenant_id))["version"] == 1
  66. async with sessions() as session:
  67. events = (
  68. await session.scalars(
  69. select(BusinessEvent).where(
  70. BusinessEvent.tenant_id == tenant_id,
  71. BusinessEvent.event_type == "tenant.quota_configured",
  72. )
  73. )
  74. ).all()
  75. assert len(events) == 1
  76. assert events[0].payload["before"]["version"] == 0
  77. assert events[0].payload["after"]["interactive_limit"] == 1
  78. for _ in range(3):
  79. response = await scenario["call"](
  80. "POST",
  81. "/openapi/v1/agent/runs",
  82. payload={
  83. "store_id": str(scenario["store"]),
  84. "message": "查询订单",
  85. },
  86. )
  87. assert response.status_code == 202
  88. assert await claim(sessions, 2)
  89. assert await claim(sessions, 2) is None
  90. await configure(
  91. sessions,
  92. tenant_id,
  93. change.model_copy(update={"expected_version": 1, "interactive_limit": 2}),
  94. )
  95. assert await claim(sessions, 2)
  96. await configure(sessions, tenant_id, change.model_copy(update={"expected_version": 2}))
  97. assert await claim(sessions, 2) is None
  98. with pytest.raises(ValueError, match="不存在"):
  99. await configure(sessions, uuid4().bytes, change)
  100. def test_invalid_reservations_and_quota_are_rejected():
  101. with pytest.raises(ValidationError):
  102. AgentSettings(global_concurrency=2, interactive_reserved=2, background_reserved=1)
  103. with pytest.raises(ValidationError):
  104. QuotaChange(
  105. expected_version=0,
  106. interactive_limit=0,
  107. background_limit=1,
  108. operator="operator",
  109. reason="原因",
  110. )
  111. async def test_background_quota_pause_and_resume(scenario): # noqa: F811
  112. sessions = scenario["app"].state.sessions
  113. call = scenario["call"]
  114. response = await call(
  115. "PUT",
  116. "/openapi/v1/me/memory/consent",
  117. payload={"expected_revision": 0, "enabled": True},
  118. )
  119. assert response.status_code == 200, response.text
  120. response = await call(
  121. "POST", "/openapi/v1/conversations", payload={"store_id": str(scenario["store"])}
  122. )
  123. path = "/openapi/v1/conversations/" + response.json()["conversation_id"]
  124. response = await call(
  125. "POST",
  126. path + "/messages",
  127. payload={"text": "请一直用中文简短回复", "expected_control_version": 1},
  128. )
  129. assert response.status_code == 201, response.text
  130. run_id = response.json()["payload"]["run_id"]
  131. response = await call("POST", f"/openapi/v1/agent/runs/{run_id}/cancel")
  132. assert response.status_code == 200
  133. change = QuotaChange(
  134. expected_version=0,
  135. interactive_limit=2,
  136. background_limit=0,
  137. operator="integration",
  138. reason="验证后台暂停",
  139. )
  140. await configure(sessions, scenario["tenant"].bytes, change)
  141. assert await claim_memory(sessions) is None
  142. await configure(
  143. sessions,
  144. scenario["tenant"].bytes,
  145. change.model_copy(update={"expected_version": 1, "background_limit": 1}),
  146. )
  147. assert await claim_memory(sessions)
  148. assert await claim_memory(sessions) is None
  149. async def test_idle_tenant_reservation_and_oversubscription(scenario): # noqa: F811
  150. sessions, state = scenario["app"].state.sessions, scenario["app"].state.state
  151. settings = AgentSettings()
  152. tenant = scenario["tenant"]
  153. change = QuotaChange(
  154. expected_version=0,
  155. interactive_limit=2,
  156. background_limit=1,
  157. interactive_reserved=2,
  158. operator="integration",
  159. reason="验证商家预留总量",
  160. )
  161. await configure(sessions, tenant.bytes, change)
  162. with pytest.raises(ValueError, match="预留总量"):
  163. await configure(sessions, uuid4().bytes, change)
  164. prefix = "verify:reserved:" + uuid4().hex
  165. reserved = {tenant.hex: 2}
  166. try:
  167. assert await acquire(
  168. state,
  169. settings,
  170. "interactive",
  171. "other",
  172. prefix=prefix,
  173. tenant="other",
  174. reserved=reserved,
  175. )
  176. assert not await acquire(
  177. state,
  178. settings,
  179. "interactive",
  180. "excess",
  181. prefix=prefix,
  182. tenant="other",
  183. reserved=reserved,
  184. )
  185. assert await acquire(
  186. state, settings, "background", "memory", prefix=prefix, reserved=reserved
  187. )
  188. for identifier in ("reserved-1", "reserved-2"):
  189. assert await acquire(
  190. state,
  191. settings,
  192. "interactive",
  193. identifier,
  194. prefix=prefix,
  195. tenant=tenant.hex,
  196. reserved=reserved,
  197. )
  198. assert await state.zcard(prefix) == 4
  199. finally:
  200. await state.delete(*keys(prefix))