| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346 |
- """Synthetic Redis-only tests: no configuration or external connections."""
- import json
- import threading
- import unittest
- from concurrent.futures import ThreadPoolExecutor
- from unittest.mock import patch
- from langgraph.checkpoint.base import empty_checkpoint
- from langgraph.types import Command
- from redis.exceptions import ConnectionError
- from src.step3_question_answer.context import (
- ContextConfig, ContextError, MIN_REUSE_BYTES, READ_CHECKPOINT, RedisContextSaver,
- )
- from src.step3_question_answer.graph import build_graph
- from src.step3_question_answer.tests.test_context import FakeRedis, fake_saver
- from src.step3_question_answer.timing import Timings, bind
- from src.step4_api.runtime import GraphRuntime
- def config(thread='synthetic-reuse', ns='', cid=None):
- values = {'thread_id': thread, 'checkpoint_ns': ns}
- if cid is not None:
- values['checkpoint_id'] = cid
- return {'configurable': values}
- def checkpoint(cid='001', text=None):
- result = empty_checkpoint()
- result['id'] = cid
- result['channel_values'] = {
- 'response': '仅限合成地区;有效期至合成日期;不含例外主体。',
- 'recommendation': {
- 'sources': [{'source_id': 'synthetic-source', 'path': '条件.例外',
- 'id': 'synthetic-source',
- 'content': ('合成原文条件、否定和尾部例外。' * 14000) if text is None else text}],
- 'summary': {'text': '合成答复[依据](https://example.invalid/policy)'},
- },
- }
- return result
- def measured_read(saver, cfg):
- timing = Timings()
- with bind(timing, 'session_result_load'):
- result = saver.get_tuple(cfg)
- return result, timing.snapshot()['measurements']
- def total(counts, name):
- return counts[name + '.session_result_load']['total']
- class CheckpointReuseTests(unittest.TestCase):
- def setUp(self):
- self.saver = fake_saver()
- self.cfg = config()
- self.key = self.saver.key('synthetic-reuse')
- def put(self, cid='001', cfg=None, text=None):
- return self.saver.put(cfg or self.cfg, checkpoint(cid, text), {'step': 1}, {})
- def test_large_body_wire_reduction_full_answer_and_fresh_pending(self):
- with self.saver.reuse_written_checkpoint('synthetic-reuse'):
- saved = self.put()
- # New writes after checkpoint storage must never come from cache.
- self.saver.put_writes(saved, [('answer', '完整合成回答'), ('__error__', '旧错误')], 'task')
- self.saver.put_writes(saved, [('__error__', '新错误')], 'task')
- cached, counts = measured_read(self.saver, self.cfg)
- self.assertEqual(total(counts, 'redis_checkpoint_cache_hit'), 1)
- self.assertIn(('task', '__error__', '新错误'), cached.pending_writes)
- self.assertIn(('task', 'answer', '完整合成回答'), cached.pending_writes)
- self.assertEqual(len(self.saver._reuse_scopes[self.key]), 1)
- normal, full = measured_read(self.saver, self.cfg)
- self.assertEqual(cached, normal)
- self.assertEqual(self.saver._reuse_scopes, {})
- self.assertLess(total(counts, 'redis_returned_bytes'), total(full, 'redis_returned_bytes') // 100)
- self.assertEqual(total(full, 'redis_returned_bytes') - total(counts, 'redis_returned_bytes'),
- total(counts, 'redis_checkpoint_cache_saved_bytes'))
- self.assertEqual(total(counts, 'checkpoint_decoded_input_bytes'), total(full, 'checkpoint_decoded_input_bytes'))
- self.assertNotIn('合成原文', json.dumps(counts, ensure_ascii=False))
- def test_new_request_restart_and_old_storage_use_full_read(self):
- with self.saver.reuse_written_checkpoint('synthetic-reuse'):
- self.put()
- original, counts = measured_read(self.saver, self.cfg)
- self.assertEqual(total(counts, 'redis_checkpoint_cache_hit'), 1)
- second = RedisContextSaver(ContextConfig('redis://localhost/0', 30), self.saver.client)
- for saver in (self.saver, second):
- with saver.reuse_written_checkpoint('synthetic-reuse'):
- restored, counts = measured_read(saver, self.cfg)
- self.assertEqual(restored, original)
- self.assertEqual(total(counts, 'redis_checkpoint_cache_hit'), 0)
- self.assertGreater(total(counts, 'redis_returned_bytes'), MIN_REUSE_BYTES)
- self.assertTrue(all(json.loads(name)[0] == 'c' for name in self.saver.client.rows[self.key]))
- def test_external_newer_checkpoint_and_same_id_rewrite_are_authoritative(self):
- other = RedisContextSaver(ContextConfig('redis://localhost/0', 30), self.saver.client)
- with self.saver.reuse_written_checkpoint('synthetic-reuse'):
- self.put()
- # Same field and same byte length, different content: length/ID alone
- # would miss this update, the Redis fingerprint must reject reuse.
- other.put(self.cfg, checkpoint('001', '合成新原文。' * 30000), {}, {})
- state, counts = measured_read(self.saver, self.cfg)
- self.assertEqual(total(counts, 'redis_checkpoint_cache_hit'), 0)
- self.assertEqual(state.checkpoint['channel_values']['recommendation']['sources'][0]['content'], '合成新原文。' * 30000)
- other.put(self.cfg, checkpoint('002'), {}, {})
- state, counts = measured_read(self.saver, self.cfg)
- self.assertEqual(state.checkpoint['id'], '002')
- self.assertEqual(total(counts, 'redis_checkpoint_cache_hit'), 0)
- def test_same_length_changed_body_corrupt_body_and_marker_string_fail_closed(self):
- with self.saver.reuse_written_checkpoint('synthetic-reuse'):
- self.put(text='甲' * 60000)
- name = self.saver._field('c', '', '001')
- before = self.saver.client.rows[self.key][name]
- # Reuse exact metadata from the original to hold encoded length fixed.
- value, metadata, parent = self.saver._decode(before)
- value['channel_values']['recommendation']['sources'][0]['content'] = '乙' * 60000
- replaced = self.saver._encode((value, metadata, parent))
- self.assertEqual(len(before), len(replaced))
- self.saver.client.rows[self.key][name] = replaced.encode()
- changed, counts = measured_read(self.saver, self.cfg)
- self.assertEqual(total(counts, 'redis_checkpoint_cache_hit'), 0)
- self.assertEqual(changed.checkpoint['channel_values']['recommendation']['sources'][0]['content'], '乙' * 60000)
- for invalid in ('1', '__reuse_written_checkpoint_v1__', 'invalid-json'):
- self.saver.client.rows[self.key][name] = invalid
- with self.assertRaisesRegex(ContextError, '^context_data_invalid$'):
- self.saver.get_tuple(self.cfg)
- def test_namespace_explicit_history_parent_and_latest_selection(self):
- with self.saver.reuse_written_checkpoint('synthetic-reuse'):
- first = self.put('001')
- second = self.put('002', first)
- current, counts = measured_read(self.saver, second)
- self.assertEqual(current.parent_config, first)
- self.assertEqual(total(counts, 'redis_checkpoint_cache_hit'), 1)
- old, counts = measured_read(self.saver, first)
- self.assertEqual(old.checkpoint['id'], '001')
- self.assertEqual(total(counts, 'redis_checkpoint_cache_hit'), 0)
- self.put('999', config(ns='other'))
- main, counts = measured_read(self.saver, self.cfg)
- self.assertEqual(main.checkpoint['id'], '002')
- self.assertEqual(total(counts, 'redis_checkpoint_cache_hit'), 0)
- branch, counts = measured_read(self.saver, config(ns='other'))
- self.assertEqual(branch.checkpoint['id'], '999')
- self.assertEqual(total(counts, 'redis_checkpoint_cache_hit'), 1)
- self.assertIsNone(self.saver.get_tuple(config(ns='missing')))
- self.assertIsNone(self.saver.get_tuple(config(cid='missing')))
- self.assertEqual(len(list(self.saver.list(self.cfg))), 2)
- def test_failure_no_stale_fallback_and_scope_cleanup(self):
- with self.assertRaisesRegex(RuntimeError, 'synthetic-failure'):
- with self.saver.reuse_written_checkpoint('synthetic-reuse'):
- self.put()
- with patch.object(self.saver.client, 'eval', side_effect=ConnectionError('private')):
- with self.assertRaisesRegex(ContextError, '^redis_connection_failed$'):
- self.put('002')
- with self.assertRaisesRegex(ContextError, '^redis_connection_failed$'):
- self.saver.get_tuple(self.cfg)
- state, counts = measured_read(self.saver, self.cfg)
- self.assertEqual(state.checkpoint['id'], '001')
- self.assertEqual(total(counts, 'redis_checkpoint_cache_hit'), 1)
- raise RuntimeError('synthetic-failure')
- self.assertEqual(self.saver._reuse_scopes, {})
- def test_expiry_deletion_and_reads_do_not_renew_ttl(self):
- with self.saver.reuse_written_checkpoint('synthetic-reuse'):
- self.put()
- self.saver.client.now = 29
- self.assertIsNotNone(self.saver.get_tuple(self.cfg))
- self.assertEqual(self.saver.client.expiry[self.key], 30)
- self.saver.client.now = 30
- self.assertIsNone(self.saver.get_tuple(self.cfg))
- self.put('002')
- self.saver.delete_thread('synthetic-reuse')
- self.assertIsNone(self.saver.get_tuple(self.cfg))
- def test_small_body_clears_large_cached_body_and_close_cleans_scope(self):
- with self.saver.reuse_written_checkpoint('synthetic-reuse'):
- self.put()
- self.put('002', text='很短')
- _, counts = measured_read(self.saver, self.cfg)
- self.assertEqual(total(counts, 'redis_checkpoint_cache_hit'), 0)
- self.assertIsNone(self.saver._reuse_scopes[self.key][0].checkpoint)
- self.saver.close()
- self.assertEqual(self.saver._reuse_scopes, {})
- def test_worker_thread_writes_visible_and_returned_state_is_not_shared(self):
- with self.saver.reuse_written_checkpoint('synthetic-reuse'):
- with ThreadPoolExecutor(1) as pool:
- pool.submit(self.put).result()
- first, counts = measured_read(self.saver, self.cfg)
- self.assertEqual(total(counts, 'redis_checkpoint_cache_hit'), 1)
- first.checkpoint['channel_values']['response'] = '客户端修改'
- second = self.saver.get_tuple(self.cfg)
- self.assertNotEqual(second.checkpoint['channel_values']['response'], '客户端修改')
- def test_intermediate_writes_do_not_hash_bodies_until_read(self):
- with self.saver.reuse_written_checkpoint('synthetic-reuse'):
- with patch('src.step3_question_answer.context.hashlib.sha1', side_effect=AssertionError('unnecessary-hash')):
- for cid in ('001', '002', '003'):
- self.put(cid)
- self.assertEqual(self.saver.get_tuple(self.cfg).checkpoint['id'], '003')
- def test_parallel_sessions_overlap_fallback_and_two_agents_are_isolated(self):
- barrier = threading.Barrier(2)
- def request(thread):
- with self.saver.reuse_written_checkpoint(thread):
- self.put(cfg=config(thread), text=thread * 30000)
- barrier.wait(5)
- return measured_read(self.saver, config(thread))
- with ThreadPoolExecutor(2) as pool:
- result = list(pool.map(request, ('AAA', 'BBB')))
- for thread, (state, counts) in zip(('AAA', 'BBB'), result):
- self.assertEqual(state.checkpoint['channel_values']['recommendation']['sources'][0]['content'], thread * 30000)
- self.assertEqual(total(counts, 'redis_checkpoint_cache_hit'), 1)
- with self.saver.reuse_written_checkpoint('synthetic-reuse'):
- self.put()
- with self.saver.reuse_written_checkpoint('synthetic-reuse'):
- self.put('002')
- self.assertEqual(total(measured_read(self.saver, self.cfg)[1], 'redis_checkpoint_cache_hit'), 0)
- self.put('003')
- self.assertEqual(total(measured_read(self.saver, self.cfg)[1], 'redis_checkpoint_cache_hit'), 0)
- from src.step4_api.talent import TalentContextSaver
- talent = TalentContextSaver(ContextConfig('redis://localhost/0', 30), self.saver.client)
- with talent.reuse_written_checkpoint('synthetic-reuse'):
- talent.put(self.cfg, checkpoint('900'), {}, {})
- self.assertEqual(talent.get_tuple(self.cfg).checkpoint['id'], '900')
- self.assertEqual(self.saver.get_tuple(self.cfg).checkpoint['id'], '003')
- self.assertEqual(self.saver._reuse_scopes, {})
- self.assertEqual(talent._reuse_scopes, {})
- def test_invalid_conditional_responses_do_not_hide_bad_state(self):
- with self.saver.reuse_written_checkpoint('synthetic-reuse'):
- self.put()
- for response in ([1, 'bad', 1], [1, self.saver._field('c', '', '001'), None],
- [1, self.saver._field('c', '', '001'), True]):
- with patch.object(self.saver.client, 'eval', return_value=response):
- with self.assertRaisesRegex(ContextError, '^context_data_invalid$'):
- self.saver.get_tuple(self.cfg)
- with patch.object(self.saver.client, 'eval', return_value=[1, self.saver._field('c', '', '001'), 1]):
- with self.assertRaisesRegex(ContextError, '^context_data_invalid$'):
- self.saver.get_tuple(self.cfg)
- def test_bytes_transport_and_pending_updates_during_read(self):
- with self.saver.reuse_written_checkpoint('synthetic-reuse'):
- saved = self.put()
- read = self.saver.client.eval
- def server(script, *args):
- if script == READ_CHECKPOINT:
- self.saver.put_writes(saved, [('__interrupt__', {'value': '新补问'})], 'task')
- result = read(script, *args)
- return ([v.encode() if isinstance(v, str) else v for v in result]
- if isinstance(result, list) else result)
- with patch.object(self.saver.client, 'eval', side_effect=server):
- state, counts = measured_read(self.saver, self.cfg)
- self.assertEqual(total(counts, 'redis_checkpoint_cache_hit'), 1)
- self.assertIn(('task', '__interrupt__', {'value': '新补问'}), state.pending_writes)
- class ReuseGraphTests(unittest.TestCase):
- def test_runtime_failure_retry_and_stopped_requests_discard_cached_body(self):
- from types import SimpleNamespace
- saver = fake_saver()
- fail = True
- def plan(*_):
- if fail:
- raise RuntimeError('synthetic-plan-failure')
- return {'queries': [], 'error': None}
- def runtime():
- return GraphRuntime(build_graph(None,
- lambda _: {'conversation_kind': 'policy_overview', 'needs_company': False},
- checkpointer=saver, retriever=SimpleNamespace(search=lambda *_: {'candidates': []}),
- recommender=SimpleNamespace(plan=plan, answer=lambda *_: {'overview': '合成答复', 'items': [], 'sources': []})))
- question = '合成问题' * 12000
- with self.assertRaisesRegex(RuntimeError, 'synthetic-plan-failure'):
- runtime().run('synthetic-reuse', question, lambda *_: None, threading.Event())
- self.assertEqual(saver._reuse_scopes, {})
- # Restore via a fresh graph as the existing /retry contract requires.
- fail = False
- rows = []
- runtime().run('synthetic-reuse', '/retry', lambda e, d: rows.append((e, d)), threading.Event())
- self.assertEqual(rows[-1], ('done', {'status': 'completed'}))
- self.assertEqual(saver._reuse_scopes, {})
- stopped = threading.Event()
- def stop(event, data):
- if event == 'progress' and data.get('stage') == 'analyze' and data.get('status') == 'completed':
- stopped.set()
- runtime().run('synthetic-stop', question, stop, stopped)
- self.assertTrue(stopped.is_set())
- self.assertEqual(saver._reuse_scopes, {})
- def test_interrupt_resume_new_process_and_complete_state(self):
- saver = fake_saver()
- graph = build_graph(None, checkpointer=saver)
- question = '合成人才培训需求' * 9000
- with saver.reuse_written_checkpoint('synthetic-reuse'):
- graph.invoke({'question': question}, config())
- state = graph.get_state(config())
- self.assertTrue(state.interrupts)
- before = saver.get_tuple(config())
- restored_saver = RedisContextSaver(ContextConfig('redis://localhost/0', 30), saver.client)
- restored = build_graph(None, checkpointer=restored_saver)
- self.assertEqual(before, restored_saver.get_tuple(config()))
- with restored_saver.reuse_written_checkpoint('synthetic-reuse'):
- restored.invoke(Command(resume={'needs_company': False}), config())
- state = restored.get_state(config())
- self.assertFalse(state.next)
- self.assertFalse(state.interrupts)
- self.assertEqual(state.values['original_question'], question)
- cached = restored_saver.get_tuple(config())
- self.assertEqual(cached, restored_saver.get_tuple(config()))
- def test_api_full_recommendation_matches_unoptimized_final_result(self):
- from types import SimpleNamespace
- answer = checkpoint()['channel_values']['recommendation']
- answer.update(items=[{'source_id': f'synthetic-card-{i}', 'title': f'合成政策{i}',
- 'card': {'kind': 'policy', 'conditions': ['合成地区', '不含例外主体']}}
- for i in (1, 2)], overview='完整合成结果', complete=True)
- def runtime(saver):
- return GraphRuntime(build_graph(None,
- lambda _: {'conversation_kind': 'policy_overview', 'needs_company': False},
- checkpointer=saver,
- retriever=SimpleNamespace(search=lambda *_: {'candidates': []}),
- recommender=SimpleNamespace(plan=lambda *_: {'queries': [], 'error': None}, answer=lambda *_: answer)))
- results = []
- for optimized in (False, True):
- saver = fake_saver()
- rows, timing = [], Timings()
- # Disable only the optimization threshold for the control, keeping
- # the same real graph, serialization and synthetic Redis adapter.
- with patch('src.step3_question_answer.context.MIN_REUSE_BYTES', 65536 if optimized else 10**12), bind(timing):
- runtime(saver).run('synthetic-reuse', '合成政策需求', lambda e, d: rows.append((e, d)), threading.Event())
- results.append(next(data for event, data in rows if event == 'result'))
- self.assertEqual(rows[-1], ('done', {'status': 'completed'}))
- self.assertEqual(saver._reuse_scopes, {})
- counts = timing.snapshot()['measurements']
- self.assertEqual(total(counts, 'redis_checkpoint_cache_hit'), int(optimized))
- self.assertEqual(results[0], results[1])
- self.assertEqual(results[1]['recommendation']['sources'], answer['sources'])
- if __name__ == '__main__':
- unittest.main()
|