Proper ts/mtime setting and tracking. Added Kanta.mtime property for reading current change time.
This commit is contained in:
+25
-1
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
from typing import Any, Generic, TypeVar
|
||||
@@ -132,6 +133,17 @@ class Kanta(Generic[T]):
|
||||
"""
|
||||
return self._impl.filename
|
||||
|
||||
@property
|
||||
def mtime(self) -> datetime | None:
|
||||
"""Last modification time carried forward from change records.
|
||||
|
||||
Returns:
|
||||
The latest ``m`` value, or ``None`` if no modification time has
|
||||
been set yet. System operations such as migrations do not update
|
||||
this value.
|
||||
"""
|
||||
return self._impl.mtime
|
||||
|
||||
async def open(self) -> None:
|
||||
"""Open the database file and start background persistence.
|
||||
|
||||
@@ -184,6 +196,7 @@ class Kanta(Generic[T]):
|
||||
user: str | None = None,
|
||||
user_display: str | None = None,
|
||||
resolver: Any = None,
|
||||
mtime: bool | datetime = True,
|
||||
):
|
||||
"""Create a transactional mutation context manager.
|
||||
|
||||
@@ -192,6 +205,12 @@ class Kanta(Generic[T]):
|
||||
user: Optional user identifier stored in metadata.
|
||||
user_display: Optional display name used for logging/resolution.
|
||||
resolver: Optional callable for resolving identifiers in logs.
|
||||
mtime: Controls the modification time ``m``. ``True`` (default)
|
||||
sets ``m`` to the current UTC time. ``False`` omits ``m`` so the
|
||||
previous modification time remains in effect; this is used for
|
||||
system operations that are not considered modifications. A
|
||||
:class:`~datetime.datetime` value sets ``m`` to that explicit
|
||||
time.
|
||||
|
||||
Returns:
|
||||
A context manager yielding the live state object for mutation.
|
||||
@@ -202,5 +221,10 @@ class Kanta(Generic[T]):
|
||||
rolled back.
|
||||
"""
|
||||
return _transaction(
|
||||
self._impl, action, user=user, user_display=user_display, resolver=resolver
|
||||
self._impl,
|
||||
action,
|
||||
user=user,
|
||||
user_display=user_display,
|
||||
resolver=resolver,
|
||||
mtime=mtime,
|
||||
)
|
||||
|
||||
+2
-1
@@ -104,8 +104,9 @@ class KantaImpl(PersistenceMixin, Generic[T]):
|
||||
serializer=self.serializer,
|
||||
)
|
||||
self.version = rr.version
|
||||
self.mtime = rr.m
|
||||
normalized = struct_to_dict(self.data, serializer=self.serializer)
|
||||
self.queue_change("migrate:msgspec", normalized)
|
||||
self.queue_change("migrate:msgspec", normalized, mtime=False)
|
||||
self.snapshot.ts = (
|
||||
datetime.fromtimestamp(rr.last_snapshot_mtime, UTC)
|
||||
if rr.last_snapshot_mtime is not None
|
||||
|
||||
+47
-15
@@ -8,7 +8,7 @@ import logging
|
||||
import threading
|
||||
from collections import deque
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -41,6 +41,7 @@ class PersistenceMixin:
|
||||
flush_interval: float
|
||||
version: int
|
||||
opened: bool
|
||||
mtime: datetime | None
|
||||
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
"""Initialize persistence-owned state used by mixin methods."""
|
||||
@@ -63,6 +64,7 @@ class PersistenceMixin:
|
||||
self.background_error = None
|
||||
self.flush_interval = flush_interval
|
||||
self.version = 0
|
||||
self.mtime: datetime | None = None
|
||||
|
||||
async def _background_loop(self) -> None:
|
||||
"""Background task that periodically flushes changes to disk."""
|
||||
@@ -89,30 +91,60 @@ class PersistenceMixin:
|
||||
|
||||
def maybe_snapshot(self) -> None:
|
||||
"""Evaluate and possibly write a snapshot from current state."""
|
||||
self.snapshot.maybe_write(self.file, self.version, self.statedict)
|
||||
self.snapshot.maybe_write(self.file, self.version, self.statedict, m=self.mtime)
|
||||
|
||||
def queue_change(
|
||||
self,
|
||||
action: str,
|
||||
current: dict,
|
||||
*,
|
||||
user: str | None = None,
|
||||
m: datetime | None = None,
|
||||
) -> None:
|
||||
"""Queue a change record internally (thread-safe)."""
|
||||
mtime: bool | datetime = True,
|
||||
) -> ChangeRecord | None:
|
||||
"""Queue a change record internally (thread-safe).
|
||||
|
||||
Args:
|
||||
action: Action label stored in the change record.
|
||||
current: New serialized state after the change.
|
||||
user: Optional actor identifier.
|
||||
mtime: Controls the modification timestamp. ``True`` (default)
|
||||
sets ``m`` to the current UTC time. ``False`` omits ``m`` so the
|
||||
previous modification time remains in effect; this is used for
|
||||
system operations that are not considered modifications. A
|
||||
:class:`~datetime.datetime` value sets ``m`` to that explicit time.
|
||||
|
||||
Returns:
|
||||
The queued :class:`ChangeRecord`, or ``None`` if the diff was empty.
|
||||
"""
|
||||
now = datetime.now(UTC)
|
||||
|
||||
if mtime is True:
|
||||
m = now
|
||||
elif mtime is False:
|
||||
m = None
|
||||
elif isinstance(mtime, datetime):
|
||||
m = mtime
|
||||
else:
|
||||
raise TypeError("mtime must be True, False, or a datetime")
|
||||
|
||||
diff = compute_diff(self.statedict, current)
|
||||
if not diff:
|
||||
return
|
||||
return None
|
||||
|
||||
record = ChangeRecord(
|
||||
ts=now,
|
||||
a=action,
|
||||
v=self.version,
|
||||
u=user,
|
||||
m=m,
|
||||
diff=diff,
|
||||
)
|
||||
with self.pending_lock:
|
||||
self.pending_changes.append(
|
||||
ChangeRecord(
|
||||
a=action,
|
||||
v=self.version,
|
||||
u=user,
|
||||
m=m,
|
||||
diff=diff,
|
||||
)
|
||||
)
|
||||
self.pending_changes.append(record)
|
||||
self.statedict = copy.deepcopy(current)
|
||||
if m is not None:
|
||||
self.mtime = m
|
||||
return record
|
||||
|
||||
def flush_sync(self) -> None:
|
||||
"""Synchronously flush all pending changes to disk."""
|
||||
|
||||
@@ -23,14 +23,12 @@ class ReplayResult:
|
||||
state: dict[str, Any],
|
||||
version: int = 0,
|
||||
has_migration: bool = False,
|
||||
last_patch_mtime: float | None = None,
|
||||
last_snapshot_mtime: float | None = None,
|
||||
m: datetime | None = None,
|
||||
):
|
||||
self.state = state
|
||||
self.version = version
|
||||
self.has_migration = has_migration
|
||||
self.last_patch_mtime = last_patch_mtime
|
||||
self.last_snapshot_mtime = last_snapshot_mtime
|
||||
self.m = m
|
||||
|
||||
@@ -63,7 +61,6 @@ def replay(
|
||||
last_snapshot_mtime: float | None = None
|
||||
m: datetime | None = None
|
||||
has_migration = False
|
||||
last_patch_mtime: float | None = None
|
||||
|
||||
if snap_payload is not None:
|
||||
try:
|
||||
@@ -112,14 +109,12 @@ def replay(
|
||||
has_migration = True
|
||||
if change.m is not None:
|
||||
m = change.m
|
||||
last_patch_mtime = change.ts.timestamp()
|
||||
version = change.v
|
||||
state = _patch_state(state, change.diff)
|
||||
return ReplayResult(
|
||||
state=state,
|
||||
version=version,
|
||||
has_migration=has_migration,
|
||||
last_patch_mtime=last_patch_mtime,
|
||||
last_snapshot_mtime=last_snapshot_mtime,
|
||||
m=m,
|
||||
)
|
||||
|
||||
+8
-4
@@ -37,7 +37,9 @@ class SnapshotState:
|
||||
def record_changes(self, count: int) -> None:
|
||||
self.changes += count
|
||||
|
||||
def maybe_write(self, file, version: int, state: dict) -> None:
|
||||
def maybe_write(
|
||||
self, file, version: int, state: dict, m: datetime | None = None
|
||||
) -> None:
|
||||
"""Write snapshot when thresholds/time policy allows it."""
|
||||
if self.changes < self._min_diffs:
|
||||
return
|
||||
@@ -51,14 +53,16 @@ class SnapshotState:
|
||||
if not file.is_open:
|
||||
return
|
||||
try:
|
||||
self._write(file, version, state, now)
|
||||
self._write(file, version, state, now, m=m)
|
||||
self._force_pending = False
|
||||
except Exception as exc:
|
||||
_logger.error("snapshot: failed to write snapshot: %r", exc)
|
||||
|
||||
def _write(self, file, version: int, state: dict, now: datetime) -> None:
|
||||
def _write(
|
||||
self, file, version: int, state: dict, now: datetime, m: datetime | None = None
|
||||
) -> None:
|
||||
"""Write a snapshot and update internal state."""
|
||||
payload = self._serializer.encode(Snapshot(ts=now, v=version, state=state))
|
||||
payload = self._serializer.encode(Snapshot(ts=now, v=version, state=state, m=m))
|
||||
record_offset = file.size() if hasattr(file, "size") else 0
|
||||
file.write(self._framer.frame_snapshot(payload, record_offset=record_offset))
|
||||
self.changes = 0
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from kanta.diff import compute_diff
|
||||
@@ -22,6 +23,7 @@ def transaction(
|
||||
user: str | None = None,
|
||||
user_display: str | None = None,
|
||||
resolver: Any = None,
|
||||
mtime: bool | datetime = True,
|
||||
):
|
||||
"""Wrap writes in a transaction and yield the live db object."""
|
||||
if impl.in_transaction:
|
||||
@@ -58,9 +60,10 @@ def transaction(
|
||||
new_dict = struct_to_dict(impl.data, serializer=impl.serializer)
|
||||
diff = compute_diff(impl.statedict, new_dict)
|
||||
if diff:
|
||||
impl.queue_change(action, new_dict, user=user)
|
||||
log_change(action, diff, user_display, impl.statedict, resolver)
|
||||
impl.statedict = new_dict
|
||||
previous = impl.statedict
|
||||
record = impl.queue_change(action, new_dict, user=user, mtime=mtime)
|
||||
if record is not None:
|
||||
log_change(action, record.diff, user_display, previous, resolver)
|
||||
except Exception:
|
||||
_logger.warning("Transaction '%s' failed, rolling back changes", action)
|
||||
if impl.transaction_snapshot is not None:
|
||||
|
||||
Reference in New Issue
Block a user