Updated docs
This commit is contained in:
@@ -51,100 +51,14 @@ asyncio.run(main())
|
|||||||
3. Let Kanta flush queued changes to disk in the background.
|
3. Let Kanta flush queued changes to disk in the background.
|
||||||
4. Use snapshots and replay for fast startup and full history.
|
4. Use snapshots and replay for fast startup and full history.
|
||||||
|
|
||||||
## Bootstrap and Open Modes
|
## Documentation
|
||||||
|
|
||||||
When `open()` creates a brand-new database, it always writes a single bootstrap
|
- [Usage patterns](https://git.zi.fi/LeoVasanko/kanta/src/branch/main/docs/usage.md) — opening, data ownership, and lifecycle patterns
|
||||||
change record from the initial data object you passed to `Kanta(...)`. The
|
- [Bootstrap and open modes](https://git.zi.fi/LeoVasanko/kanta/src/branch/main/docs/bootstrap.md) — seeding new databases, strict and read-only opens
|
||||||
simplest bootstrap is therefore the object itself — no extra code is required.
|
- [Validation](https://git.zi.fi/LeoVasanko/kanta/src/branch/main/docs/validation.md) — `@kanta.validate` integrity checks on open and transactions
|
||||||
|
- [Migrations](https://git.zi.fi/LeoVasanko/kanta/src/branch/main/docs/migrations.md) — versioned schema evolution with `migrate_vN`
|
||||||
|
- [Retention and rotation](https://git.zi.fi/LeoVasanko/kanta/src/branch/main/docs/rotation.md) — bounding history to a time window
|
||||||
|
- [Fatal error handlers](https://git.zi.fi/LeoVasanko/kanta/src/branch/main/docs/fatal-errors.md) — observing background write failures
|
||||||
|
- [On-disk format](https://git.zi.fi/LeoVasanko/kanta/src/branch/main/docs/database.md) — record layout and invariants
|
||||||
|
|
||||||
Bootstrap handlers are optional. Use them only when you need to modify the
|
Kanta in JSON mode (default) stores newline-delimited records, so transaction history is viewable in any text editor; MsgPack mode uses binary records with length and checksum to guard against corruption.
|
||||||
initial state at creation time, for example to seed defaults or perform
|
|
||||||
expensive/external setup that should happen exactly once:
|
|
||||||
|
|
||||||
```python
|
|
||||||
kanta = Kanta("data.kantadb", Data())
|
|
||||||
|
|
||||||
@kanta.bootstrap(action="seed", user="system")
|
|
||||||
def seed_defaults(data) -> None:
|
|
||||||
data.users["admin"] = User(name="Admin")
|
|
||||||
|
|
||||||
await kanta.open()
|
|
||||||
```
|
|
||||||
|
|
||||||
You can also use `@kanta.bootstrap` with no arguments and async handlers:
|
|
||||||
|
|
||||||
```python
|
|
||||||
@kanta.bootstrap
|
|
||||||
async def bootstrap_async(data) -> None:
|
|
||||||
data.counter = 1
|
|
||||||
```
|
|
||||||
|
|
||||||
Whether or not handlers are registered, exactly one bootstrap change record is
|
|
||||||
written when a new database is created. The record contains the initial object,
|
|
||||||
or the state after all bootstrap handlers have run. When handlers are present:
|
|
||||||
- they run in registration order,
|
|
||||||
- bootstrap metadata (`action`, `user`, `mtime`) is taken from the last
|
|
||||||
registration.
|
|
||||||
|
|
||||||
If any bootstrap handler raises, Kanta closes and removes the database file,
|
|
||||||
then re-raises the error.
|
|
||||||
|
|
||||||
`open()` also supports strict open mode:
|
|
||||||
|
|
||||||
```python
|
|
||||||
await kanta.open(create=False)
|
|
||||||
```
|
|
||||||
|
|
||||||
With `create=False`, open fails if the database file does not exist or is
|
|
||||||
empty.
|
|
||||||
|
|
||||||
Read-only mode opens an existing database without locking it or starting the
|
|
||||||
background flush task. This is useful for readers that must not block the
|
|
||||||
writer or modify the file:
|
|
||||||
|
|
||||||
```python
|
|
||||||
await kanta.open(readonly=True)
|
|
||||||
```
|
|
||||||
|
|
||||||
In read-only mode, records are replayed and migrations are applied in memory,
|
|
||||||
but transactions and explicit flushes are rejected and the file is never
|
|
||||||
created if missing.
|
|
||||||
|
|
||||||
## Fatal Error Handlers
|
|
||||||
|
|
||||||
Fatal background write errors can be observed with a decorator:
|
|
||||||
|
|
||||||
```python
|
|
||||||
import os
|
|
||||||
import signal
|
|
||||||
|
|
||||||
@kanta.fatal_error
|
|
||||||
async def on_fatal(err):
|
|
||||||
os.kill(os.getpid(), signal.SIGTERM) # Die
|
|
||||||
```
|
|
||||||
|
|
||||||
Multiple fatal handlers are supported and run in registration order.
|
|
||||||
|
|
||||||
## Migrations
|
|
||||||
|
|
||||||
Adding or removing a field and other such simple operations are automatic, but when the time comes to really change your data model, implement a `migrate_v1` function that converts your old data to the new form. This works on plain built-in dict and other types, to avoid needing to preserve old versions of your structs.
|
|
||||||
|
|
||||||
Pass a module (or import path) containing `migrate_vN` functions:
|
|
||||||
|
|
||||||
```python
|
|
||||||
kanta = Kanta("data.kantadb", Data(), migrations="myapp.migrations")
|
|
||||||
await kanta.open()
|
|
||||||
```
|
|
||||||
|
|
||||||
Kanta tracks migration version metadata automatically, and fast forwards your database to current version by running all the migrations needed while opening the database.
|
|
||||||
|
|
||||||
## On-Disk Format
|
|
||||||
|
|
||||||
Kanta in JSON mode (default) stores newline-delimited records. Transaction history is viewable by any simple text editor, and rollbacks to prior state are done by simply removing final lines (one per transaction)
|
|
||||||
|
|
||||||
MsgPack mode uses binary records with length and checksum to avoid data corruption.
|
|
||||||
|
|
||||||
- Change line: JSON object with metadata + `diff`
|
|
||||||
- Snapshot line: `SNAPSHOT { ... full state ... }`
|
|
||||||
|
|
||||||
See `docs/database.md` for format details and invariants.
|
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# Bootstrap and Open Modes
|
||||||
|
|
||||||
|
When `open()` creates a brand-new database, it always writes a single bootstrap change record from the initial data object you passed to `Kanta(...)`. The simplest bootstrap is therefore the object itself — no extra code is required.
|
||||||
|
|
||||||
|
Bootstrap handlers are optional. Use them only when you need to modify the initial state at creation time, for example to seed defaults or perform expensive/external setup that should happen exactly once:
|
||||||
|
|
||||||
|
```python
|
||||||
|
kanta = Kanta("data.kantadb", Data())
|
||||||
|
|
||||||
|
@kanta.bootstrap(action="seed", user="system")
|
||||||
|
def seed_defaults(data) -> None:
|
||||||
|
data.users["admin"] = User(name="Admin")
|
||||||
|
|
||||||
|
await kanta.open()
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also use `@kanta.bootstrap` with no arguments and async handlers:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@kanta.bootstrap
|
||||||
|
async def bootstrap_async(data) -> None:
|
||||||
|
data.counter = 1
|
||||||
|
```
|
||||||
|
|
||||||
|
Whether or not handlers are registered, exactly one bootstrap change record is written when a new database is created. The record contains the initial object, or the state after all bootstrap handlers have run. When handlers are present:
|
||||||
|
|
||||||
|
- they run in registration order,
|
||||||
|
- bootstrap metadata (`action`, `user`, `mtime`) is taken from the last registration.
|
||||||
|
|
||||||
|
If any bootstrap handler raises, Kanta closes and removes the database file, then re-raises the error.
|
||||||
|
|
||||||
|
## Strict open mode
|
||||||
|
|
||||||
|
```python
|
||||||
|
await kanta.open(create=False)
|
||||||
|
```
|
||||||
|
|
||||||
|
With `create=False`, open fails if the database file does not exist or is empty.
|
||||||
|
|
||||||
|
## Read-only mode
|
||||||
|
|
||||||
|
Read-only mode opens an existing database without locking it or starting the background flush task. This is useful for readers that must not block the writer or modify the file:
|
||||||
|
|
||||||
|
```python
|
||||||
|
await kanta.open(readonly=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
In read-only mode, records are replayed and migrations are applied in memory, but transactions and explicit flushes are rejected and the file is never created if missing.
|
||||||
+44
-140
@@ -1,13 +1,13 @@
|
|||||||
# Kanta Database Format and Design Principles
|
# Kanta Database Format and Design Principles
|
||||||
|
|
||||||
This document describes the on-disk format and design principles of Kanta.
|
This document describes the on-disk format and design principles of Kanta. It is intentionally focused on the current standalone package behavior.
|
||||||
It is intentionally focused on the current standalone package behavior.
|
|
||||||
|
|
||||||
## Core Principles
|
## Core Principles
|
||||||
|
|
||||||
1. Append-only durability
|
1. Append-only durability
|
||||||
- State changes are persisted as appended JSON lines.
|
- State changes are persisted as appended JSON lines.
|
||||||
- Existing lines are never edited in place.
|
- Existing lines are never edited in place.
|
||||||
|
- In JSON mode the history is viewable in any text editor, and a manual rollback to a prior state is possible by removing final lines (one per transaction).
|
||||||
|
|
||||||
2. Differential persistence
|
2. Differential persistence
|
||||||
- Kanta stores diffs (patches), not full state, for normal writes.
|
- Kanta stores diffs (patches), not full state, for normal writes.
|
||||||
@@ -28,8 +28,7 @@ It is intentionally focused on the current standalone package behavior.
|
|||||||
|
|
||||||
## On-Disk Record Types
|
## On-Disk Record Types
|
||||||
|
|
||||||
Kanta uses a newline-delimited stream where each line is either a change
|
Kanta uses a newline-delimited stream where each line is either a change record or a snapshot record.
|
||||||
record or a snapshot record.
|
|
||||||
|
|
||||||
### Change record
|
### Change record
|
||||||
|
|
||||||
@@ -68,29 +67,24 @@ Fields:
|
|||||||
3. Replay subsequent change records in order using patch application.
|
3. Replay subsequent change records in order using patch application.
|
||||||
4. The final replay state becomes in-memory `kanta.data`.
|
4. The final replay state becomes in-memory `kanta.data`.
|
||||||
|
|
||||||
This model provides fast startup for large logs while retaining append-only
|
This model provides fast startup for large logs while retaining append-only history.
|
||||||
history.
|
|
||||||
|
|
||||||
## Serialization Semantics
|
## Serialization Semantics
|
||||||
|
|
||||||
- In-memory data is defined by an application `msgspec.Struct` type.
|
- In-memory data is defined by an application `msgspec.Struct` type.
|
||||||
- Kanta round-trips through plain builtins for persistence and diffing.
|
- Kanta round-trips through plain builtins for persistence and diffing.
|
||||||
- Dict keys are serialized as strings (`str_keys=True`) for stable JSON form.
|
- Dict keys are serialized as strings (`str_keys=True`) for stable JSON form.
|
||||||
- Normalization changes introduced by struct decode/encode are logged together
|
- Normalization changes introduced by struct decode/encode are logged together with migrations as `migrate:vN`, or as `migrate:msgspec` when no migration ran but normalization still produces a diff.
|
||||||
with migrations as `migrate:vN`, or as `migrate:msgspec` when no migration
|
|
||||||
ran but normalization still produces a diff.
|
|
||||||
|
|
||||||
## Transaction Semantics
|
## Transaction Semantics
|
||||||
|
|
||||||
- `kanta.transaction(action=...)` captures a pre-transaction snapshot dict.
|
- `kanta.transaction(action=...)` captures a pre-transaction snapshot dict.
|
||||||
- By default a transaction updates the modification time `m` to the current UTC
|
- By default a transaction updates the modification time `m` to the current UTC time.
|
||||||
time.
|
|
||||||
- `mtime=True|False|datetime` controls the modification time `m`:
|
- `mtime=True|False|datetime` controls the modification time `m`:
|
||||||
- `True` (default) sets `m` to the current UTC time.
|
- `True` (default) sets `m` to the current UTC time.
|
||||||
- `False` omits `m`, leaving the previous modification time in effect.
|
- `False` omits `m`, leaving the previous modification time in effect.
|
||||||
- A `datetime` sets `m` to that explicit value.
|
- A `datetime` sets `m` to that explicit value.
|
||||||
- System operations such as `migrate:msgspec` use `mtime=False` so they are not
|
- System operations such as `migrate:msgspec` use `mtime=False` so they are not considered modifications and do not advance `m`.
|
||||||
considered modifications and do not advance `m`.
|
|
||||||
- On success:
|
- On success:
|
||||||
- compute diff between previous builtins and current builtins,
|
- compute diff between previous builtins and current builtins,
|
||||||
- queue a `ChangeRecord` if non-empty,
|
- queue a `ChangeRecord` if non-empty,
|
||||||
@@ -103,9 +97,7 @@ Nested transactions are rejected.
|
|||||||
|
|
||||||
## Modification Time
|
## Modification Time
|
||||||
|
|
||||||
`kanta.mtime` exposes the last modification time carried forward from change
|
`kanta.mtime` exposes the last modification time carried forward from change records. It is updated by normal transactions and preserved across snapshots and reloads, while system operations such as migrations leave it unchanged.
|
||||||
records. It is updated by normal transactions and preserved across snapshots and
|
|
||||||
reloads, while system operations such as migrations leave it unchanged.
|
|
||||||
|
|
||||||
## Flush and Lifecycle
|
## Flush and Lifecycle
|
||||||
|
|
||||||
@@ -120,88 +112,52 @@ reloads, while system operations such as migrations leave it unchanged.
|
|||||||
- `await kanta.open()` (default) creates the database file if missing.
|
- `await kanta.open()` (default) creates the database file if missing.
|
||||||
- `await kanta.open(create=False)` fails when the file is missing or empty.
|
- `await kanta.open(create=False)` fails when the file is missing or empty.
|
||||||
- `await kanta.open(readonly=True)` opens an existing database read-only.
|
- `await kanta.open(readonly=True)` opens an existing database read-only.
|
||||||
- The file is opened without acquiring a lock and without a background flush
|
- The file is opened without acquiring a lock and without a background flush task.
|
||||||
task.
|
|
||||||
- Existing records are replayed and migrations are still applied in memory.
|
- Existing records are replayed and migrations are still applied in memory.
|
||||||
- Transactions and explicit flushes are rejected.
|
- Transactions and explicit flushes are rejected.
|
||||||
- The file is never created if missing.
|
- The file is never created if missing.
|
||||||
|
|
||||||
### Callbacks
|
### Callbacks
|
||||||
|
|
||||||
All callbacks are registered via decorators and receive arguments by their
|
All callbacks are registered via decorators and receive arguments by their annotation types. Parameters without a supported annotation are only allowed when they have a default value.
|
||||||
annotation types. Parameters without a supported annotation are only allowed
|
|
||||||
when they have a default value.
|
|
||||||
|
|
||||||
#### Bootstrap Callbacks
|
#### Bootstrap Callbacks
|
||||||
|
|
||||||
- When `open()` creates a new database, it always writes a single bootstrap
|
- When `open()` creates a new database, it always writes a single bootstrap `ChangeRecord`.
|
||||||
`ChangeRecord`.
|
- The simplest bootstrap is the initial data object passed to `Kanta(...)`; bootstrap callbacks are optional and only needed when you want to modify or enrich that object at creation time.
|
||||||
- The simplest bootstrap is the initial data object passed to `Kanta(...)`;
|
|
||||||
bootstrap callbacks are optional and only needed when you want to modify or
|
|
||||||
enrich that object at creation time.
|
|
||||||
- Register callbacks via:
|
- Register callbacks via:
|
||||||
- `@kanta.bootstrap`
|
- `@kanta.bootstrap`
|
||||||
- `@kanta.bootstrap(action=..., user=..., mtime=...)`
|
- `@kanta.bootstrap(action=..., user=..., mtime=...)`
|
||||||
- Bootstrap callbacks may be sync or async. The live root data object is
|
- Bootstrap callbacks may be sync or async. The live root data object is injected by annotating a parameter with the struct type passed to `Kanta`, and the `Kanta` instance itself can be injected by annotating a parameter with `Kanta`.
|
||||||
injected by annotating a parameter with the struct type passed to `Kanta`,
|
|
||||||
and the `Kanta` instance itself can be injected by annotating a parameter
|
|
||||||
with `Kanta`.
|
|
||||||
- Multiple bootstrap callbacks are supported:
|
- Multiple bootstrap callbacks are supported:
|
||||||
- callbacks execute in registration order,
|
- callbacks execute in registration order,
|
||||||
- exactly one bootstrap `ChangeRecord` is queued,
|
- exactly one bootstrap `ChangeRecord` is queued,
|
||||||
- bootstrap metadata (`action`, `user`, `mtime`) is taken from the last
|
- bootstrap metadata (`action`, `user`, `mtime`) is taken from the last callback registration.
|
||||||
callback registration.
|
- If no bootstrap callbacks are registered, the bootstrap record still uses `action="bootstrap"` and contains the initial data object.
|
||||||
- If no bootstrap callbacks are registered, the bootstrap record still uses
|
- If any bootstrap callback raises, Kanta closes and removes the database file, then re-raises the exception.
|
||||||
`action="bootstrap"` and contains the initial data object.
|
|
||||||
- If any bootstrap callback raises, Kanta closes and removes the database file,
|
|
||||||
then re-raises the exception.
|
|
||||||
|
|
||||||
#### Fatal Error Handlers
|
#### Fatal Error Handlers
|
||||||
|
|
||||||
- Fatal background persistence errors can be handled with `@kanta.fatal_error`.
|
- Fatal background persistence errors can be handled with `@kanta.fatal_error`.
|
||||||
- Handlers may be sync or async. The `DatabaseError` is injected by annotating
|
- Handlers may be sync or async. The `DatabaseError` is injected by annotating a parameter with `DatabaseError`; `Kanta` may also be injected.
|
||||||
a parameter with `DatabaseError`; `Kanta` may also be injected.
|
- Multiple handlers are supported and invoked in registration order. A failing handler is logged and does not prevent subsequent handlers from running.
|
||||||
- Multiple handlers are supported and invoked in registration order. A failing
|
|
||||||
handler is logged and does not prevent subsequent handlers from running.
|
|
||||||
|
|
||||||
#### Clock
|
#### Clock
|
||||||
|
|
||||||
- `@kanta.clock` registers a callback `() -> datetime` that replaces the
|
- `@kanta.clock` registers a callback `() -> datetime` that replaces the default UTC clock. Its value is used for all record timestamps (`ts`, and `m` when `mtime` is `True`) and for snapshot timestamps.
|
||||||
default UTC clock. Its value is used for all record timestamps (`ts`, and
|
- The clock is only read when a timestamp is actually produced; no-op transactions and skipped snapshot checks do not read it.
|
||||||
`m` when `mtime` is `True`) and for snapshot timestamps.
|
- Register before `open()` so that bootstrap and migration records use the custom clock as well. This is mainly useful for tests and reproducible demos.
|
||||||
- The clock is only read when a timestamp is actually produced; no-op
|
|
||||||
transactions and skipped snapshot checks do not read it.
|
|
||||||
- Register before `open()` so that bootstrap and migration records use the
|
|
||||||
custom clock as well. This is mainly useful for tests and reproducible
|
|
||||||
demos.
|
|
||||||
|
|
||||||
#### Transaction Log Formatting
|
#### Transaction Log Formatting
|
||||||
|
|
||||||
- Logfmt callbacks prettify identifiers in the change log and are registered with
|
- Logfmt callbacks prettify identifiers in the change log and are registered with `@kanta.logfmt`.
|
||||||
`@kanta.logfmt`.
|
- A logfmt callback is called for every value Kanta renders: diff values, path components, and the transaction `user`. It receives the value as its first parameter and optionally a `path: str` parameter with the dot-notation path to the value. The special path `"$user"` is used when rendering the transaction actor.
|
||||||
- A logfmt callback is called for every value Kanta renders: diff values, path
|
- The callback returns `str | None`: a string replaces the default rendering, while `None` means "fall through to the next formatter".
|
||||||
components, and the transaction `user`. It receives the value as its first
|
- State dicts are injected by parameter name or annotation tag, which share the same vocabulary: `prev` receives the previous state dict and `state` the current one. Matching by name ignores the annotation entirely. The `DictPrev`/`DictState` aliases (`Annotated[dict, "prev"]` / `Annotated[dict, "state"]`) work under any parameter name, and a tag takes precedence over the name. `DictPre` and `DictPost` are kept as aliases of `DictPrev` and `DictState`. The `Kanta` instance can also be injected.
|
||||||
parameter and optionally a `path: str` parameter with the dot-notation path
|
- Alternatively, a logfmt callback can be a class inheriting from `LogFmt`; the framework instantiates it with the state dicts and calls its `resolve(value, path) -> str | None` method.
|
||||||
to the value. The special path `"$user"` is used when rendering the
|
- Multiple logfmt callbacks are stacked in registration order; the first callback to return a non-`None` result wins. If none handle a value, Kanta falls back to its default formatting.
|
||||||
transaction actor, replacing the old `user_display` parameter.
|
|
||||||
- The callback returns `str | None`: a string replaces the default rendering,
|
|
||||||
while `None` means "fall through to the next formatter".
|
|
||||||
- State dicts are injected by parameter name or annotation tag, which share
|
|
||||||
the same vocabulary: `prev` receives the previous state dict and `state`
|
|
||||||
the current one. Matching by name ignores the annotation entirely. The
|
|
||||||
`DictPrev`/`DictState` aliases (`Annotated[dict, "prev"]` /
|
|
||||||
`Annotated[dict, "state"]`) work under any parameter name, and a tag takes
|
|
||||||
precedence over the name. `DictPre` and `DictPost` are kept as aliases of
|
|
||||||
`DictPrev` and `DictState`. The `Kanta` instance can also be injected.
|
|
||||||
- Alternatively, a logfmt callback can be a class inheriting from `LogFmt`; the
|
|
||||||
framework instantiates it with the state dicts and calls its
|
|
||||||
`resolve(value, path) -> str | None` method.
|
|
||||||
- Multiple logfmt callbacks are stacked in registration order; the first
|
|
||||||
callback to return a non-`None` result wins. If none handle a value, Kanta
|
|
||||||
falls back to its default formatting.
|
|
||||||
|
|
||||||
The decorator accepts an optional ``path`` so the callback only runs for
|
The decorator accepts an optional ``path`` so the callback only runs for values at that exact path:
|
||||||
values at that exact path:
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@kanta.logfmt(path="$user")
|
@kanta.logfmt(path="$user")
|
||||||
@@ -215,63 +171,21 @@ def resolve_user_key(value: str) -> str | None:
|
|||||||
|
|
||||||
#### Transaction Log Headers
|
#### Transaction Log Headers
|
||||||
|
|
||||||
- By default a transaction is logged with an `action by user` header followed
|
- By default a transaction is logged with an `action by user` header followed by the diff lines. Added paths are colored green, deleted paths red.
|
||||||
by the diff lines. Added paths are colored green, deleted paths red.
|
- `kanta.transaction(..., extra=...)` accepts a display-only value that is shown after the action in the header. Anything other than `None` is printed str-converted (colored by Kanta), unless a custom logemit handler does something else with it; it is never persisted in the `ChangeRecord`.
|
||||||
- `kanta.transaction(..., extra=...)` accepts a display-only value that is
|
- `kanta.transaction(..., logdiff=False)` skips building and printing the diff body and logs only the header, which is useful for large or noisy changesets. Diff output can also be disabled globally with `configure_logging(diff=False)`; diff lines are emitted on the `kanta.transaction.diff` child logger so applications can route or silence them separately from the headers.
|
||||||
shown after the action in the header. Anything other than `None` is
|
|
||||||
printed str-converted (colored by Kanta), unless a custom logemit handler
|
|
||||||
does something else with it; it is never persisted in the `ChangeRecord`.
|
|
||||||
- `kanta.transaction(..., logdiff=False)` skips building and printing the diff
|
|
||||||
body and logs only the header, which is useful for large or noisy
|
|
||||||
changesets. Diff output can also be disabled globally with
|
|
||||||
`configure_logging(diff=False)`; diff lines are emitted on the
|
|
||||||
`kanta.transaction.diff` child logger so applications can route or silence
|
|
||||||
them separately from the headers.
|
|
||||||
|
|
||||||
#### Log Emitters
|
#### Log Emitters
|
||||||
|
|
||||||
- Every change-related message Kanta emits (transaction/bootstrap/migration
|
Every change-related message Kanta emits (transaction/bootstrap/migration changes, file created/opened lines, migration summaries, aborted transactions) is described by a `kanta.logging.LogEvent` and dispatched through `kanta.logging.emit_event`. Kanta's own output goes through the same mechanism: when no `logemit` callback handles an event, `kanta.logging.default_emit` renders it with the built-in formatting.
|
||||||
changes, file created/opened lines, migration summaries, aborted
|
|
||||||
transactions) is described by a `kanta.logging.LogEvent` and dispatched
|
- A `LogEvent` carries the event `kind` (`"change"`, `"created"`, `"opened"`, `"migrated"`, `"aborted"`), the preferred `logger` and `level`, the `kanta` instance, and all relevant state: `action`, `user`, `extra`, `error` (for aborted transactions), `diff`, `previous`/`current` state dicts, the built `logfmt` chain, and version info for migration events. Application-specific context (e.g. a connection id) can be stored in `kanta.ctx` — a user-writable namespace — and read back in callbacks as `event.kanta.ctx`, which also covers creation/bootstrap events.
|
||||||
through
|
- The built-in formatting is assembled from standard blocks that custom emitters can reuse as-is or replace piecemeal:
|
||||||
`kanta.logging.emit_event`. Kanta's own output goes through the same
|
- `event.header` — a lazy property producing the default one-line header for any kind: `<action>[ <extra>][ by <user>]` for changes, `<action>[ <extra>][ by <user>] transaction aborted: <error>` for aborts, and the `🛢️ <file> created|opened|migrated ...` summaries. It is settable: assign `event.header = ...` and return truthy to restyle the header while keeping the default diff routing.
|
||||||
mechanism: when no `logemit` callback handles an event,
|
- `event.diff_lines` — a lazy property producing the pretty diff body for change events (built only if accessed).
|
||||||
`kanta.logging.default_emit` renders it with the built-in formatting.
|
|
||||||
- A `LogEvent` carries the event `kind` (`"change"`, `"created"`,
|
|
||||||
`"opened"`, `"migrated"`, `"aborted"`), the preferred `logger` and `level`,
|
|
||||||
the
|
|
||||||
`kanta` instance, and all relevant state: `action`, `user`, `extra`,
|
|
||||||
`error` (for aborted transactions), `diff`, `previous`/`current` state
|
|
||||||
dicts, the built `logfmt` chain, and version info for migration events.
|
|
||||||
Application-specific context (e.g. a connection id) can be stored in
|
|
||||||
`kanta.ctx` — a user-writable namespace — and read back in callbacks as
|
|
||||||
`event.kanta.ctx`, which also covers creation/bootstrap events.
|
|
||||||
- The built-in formatting is assembled from standard blocks that custom
|
|
||||||
emitters can reuse as-is or replace piecemeal:
|
|
||||||
- `event.header` — a lazy property producing the default one-line header
|
|
||||||
for any kind: `<action>[ <extra>][ by <user>]` for changes,
|
|
||||||
`<action>[ <extra>][ by <user>] transaction aborted: <error>` for aborts,
|
|
||||||
and the `🛢️ <file> created|opened|migrated ...` summaries. It is
|
|
||||||
settable: assign
|
|
||||||
`event.header = ...` and return truthy to restyle the header while
|
|
||||||
keeping the default diff routing.
|
|
||||||
- `event.diff_lines` — a lazy property producing the pretty diff body for
|
|
||||||
change events (built only if accessed).
|
|
||||||
- `default_emit` itself is just `header` plus the `diff_lines` routing.
|
- `default_emit` itself is just `header` plus the `diff_lines` routing.
|
||||||
- `@kanta.logemit` registers a callback receiving the event. The callback
|
- `@kanta.logemit` registers a callback receiving the event. The callback decides what is logged and where: it may log one or more messages on `event.logger`, log somewhere else, or nothing at all. A falsy return value marks the event handled and stops the chain; a truthy return value passes the event — possibly modified — to the next registered callback. When all callbacks pass, `default_emit` renders the event; a callback may also call `default_emit(event)` itself to delegate events it does not customize. Operational diagnostics (integrity errors, background flush failures) do not go through this mechanism.
|
||||||
decides what is logged and where: it may log one or more messages on
|
- Logging never breaks functionality: a crashing `logemit` callback is reported with `logger.exception` and the event falls back to the built-in formatting; if the built-in formatting itself fails, the error is reported and swallowed. The same applies to `logfmt` callbacks (a failing one is treated as a fall-through) and `logmigr` callbacks.
|
||||||
`event.logger`, log somewhere else, or nothing at all. A falsy return
|
|
||||||
value marks the event handled and stops the chain; a truthy return value
|
|
||||||
passes the event — possibly modified — to the next registered callback.
|
|
||||||
When all callbacks pass, `default_emit` renders the event; a callback may
|
|
||||||
also call `default_emit(event)` itself to delegate events it does not
|
|
||||||
customize. Operational diagnostics (integrity errors, background flush
|
|
||||||
failures) do not go through this mechanism.
|
|
||||||
- Logging never breaks functionality: a crashing `logemit` callback is
|
|
||||||
reported with `logger.exception` and the event falls back to the built-in
|
|
||||||
formatting; if the built-in formatting itself fails, the error is reported
|
|
||||||
and swallowed. The same applies to `logfmt` callbacks (a failing one is
|
|
||||||
treated as a fall-through) and `logmigr` callbacks.
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@kanta.logemit
|
@kanta.logemit
|
||||||
@@ -286,17 +200,9 @@ def emit(ev: LogEvent):
|
|||||||
#### Terminal Formatting Helpers
|
#### Terminal Formatting Helpers
|
||||||
|
|
||||||
- `kanta.tty` provides the building blocks used by Kanta's own rendering:
|
- `kanta.tty` provides the building blocks used by Kanta's own rendering:
|
||||||
- `colors`: the mutable color palette. Colors are bare SGR parameter
|
- `colors`: the mutable color palette. Colors are bare SGR parameter strings (e.g. `"1;34"`, `"38;5;226"`) without escape framing. Attributes are read at render time, so assignments (`colors.action = "36"`) and additions (`colors.session = "38;5;226"`) take effect immediately.
|
||||||
strings (e.g. `"1;34"`, `"38;5;226"`) without escape framing. Attributes
|
- `Line`: builds a terminal string part by part. Calling it appends content (`str`-converted); `.<colorname>` arms a palette color for the next call only, and the reset is folded into a single escape sequence with whatever color comes next. `width=`/`align=` pad by display width; `str(line)` finishes the line and restores default colors.
|
||||||
are read at render time, so assignments (`colors.action = "36"`) and
|
- `strip_ansi`, `displaywidth` (wide chars and emoji count correctly) and `pad` for working with pre-colored strings.
|
||||||
additions (`colors.session = "38;5;226"`) take effect immediately.
|
|
||||||
- `Line`: builds a terminal string part by part. Calling it appends
|
|
||||||
content (`str`-converted); `.<colorname>` arms a palette color for the
|
|
||||||
next call only, and the reset is folded into a single escape sequence
|
|
||||||
with whatever color comes next. `width=`/`align=` pad by display width;
|
|
||||||
`str(line)` finishes the line and restores default colors.
|
|
||||||
- `strip_ansi`, `displaywidth` (wide chars and emoji count correctly) and
|
|
||||||
`pad` for working with pre-colored strings.
|
|
||||||
|
|
||||||
## Migrations
|
## Migrations
|
||||||
|
|
||||||
@@ -308,8 +214,6 @@ def emit(ev: LogEvent):
|
|||||||
|
|
||||||
## Safety Invariants
|
## Safety Invariants
|
||||||
|
|
||||||
- Any detected out-of-transaction mutation is treated as a fatal consistency
|
- Any detected out-of-transaction mutation is treated as a fatal consistency violation.
|
||||||
violation.
|
|
||||||
- Flush failures mark the instance as failed and trigger shutdown behavior.
|
- Flush failures mark the instance as failed and trigger shutdown behavior.
|
||||||
- Object identity of `kanta.data` is preserved across rollback when possible,
|
- Object identity of `kanta.data` is preserved across rollback when possible, minimizing stale-reference hazards for callers.
|
||||||
minimizing stale-reference hazards for callers.
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Fatal Error Handlers
|
||||||
|
|
||||||
|
Fatal background write errors can be observed with a decorator:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
|
||||||
|
@kanta.fatal_error
|
||||||
|
async def on_fatal(err):
|
||||||
|
os.kill(os.getpid(), signal.SIGTERM) # Die
|
||||||
|
```
|
||||||
|
|
||||||
|
Multiple fatal handlers are supported and run in registration order.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Migrations
|
||||||
|
|
||||||
|
Adding or removing a field and other such simple operations are automatic, but when the time comes to really change your data model, implement a `migrate_v1` function that converts your old data to the new form. This works on plain built-in dict and other types, to avoid needing to preserve old versions of your structs.
|
||||||
|
|
||||||
|
Pass a module (or import path) containing `migrate_vN` functions:
|
||||||
|
|
||||||
|
```python
|
||||||
|
kanta = Kanta("data.kantadb", Data(), migrations="myapp.migrations")
|
||||||
|
await kanta.open()
|
||||||
|
```
|
||||||
|
|
||||||
|
Kanta tracks migration version metadata automatically, and fast forwards your database to current version by running all the migrations needed while opening the database.
|
||||||
+41
-238
@@ -1,35 +1,20 @@
|
|||||||
# Database Rotation
|
# Database Rotation
|
||||||
|
|
||||||
Goal: bound the on-disk history of a kanta database to a configurable retention
|
Kanta can bound the on-disk history of a database to a configurable retention window (e.g. the last 30 days) by *rotating* the database file: the aged-out content is copied to a timestamped sibling file and the main file is truncated and rewritten in place with only the retained history plus fresh snapshots. Normal operation stays append-only under the exclusive lock; rotation is the only operation that rewrites the file.
|
||||||
window (e.g. the last 30 days) by *rotating* the database file: the old content
|
|
||||||
is copied to a timestamped sibling file and the main file is truncated and
|
|
||||||
rewritten in place with only the retained history plus fresh snapshots. Normal
|
|
||||||
operation stays append-only under the exclusive lock; rotation is the only
|
|
||||||
operation that rewrites the file.
|
|
||||||
|
|
||||||
## Current facts the design must respect
|
## Configuration
|
||||||
|
|
||||||
- The writer holds an exclusive `flock` on the file from `open()` until
|
Rotation is enabled with a keyword option on `Kanta(...)`:
|
||||||
`close()` (`kanta/filelock.py`). No other process can safely touch the file
|
|
||||||
while a writer has it open.
|
- `retention: timedelta | int | None = None` — history window to keep; a plain `int` is interpreted as a number of days. `None` (default) disables rotation entirely.
|
||||||
- Records are append-only frames. Each `ChangeRecord` carries `ts` (record time)
|
|
||||||
and `m` (modification time); snapshots carry `ts`, `v` (schema version) and
|
Rotation uses the same clock as record timestamps, so a custom `@kanta.clock` callback controls it as well (useful in tests).
|
||||||
`state` (`kanta/structs.py`).
|
|
||||||
- Replay reads the whole file, then starts from the **last snapshot**
|
## When rotation runs
|
||||||
(`framer.scan_last_snapshot`, `serialization/base.py:replay`). Anything before
|
|
||||||
the last snapshot is already logically dead.
|
Rotation happens inside `Kanta.open()`, after acquiring the exclusive lock and before replay. At that point the file is quiescent: no background flush loop is running yet and no records are in flight. Rotation therefore never races with the background writer, snapshot requests, or migration snapshot writes.
|
||||||
- Snapshot state is validated in tooling: replayed state must equal snapshot
|
|
||||||
state (`kanta/replaylog.py`). A snapshot is therefore a consistency
|
Consequence: a database that is never reopened never rotates. For long-running services, rotation takes effect on the next restart.
|
||||||
checkpoint, not just an accelerator.
|
|
||||||
- `BinFramer` checksums are **offset-keyed** (checksum includes the absolute
|
|
||||||
`record_offset`). A binary frame copied to a different byte offset is
|
|
||||||
corrupted. `LineFramer` (JSONL) has no checksums.
|
|
||||||
- There is **no fsync/fdatasync** anywhere; durability currently relies on the
|
|
||||||
OS page cache. Rotation must not make this worse, and should fix it for the
|
|
||||||
rotation path at minimum.
|
|
||||||
- Migrations run on open, after replay, against the snapshot/replay version.
|
|
||||||
A snapshot records the version it was written at, so "db already migrated"
|
|
||||||
survives in the snapshot even if the migrations produced no change records.
|
|
||||||
|
|
||||||
## Rotated file naming
|
## Rotated file naming
|
||||||
|
|
||||||
@@ -39,229 +24,47 @@ The history that aged out is preserved at:
|
|||||||
{stem}@{ISO-8601 timestamp}.kantadb
|
{stem}@{ISO-8601 timestamp}.kantadb
|
||||||
```
|
```
|
||||||
|
|
||||||
- `{stem}` is the original filename with its extension stripped
|
- `{stem}` is the original filename with its extension stripped (`Path(filename).stem`), so databases named `data`, `data.kantadb`, or `data.db` all rotate to `data@….kantadb`.
|
||||||
(`Path(filename).stem`).
|
- The timestamp is the `ts` of the last record dropped by the rotation (the leading snapshot of the rewritten main file carries the same `ts`), not the rotation time — the name tells you exactly which point in history the rotated file ends at. It is rendered in ISO 8601 basic format at second precision (e.g. `20260902T143000Z`); the exact microsecond timestamp of the cutoff remains available inside the file as the `ts` of its final line and of the leading snapshot of the new file.
|
||||||
- The timestamp is the **ts of the last record dropped by the rotation** (see
|
- Rotated files live in the same directory as the main file.
|
||||||
step 4 — the leading snapshot of the rewritten main file carries the same
|
- On collision (a rotated file with the same name already exists), an incrementing suffix is inserted before the extension (`data@….1.kantadb`, `data@….2.kantadb`, …) rather than overwriting.
|
||||||
ts), not the current time. The name tells you exactly which point in history
|
- Rotated files are never deleted by rotation.
|
||||||
the rotated file ends at. Rendered in ISO 8601 basic format at second
|
|
||||||
precision (e.g. `20260902T143000Z`). The exact microsecond timestamp of the
|
|
||||||
cutoff remains available inside the file (it is the ``ts`` of the final
|
|
||||||
line of the rotated file and of the snapshot at the start of the new file);
|
|
||||||
a second rotation within the same second cannot occur because rotation
|
|
||||||
requires history to have aged past the cutoff.
|
|
||||||
- The rotated name always ends in `.kantadb`, regardless of the original
|
|
||||||
extension. Users may name their databases with no extension, `.kantadb`, or
|
|
||||||
anything else (`.db`, …). Since the rotated name is derived from the *stem*,
|
|
||||||
all of these work uniformly: `data` → `data@20260902T143000Z.kantadb`,
|
|
||||||
`data.kantadb` → `data@….kantadb`, `data.db` → `data@….kantadb`.
|
|
||||||
- Rotated files live in the same directory.
|
|
||||||
- Collision: if a rotated file with the same name already exists (rotation
|
|
||||||
rerun over identical history — should be prevented by the eligibility check
|
|
||||||
below, but be defensive), append a disambiguating suffix rather than
|
|
||||||
overwriting.
|
|
||||||
|
|
||||||
## Why in-place rewrite (and not rename-and-recreate)
|
## Rotation algorithm
|
||||||
|
|
||||||
An earlier draft renamed the locked file away and created a fresh file at the
|
Let `cutoff = now - retention`. All planning happens on the in-memory bytes of the database file already read by open; no second disk read is needed.
|
||||||
main path. That opens a race: between the rename and the creation of the new
|
|
||||||
file, a second instance can open the (now missing) main path with `O_CREAT`,
|
|
||||||
acquire its own lock on the fresh inode, and bootstrap an empty database. The
|
|
||||||
rotating instance then cannot lock the path it needs, and two divergent
|
|
||||||
databases exist. `flock` is attached to the open file description (inode), not
|
|
||||||
the path — renaming never blocks a newcomer.
|
|
||||||
|
|
||||||
Instead, rotation **never renames or unlinks the main file and never releases
|
1. **Eligibility.** Rotation is skipped when there is nothing to do: when the file contains no change records older than `cutoff` (the retention window already covers all history), or when the file contains no change records at all (a snapshot-only file is treated as already fully rotated and never re-rotated).
|
||||||
its lock**:
|
|
||||||
|
|
||||||
- Unix: `ftruncate(fd, 0)` on the open, locked fd is unaffected by the flock
|
2. **Replay base.** The base is the newest snapshot whose `ts <= cutoff`, falling back to the start of file when no such snapshot exists. Starting at the most recent snapshot would silently drop history that must be retained.
|
||||||
and does not affect it. Subsequent writes use `lseek(fd, 0, SEEK_END)` +
|
|
||||||
`os.write` (`filelock.py:226`), which work identically after a truncate, so
|
|
||||||
append-mode operation continues unchanged.
|
|
||||||
- Windows: this is also the *more* portable option — the DB is opened with
|
|
||||||
`FILE_SHARE_READ` only (`filelock.py:240`), so renaming the locked file would
|
|
||||||
fail outright on Windows. In-place rewrite only needs `SetFilePointer(0)` +
|
|
||||||
`SetEndOfFile` on a handle we own.
|
|
||||||
- The main path therefore exists and remains locked throughout; a second
|
|
||||||
instance opening it at any moment gets either the old content or the new,
|
|
||||||
never a missing or half-created file, and never its own lock.
|
|
||||||
|
|
||||||
The only new capability `LockedFile` needs is a `replace_content(data)` method
|
3. **Replay and validate.** The file is replayed from the base forward to the end. Records with `ts < cutoff` are applied to the replay (they are needed to reach the cutoff state) but not retained in the output. At every snapshot encountered after the base, the replayed state is validated against the snapshot state; a mismatch means the history is corrupt, and rotation is aborted with a `DatabaseError`, leaving the original file untouched. The byte offset just after the last record with `ts < cutoff` (frame-boundary aligned) is remembered as `cutoff_end`.
|
||||||
(seek 0, truncate, write, fsync) implemented per platform.
|
|
||||||
|
|
||||||
## When to rotate: at open time, not at runtime
|
4. **Copy the original aside.** The main file is copied with `shutil.copy2` directly to its final `{stem}@{ts}.kantadb` name. On filesystems with copy-on-write this performs a cheap reflink copy. A failure here aborts rotation with the original file intact.
|
||||||
|
|
||||||
Rotation happens **inside `Kanta.open()`, after acquiring the lock, before
|
5. **Rewrite the main file in place.** On the locked file descriptor the content is replaced (seek 0, truncate, write, fsync) with, in order:
|
||||||
replay**, gated by a retention option (see Configuration). Rationale:
|
1. A **snapshot of the state at the cutoff**, stamped with the schema version and modification time in effect at the cutoff. Its `ts` is the `ts` of the last pre-cutoff record — the same timestamp used in the rotated filename. This snapshot is the new replay base and carries the version forward so migrations are not re-run; it is always written.
|
||||||
|
2. The retained change records (`ts >= cutoff`), recreated record by record — no internal snapshots are carried over.
|
||||||
|
3. A **final snapshot** of the state after the last retained record, written only when enough changes were retained to warrant one (the same policy as regular snapshot writes). If no records survived the cutoff, the new file consists of the single leading snapshot and nothing else — the steady state for databases whose history has fully aged out.
|
||||||
|
|
||||||
- The lock is already held and no background flush loop is running yet, so the
|
6. **Trim the rotated copy.** The rotated file is truncated to `cutoff_end` bytes, so it contains only the dropped history and does not duplicate the records retained in the main file. The cut is at a frame boundary, so the rotated file remains a valid, replayable database on its own. This happens only after step 5's fsync, so until then the rotated file still holds the complete original content as a crash-recovery anchor.
|
||||||
file is quiescent — no in-flight `pending_changes`, no concurrent snapshots.
|
|
||||||
- Runtime rotation would have to fence the background writer, drain the queue,
|
|
||||||
and prove no record lands in the file after the cutoff was computed. That
|
|
||||||
is a second synchronization protocol for a rare operation; not worth it.
|
|
||||||
- Open-time rotation also means rotation never races with `request_snapshot()`
|
|
||||||
or migration snapshot writes, which all happen under the same open() sequence.
|
|
||||||
|
|
||||||
Consequence: a database that is never reopened never rotates. Document this;
|
7. **Continue normal open.** Replay and migrations proceed on the same locked file. Because the leading snapshot carries the current version, migrations run exactly as they would have against the old content.
|
||||||
for long-running services, rotation takes effect on the next restart.
|
|
||||||
|
|
||||||
## Rotation algorithm (under the exclusive lock)
|
Failure rule: any error before step 5 leaves the main file byte-identical (only an extra copy exists). A crash during step 5 may leave the main file torn, but the rotated copy still holds the complete original content — recovery is copying it back. After step 6 the split is complete and both files are consistent.
|
||||||
|
|
||||||
Let `cutoff = now - retention`. Steps 1–3 operate on the bytes already read
|
## Design notes
|
||||||
into memory by `open_and_read`; no second disk read is needed.
|
|
||||||
|
|
||||||
1. **Check eligibility.** Skip rotation when there is nothing to do:
|
**In-place rewrite, not rename-and-recreate.** The writer holds an exclusive `flock` on the file from `open()` until `close()`, and `flock` is attached to the open file description (inode), not the path. Renaming the locked file away and creating a fresh file at the main path would open a race: between the rename and the creation, a second instance could open the missing path with `O_CREAT`, acquire its own lock on the fresh inode, and bootstrap a divergent database. On Windows, renaming the locked file would fail outright (the database is opened with `FILE_SHARE_READ` only). Rotation therefore never renames or unlinks the main file and never releases its lock; a second instance opening the path at any moment gets either the old content or the new, and never its own lock. The only primitive this requires is `LockedFile.replace_content()` (seek 0, truncate, write, fsync).
|
||||||
- The file contains **no change records older than `cutoff`** — the
|
|
||||||
retention window already covers all history.
|
|
||||||
- The file contains **no change records at all** (snapshot-only file).
|
|
||||||
Opening a long-untouched database may legitimately rotate it down to a
|
|
||||||
single snapshot (that *is* the intended purge), but once a file has been
|
|
||||||
reduced to just a snapshot, rotating it again would be a pure no-op
|
|
||||||
rewrite. Treat "no change records" as "already fully rotated" and skip.
|
|
||||||
|
|
||||||
2. **Find the replay base.** Replay normally starts at the most recent
|
**Rewrite (re-frame), not verbatim copy**, for all records written to the main file. `BinFramer` checksums are offset-keyed (the checksum includes the absolute `record_offset`), so a verbatim byte copy to a new offset would be unreadable; binary records are re-framed at their new offsets. Rewriting also normalizes encoding drift and lets rotation drop the redundant intermediate snapshots the original file accumulated. The rotated copy is the one place where verbatim bytes are used — a raw `copy2` plus a frame-aligned tail truncation — which is safe precisely because it preserves original offsets: the truncated prefix keeps every frame at its original `record_offset`, so binary checksums stay valid.
|
||||||
snapshot, but that snapshot's `ts` is likely newer than `cutoff` — replaying
|
|
||||||
from it would silently drop history we intend to keep. Instead, scan
|
|
||||||
**backwards from the end of file**, collecting snapshots newest-first, and
|
|
||||||
pick the oldest snapshot `S` whose `ts <= cutoff` (i.e. walk back past
|
|
||||||
snapshots until one covers the required range, or until start of file). If
|
|
||||||
no such snapshot exists, `S` is "start of file" and the retained range is
|
|
||||||
replayed from the empty initial state.
|
|
||||||
- For `LineFramer` this is a reverse scan for `\nSNAPSHOT ` lines.
|
|
||||||
- For `BinFramer` frames are forward-scannable only; keep the forward scan
|
|
||||||
but record every snapshot position, then pick from the collected list.
|
|
||||||
|
|
||||||
3. **Replay and validate.** Replay from `S` (or start of file) forward to end
|
## Integrity guarantees
|
||||||
of file, keeping every record with `ts >= cutoff`. At **every** snapshot
|
|
||||||
encountered after `S`, validate that the replayed state equals the snapshot
|
|
||||||
state; a mismatch means the history is corrupt or the chosen base is wrong —
|
|
||||||
abort rotation (leave the original file untouched) and surface the error.
|
|
||||||
The last snapshot in the file must always validate; if even that fails,
|
|
||||||
rotation must not proceed.
|
|
||||||
- Records with `ts < cutoff` are applied to the replay (they are needed to
|
|
||||||
reach the cutoff state) but not retained in the output.
|
|
||||||
- Remember `cutoff_end`: the byte offset in the original content just after
|
|
||||||
the last record with `ts < cutoff` (frame-boundary aligned). The rotated
|
|
||||||
file will be truncated to this length in step 6.
|
|
||||||
|
|
||||||
4. **Copy the original aside.** `shutil.copy2(main_path, rotated_path)` —
|
|
||||||
no lock needed on the copy, and no temporary name: the content is written
|
|
||||||
directly to its final `{stem}@{ts}.kantadb` name. `copy2` preserves
|
|
||||||
metadata and, on filesystems with copy-on-write (btrfs, XFS with reflinks,
|
|
||||||
APFS, …), performs a cheap reflink copy instead of duplicating data; it is
|
|
||||||
also generally faster than re-writing the same bytes from memory. The
|
|
||||||
original bytes remain readable from the locked fd if the copy fails, so a
|
|
||||||
failure here simply aborts rotation.
|
|
||||||
|
|
||||||
5. **Rewrite the main file in place.** On the locked fd: seek to 0, truncate
|
|
||||||
to 0, write the new content, `fdatasync`. The new content is, in order:
|
|
||||||
1. A **snapshot of the state at the cutoff** — the replayed state after
|
|
||||||
applying all records with `ts < cutoff`, stamped with the **schema
|
|
||||||
version in effect at the cutoff**. Its `ts` is the **ts of the last
|
|
||||||
pre-cutoff record** (not the rotation time), and this is exactly the
|
|
||||||
timestamp used in the rotated filename. This snapshot is the new replay
|
|
||||||
base and carries the version forward so migrations are not re-run; it is
|
|
||||||
always written.
|
|
||||||
2. The retained change records (`ts >= cutoff`), **recreated record by
|
|
||||||
record** — no internal snapshots are carried over, even if the original
|
|
||||||
file had many in the retained range.
|
|
||||||
3. A **final snapshot** of the state after the last retained record,
|
|
||||||
stamped with the version of the last retained record — written **only
|
|
||||||
if** there were
|
|
||||||
retained change records (and, in line with the existing snapshot policy
|
|
||||||
in `kanta/snapshot.py`, only when a meaningful number of changes
|
|
||||||
accumulated; a handful of trailing changes need not force one). If no
|
|
||||||
records survived the cutoff, the new file consists of the single leading
|
|
||||||
snapshot and nothing else — this is the steady state for databases whose
|
|
||||||
history has fully aged out, and the eligibility check in step 1 prevents
|
|
||||||
re-rotating such files.
|
|
||||||
|
|
||||||
6. **Trim the rotated copy.** Truncate `{stem}@{ts}.kantadb` to `cutoff_end`
|
|
||||||
bytes, so it contains **only the dropped history** and does not duplicate
|
|
||||||
the records retained in the main file. The cut is at a frame boundary, so
|
|
||||||
the rotated file remains a valid, replayable database on its own (it is a
|
|
||||||
prefix of a valid log). This truncation happens only after step 5's fsync,
|
|
||||||
so until then the rotated file still holds the complete original content as
|
|
||||||
a crash-recovery anchor.
|
|
||||||
|
|
||||||
7. **Continue normal open.** Replay/migrations proceed on the same locked fd.
|
|
||||||
Because the leading snapshot carries the current version, migrations run
|
|
||||||
exactly as they would have against the old content.
|
|
||||||
|
|
||||||
Failure rule: any error before step 5 leaves the main file byte-identical
|
|
||||||
(only an extra copy exists). A crash during step 5 may leave the main file
|
|
||||||
torn, but the rotated copy still holds the complete original content
|
|
||||||
(truncated only after the main file is durable) — recovery is copying it back.
|
|
||||||
After step 6 the split is complete and both files are consistent.
|
|
||||||
|
|
||||||
## Verbatim copy or rewrite?
|
|
||||||
|
|
||||||
**Rewrite (re-frame), not verbatim copy**, for all records written to the main
|
|
||||||
file:
|
|
||||||
|
|
||||||
- `BinFramer` checksums include `record_offset`, so a verbatim byte copy to a
|
|
||||||
new offset is unreadable. Binary records must be re-framed at their new
|
|
||||||
offsets regardless.
|
|
||||||
- Rewriting also normalizes encoding drift and lets us drop the redundant
|
|
||||||
intermediate snapshots the original file accumulated: none of them are
|
|
||||||
carried over — the new file contains only the leading cutoff snapshot, the
|
|
||||||
recreated change records, and (conditionally) the final snapshot.
|
|
||||||
|
|
||||||
The rotated copy is the one place where verbatim bytes are used — a raw
|
|
||||||
`copy2` plus a frame-aligned tail truncation — which is safe precisely because
|
|
||||||
it preserves original offsets (the truncated prefix keeps every frame at its
|
|
||||||
original `record_offset`, so binary checksums stay valid).
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
Add keyword options to `Kanta(...)` (`kanta/kanta.py`), surfaced through
|
|
||||||
`open()`:
|
|
||||||
|
|
||||||
- `retention: timedelta | int | None = None` — history window to keep; a plain
|
|
||||||
`int` is interpreted as a number of days. `None` (default) disables rotation
|
|
||||||
entirely; current behavior is unchanged.
|
|
||||||
- `rotate_keep: int = 3` (optional, later) — how many rotated backups to
|
|
||||||
retain; older ones are pruned at rotation time.
|
|
||||||
|
|
||||||
Rotation uses `impl.now()` so the `@Kanta.clock` test clock controls it, same
|
|
||||||
as record timestamps.
|
|
||||||
|
|
||||||
## Integrity checklist
|
|
||||||
|
|
||||||
- Rotation runs under the exclusive lock, before the background writer starts.
|
- Rotation runs under the exclusive lock, before the background writer starts.
|
||||||
- The main path is never renamed, unlinked, or unlocked during rotation; no
|
- The main path is never renamed, unlinked, or unlocked during rotation; no bootstrap race with a second instance is possible.
|
||||||
bootstrap race with a second instance is possible.
|
- Replay is validated against every snapshot in range; any validation failure aborts rotation with the original file intact.
|
||||||
- Replay base is chosen by walking snapshots backwards until the retained range
|
- A leading cutoff snapshot (ts = last pre-cutoff record, schema version at the cutoff) is always written; a final snapshot is written only when warranted by retained changes.
|
||||||
is covered; replay is validated against every snapshot in range.
|
- The full original content sits at `{stem}@{ts}.kantadb` before the main file is touched, and is only trimmed to the dropped-history prefix after the rewritten main file is fsynced.
|
||||||
- A leading cutoff snapshot (ts = last pre-cutoff record, current schema
|
- Rotated files are never deleted by rotation.
|
||||||
version) is always written; a final snapshot is written only when warranted
|
- Files with no change records (already reduced to a snapshot) are never re-rotated.
|
||||||
by retained changes.
|
|
||||||
- The full original content sits at `{stem}@{ts}.kantadb` before the main file
|
|
||||||
is touched, and is only trimmed to the dropped-history prefix after the
|
|
||||||
rewritten main file is `fdatasync`ed.
|
|
||||||
- Rotated files are never deleted by the rotation itself.
|
|
||||||
- Any validation failure aborts rotation with the original file intact.
|
|
||||||
- Files with no change records (already reduced to a snapshot) are never
|
|
||||||
re-rotated.
|
|
||||||
|
|
||||||
## Testing notes
|
|
||||||
|
|
||||||
- Use the test clock (`tests/test_clock.py`) to age records past the cutoff.
|
|
||||||
- Cover both framers: JSONL rotation and BinFramer rotation (assert the
|
|
||||||
rewritten binary file passes checksum validation and replays identically,
|
|
||||||
and that the truncated rotated prefix still passes checksum validation).
|
|
||||||
- Assert state equality before/after rotation, version continuity (no
|
|
||||||
re-migration), correct behavior when no snapshot precedes the cutoff, when
|
|
||||||
the newest snapshot is already older than the cutoff, and when retention
|
|
||||||
covers everything (no-op).
|
|
||||||
- Naming: databases named `x`, `x.kantadb`, and `x.db` all rotate to
|
|
||||||
`x@{ts}.kantadb`; the timestamp equals the last dropped record's ts and the
|
|
||||||
leading snapshot's ts.
|
|
||||||
- Assert the rotated file ends exactly at the last dropped record's frame
|
|
||||||
boundary (no overlap with the retained history in the main file).
|
|
||||||
- No-change files: a snapshot-only database opened with retention set is left
|
|
||||||
untouched (no copy, no rewrite).
|
|
||||||
- Aged-out database: all history older than the cutoff → new file contains
|
|
||||||
exactly one snapshot; opening it again performs no rotation.
|
|
||||||
- Concurrency: while one instance rotates, a second instance opening the main
|
|
||||||
path must fail with the normal "already locked" error at every stage.
|
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# Usage Patterns: Opening and Data Handling
|
||||||
|
|
||||||
|
This document covers the basics of opening a database and working with the live data object: who owns it, where initial data comes from, and the lifecycle patterns Kanta supports.
|
||||||
|
|
||||||
|
## The data object belongs to you
|
||||||
|
|
||||||
|
You pass the root state object to `Kanta(...)`, and Kanta never replaces it with a new instance. Replay, migrations, transaction rollbacks — all restore or mutate the object's contents in place, preserving its identity. This means you may hold external references to the object (or to parts of it) and they stay valid for the lifetime of the `Kanta` instance:
|
||||||
|
|
||||||
|
```python
|
||||||
|
data = Data()
|
||||||
|
kanta = Kanta("data.kantadb", data)
|
||||||
|
await kanta.open()
|
||||||
|
|
||||||
|
assert kanta.data is data # always the same object
|
||||||
|
```
|
||||||
|
|
||||||
|
Access the state as `kanta.data`, via your own reference, or both — they are the same object. Mutations must happen inside a transaction (see below); Kanta treats any detected out-of-transaction mutation as a fatal consistency violation.
|
||||||
|
|
||||||
|
Note that replacing the whole object is also possible (`kanta.data = Data()` has a setter), but then previously held references point at the old object — prefer in-place mutation.
|
||||||
|
|
||||||
|
## Where initial data comes from
|
||||||
|
|
||||||
|
The object passed to `Kanta(...)` seeds the database: when `open()` creates a brand-new (missing or empty) file, it writes a single bootstrap change record containing that object's contents, optionally modified by `@kanta.bootstrap` handlers (see [Bootstrap and open modes](bootstrap.md)).
|
||||||
|
|
||||||
|
When the file already exists, the constructor argument is *not* used as state — replay rebuilds the contents of `self.data` in place from the stored records, and the argument only defines the struct type. Consequence: if you close an instance, delete the file, and `open()` again, the new database is bootstrapped from the object's *current* contents — the old database's final state, not its original initial values. For a genuinely fresh start, construct a new data object (or reset the fields in a bootstrap handler).
|
||||||
|
|
||||||
|
## Lifecycle patterns
|
||||||
|
|
||||||
|
### Context manager (single database, scoped lifetime)
|
||||||
|
|
||||||
|
```python
|
||||||
|
async with Kanta("data.kantadb", Data()) as kanta:
|
||||||
|
with kanta.transaction(action="create_user") as data:
|
||||||
|
data.users[user_id] = User(name="Alice")
|
||||||
|
```
|
||||||
|
|
||||||
|
`async with` guarantees `open()`/`close()` pairing: the final flush happens on exit even on errors. Both `as` bindings are pure shortcuts — `async with Kanta(...) as kanta` binds the `Kanta` instance itself, and `with kanta.transaction(...) as data` binds exactly `kanta.data`. Use them or don't:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async with Kanta("data.kantadb", Data()) as db:
|
||||||
|
with db.transaction(action="rename"):
|
||||||
|
db.data.users[user_id].name = "Bob" # same object as `data` above
|
||||||
|
```
|
||||||
|
|
||||||
|
### Module-level global (typical application state)
|
||||||
|
|
||||||
|
When the database lives as long as the process, define it once and open/close at application startup and shutdown:
|
||||||
|
|
||||||
|
```python
|
||||||
|
kanta = Kanta("data.kantadb", Data())
|
||||||
|
data = kanta.data # optional: your own direct reference
|
||||||
|
|
||||||
|
|
||||||
|
async def startup() -> None:
|
||||||
|
await kanta.open()
|
||||||
|
|
||||||
|
|
||||||
|
async def shutdown() -> None:
|
||||||
|
await kanta.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def create_user(name: str) -> None:
|
||||||
|
with kanta.transaction(action="create_user") as d:
|
||||||
|
d.users[uuid7()] = User(name=name)
|
||||||
|
```
|
||||||
|
|
||||||
|
Transactions are synchronous context managers, so no `await` is needed per operation; the background task flushes queued changes periodically.
|
||||||
|
|
||||||
|
### Dynamically created databases (per-project, per-tenant, …)
|
||||||
|
|
||||||
|
Nothing requires module-level definitions. An app managing many databases simply constructs instances on demand and tracks them itself:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class ProjectStore:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.projects: dict[str, Kanta[Data]] = {}
|
||||||
|
|
||||||
|
async def get(self, project_id: str) -> Kanta[Data]:
|
||||||
|
kanta = self.projects.get(project_id)
|
||||||
|
if kanta is None:
|
||||||
|
kanta = Kanta(f"projects/{project_id}.kantadb", Data())
|
||||||
|
await kanta.open()
|
||||||
|
self.projects[project_id] = kanta
|
||||||
|
return kanta
|
||||||
|
```
|
||||||
|
|
||||||
|
Remember that each open writer holds an exclusive lock on its file, so keep one `Kanta` instance per path and close instances you no longer need.
|
||||||
|
|
||||||
|
## Transactions in brief
|
||||||
|
|
||||||
|
- `with kanta.transaction(action=..., user=...) as data:` yields the live state object for mutation.
|
||||||
|
- On success, Kanta computes a diff against the pre-transaction state and queues a change record; on exception, the in-memory data is rolled back and the exception is re-raised.
|
||||||
|
- Nested transactions are rejected.
|
||||||
|
- A transaction that changes nothing queues no record.
|
||||||
|
|
||||||
|
See [On-disk format](database.md) for the full transaction semantics and [Validation](validation.md) for integrity checks that run inside each transaction.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Validation
|
||||||
|
|
||||||
|
`@kanta.validate` registers an integrity validator for the database state, beyond the structural validation msgspec already performs during decoding.
|
||||||
|
|
||||||
|
```python
|
||||||
|
@kanta.validate
|
||||||
|
def check_users(data: Data) -> None:
|
||||||
|
for user in data.users.values():
|
||||||
|
if not user.name:
|
||||||
|
raise ValueError("user without a name")
|
||||||
|
```
|
||||||
|
|
||||||
|
Validators receive the live data object (and optionally the `Kanta` instance as a second annotated parameter) and must **raise an exception** when the data is inconsistent. They are not intended to modify or correct the data — only to fail.
|
||||||
|
|
||||||
|
Validators run:
|
||||||
|
|
||||||
|
- **on open** — after replay, msgspec decoding and migrations, before the database becomes usable; a failure aborts the open and releases the file,
|
||||||
|
- **after each transaction** — before the change is committed to history; a failure rolls the transaction back, so invalid state never reaches the log.
|
||||||
|
|
||||||
|
Multiple validators may be registered; they run in registration order until the first failure. Validators must be synchronous (transactions are synchronous) — async callbacks are rejected at registration time.
|
||||||
Reference in New Issue
Block a user