test_answer_cache_retention.py 4.1 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182
  1. """Offline cache lifecycle contracts; never connects to business services."""
  2. import json
  3. import unittest
  4. from unittest.mock import patch
  5. from step4_web import answer_cache_retention as retention
  6. from step4_web.answer_cache import PREFIX, ACTIVE_VERSION, DELETE_IF_SAME
  7. class Redis:
  8. def __init__(self):
  9. self.entries = {}
  10. self.ttls = {}
  11. def set(self, key, raw):
  12. self.entries[key] = raw
  13. self.ttls.pop(key, None)
  14. def get(self, key):
  15. return self.entries.get(key)
  16. def scan_iter(self, match, count):
  17. return (k for k in list(self.entries) if k.startswith(PREFIX))
  18. def eval(self, script, count, key, raw, *args):
  19. if self.entries.get(key) != raw:
  20. return 0
  21. if script == DELETE_IF_SAME:
  22. del self.entries[key]
  23. self.ttls.pop(key, None)
  24. else:
  25. self.set(key, args[0])
  26. return 1
  27. class RetentionTests(unittest.TestCase):
  28. def setUp(self):
  29. self.redis = Redis()
  30. for name, version in [('old1', 'v0'), ('old2', 'v-1'), ('current', 'v1')]:
  31. self.redis.set(PREFIX + name, json.dumps({'request': {'data_version': version},
  32. 'approved': True, 'expires_at': 100}))
  33. self.redis.ttls[PREFIX + name] = 60
  34. self.redis.set('checkpoint:thread1', 'keep')
  35. self.redis.set('other:cache', 'keep')
  36. def test_all_old_versions_removed_current_migrated_context_untouched(self):
  37. report = retention.clean_entries(self.redis, 'v1')
  38. self.assertEqual(report, {'removed': 2, 'preserved': 1})
  39. self.assertNotIn(PREFIX + 'old1', self.redis.entries)
  40. self.assertNotIn(PREFIX + 'old2', self.redis.entries)
  41. self.assertNotIn(PREFIX + 'current', self.redis.ttls)
  42. self.assertNotIn('expires_at', json.loads(self.redis.get(PREFIX + 'current')))
  43. self.assertEqual(self.redis.get('checkpoint:thread1'), 'keep')
  44. self.assertEqual(self.redis.get('other:cache'), 'keep')
  45. self.assertEqual(self.redis.get(ACTIVE_VERSION), 'v1')
  46. def test_completed_version_skips_scan_but_new_version_cleans(self):
  47. retention.clean_entries(self.redis, 'v1')
  48. with patch.object(self.redis, 'scan_iter', side_effect=AssertionError):
  49. self.assertTrue(retention.clean_entries(self.redis, 'v1')['already_clean'])
  50. self.assertEqual(retention.clean_entries(self.redis, 'v2')['removed'], 1)
  51. def test_failure_leaves_retry_pending_and_fences_old_writes(self):
  52. with patch.object(self.redis, 'eval', side_effect=TimeoutError):
  53. with self.assertRaises(TimeoutError):
  54. retention.clean_entries(self.redis, 'v1')
  55. self.assertIsNone(self.redis.get(retention.CLEANED_VERSION))
  56. self.assertEqual(self.redis.get(ACTIVE_VERSION), 'v1')
  57. self.assertEqual(retention.clean_entries(self.redis, 'v1')['removed'], 2)
  58. def test_changed_entry_not_deleted_by_stale_scan(self):
  59. original = self.redis.eval
  60. def race(script, count, key, raw, *args):
  61. if key == PREFIX + 'old1':
  62. self.redis.set(key, json.dumps({'request': {'data_version': 'v1'}}))
  63. return original(script, count, key, raw, *args)
  64. with patch.object(self.redis, 'eval', side_effect=race):
  65. retention.clean_entries(self.redis, 'v1')
  66. self.assertIn(PREFIX + 'old1', self.redis.entries)
  67. def test_unreadable_own_entry_removed(self):
  68. self.redis.set(PREFIX + 'broken', 'broken')
  69. self.assertEqual(retention.clean_entries(self.redis, 'v1')['removed'], 3)
  70. def test_outage_reports_warning(self):
  71. with patch.object(retention, 'get_settings', side_effect=TimeoutError):
  72. self.assertIn('warning', retention.cleanup_answer_cache_for_release('v1'))
  73. def test_busy_publication_defers_cleanup(self):
  74. with patch('step4_web.data_update.update_lock', side_effect=RuntimeError), patch.object(
  75. retention, 'cleanup_answer_cache_for_release') as cleanup:
  76. self.assertIn('warning', retention.retry_cleanup())
  77. cleanup.assert_not_called()
  78. if __name__ == '__main__':
  79. unittest.main()