Implement database rotation with n days retention #2
+9
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import ModuleType, SimpleNamespace
|
from types import ModuleType, SimpleNamespace
|
||||||
from typing import Any, Generic, TypeVar
|
from typing import Any, Generic, TypeVar
|
||||||
@@ -53,6 +53,7 @@ class Kanta(Generic[T]):
|
|||||||
migrations: ModuleType | str | None = None,
|
migrations: ModuleType | str | None = None,
|
||||||
serializer: Serializer | None = None,
|
serializer: Serializer | None = None,
|
||||||
flush_interval: float = 0.1,
|
flush_interval: float = 0.1,
|
||||||
|
retention: timedelta | None = None,
|
||||||
):
|
):
|
||||||
"""Initialize a Kanta persistence instance.
|
"""Initialize a Kanta persistence instance.
|
||||||
|
|
||||||
@@ -63,6 +64,12 @@ class Kanta(Generic[T]):
|
|||||||
migrations: Optional migrations module object or import path.
|
migrations: Optional migrations module object or import path.
|
||||||
flush_interval: Background flush interval in seconds.
|
flush_interval: Background flush interval in seconds.
|
||||||
serializer: Optional serializer implementation.
|
serializer: Optional serializer implementation.
|
||||||
|
retention: Optional history retention window. When set, opening the
|
||||||
|
database rotates it: history older than ``now - retention`` is
|
||||||
|
moved to a ``{stem}@{timestamp}.kantadb`` sibling file and the
|
||||||
|
main file is rewritten with a fresh snapshot plus the retained
|
||||||
|
records (see ``docs/rotation.md``). ``None`` (default) disables
|
||||||
|
rotation.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ImportError: If ``migrations`` is a string path that cannot be imported.
|
ImportError: If ``migrations`` is a string path that cannot be imported.
|
||||||
@@ -78,6 +85,7 @@ class Kanta(Generic[T]):
|
|||||||
type=data_type,
|
type=data_type,
|
||||||
migrations=migrations,
|
migrations=migrations,
|
||||||
flush_interval=flush_interval,
|
flush_interval=flush_interval,
|
||||||
|
retention=retention,
|
||||||
kanta=self,
|
kanta=self,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+28
-1
@@ -6,7 +6,7 @@ import asyncio
|
|||||||
import copy
|
import copy
|
||||||
import importlib
|
import importlib
|
||||||
import logging
|
import logging
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime, timedelta
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from typing import Any, Generic, TypeVar
|
from typing import Any, Generic, TypeVar
|
||||||
|
|
||||||
@@ -21,6 +21,7 @@ from kanta.logging import (
|
|||||||
)
|
)
|
||||||
from kanta.migrations import MigrationReport, Migrations
|
from kanta.migrations import MigrationReport, Migrations
|
||||||
from kanta.persistence import PersistenceMixin
|
from kanta.persistence import PersistenceMixin
|
||||||
|
from kanta.rotation import execute_rotation, plan_rotation
|
||||||
from kanta.serialization import restore_data_in_place, struct_to_dict
|
from kanta.serialization import restore_data_in_place, struct_to_dict
|
||||||
from kanta.serialization.base import replay
|
from kanta.serialization.base import replay
|
||||||
|
|
||||||
@@ -42,6 +43,7 @@ class KantaImpl(PersistenceMixin, Generic[T]):
|
|||||||
self.data: T = kwargs.pop("data")
|
self.data: T = kwargs.pop("data")
|
||||||
self._kanta = kwargs.pop("kanta", None)
|
self._kanta = kwargs.pop("kanta", None)
|
||||||
migrations = kwargs.pop("migrations", None)
|
migrations = kwargs.pop("migrations", None)
|
||||||
|
self.retention: timedelta | None = kwargs.pop("retention", None)
|
||||||
self.ctx = SimpleNamespace()
|
self.ctx = SimpleNamespace()
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
self.migrations: Migrations | None = None
|
self.migrations: Migrations | None = None
|
||||||
@@ -181,6 +183,9 @@ class KantaImpl(PersistenceMixin, Generic[T]):
|
|||||||
# From this point the file is open and must be closed via close().
|
# From this point the file is open and must be closed via close().
|
||||||
self.opened = True
|
self.opened = True
|
||||||
|
|
||||||
|
if content and self.retention is not None and not readonly:
|
||||||
|
content = await self._maybe_rotate(content, log)
|
||||||
|
|
||||||
if content:
|
if content:
|
||||||
try:
|
try:
|
||||||
rr = replay(
|
rr = replay(
|
||||||
@@ -375,6 +380,28 @@ class KantaImpl(PersistenceMixin, Generic[T]):
|
|||||||
if not self.readonly:
|
if not self.readonly:
|
||||||
self.background_task = asyncio.create_task(self._background_loop())
|
self.background_task = asyncio.create_task(self._background_loop())
|
||||||
|
|
||||||
|
async def _maybe_rotate(self, content: bytes, log: bool | logging.Logger) -> bytes:
|
||||||
|
"""Rotate history older than the retention window (see docs/rotation.md).
|
||||||
|
|
||||||
|
Runs while the file is locked and quiescent, before replay. Returns
|
||||||
|
the (possibly replaced) content to replay. Rotation failures abort the
|
||||||
|
open with the original file intact.
|
||||||
|
"""
|
||||||
|
cutoff = self.now() - self.retention
|
||||||
|
plan = await asyncio.to_thread(
|
||||||
|
plan_rotation,
|
||||||
|
content,
|
||||||
|
framer=self.framer,
|
||||||
|
serializer=self.serializer,
|
||||||
|
cutoff=cutoff,
|
||||||
|
now=self.now(),
|
||||||
|
min_diffs=self.snapshot.min_diffs,
|
||||||
|
)
|
||||||
|
if plan is None:
|
||||||
|
return content
|
||||||
|
await asyncio.to_thread(execute_rotation, self.filename, self.file, plan, log=log)
|
||||||
|
return plan.new_content
|
||||||
|
|
||||||
async def close(self) -> None:
|
async def close(self) -> None:
|
||||||
"""Stop the background task, flush pending changes, and release the file lock."""
|
"""Stop the background task, flush pending changes, and release the file lock."""
|
||||||
if not self.opened:
|
if not self.opened:
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
"""Database rotation: bound on-disk history to a retention window.
|
||||||
|
|
||||||
|
See docs/rotation.md for the design. All planning happens on the in-memory
|
||||||
|
bytes of the database file; the caller (KantaImpl.open) performs the actual
|
||||||
|
copy-aside, in-place rewrite and rotated-file trimming under the file lock.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import logging
|
||||||
|
import shutil
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from kanta.exceptions import DatabaseError
|
||||||
|
from kanta.structs import ChangeRecord, Snapshot
|
||||||
|
from kanta.serialization.base import Serializer, apply_diff
|
||||||
|
from kanta.serialization.framing import Framer
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RotationPlan:
|
||||||
|
"""Everything needed to execute a rotation on disk."""
|
||||||
|
|
||||||
|
new_content: bytes
|
||||||
|
cutoff_end: int # byte length of the dropped-history prefix
|
||||||
|
rotated_ts: datetime # ts of the last dropped change record
|
||||||
|
retained_changes: int
|
||||||
|
|
||||||
|
|
||||||
|
def rotated_path_for(path: Path, ts: datetime) -> Path:
|
||||||
|
"""Sibling path for the rotated history: ``{stem}@{ISO-basic-ts}.kantadb``.
|
||||||
|
|
||||||
|
The timestamp uses ISO 8601 basic format with microsecond precision so it
|
||||||
|
matches the record ``ts`` values stored in the database. On collision an
|
||||||
|
incrementing suffix is inserted before the extension.
|
||||||
|
"""
|
||||||
|
stamp = ts.strftime("%Y%m%dT%H%M%S.%fZ")
|
||||||
|
candidate = path.with_name(f"{path.stem}@{stamp}.kantadb")
|
||||||
|
n = 1
|
||||||
|
while candidate.exists():
|
||||||
|
candidate = path.with_name(f"{path.stem}@{stamp}.{n}.kantadb")
|
||||||
|
n += 1
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
class _Entry:
|
||||||
|
"""One parsed record frame with its byte range."""
|
||||||
|
|
||||||
|
__slots__ = ("is_snapshot", "record", "byte_pos", "end_pos")
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
is_snapshot: bool,
|
||||||
|
record: ChangeRecord | Snapshot,
|
||||||
|
byte_pos: int,
|
||||||
|
end_pos: int,
|
||||||
|
) -> None:
|
||||||
|
self.is_snapshot = is_snapshot
|
||||||
|
self.record = record
|
||||||
|
self.byte_pos = byte_pos
|
||||||
|
self.end_pos = end_pos
|
||||||
|
|
||||||
|
|
||||||
|
def _scan(
|
||||||
|
content: bytes, *, framer: Framer, serializer: Serializer
|
||||||
|
) -> list[_Entry]:
|
||||||
|
"""Decode every record in *content* with byte ranges."""
|
||||||
|
raw = list(framer.iter_records(content, 0))
|
||||||
|
entries: list[_Entry] = []
|
||||||
|
for i, (is_snapshot, payload, _line, byte_pos) in enumerate(raw):
|
||||||
|
end_pos = raw[i + 1][3] if i + 1 < len(raw) else len(content)
|
||||||
|
record = serializer.decode(
|
||||||
|
payload, type=Snapshot if is_snapshot else ChangeRecord
|
||||||
|
)
|
||||||
|
entries.append(_Entry(is_snapshot, record, byte_pos, end_pos))
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
def plan_rotation(
|
||||||
|
content: bytes,
|
||||||
|
*,
|
||||||
|
framer: Framer,
|
||||||
|
serializer: Serializer,
|
||||||
|
cutoff: datetime,
|
||||||
|
now: datetime,
|
||||||
|
min_diffs: int,
|
||||||
|
) -> RotationPlan | None:
|
||||||
|
"""Plan a rotation of *content*, or return None when there is nothing to do.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
DatabaseError: If replay from the chosen base snapshot does not match
|
||||||
|
a snapshot found inside the file (corrupt history). Rotation must
|
||||||
|
be aborted and the original file left untouched.
|
||||||
|
"""
|
||||||
|
if not content:
|
||||||
|
return None
|
||||||
|
entries = _scan(content, framer=framer, serializer=serializer)
|
||||||
|
changes = [e for e in entries if not e.is_snapshot]
|
||||||
|
if not changes:
|
||||||
|
return None # snapshot-only file: already fully rotated
|
||||||
|
|
||||||
|
dropped = [e for e in changes if e.record.ts < cutoff]
|
||||||
|
if not dropped:
|
||||||
|
return None # retention window covers all history
|
||||||
|
|
||||||
|
retained = [e for e in changes if e.record.ts >= cutoff]
|
||||||
|
rotated_ts = dropped[-1].record.ts
|
||||||
|
cutoff_end = retained[0].byte_pos if retained else len(content)
|
||||||
|
|
||||||
|
# Replay base: walk snapshots newest-first and take the first (newest)
|
||||||
|
# one predating the cutoff; fall back to start of file.
|
||||||
|
base: _Entry | None = None
|
||||||
|
for e in reversed([e for e in entries if e.is_snapshot]):
|
||||||
|
if e.record.ts <= cutoff:
|
||||||
|
base = e
|
||||||
|
break
|
||||||
|
|
||||||
|
state: dict[str, Any] = {}
|
||||||
|
version = 0
|
||||||
|
m: datetime | None = None
|
||||||
|
if base is not None:
|
||||||
|
snap = base.record
|
||||||
|
assert isinstance(snap, Snapshot)
|
||||||
|
state = dict(snap.state)
|
||||||
|
version = snap.v
|
||||||
|
m = snap.m
|
||||||
|
|
||||||
|
state_at_cutoff: dict[str, Any] | None = None
|
||||||
|
version_at_cutoff = version
|
||||||
|
m_at_cutoff = m
|
||||||
|
final_version = version
|
||||||
|
final_m = m
|
||||||
|
|
||||||
|
for e in entries:
|
||||||
|
if base is not None and e.byte_pos <= base.byte_pos:
|
||||||
|
continue
|
||||||
|
if e.is_snapshot:
|
||||||
|
snap = e.record
|
||||||
|
assert isinstance(snap, Snapshot)
|
||||||
|
if snap.state != state:
|
||||||
|
raise DatabaseError(
|
||||||
|
"rotation aborted: replayed state does not match snapshot "
|
||||||
|
f"at byte {e.byte_pos}",
|
||||||
|
action="rotate",
|
||||||
|
)
|
||||||
|
if snap.m is not None:
|
||||||
|
m = snap.m
|
||||||
|
continue
|
||||||
|
change = e.record
|
||||||
|
assert isinstance(change, ChangeRecord)
|
||||||
|
state = apply_diff(state, change.diff)
|
||||||
|
version = change.v
|
||||||
|
if change.m is not None:
|
||||||
|
m = change.m
|
||||||
|
if change.ts < cutoff:
|
||||||
|
state_at_cutoff = copy.deepcopy(state)
|
||||||
|
version_at_cutoff = version
|
||||||
|
m_at_cutoff = m
|
||||||
|
final_version = version
|
||||||
|
final_m = m
|
||||||
|
|
||||||
|
# There is at least one dropped change, so the cutoff state is known.
|
||||||
|
assert state_at_cutoff is not None
|
||||||
|
|
||||||
|
# Build the new content: leading cutoff snapshot, retained changes
|
||||||
|
# re-framed at fresh offsets, and a final snapshot when enough changes
|
||||||
|
# survived to warrant one (mirrors the regular snapshot policy).
|
||||||
|
out = bytearray()
|
||||||
|
leading = serializer.encode(
|
||||||
|
Snapshot(ts=rotated_ts, v=version_at_cutoff, state=state_at_cutoff, m=m_at_cutoff)
|
||||||
|
)
|
||||||
|
out += framer.frame_snapshot(leading, record_offset=0)
|
||||||
|
for e in retained:
|
||||||
|
payload = serializer.encode(e.record)
|
||||||
|
out += framer.frame_change(payload, record_offset=len(out))
|
||||||
|
if len(retained) >= min_diffs:
|
||||||
|
closing = serializer.encode(
|
||||||
|
Snapshot(ts=now, v=final_version, state=state, m=final_m)
|
||||||
|
)
|
||||||
|
out += framer.frame_snapshot(closing, record_offset=len(out))
|
||||||
|
|
||||||
|
return RotationPlan(
|
||||||
|
new_content=bytes(out),
|
||||||
|
cutoff_end=cutoff_end,
|
||||||
|
rotated_ts=rotated_ts,
|
||||||
|
retained_changes=len(retained),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def execute_rotation(
|
||||||
|
path: Path, file, plan: RotationPlan, *, log: bool | logging.Logger = True
|
||||||
|
) -> Path:
|
||||||
|
"""Execute a planned rotation on disk. Caller must hold the lock on *file*.
|
||||||
|
|
||||||
|
1. Copy the original content aside to ``{stem}@{ts}.kantadb``.
|
||||||
|
2. Rewrite the locked file in place with the new content and fsync.
|
||||||
|
3. Trim the rotated copy to the dropped-history prefix.
|
||||||
|
|
||||||
|
Returns the rotated file path.
|
||||||
|
"""
|
||||||
|
rotated = rotated_path_for(path, plan.rotated_ts)
|
||||||
|
shutil.copy2(path, rotated)
|
||||||
|
file.replace_content(plan.new_content)
|
||||||
|
with open(rotated, "r+b") as f:
|
||||||
|
f.truncate(plan.cutoff_end)
|
||||||
|
if log:
|
||||||
|
_logger.info(
|
||||||
|
"rotated %s: kept %d change record(s), history before %s moved to %s",
|
||||||
|
path,
|
||||||
|
plan.retained_changes,
|
||||||
|
plan.rotated_ts.isoformat(),
|
||||||
|
rotated,
|
||||||
|
)
|
||||||
|
return rotated
|
||||||
@@ -35,6 +35,11 @@ class SnapshotState:
|
|||||||
"""Force snapshot write on next check."""
|
"""Force snapshot write on next check."""
|
||||||
self._force_pending = True
|
self._force_pending = True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def min_diffs(self) -> int:
|
||||||
|
"""Minimum accumulated changes before a snapshot may be written."""
|
||||||
|
return self._min_diffs
|
||||||
|
|
||||||
def record_changes(self, count: int) -> None:
|
def record_changes(self, count: int) -> None:
|
||||||
self.changes += count
|
self.changes += count
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user