redis_recovery_acceptance.py 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133
  1. import asyncio
  2. import importlib.util
  3. import json
  4. import time
  5. from pathlib import Path
  6. from uuid import uuid4
  7. import pytest
  8. from sqlalchemy import select
  9. from test_after_sales import scenario # noqa: F401
  10. from zhifu.modules.agent_runtime.capacity import acquire, keys, release
  11. from zhifu.modules.agent_runtime.config import AgentSettings
  12. from zhifu.modules.memory.models import MemoryProfile
  13. async def test_actual_memory_snapshot_and_isolated_recovery(scenario): # noqa: F811
  14. spec = importlib.util.spec_from_file_location("redis_recovery", "scripts/redis_recovery.py")
  15. recovery = importlib.util.module_from_spec(spec)
  16. spec.loader.exec_module(recovery)
  17. call, state = scenario["call"], scenario["app"].state.state
  18. path = "/openapi/v1/me/memory"
  19. response = await call(
  20. "PUT", path + "/consent", payload={"expected_revision": 0, "enabled": True}
  21. )
  22. assert response.status_code == 200
  23. response = await call(
  24. "PUT",
  25. path,
  26. payload={
  27. "expected_revision": 1,
  28. "communication": {"response_style": "concise", "language": "zh-CN"},
  29. },
  30. )
  31. assert response.status_code == 200, response.text
  32. async with scenario["app"].state.sessions() as session:
  33. row = await session.scalar(
  34. select(MemoryProfile).where(
  35. MemoryProfile.tenant_id == scenario["tenant"].bytes,
  36. MemoryProfile.subject_id == scenario["accounts"]["customer"].bytes,
  37. )
  38. )
  39. value_key = row.value_key
  40. found = []
  41. async for key in state.scan_iter(match="zhifu_memory:*"):
  42. content = await state.json().get(key)
  43. if content["key"] == value_key:
  44. found.append((key.decode(), content))
  45. assert len(found) == 1
  46. key, expected = found[0]
  47. expiry = await state.pexpiretime(key)
  48. assert expiry > int(time.time() * 1000)
  49. transient = "monitor:worker:recovery:" + uuid4().hex
  50. expiring = "recovery:expires:" + uuid4().hex
  51. await state.set(transient, "active", ex=120)
  52. await state.set(expiring, "expires", ex=15)
  53. expires_at = await state.pexpiretime(expiring)
  54. capacity_id = uuid4().hex
  55. assert await acquire(state, AgentSettings(), "background", capacity_id)
  56. interactive_id = uuid4().hex
  57. assert await acquire(
  58. state, AgentSettings(), "interactive", interactive_id, tenant=scenario["tenant"].hex
  59. )
  60. try:
  61. directory = await asyncio.to_thread(recovery.backup)
  62. assert time.time() * 1000 < expires_at, "快照完成前短期记录已过期,需重新安排验收时间"
  63. # RDB 保留绝对到期时间,恢复过程不能重新开始原 TTL。
  64. await asyncio.sleep(max(0, expires_at / 1000 - time.time()) + 0.2)
  65. def inspect_recovery():
  66. with recovery.recovered(directory) as (container, entry):
  67. assert json.loads(recovery.cli(container, "JSON.GET", key)) == expected
  68. assert recovery.cli(container, "PEXPIRETIME", key) == expiry
  69. assert recovery.cli(container, "EXISTS", expiring) == 0
  70. assert recovery.cli(container, "GET", transient) == "active"
  71. assert (
  72. recovery.cli(container, "ZSCORE", "agent:active:global:background", capacity_id)
  73. is not None
  74. )
  75. assert (
  76. recovery.cli(container, "HGET", "agent:active:global:owners", interactive_id)
  77. == scenario["tenant"].hex
  78. )
  79. query = "@key:{" + value_key + "}"
  80. result = recovery.cli(container, "FT.SEARCH", "zhifu_memory", query, "NOCONTENT")
  81. assert result["total_results"] == 1, result
  82. removed = recovery.clear_transient(container)
  83. assert recovery.cli(container, "EXISTS", transient) == 0
  84. assert recovery.cli(container, "EXISTS", *keys("agent:active:global")) == 0
  85. assert json.loads(recovery.cli(container, "JSON.GET", key)) == expected
  86. return {"indexes": entry["indexes"], "transient_removed": removed}
  87. report = await asyncio.to_thread(inspect_recovery)
  88. source = await asyncio.to_thread(
  89. recovery.docker,
  90. "compose",
  91. "ps",
  92. "-q",
  93. "redis-state",
  94. cwd=recovery.ROOT,
  95. capture_output=True,
  96. )
  97. with pytest.raises(ValueError, match="独立恢复容器"):
  98. await asyncio.to_thread(recovery.clear_transient, source.stdout.decode().strip())
  99. assert await state.get(transient) == b"active"
  100. assert (await call("GET", path)).json()["content"] == response.json()["content"]
  101. backup_path = directory / "state.rdb"
  102. original = await asyncio.to_thread(backup_path.read_bytes)
  103. try:
  104. await asyncio.to_thread(backup_path.write_bytes, original + b"invalid")
  105. with pytest.raises(ValueError, match="SHA256"):
  106. await asyncio.to_thread(recovery.verify, directory)
  107. finally:
  108. await asyncio.to_thread(backup_path.write_bytes, original)
  109. report.update(
  110. memory_equal=True,
  111. absolute_expiry_equal=True,
  112. expired_key_absent=True,
  113. search_index_query=True,
  114. source_unchanged=True,
  115. corrupted_backup_rejected=True,
  116. source_cleanup_rejected=True,
  117. model_pool_leases_cleared=True,
  118. )
  119. await asyncio.to_thread(
  120. Path("docs/verification/redis-recovery.json").write_text,
  121. json.dumps(report, indent=2),
  122. encoding="utf-8",
  123. )
  124. finally:
  125. await release(state, capacity_id)
  126. await release(state, interactive_id)
  127. await state.delete(transient, expiring)