Files
Hy-Memory/tests/test_mysql_metrics_atomic.py
T
dc740604d2 fix(mysql): merge minute metrics under one row lock (#97)
store_metrics_minute() read the row, merged counters in Python, then wrote
with a separate SELECT + UPDATE/INSERT. Two instances flushing the same
minute could both read the old value and overwrite each other, and a
concurrent insert could lose the whole batch to a duplicate-key error that
the outer except swallowed.

Run the read-merge-write in one transaction with SELECT ... FOR UPDATE and
retry once on a duplicate key or deadlock. Add unit regressions for the
insert and merge paths.

Co-authored-by: qiye.sjh <shijiahao_0802@youzan.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-10-21 12:19:55 +08:00

112 lines
2.6 KiB
Python

# -*- coding: utf-8 -*-
"""MySQL minute metrics must merge under one row lock."""
import asyncio
import json
import pytest
pytest.importorskip("aiomysql")
from hy_memory.config import MemoryConfig # noqa: E402
from hy_memory.data.cache_mysql import MysqlCache # noqa: E402
class FakeCursor:
def __init__(self, row):
self.row = row
self.executed = []
async def __aenter__(self):
return self
async def __aexit__(self, *args):
return None
async def execute(self, sql, args=None):
self.executed.append((sql, args))
async def fetchone(self):
return self.row
class FakeConnection:
def __init__(self, row):
self.cursor_obj = FakeCursor(row)
self.begun = False
self.committed = False
self.rolled_back = False
async def begin(self):
self.begun = True
async def commit(self):
self.committed = True
async def rollback(self):
self.rolled_back = True
def cursor(self, *args, **kwargs):
return self.cursor_obj
class _Acquire:
def __init__(self, connection):
self.connection = connection
async def __aenter__(self):
return self.connection
async def __aexit__(self, *args):
return None
class FakePool:
def __init__(self, connection):
self.connection = connection
def acquire(self):
return _Acquire(self.connection)
def _cache_with(row):
cache = MysqlCache(MemoryConfig())
connection = FakeConnection(row)
cache._pool = FakePool(connection)
return cache, connection
def test_metrics_insert_takes_the_row_lock_and_commits():
cache, connection = _cache_with(None)
asyncio.run(
cache.store_metrics_minute("2026-01-01T00:00", {"sys1_started": 2})
)
statements = [sql for sql, _ in connection.cursor_obj.executed]
assert any("FOR UPDATE" in sql for sql in statements)
assert any(sql.startswith("INSERT") for sql in statements)
assert connection.begun and connection.committed
assert not connection.rolled_back
def test_metrics_update_merges_existing_buckets():
old = {"sys1_started": 1, "sys1_timing_sums": {"a": 1}}
cache, connection = _cache_with({"data": json.dumps(old)})
asyncio.run(
cache.store_metrics_minute(
"2026-01-01T00:00",
{"sys1_started": 2, "sys1_timing_sums": {"a": 3}},
)
)
update = next(
args for sql, args in connection.cursor_obj.executed
if sql.startswith("UPDATE")
)
merged = json.loads(update[0])
assert merged["sys1_started"] == 3
assert merged["sys1_timing_sums"]["a"] == 4
assert connection.committed