test_context_timing.py 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142
  1. import base64
  2. import json
  3. import threading
  4. import unittest
  5. from concurrent.futures import ThreadPoolExecutor
  6. from unittest.mock import patch
  7. from redis.exceptions import ConnectionError
  8. from src.step3_question_answer.context import ContextError, stored_bytes
  9. from src.step3_question_answer.tests.test_context import fake_saver
  10. from src.step3_question_answer.timing import Timings, bind, span
  11. from src.step3_question_answer.graph import build_graph
  12. from src.step4_api.runtime import GraphRuntime
  13. class Clock:
  14. def __init__(self):
  15. self.now = 0
  16. def __call__(self):
  17. return self.now
  18. class ContextTimingTests(unittest.TestCase):
  19. def test_directed_read_matches_legacy_history_and_does_not_mutate(self):
  20. saver, cfg, fields = self.seed()
  21. ns = '子图"\\namespace'
  22. fields[saver._field('c', ns, '999')] = saver._encode(({'id': '999'}, {}, None))
  23. fields[saver._field('w', ns, '999', 'task', -1)] = saver._encode(('task', '__error__', 'other'))
  24. fields[saver._field('w', '', '001', 'old', 0)] = saver._encode(('old', 'answer', 'old'))
  25. before = dict(fields)
  26. history = list(saver.list(cfg))
  27. with patch.object(saver.client, 'hgetall', side_effect=AssertionError('full read forbidden')):
  28. self.assertEqual(saver.get_tuple(cfg), next(item for item in history if item.checkpoint['id'] == '002'))
  29. for item in history:
  30. self.assertEqual(saver.get_tuple(item.config), item)
  31. other = {'configurable': dict(cfg['configurable'], checkpoint_ns=ns)}
  32. self.assertEqual(saver.get_tuple(other).pending_writes, [('task', '__error__', 'other')])
  33. for overrides in ({'checkpoint_id': 'absent'}, {'checkpoint_ns': 'absent'}):
  34. self.assertIsNone(saver.get_tuple({'configurable': dict(cfg['configurable'], **overrides)}))
  35. self.assertEqual(fields, before)
  36. self.assertEqual(saver.client.expiry[saver.key(cfg['configurable']['thread_id'])], 30)
  37. def seed(self, thread='PRIVATE_THREAD'):
  38. saver = fake_saver()
  39. cfg = {'configurable': {'thread_id': thread}}
  40. key = saver.key(thread)
  41. fields = {
  42. saver._field('c', '', '001'): saver._encode(({'id': '001', 'data': 'PRIVATE_HISTORY' * 1000}, {}, None)),
  43. saver._field('c', '', '002'): saver._encode(({'id': '002', 'data': 'PRIVATE_CURRENT'}, {}, '001')),
  44. saver._field('w', '', '002', 'task', 0): saver._encode(('task', 'channel', 'PRIVATE_PENDING')),
  45. saver._field('request', 'v1', 'r'): json.dumps({'question': 'PRIVATE_QUESTION'}),
  46. }
  47. saver.client.rows[key] = fields
  48. saver.client.expiry[key] = 30
  49. return saver, cfg, fields
  50. def test_io_decode_and_full_hash_vs_selected_bytes_are_distinct(self):
  51. saver, cfg, fields = self.seed()
  52. clock = Clock()
  53. timing = Timings(clock=clock)
  54. original_get = saver.client.eval
  55. original_load = saver.serde.loads_typed
  56. original_b64 = base64.b64decode
  57. def get(*args):
  58. clock.now += .2
  59. return original_get(*args)
  60. def load(value):
  61. clock.now += .03
  62. return original_load(value)
  63. def b64(*args, **kwargs):
  64. clock.now += .02
  65. return original_b64(*args, **kwargs)
  66. with patch.object(saver.client, 'eval', side_effect=get), \
  67. patch.object(saver.serde, 'loads_typed', side_effect=load), \
  68. patch('src.step3_question_answer.context.base64.b64decode', side_effect=b64), \
  69. bind(timing), span('session_result_load', 'session_result_load'):
  70. result = saver.get_tuple(cfg)
  71. report = timing.snapshot()
  72. spans, counts = report['spans'], report['measurements']
  73. suffix = '.session_result_load'
  74. self.assertEqual(spans['redis_checkpoint_read' + suffix]['total_ms'], 200)
  75. self.assertEqual(spans['checkpoint_deserialize' + suffix]['total_ms'], 60)
  76. self.assertEqual(spans['checkpoint_base64_decode' + suffix]['total_ms'], 40)
  77. self.assertEqual(spans['session_result_load']['total_ms'], 300)
  78. self.assertEqual(result.checkpoint['id'], '002')
  79. self.assertEqual(result.pending_writes, [('task', 'channel', 'PRIVATE_PENDING')])
  80. self.assertEqual(counts['redis_scanned_fields' + suffix]['total'], 4)
  81. self.assertEqual(counts['redis_returned_fields' + suffix]['total'], 2)
  82. self.assertEqual(counts['redis_checkpoint_fields' + suffix]['total'], 1)
  83. self.assertEqual(counts['redis_write_fields' + suffix]['total'], 1)
  84. self.assertEqual(counts['redis_request_fields' + suffix]['total'], 0)
  85. received = sum(stored_bytes(k) + stored_bytes(v) for k, v in fields.items() if json.loads(k)[:3] in (['c', '', '002'], ['w', '', '002']))
  86. self.assertEqual(counts['redis_returned_bytes' + suffix]['total'], received)
  87. self.assertEqual(counts['checkpoint_decoded_input_bytes' + suffix]['count'], 2)
  88. self.assertLess(received, sum(stored_bytes(k) + stored_bytes(v) for k, v in fields.items()) // 10)
  89. self.assertNotIn('PRIVATE', json.dumps(report))
  90. def test_connection_and_corrupt_value_failures_still_have_safe_measurements(self):
  91. saver, cfg, _ = self.seed()
  92. timing = Timings()
  93. with bind(timing, 'session_result_load'), patch.object(saver.client, 'eval', side_effect=ConnectionError('PRIVATE')):
  94. with self.assertRaisesRegex(ContextError, '^redis_connection_failed$'):
  95. saver.get_tuple(cfg)
  96. self.assertEqual(timing.snapshot()['spans']['redis_checkpoint_read.session_result_load']['failed_count'], 1)
  97. timing = Timings()
  98. saver.client.rows[saver.key(cfg['configurable']['thread_id'])][saver._field('c', '', '002')] = 'PRIVATE_INVALID_JSON'
  99. with bind(timing, 'session_result_load'), self.assertRaisesRegex(ContextError, '^context_data_invalid$'):
  100. saver.get_tuple(cfg)
  101. report = timing.snapshot()
  102. self.assertEqual(report['spans']['checkpoint_json_decode.session_result_load']['failed_count'], 1)
  103. self.assertNotIn('PRIVATE', json.dumps(report))
  104. def test_parallel_telemetry_is_isolated_and_empty_session_is_counted(self):
  105. barrier = threading.Barrier(2)
  106. def read(present):
  107. saver, cfg, _ = self.seed(str(present))
  108. if not present:
  109. saver.client.rows.clear()
  110. timing = Timings()
  111. with bind(timing, 'session_result_load'):
  112. barrier.wait(timeout=3)
  113. saver.get_tuple(cfg)
  114. return timing.snapshot()
  115. with ThreadPoolExecutor(2) as pool:
  116. reports = list(pool.map(read, [True, False]))
  117. self.assertEqual([r['measurements']['redis_returned_fields.session_result_load']['total'] for r in reports], [2, 0])
  118. self.assertNotIn('checkpoint_deserialize.session_result_load', reports[1]['spans'])
  119. def test_real_graph_final_read_is_attributed_separately_from_initial_read(self):
  120. saver = fake_saver()
  121. graph = build_graph(None, lambda _: {'conversation_kind': 'smalltalk', 'direct_response': '合成答复'}, checkpointer=saver)
  122. timing = Timings()
  123. events = []
  124. with bind(timing):
  125. GraphRuntime(graph).run('synthetic', '合成问题', lambda e, d: events.append((e, d)), threading.Event())
  126. result = timing.snapshot()
  127. self.assertEqual(events[-1], ('done', {'status': 'completed'}))
  128. self.assertIn('redis_checkpoint_read.session_load', result['spans'])
  129. self.assertIn('redis_checkpoint_read.session_result_load', result['spans'])
  130. self.assertEqual(result['measurements']['redis_checkpoint_fields.session_result_load']['total'], 1)
  131. self.assertEqual(result['measurements']['redis_returned_fields.session_load']['total'], 0)
  132. self.assertIn('checkpoint_deserialize.session_result_load', result['spans'])