Proper ts/mtime setting and tracking. Added Kanta.mtime property for reading current change time.

This commit is contained in:
2026-06-13 00:49:35 +00:00
parent e8d123a748
commit ebfed10f24
8 changed files with 247 additions and 30 deletions
+25 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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."""
-5
View File
@@ -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
View File
@@ -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
+6 -3
View File
@@ -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: