test_memory_cgroup_v1.py 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186
  1. """Synthetic proc/cgroup fixtures; no host configuration or external calls."""
  2. from mmap import PAGESIZE
  3. from pathlib import Path
  4. from tempfile import TemporaryDirectory
  5. from types import SimpleNamespace
  6. import sys
  7. import unittest
  8. from unittest.mock import Mock, patch
  9. import httpx
  10. from src.step4_api.app import create_app
  11. from src.step4_api.config import Settings
  12. from src.step4_api.memory import Admission, MIB, cgroup_memory, read_memory, require_hard_limit
  13. class V1Fixture:
  14. def __init__(self, directory, group='/user.slice/user-1000.slice/session-1.scope', root='/'):
  15. self.proc = Path(directory) / 'proc'
  16. (self.proc / 'self').mkdir(parents=True)
  17. self.group_file = self.proc / 'self/cgroup'
  18. self.mountinfo = self.proc / 'self/mountinfo'
  19. self.mount = Path(directory) / 'memory controller'
  20. self.mount.mkdir()
  21. self.root = root
  22. self.group_file.write_text(f'12:rdma:/\n6:memory:{group}\n1:name=systemd:/session\n')
  23. self.write_mountinfo()
  24. relative = group[len(root):] if root != '/' and group.startswith(root) else group
  25. self.current = self.mount / relative.lstrip('/')
  26. self.current.mkdir(parents=True, exist_ok=True)
  27. node = self.current
  28. while True:
  29. self.set_memory(node, sys.maxsize // PAGESIZE * PAGESIZE, 0)
  30. if node == self.mount:
  31. break
  32. node = node.parent
  33. (self.proc / 'meminfo').write_text('MemTotal: 16777216 kB\nMemAvailable: 8388608 kB\n')
  34. (self.proc / 'self/statm').write_text(f'999999 {128 * MIB // PAGESIZE} 0\n')
  35. def write_mountinfo(self):
  36. mount = str(self.mount).replace('\\', '/').replace(' ', r'\040')
  37. self.mountinfo.write_text(f'33 22 0:29 {self.root} {mount} rw,nosuid - cgroup cgroup rw,memory\n')
  38. @staticmethod
  39. def set_memory(node, maximum, usage):
  40. (node / 'memory.limit_in_bytes').write_text(str(maximum))
  41. (node / 'memory.usage_in_bytes').write_text(str(usage))
  42. def read_cgroup(self, **kwargs):
  43. return cgroup_memory(self.group_file, mountinfo=self.mountinfo, **kwargs)
  44. class CgroupV1Tests(unittest.TestCase):
  45. def fixture(self, **kwargs):
  46. temp = TemporaryDirectory()
  47. self.addCleanup(temp.cleanup)
  48. return V1Fixture(temp.name, **kwargs)
  49. def test_standalone_diagnostic_observes_parent_pressure(self):
  50. tree = self.fixture()
  51. tree.set_memory(tree.current, 2048 * MIB, 256 * MIB)
  52. tree.set_memory(tree.current.parent, 4096 * MIB, 4000 * MIB)
  53. self.assertEqual(tree.read_cgroup(), (2048 * MIB, 96 * MIB))
  54. with patch('src.step4_api.memory.sys.platform', 'linux'):
  55. self.assertEqual(read_memory(tree.proc).available, 96 * MIB)
  56. tree.set_memory(tree.current.parent, 4096 * MIB, 512 * MIB)
  57. self.assertEqual(read_memory(tree.proc).available, 1792 * MIB)
  58. def test_unlimited_uses_host_available_and_has_no_finite_hard_limit(self):
  59. tree = self.fixture()
  60. self.assertEqual(tree.read_cgroup(), (None, None))
  61. with patch('src.step4_api.memory.sys.platform', 'linux'):
  62. sample = read_memory(tree.proc)
  63. self.assertEqual((sample.rss, sample.available), (128 * MIB, 8192 * MIB))
  64. def test_root_group_and_over_limit_usage(self):
  65. tree = self.fixture(group='/')
  66. tree.set_memory(tree.mount, 100 * MIB, 101 * MIB)
  67. self.assertEqual(tree.read_cgroup(), (100 * MIB, 0))
  68. def test_host_headroom_still_limits_large_cgroup(self):
  69. tree = self.fixture()
  70. tree.set_memory(tree.current, 16 * 1024 * MIB, MIB)
  71. with patch('src.step4_api.memory.sys.platform', 'linux'):
  72. self.assertEqual(read_memory(tree.proc).available, 8192 * MIB)
  73. def test_combined_controller_and_hybrid_prefers_memory_v1(self):
  74. tree = self.fixture(group='/service')
  75. tree.group_file.write_text('0::/unified\n6:cpu,memory:/service\n')
  76. tree.set_memory(tree.current, 900 * MIB, 100 * MIB)
  77. self.assertEqual(tree.read_cgroup(), (900 * MIB, 800 * MIB))
  78. def test_bind_mount_root_and_namespaced_paths(self):
  79. for group in ('/tenant/service', '/service', '/'):
  80. with self.subTest(group=group):
  81. tree = self.fixture(group=group, root='/tenant')
  82. tree.set_memory(tree.current, 1500 * MIB, 200 * MIB)
  83. self.assertEqual(tree.read_cgroup(), (1500 * MIB, 1300 * MIB))
  84. def test_unrelated_mounts_are_ignored(self):
  85. tree = self.fixture()
  86. prefix = '44 22 0:30 / /missing rw - cgroup cgroup rw,cpu\n'
  87. tree.mountinfo.write_text(prefix + tree.mountinfo.read_text())
  88. self.assertEqual(tree.read_cgroup(), (None, None))
  89. def test_missing_mount_or_group_is_not_host_only_fallback(self):
  90. for kind in ('mount', 'group', 'controller', 'traversal'):
  91. with self.subTest(kind=kind):
  92. tree = self.fixture()
  93. if kind == 'mount':
  94. tree.mountinfo.write_text('')
  95. elif kind == 'group':
  96. tree.group_file.write_text('6:memory:/missing\n')
  97. elif kind == 'controller':
  98. tree.group_file.write_text('6:cpu:/\n')
  99. else:
  100. tree.group_file.write_text('6:memory:/../outside\n')
  101. with self.assertRaises(OSError):
  102. tree.read_cgroup()
  103. def test_standalone_diagnostic_reports_corrupt_missing_or_denied_accounting(self):
  104. for kind in ('missing', 'bad_limit', 'negative_limit', 'negative_usage', 'denied'):
  105. with self.subTest(kind=kind):
  106. tree = self.fixture()
  107. path = tree.current / 'memory.usage_in_bytes'
  108. if kind == 'missing':
  109. path.unlink()
  110. elif kind == 'bad_limit':
  111. (tree.current / 'memory.limit_in_bytes').write_text('invalid')
  112. elif kind == 'negative_limit':
  113. tree.set_memory(tree.current, -1, 0)
  114. elif kind == 'negative_usage':
  115. tree.set_memory(tree.current, 100, -1)
  116. with patch('src.step4_api.memory.sys.platform', 'linux'):
  117. if kind == 'denied':
  118. with patch.object(Path, 'read_text', side_effect=PermissionError()):
  119. with self.assertRaises(PermissionError):
  120. read_memory(tree.proc)
  121. else:
  122. with self.assertRaises((OSError, ValueError)):
  123. read_memory(tree.proc)
  124. def test_v1_sampling_does_not_claim_v2_service_isolation(self):
  125. tree = self.fixture()
  126. tree.set_memory(tree.current, 2048 * MIB, 100 * MIB)
  127. self.assertEqual(tree.read_cgroup(), (2048 * MIB, 1948 * MIB))
  128. with patch.dict('os.environ', {'API_REQUIRE_MEMORY_CGROUP': '1', 'API_MEMORY_HARD_LIMIT_MB': '4096'}), \
  129. patch('src.step4_api.memory.sys.platform', 'linux'), \
  130. patch('src.step4_api.memory.cgroup_memory', side_effect=tree.read_cgroup):
  131. with self.assertRaisesRegex(OSError, 'cgroup_v2_required'):
  132. require_hard_limit()
  133. class V1HttpTests(unittest.IsolatedAsyncioTestCase):
  134. async def test_questions_run_without_memory_sampling_even_when_files_break(self):
  135. with TemporaryDirectory() as directory:
  136. tree = V1Fixture(directory)
  137. reader = Mock(side_effect=lambda: read_memory(tree.proc))
  138. admission = Admission(Settings(), reader)
  139. def run(thread_id, question, emit, *args, **kwargs):
  140. emit('result', {'response': 'synthetic answer'})
  141. emit('done', {})
  142. runtime = SimpleNamespace(admission=admission, close=lambda: None, run=Mock(side_effect=run))
  143. app = create_app(Settings(), lambda _: runtime)
  144. with patch('src.step4_api.memory.sys.platform', 'linux'):
  145. async with app.router.lifespan_context(app):
  146. async with httpx.AsyncClient(transport=httpx.ASGITransport(app), base_url='http://test') as client:
  147. questions = ('我想在青浦开一家餐饮店,需要办理哪些手续?', '个人租房有什么支持?', '你好')
  148. for i, question in enumerate(questions):
  149. response = await client.post('/api/chat', json={'thread_id': f'v1-{i}', 'question': question})
  150. self.assertEqual(response.status_code, 200)
  151. self.assertIn('event: result', response.text)
  152. self.assertEqual(runtime.run.call_count, len(questions))
  153. tree.set_memory(tree.current, 400 * MIB, 350 * MIB)
  154. response = await client.post('/api/chat', json={'thread_id': 'low', 'question': 'test'})
  155. self.assertEqual(response.status_code, 200)
  156. self.assertIn('event: result', response.text)
  157. (tree.current / 'memory.limit_in_bytes').unlink()
  158. response = await client.post('/api/chat', json={'thread_id': 'failed', 'question': 'test'})
  159. self.assertEqual(response.status_code, 200)
  160. self.assertIn('event: result', response.text)
  161. self.assertEqual(runtime.run.call_count, len(questions) + 2)
  162. reader.assert_not_called()