Закрывает замечания ревью QA по PR #50: * #54: все чтения Store идут через Database.read() под тем же RLock, что транзакции; контракт «одно соединение, все операции под локом» задокументирован в db.py и README модуля. Устраняет TypeError/IndexError на общем соединении, грязные чтения и выдачу состояния, которое затем откатывается падающим apply. * #55 D-1: apply ловит BaseException — отмена/CancelledError/ KeyboardInterrupt вызывают Applier.revert(previous) и пишут failed в audit_log (без «висящей» записи staged); падение revert попадает в detail. * #55 D-2: set_geoip_schedule читает old_value внутри той же транзакции. Refs #20, Refs #54, Refs #55
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
"""Репозиторий сущностей состояния (issue #20).
|
||||
|
||||
Каждая мутация выполняется в одной транзакции, запись в ``audit_log``
|
||||
(«кто/когда/что/old→new») коммитится там же. Чтения идут по соединению WAL
|
||||
и не блокируют писателя.
|
||||
(«кто/когда/что/old→new») коммитится там же. Чтения идут через
|
||||
:meth:`Database.read` — под тем же ``RLock``, что и транзакции (#54), поэтому
|
||||
читатель не видит незафиксированных строк и состояния, которое затем будет
|
||||
откачено, и не может получить перемешанный результат операторов другого потока.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -79,6 +81,20 @@ def _device_from_row(row: sqlite3.Row) -> Device:
|
||||
)
|
||||
|
||||
|
||||
def _geoip_from_row(conn: sqlite3.Connection) -> GeoIpSchedule:
|
||||
row = conn.execute(
|
||||
"SELECT enabled, cron, provider, updated_at FROM geoip_schedule WHERE id = 1"
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return GeoIpSchedule()
|
||||
return GeoIpSchedule(
|
||||
enabled=bool(row["enabled"]),
|
||||
cron=str(row["cron"]),
|
||||
provider=str(row["provider"]),
|
||||
updated_at=str(row["updated_at"]),
|
||||
)
|
||||
|
||||
|
||||
class Store:
|
||||
"""Доступ к состоянию: чтение + аудируемые мутации."""
|
||||
|
||||
@@ -90,80 +106,75 @@ class Store:
|
||||
return self._db
|
||||
|
||||
# -- чтение ---------------------------------------------------------
|
||||
# Чтения идут через Database.read() под тем же RLock, что транзакции
|
||||
# (#54): это исключает перемешивание операторов разных потоков и грязные
|
||||
# чтения незафиксированных строк.
|
||||
def get_mode(self) -> Mode:
|
||||
row = self._db.connection.execute("SELECT mode FROM routing_mode WHERE id = 1").fetchone()
|
||||
with self._db.read() as conn:
|
||||
row = conn.execute("SELECT mode FROM routing_mode WHERE id = 1").fetchone()
|
||||
if row is None:
|
||||
return Mode.OFF
|
||||
return Mode(str(row["mode"]))
|
||||
|
||||
def get_geoip_schedule(self) -> GeoIpSchedule:
|
||||
row = self._db.connection.execute(
|
||||
"SELECT enabled, cron, provider, updated_at FROM geoip_schedule WHERE id = 1"
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return GeoIpSchedule()
|
||||
return GeoIpSchedule(
|
||||
enabled=bool(row["enabled"]),
|
||||
cron=str(row["cron"]),
|
||||
provider=str(row["provider"]),
|
||||
updated_at=str(row["updated_at"]),
|
||||
)
|
||||
with self._db.read() as conn:
|
||||
return _geoip_from_row(conn)
|
||||
|
||||
def list_rules(self, *, enabled_only: bool = False) -> list[Rule]:
|
||||
sql = "SELECT * FROM routing_rules"
|
||||
if enabled_only:
|
||||
sql += " WHERE enabled = 1"
|
||||
sql += " ORDER BY priority, id"
|
||||
return [_rule_from_row(row) for row in self._db.connection.execute(sql).fetchall()]
|
||||
with self._db.read() as conn:
|
||||
rows = conn.execute(sql).fetchall()
|
||||
return [_rule_from_row(row) for row in rows]
|
||||
|
||||
def get_rule(self, rule_id: int) -> Rule | None:
|
||||
row = self._db.connection.execute(
|
||||
"SELECT * FROM routing_rules WHERE id = ?", (rule_id,)
|
||||
).fetchone()
|
||||
with self._db.read() as conn:
|
||||
row = conn.execute("SELECT * FROM routing_rules WHERE id = ?", (rule_id,)).fetchone()
|
||||
return None if row is None else _rule_from_row(row)
|
||||
|
||||
def get_rule_by_name(self, name: str) -> Rule | None:
|
||||
row = self._db.connection.execute(
|
||||
"SELECT * FROM routing_rules WHERE name = ?", (name,)
|
||||
).fetchone()
|
||||
with self._db.read() as conn:
|
||||
row = conn.execute("SELECT * FROM routing_rules WHERE name = ?", (name,)).fetchone()
|
||||
return None if row is None else _rule_from_row(row)
|
||||
|
||||
def list_exceptions(self) -> list[RuleException]:
|
||||
rows = self._db.connection.execute("SELECT * FROM rule_exceptions ORDER BY id").fetchall()
|
||||
with self._db.read() as conn:
|
||||
rows = conn.execute("SELECT * FROM rule_exceptions ORDER BY id").fetchall()
|
||||
return [_exception_from_row(row) for row in rows]
|
||||
|
||||
def get_exception(self, match: str) -> RuleException | None:
|
||||
row = self._db.connection.execute(
|
||||
"SELECT * FROM rule_exceptions WHERE match = ?", (match,)
|
||||
).fetchone()
|
||||
with self._db.read() as conn:
|
||||
row = conn.execute("SELECT * FROM rule_exceptions WHERE match = ?", (match,)).fetchone()
|
||||
return None if row is None else _exception_from_row(row)
|
||||
|
||||
def list_configs(self) -> list[ConfigMeta]:
|
||||
rows = self._db.connection.execute("SELECT * FROM configs ORDER BY name").fetchall()
|
||||
with self._db.read() as conn:
|
||||
rows = conn.execute("SELECT * FROM configs ORDER BY name").fetchall()
|
||||
return [_config_from_row(row) for row in rows]
|
||||
|
||||
def get_config(self, name: str) -> ConfigMeta | None:
|
||||
row = self._db.connection.execute(
|
||||
"SELECT * FROM configs WHERE name = ?", (name,)
|
||||
).fetchone()
|
||||
with self._db.read() as conn:
|
||||
row = conn.execute("SELECT * FROM configs WHERE name = ?", (name,)).fetchone()
|
||||
return None if row is None else _config_from_row(row)
|
||||
|
||||
def list_devices(self) -> list[Device]:
|
||||
rows = self._db.connection.execute("SELECT * FROM devices ORDER BY name").fetchall()
|
||||
with self._db.read() as conn:
|
||||
rows = conn.execute("SELECT * FROM devices ORDER BY name").fetchall()
|
||||
return [_device_from_row(row) for row in rows]
|
||||
|
||||
def get_device(self, mac: str) -> Device | None:
|
||||
row = self._db.connection.execute(
|
||||
"SELECT * FROM devices WHERE mac = ?", (validation.normalize_mac(mac),)
|
||||
).fetchone()
|
||||
normalized = validation.normalize_mac(mac)
|
||||
with self._db.read() as conn:
|
||||
row = conn.execute("SELECT * FROM devices WHERE mac = ?", (normalized,)).fetchone()
|
||||
return None if row is None else _device_from_row(row)
|
||||
|
||||
def list_audit(
|
||||
self, *, entity: str | None = None, entity_id: str | int | None = None, limit: int = 100
|
||||
) -> list[AuditEntry]:
|
||||
return audit.list_entries(
|
||||
self._db.connection, entity=entity, entity_id=entity_id, limit=limit
|
||||
)
|
||||
with self._db.read() as conn:
|
||||
return audit.list_entries(conn, entity=entity, entity_id=entity_id, limit=limit)
|
||||
|
||||
# -- мутации: правила ------------------------------------------------
|
||||
def upsert_rule(self, rule: Rule, *, actor: str) -> Rule:
|
||||
@@ -358,22 +369,25 @@ class Store:
|
||||
actor: str,
|
||||
) -> GeoIpSchedule:
|
||||
"""Обновить расписание GeoIP (частичное обновление) и записать аудит."""
|
||||
current = self.get_geoip_schedule()
|
||||
updated = GeoIpSchedule(
|
||||
enabled=current.enabled if enabled is None else enabled,
|
||||
cron=validation.validate_cron(current.cron if cron is None else cron),
|
||||
provider=validation.validate_provider(
|
||||
current.provider if provider is None else provider
|
||||
),
|
||||
)
|
||||
now = utcnow()
|
||||
with self._db.transaction() as conn:
|
||||
# чтение old_value — внутри той же транзакции, что и запись (#55 D-2):
|
||||
# иначе при конкурентном обновлении журнал заявит устаревшее «старое».
|
||||
current = _geoip_from_row(conn)
|
||||
stored = GeoIpSchedule(
|
||||
enabled=current.enabled if enabled is None else enabled,
|
||||
cron=validation.validate_cron(current.cron if cron is None else cron),
|
||||
provider=validation.validate_provider(
|
||||
current.provider if provider is None else provider
|
||||
),
|
||||
updated_at=now,
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO geoip_schedule (id, enabled, cron, provider, updated_at)"
|
||||
" VALUES (1, ?, ?, ?, ?)"
|
||||
" ON CONFLICT(id) DO UPDATE SET enabled = excluded.enabled, cron = excluded.cron,"
|
||||
" provider = excluded.provider, updated_at = excluded.updated_at",
|
||||
(1 if updated.enabled else 0, updated.cron, updated.provider, now),
|
||||
(1 if stored.enabled else 0, stored.cron, stored.provider, now),
|
||||
)
|
||||
audit.record(
|
||||
conn,
|
||||
@@ -382,9 +396,9 @@ class Store:
|
||||
entity=Entity.GEOIP_SCHEDULE.value,
|
||||
entity_id=1,
|
||||
old_value=current.as_dict(),
|
||||
new_value=updated.as_dict(),
|
||||
new_value=stored.as_dict(),
|
||||
)
|
||||
return replace(updated, updated_at=now)
|
||||
return stored
|
||||
|
||||
# -- мутации: конфиги -------------------------------------------------
|
||||
def upsert_config(self, config: ConfigMeta, *, actor: str) -> ConfigMeta:
|
||||
@@ -569,11 +583,17 @@ class Store:
|
||||
"snapshots": "state_snapshots",
|
||||
}
|
||||
result: dict[str, int] = {}
|
||||
for key, table in tables.items():
|
||||
row = self._db.connection.execute(f"SELECT COUNT(*) FROM {table}").fetchone() # noqa: S608
|
||||
result[key] = int(row[0]) if row is not None else 0
|
||||
with self._db.read() as conn: # один снимок на все таблицы: счётчики согласованы
|
||||
for key, table in tables.items():
|
||||
row = conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone() # noqa: S608
|
||||
result[key] = int(row[0]) if row is not None else 0
|
||||
return result
|
||||
|
||||
def raw(self) -> sqlite3.Connection:
|
||||
"""Соединение для кода, которому нужен доступ ниже уровня репозитория."""
|
||||
"""Соединение для кода, которому нужен доступ ниже уровня репозитория.
|
||||
|
||||
Лок не берётся: в рантайме используйте :meth:`Database.read` /
|
||||
:meth:`Database.transaction` (#54), иначе возможны гонки на общем
|
||||
соединении. Метод оставлен для однопоточного кода и миграций.
|
||||
"""
|
||||
return self._db.connection
|
||||
|
||||
Reference in New Issue
Block a user