| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182 |
- """Offline cache lifecycle contracts; never connects to business services."""
- import json
- import unittest
- from unittest.mock import patch
- from step4_web import answer_cache_retention as retention
- from step4_web.answer_cache import PREFIX, ACTIVE_VERSION, DELETE_IF_SAME
- class Redis:
- def __init__(self):
- self.entries = {}
- self.ttls = {}
- def set(self, key, raw):
- self.entries[key] = raw
- self.ttls.pop(key, None)
- def get(self, key):
- return self.entries.get(key)
- def scan_iter(self, match, count):
- return (k for k in list(self.entries) if k.startswith(PREFIX))
- def eval(self, script, count, key, raw, *args):
- if self.entries.get(key) != raw:
- return 0
- if script == DELETE_IF_SAME:
- del self.entries[key]
- self.ttls.pop(key, None)
- else:
- self.set(key, args[0])
- return 1
- class RetentionTests(unittest.TestCase):
- def setUp(self):
- self.redis = Redis()
- for name, version in [('old1', 'v0'), ('old2', 'v-1'), ('current', 'v1')]:
- self.redis.set(PREFIX + name, json.dumps({'request': {'data_version': version},
- 'approved': True, 'expires_at': 100}))
- self.redis.ttls[PREFIX + name] = 60
- self.redis.set('checkpoint:thread1', 'keep')
- self.redis.set('other:cache', 'keep')
- def test_all_old_versions_removed_current_migrated_context_untouched(self):
- report = retention.clean_entries(self.redis, 'v1')
- self.assertEqual(report, {'removed': 2, 'preserved': 1})
- self.assertNotIn(PREFIX + 'old1', self.redis.entries)
- self.assertNotIn(PREFIX + 'old2', self.redis.entries)
- self.assertNotIn(PREFIX + 'current', self.redis.ttls)
- self.assertNotIn('expires_at', json.loads(self.redis.get(PREFIX + 'current')))
- self.assertEqual(self.redis.get('checkpoint:thread1'), 'keep')
- self.assertEqual(self.redis.get('other:cache'), 'keep')
- self.assertEqual(self.redis.get(ACTIVE_VERSION), 'v1')
- def test_completed_version_skips_scan_but_new_version_cleans(self):
- retention.clean_entries(self.redis, 'v1')
- with patch.object(self.redis, 'scan_iter', side_effect=AssertionError):
- self.assertTrue(retention.clean_entries(self.redis, 'v1')['already_clean'])
- self.assertEqual(retention.clean_entries(self.redis, 'v2')['removed'], 1)
- def test_failure_leaves_retry_pending_and_fences_old_writes(self):
- with patch.object(self.redis, 'eval', side_effect=TimeoutError):
- with self.assertRaises(TimeoutError):
- retention.clean_entries(self.redis, 'v1')
- self.assertIsNone(self.redis.get(retention.CLEANED_VERSION))
- self.assertEqual(self.redis.get(ACTIVE_VERSION), 'v1')
- self.assertEqual(retention.clean_entries(self.redis, 'v1')['removed'], 2)
- def test_changed_entry_not_deleted_by_stale_scan(self):
- original = self.redis.eval
- def race(script, count, key, raw, *args):
- if key == PREFIX + 'old1':
- self.redis.set(key, json.dumps({'request': {'data_version': 'v1'}}))
- return original(script, count, key, raw, *args)
- with patch.object(self.redis, 'eval', side_effect=race):
- retention.clean_entries(self.redis, 'v1')
- self.assertIn(PREFIX + 'old1', self.redis.entries)
- def test_unreadable_own_entry_removed(self):
- self.redis.set(PREFIX + 'broken', 'broken')
- self.assertEqual(retention.clean_entries(self.redis, 'v1')['removed'], 3)
- def test_outage_reports_warning(self):
- with patch.object(retention, 'get_settings', side_effect=TimeoutError):
- self.assertIn('warning', retention.cleanup_answer_cache_for_release('v1'))
- def test_busy_publication_defers_cleanup(self):
- with patch('step4_web.data_update.update_lock', side_effect=RuntimeError), patch.object(
- retention, 'cleanup_answer_cache_for_release') as cleanup:
- self.assertIn('warning', retention.retry_cleanup())
- cleanup.assert_not_called()
- if __name__ == '__main__':
- unittest.main()
|