test_context_reuse.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346
  1. """Synthetic Redis-only tests: no configuration or external connections."""
  2. import json
  3. import threading
  4. import unittest
  5. from concurrent.futures import ThreadPoolExecutor
  6. from unittest.mock import patch
  7. from langgraph.checkpoint.base import empty_checkpoint
  8. from langgraph.types import Command
  9. from redis.exceptions import ConnectionError
  10. from src.step3_question_answer.context import (
  11. ContextConfig, ContextError, MIN_REUSE_BYTES, READ_CHECKPOINT, RedisContextSaver,
  12. )
  13. from src.step3_question_answer.graph import build_graph
  14. from src.step3_question_answer.tests.test_context import FakeRedis, fake_saver
  15. from src.step3_question_answer.timing import Timings, bind
  16. from src.step4_api.runtime import GraphRuntime
  17. def config(thread='synthetic-reuse', ns='', cid=None):
  18. values = {'thread_id': thread, 'checkpoint_ns': ns}
  19. if cid is not None:
  20. values['checkpoint_id'] = cid
  21. return {'configurable': values}
  22. def checkpoint(cid='001', text=None):
  23. result = empty_checkpoint()
  24. result['id'] = cid
  25. result['channel_values'] = {
  26. 'response': '仅限合成地区;有效期至合成日期;不含例外主体。',
  27. 'recommendation': {
  28. 'sources': [{'source_id': 'synthetic-source', 'path': '条件.例外',
  29. 'id': 'synthetic-source',
  30. 'content': ('合成原文条件、否定和尾部例外。' * 14000) if text is None else text}],
  31. 'summary': {'text': '合成答复[依据](https://example.invalid/policy)'},
  32. },
  33. }
  34. return result
  35. def measured_read(saver, cfg):
  36. timing = Timings()
  37. with bind(timing, 'session_result_load'):
  38. result = saver.get_tuple(cfg)
  39. return result, timing.snapshot()['measurements']
  40. def total(counts, name):
  41. return counts[name + '.session_result_load']['total']
  42. class CheckpointReuseTests(unittest.TestCase):
  43. def setUp(self):
  44. self.saver = fake_saver()
  45. self.cfg = config()
  46. self.key = self.saver.key('synthetic-reuse')
  47. def put(self, cid='001', cfg=None, text=None):
  48. return self.saver.put(cfg or self.cfg, checkpoint(cid, text), {'step': 1}, {})
  49. def test_large_body_wire_reduction_full_answer_and_fresh_pending(self):
  50. with self.saver.reuse_written_checkpoint('synthetic-reuse'):
  51. saved = self.put()
  52. # New writes after checkpoint storage must never come from cache.
  53. self.saver.put_writes(saved, [('answer', '完整合成回答'), ('__error__', '旧错误')], 'task')
  54. self.saver.put_writes(saved, [('__error__', '新错误')], 'task')
  55. cached, counts = measured_read(self.saver, self.cfg)
  56. self.assertEqual(total(counts, 'redis_checkpoint_cache_hit'), 1)
  57. self.assertIn(('task', '__error__', '新错误'), cached.pending_writes)
  58. self.assertIn(('task', 'answer', '完整合成回答'), cached.pending_writes)
  59. self.assertEqual(len(self.saver._reuse_scopes[self.key]), 1)
  60. normal, full = measured_read(self.saver, self.cfg)
  61. self.assertEqual(cached, normal)
  62. self.assertEqual(self.saver._reuse_scopes, {})
  63. self.assertLess(total(counts, 'redis_returned_bytes'), total(full, 'redis_returned_bytes') // 100)
  64. self.assertEqual(total(full, 'redis_returned_bytes') - total(counts, 'redis_returned_bytes'),
  65. total(counts, 'redis_checkpoint_cache_saved_bytes'))
  66. self.assertEqual(total(counts, 'checkpoint_decoded_input_bytes'), total(full, 'checkpoint_decoded_input_bytes'))
  67. self.assertNotIn('合成原文', json.dumps(counts, ensure_ascii=False))
  68. def test_new_request_restart_and_old_storage_use_full_read(self):
  69. with self.saver.reuse_written_checkpoint('synthetic-reuse'):
  70. self.put()
  71. original, counts = measured_read(self.saver, self.cfg)
  72. self.assertEqual(total(counts, 'redis_checkpoint_cache_hit'), 1)
  73. second = RedisContextSaver(ContextConfig('redis://localhost/0', 30), self.saver.client)
  74. for saver in (self.saver, second):
  75. with saver.reuse_written_checkpoint('synthetic-reuse'):
  76. restored, counts = measured_read(saver, self.cfg)
  77. self.assertEqual(restored, original)
  78. self.assertEqual(total(counts, 'redis_checkpoint_cache_hit'), 0)
  79. self.assertGreater(total(counts, 'redis_returned_bytes'), MIN_REUSE_BYTES)
  80. self.assertTrue(all(json.loads(name)[0] == 'c' for name in self.saver.client.rows[self.key]))
  81. def test_external_newer_checkpoint_and_same_id_rewrite_are_authoritative(self):
  82. other = RedisContextSaver(ContextConfig('redis://localhost/0', 30), self.saver.client)
  83. with self.saver.reuse_written_checkpoint('synthetic-reuse'):
  84. self.put()
  85. # Same field and same byte length, different content: length/ID alone
  86. # would miss this update, the Redis fingerprint must reject reuse.
  87. other.put(self.cfg, checkpoint('001', '合成新原文。' * 30000), {}, {})
  88. state, counts = measured_read(self.saver, self.cfg)
  89. self.assertEqual(total(counts, 'redis_checkpoint_cache_hit'), 0)
  90. self.assertEqual(state.checkpoint['channel_values']['recommendation']['sources'][0]['content'], '合成新原文。' * 30000)
  91. other.put(self.cfg, checkpoint('002'), {}, {})
  92. state, counts = measured_read(self.saver, self.cfg)
  93. self.assertEqual(state.checkpoint['id'], '002')
  94. self.assertEqual(total(counts, 'redis_checkpoint_cache_hit'), 0)
  95. def test_same_length_changed_body_corrupt_body_and_marker_string_fail_closed(self):
  96. with self.saver.reuse_written_checkpoint('synthetic-reuse'):
  97. self.put(text='甲' * 60000)
  98. name = self.saver._field('c', '', '001')
  99. before = self.saver.client.rows[self.key][name]
  100. # Reuse exact metadata from the original to hold encoded length fixed.
  101. value, metadata, parent = self.saver._decode(before)
  102. value['channel_values']['recommendation']['sources'][0]['content'] = '乙' * 60000
  103. replaced = self.saver._encode((value, metadata, parent))
  104. self.assertEqual(len(before), len(replaced))
  105. self.saver.client.rows[self.key][name] = replaced.encode()
  106. changed, counts = measured_read(self.saver, self.cfg)
  107. self.assertEqual(total(counts, 'redis_checkpoint_cache_hit'), 0)
  108. self.assertEqual(changed.checkpoint['channel_values']['recommendation']['sources'][0]['content'], '乙' * 60000)
  109. for invalid in ('1', '__reuse_written_checkpoint_v1__', 'invalid-json'):
  110. self.saver.client.rows[self.key][name] = invalid
  111. with self.assertRaisesRegex(ContextError, '^context_data_invalid$'):
  112. self.saver.get_tuple(self.cfg)
  113. def test_namespace_explicit_history_parent_and_latest_selection(self):
  114. with self.saver.reuse_written_checkpoint('synthetic-reuse'):
  115. first = self.put('001')
  116. second = self.put('002', first)
  117. current, counts = measured_read(self.saver, second)
  118. self.assertEqual(current.parent_config, first)
  119. self.assertEqual(total(counts, 'redis_checkpoint_cache_hit'), 1)
  120. old, counts = measured_read(self.saver, first)
  121. self.assertEqual(old.checkpoint['id'], '001')
  122. self.assertEqual(total(counts, 'redis_checkpoint_cache_hit'), 0)
  123. self.put('999', config(ns='other'))
  124. main, counts = measured_read(self.saver, self.cfg)
  125. self.assertEqual(main.checkpoint['id'], '002')
  126. self.assertEqual(total(counts, 'redis_checkpoint_cache_hit'), 0)
  127. branch, counts = measured_read(self.saver, config(ns='other'))
  128. self.assertEqual(branch.checkpoint['id'], '999')
  129. self.assertEqual(total(counts, 'redis_checkpoint_cache_hit'), 1)
  130. self.assertIsNone(self.saver.get_tuple(config(ns='missing')))
  131. self.assertIsNone(self.saver.get_tuple(config(cid='missing')))
  132. self.assertEqual(len(list(self.saver.list(self.cfg))), 2)
  133. def test_failure_no_stale_fallback_and_scope_cleanup(self):
  134. with self.assertRaisesRegex(RuntimeError, 'synthetic-failure'):
  135. with self.saver.reuse_written_checkpoint('synthetic-reuse'):
  136. self.put()
  137. with patch.object(self.saver.client, 'eval', side_effect=ConnectionError('private')):
  138. with self.assertRaisesRegex(ContextError, '^redis_connection_failed$'):
  139. self.put('002')
  140. with self.assertRaisesRegex(ContextError, '^redis_connection_failed$'):
  141. self.saver.get_tuple(self.cfg)
  142. state, counts = measured_read(self.saver, self.cfg)
  143. self.assertEqual(state.checkpoint['id'], '001')
  144. self.assertEqual(total(counts, 'redis_checkpoint_cache_hit'), 1)
  145. raise RuntimeError('synthetic-failure')
  146. self.assertEqual(self.saver._reuse_scopes, {})
  147. def test_expiry_deletion_and_reads_do_not_renew_ttl(self):
  148. with self.saver.reuse_written_checkpoint('synthetic-reuse'):
  149. self.put()
  150. self.saver.client.now = 29
  151. self.assertIsNotNone(self.saver.get_tuple(self.cfg))
  152. self.assertEqual(self.saver.client.expiry[self.key], 30)
  153. self.saver.client.now = 30
  154. self.assertIsNone(self.saver.get_tuple(self.cfg))
  155. self.put('002')
  156. self.saver.delete_thread('synthetic-reuse')
  157. self.assertIsNone(self.saver.get_tuple(self.cfg))
  158. def test_small_body_clears_large_cached_body_and_close_cleans_scope(self):
  159. with self.saver.reuse_written_checkpoint('synthetic-reuse'):
  160. self.put()
  161. self.put('002', text='很短')
  162. _, counts = measured_read(self.saver, self.cfg)
  163. self.assertEqual(total(counts, 'redis_checkpoint_cache_hit'), 0)
  164. self.assertIsNone(self.saver._reuse_scopes[self.key][0].checkpoint)
  165. self.saver.close()
  166. self.assertEqual(self.saver._reuse_scopes, {})
  167. def test_worker_thread_writes_visible_and_returned_state_is_not_shared(self):
  168. with self.saver.reuse_written_checkpoint('synthetic-reuse'):
  169. with ThreadPoolExecutor(1) as pool:
  170. pool.submit(self.put).result()
  171. first, counts = measured_read(self.saver, self.cfg)
  172. self.assertEqual(total(counts, 'redis_checkpoint_cache_hit'), 1)
  173. first.checkpoint['channel_values']['response'] = '客户端修改'
  174. second = self.saver.get_tuple(self.cfg)
  175. self.assertNotEqual(second.checkpoint['channel_values']['response'], '客户端修改')
  176. def test_intermediate_writes_do_not_hash_bodies_until_read(self):
  177. with self.saver.reuse_written_checkpoint('synthetic-reuse'):
  178. with patch('src.step3_question_answer.context.hashlib.sha1', side_effect=AssertionError('unnecessary-hash')):
  179. for cid in ('001', '002', '003'):
  180. self.put(cid)
  181. self.assertEqual(self.saver.get_tuple(self.cfg).checkpoint['id'], '003')
  182. def test_parallel_sessions_overlap_fallback_and_two_agents_are_isolated(self):
  183. barrier = threading.Barrier(2)
  184. def request(thread):
  185. with self.saver.reuse_written_checkpoint(thread):
  186. self.put(cfg=config(thread), text=thread * 30000)
  187. barrier.wait(5)
  188. return measured_read(self.saver, config(thread))
  189. with ThreadPoolExecutor(2) as pool:
  190. result = list(pool.map(request, ('AAA', 'BBB')))
  191. for thread, (state, counts) in zip(('AAA', 'BBB'), result):
  192. self.assertEqual(state.checkpoint['channel_values']['recommendation']['sources'][0]['content'], thread * 30000)
  193. self.assertEqual(total(counts, 'redis_checkpoint_cache_hit'), 1)
  194. with self.saver.reuse_written_checkpoint('synthetic-reuse'):
  195. self.put()
  196. with self.saver.reuse_written_checkpoint('synthetic-reuse'):
  197. self.put('002')
  198. self.assertEqual(total(measured_read(self.saver, self.cfg)[1], 'redis_checkpoint_cache_hit'), 0)
  199. self.put('003')
  200. self.assertEqual(total(measured_read(self.saver, self.cfg)[1], 'redis_checkpoint_cache_hit'), 0)
  201. from src.step4_api.talent import TalentContextSaver
  202. talent = TalentContextSaver(ContextConfig('redis://localhost/0', 30), self.saver.client)
  203. with talent.reuse_written_checkpoint('synthetic-reuse'):
  204. talent.put(self.cfg, checkpoint('900'), {}, {})
  205. self.assertEqual(talent.get_tuple(self.cfg).checkpoint['id'], '900')
  206. self.assertEqual(self.saver.get_tuple(self.cfg).checkpoint['id'], '003')
  207. self.assertEqual(self.saver._reuse_scopes, {})
  208. self.assertEqual(talent._reuse_scopes, {})
  209. def test_invalid_conditional_responses_do_not_hide_bad_state(self):
  210. with self.saver.reuse_written_checkpoint('synthetic-reuse'):
  211. self.put()
  212. for response in ([1, 'bad', 1], [1, self.saver._field('c', '', '001'), None],
  213. [1, self.saver._field('c', '', '001'), True]):
  214. with patch.object(self.saver.client, 'eval', return_value=response):
  215. with self.assertRaisesRegex(ContextError, '^context_data_invalid$'):
  216. self.saver.get_tuple(self.cfg)
  217. with patch.object(self.saver.client, 'eval', return_value=[1, self.saver._field('c', '', '001'), 1]):
  218. with self.assertRaisesRegex(ContextError, '^context_data_invalid$'):
  219. self.saver.get_tuple(self.cfg)
  220. def test_bytes_transport_and_pending_updates_during_read(self):
  221. with self.saver.reuse_written_checkpoint('synthetic-reuse'):
  222. saved = self.put()
  223. read = self.saver.client.eval
  224. def server(script, *args):
  225. if script == READ_CHECKPOINT:
  226. self.saver.put_writes(saved, [('__interrupt__', {'value': '新补问'})], 'task')
  227. result = read(script, *args)
  228. return ([v.encode() if isinstance(v, str) else v for v in result]
  229. if isinstance(result, list) else result)
  230. with patch.object(self.saver.client, 'eval', side_effect=server):
  231. state, counts = measured_read(self.saver, self.cfg)
  232. self.assertEqual(total(counts, 'redis_checkpoint_cache_hit'), 1)
  233. self.assertIn(('task', '__interrupt__', {'value': '新补问'}), state.pending_writes)
  234. class ReuseGraphTests(unittest.TestCase):
  235. def test_runtime_failure_retry_and_stopped_requests_discard_cached_body(self):
  236. from types import SimpleNamespace
  237. saver = fake_saver()
  238. fail = True
  239. def plan(*_):
  240. if fail:
  241. raise RuntimeError('synthetic-plan-failure')
  242. return {'queries': [], 'error': None}
  243. def runtime():
  244. return GraphRuntime(build_graph(None,
  245. lambda _: {'conversation_kind': 'policy_overview', 'needs_company': False},
  246. checkpointer=saver, retriever=SimpleNamespace(search=lambda *_: {'candidates': []}),
  247. recommender=SimpleNamespace(plan=plan, answer=lambda *_: {'overview': '合成答复', 'items': [], 'sources': []})))
  248. question = '合成问题' * 12000
  249. with self.assertRaisesRegex(RuntimeError, 'synthetic-plan-failure'):
  250. runtime().run('synthetic-reuse', question, lambda *_: None, threading.Event())
  251. self.assertEqual(saver._reuse_scopes, {})
  252. # Restore via a fresh graph as the existing /retry contract requires.
  253. fail = False
  254. rows = []
  255. runtime().run('synthetic-reuse', '/retry', lambda e, d: rows.append((e, d)), threading.Event())
  256. self.assertEqual(rows[-1], ('done', {'status': 'completed'}))
  257. self.assertEqual(saver._reuse_scopes, {})
  258. stopped = threading.Event()
  259. def stop(event, data):
  260. if event == 'progress' and data.get('stage') == 'analyze' and data.get('status') == 'completed':
  261. stopped.set()
  262. runtime().run('synthetic-stop', question, stop, stopped)
  263. self.assertTrue(stopped.is_set())
  264. self.assertEqual(saver._reuse_scopes, {})
  265. def test_interrupt_resume_new_process_and_complete_state(self):
  266. saver = fake_saver()
  267. graph = build_graph(None, checkpointer=saver)
  268. question = '合成人才培训需求' * 9000
  269. with saver.reuse_written_checkpoint('synthetic-reuse'):
  270. graph.invoke({'question': question}, config())
  271. state = graph.get_state(config())
  272. self.assertTrue(state.interrupts)
  273. before = saver.get_tuple(config())
  274. restored_saver = RedisContextSaver(ContextConfig('redis://localhost/0', 30), saver.client)
  275. restored = build_graph(None, checkpointer=restored_saver)
  276. self.assertEqual(before, restored_saver.get_tuple(config()))
  277. with restored_saver.reuse_written_checkpoint('synthetic-reuse'):
  278. restored.invoke(Command(resume={'needs_company': False}), config())
  279. state = restored.get_state(config())
  280. self.assertFalse(state.next)
  281. self.assertFalse(state.interrupts)
  282. self.assertEqual(state.values['original_question'], question)
  283. cached = restored_saver.get_tuple(config())
  284. self.assertEqual(cached, restored_saver.get_tuple(config()))
  285. def test_api_full_recommendation_matches_unoptimized_final_result(self):
  286. from types import SimpleNamespace
  287. answer = checkpoint()['channel_values']['recommendation']
  288. answer.update(items=[{'source_id': f'synthetic-card-{i}', 'title': f'合成政策{i}',
  289. 'card': {'kind': 'policy', 'conditions': ['合成地区', '不含例外主体']}}
  290. for i in (1, 2)], overview='完整合成结果', complete=True)
  291. def runtime(saver):
  292. return GraphRuntime(build_graph(None,
  293. lambda _: {'conversation_kind': 'policy_overview', 'needs_company': False},
  294. checkpointer=saver,
  295. retriever=SimpleNamespace(search=lambda *_: {'candidates': []}),
  296. recommender=SimpleNamespace(plan=lambda *_: {'queries': [], 'error': None}, answer=lambda *_: answer)))
  297. results = []
  298. for optimized in (False, True):
  299. saver = fake_saver()
  300. rows, timing = [], Timings()
  301. # Disable only the optimization threshold for the control, keeping
  302. # the same real graph, serialization and synthetic Redis adapter.
  303. with patch('src.step3_question_answer.context.MIN_REUSE_BYTES', 65536 if optimized else 10**12), bind(timing):
  304. runtime(saver).run('synthetic-reuse', '合成政策需求', lambda e, d: rows.append((e, d)), threading.Event())
  305. results.append(next(data for event, data in rows if event == 'result'))
  306. self.assertEqual(rows[-1], ('done', {'status': 'completed'}))
  307. self.assertEqual(saver._reuse_scopes, {})
  308. counts = timing.snapshot()['measurements']
  309. self.assertEqual(total(counts, 'redis_checkpoint_cache_hit'), int(optimized))
  310. self.assertEqual(results[0], results[1])
  311. self.assertEqual(results[1]['recommendation']['sources'], answer['sources'])
  312. if __name__ == '__main__':
  313. unittest.main()