Rewritten from scratch against internal/database on next. Every table and column in schema/001.sql is listed, with the indexes. Added what the old text lacked: files.source_path, the local_meta and schema_migrations tables, and five indexes. Corrected what it had wrong: blobs.blob_hash holds a placeholder, not NULL, until the blob is closed; blob_chunks rows are written when the blob is closed; queries are parameterised, not prepared. Model: fable-5-1
406 lines
14 KiB
Markdown
406 lines
14 KiB
Markdown
# vaultik data model
|
|
|
|
`vaultik` keeps a SQLite database on the backed-up machine, called the local
|
|
index. This document describes its tables and how each command uses them.
|
|
|
|
The local index has two jobs:
|
|
|
|
- It records which files, chunks and blobs are already stored. The next backup
|
|
then uploads only what changed.
|
|
- It is the source of each snapshot's own database. A backup uploads a trimmed,
|
|
encrypted copy of the index with every snapshot.
|
|
|
|
A restore does not need the local index. It reads the copy that was uploaded
|
|
with the snapshot.
|
|
|
|
## Terms
|
|
|
|
- **Snapshot**: one backup of the paths listed under one name in the config.
|
|
- **Chunk**: a piece of a file's content, named by the SHA-256 hash of that
|
|
content.
|
|
- **Blob**: one object at the destination. It holds many chunks, compressed and
|
|
then encrypted.
|
|
- **Destination**: the place the backups are stored.
|
|
- **Manifest**: the unencrypted list of the blobs one snapshot uses.
|
|
- **Remote key**: a hash of the snapshot ID. It names the snapshot's directory
|
|
at the destination.
|
|
|
|
## Where the index is
|
|
|
|
The index is the file `index.sqlite` in the platform data directory. That is
|
|
`~/.local/share/vaultik/` on Linux and `~/Library/Application Support/vaultik/`
|
|
on macOS. The `index_path` setting and the `VAULTIK_INDEX_PATH` environment
|
|
variable change the path.
|
|
|
|
The file is readable only by its owner, because it lists every backed-up path.
|
|
`vaultik` sets that mode each time it opens the index.
|
|
|
|
## Schema migrations
|
|
|
|
`vaultik` supports schema migrations. They are the numbered SQL files in
|
|
`internal/database/schema/`, which are built into the binary.
|
|
|
|
- `000.sql` creates the `schema_migrations` table. That table records each
|
|
migration that has run.
|
|
- `001.sql` creates every other table.
|
|
|
|
`database.New` opens the index. It then applies, in order, every migration that
|
|
the index has not recorded.
|
|
|
|
Before version 1.0, no migration is added, because nothing is installed anywhere
|
|
yet. A schema change edits `001.sql` directly, with the code that uses the
|
|
changed tables.
|
|
|
|
This has one consequence. An index created before such a change has already
|
|
recorded `001.sql` as run, so it keeps the old schema and can become unusable.
|
|
To rebuild it, run `vaultik database delete` and then a full backup.
|
|
|
|
After version 1.0, each schema change is a new numbered file. An existing index
|
|
is then migrated the first time an updated `vaultik` opens it. A file that was
|
|
part of a release is never edited.
|
|
|
|
Migrations apply to the local index only. A snapshot's database at the
|
|
destination is read as it was written. Each snapshot records the `vaultik`
|
|
version that wrote it.
|
|
|
|
## Tables
|
|
|
|
Times are whole seconds since the Unix epoch, in UTC. The two exceptions are
|
|
noted at their columns.
|
|
|
|
### `files`
|
|
|
|
One row for each backed-up path: a regular file, a symlink or a directory.
|
|
|
|
- `id`: a UUID. Primary key.
|
|
- `path`: the absolute path. Unique.
|
|
- `source_path`: the configured path that the scan found this entry under. A
|
|
restore does not read it.
|
|
- `mtime`: the modification time.
|
|
- `mtime_nsec`: the nanoseconds within that second.
|
|
- `size`: the size in bytes.
|
|
- `mode`: the permission bits and the file type.
|
|
- `uid`: the owner's user ID.
|
|
- `gid`: the owner's group ID.
|
|
- `link_target`: the target of a symlink. It is `NULL` for other entries.
|
|
|
|
`idx_files_path` indexes `path`.
|
|
|
|
There is one row for each path, and it is updated when the file changes. The
|
|
row is shared by every snapshot in the local index that lists the path. An
|
|
older snapshot's own view of the file is in that snapshot's database at the
|
|
destination.
|
|
|
|
### `chunks`
|
|
|
|
One row for each distinct chunk.
|
|
|
|
- `chunk_hash`: the SHA-256 hash of the chunk's content, in hex. Primary key.
|
|
- `size`: the size in bytes.
|
|
|
|
### `file_chunks`
|
|
|
|
The chunks that make up each file, in order.
|
|
|
|
- `file_id`: the file. References `files.id`.
|
|
- `idx`: the position of the chunk within the file, counted from 0.
|
|
- `chunk_hash`: the chunk. References `chunks.chunk_hash`.
|
|
|
|
The primary key is (`file_id`, `idx`). `idx_file_chunks_chunk_hash` indexes
|
|
`chunk_hash`.
|
|
|
|
### `chunk_files`
|
|
|
|
The reverse of `file_chunks`: the files that contain each chunk.
|
|
|
|
- `chunk_hash`: the chunk. References `chunks.chunk_hash`.
|
|
- `file_id`: the file. References `files.id`.
|
|
- `file_offset`: the byte offset of the chunk within the file.
|
|
- `length`: the length of the chunk in bytes.
|
|
|
|
The primary key is (`chunk_hash`, `file_id`). `idx_chunk_files_file_id` indexes
|
|
`file_id`.
|
|
|
|
### `blobs`
|
|
|
|
One row for each blob.
|
|
|
|
- `id`: a UUID, given when packing of the blob starts. Primary key.
|
|
- `blob_hash`: the name of the blob at the destination. Unique. Until the blob
|
|
is closed, it holds a placeholder that starts with `temp-placeholder-`.
|
|
- `created_ts`: when packing started.
|
|
- `finished_ts`: when the blob was closed. It is `NULL` before that.
|
|
- `uncompressed_size`: the total size of the blob's chunks.
|
|
- `compressed_size`: the size after compression and encryption.
|
|
- `uploaded_ts`: when the upload finished. It is `NULL` before that.
|
|
|
|
`blob_hash` is `hex(SHA256(SHA256(uncompressed contents)))`. See
|
|
[REPOSTRUCTURE.md](REPOSTRUCTURE.md#blobs).
|
|
|
|
Once a blob is uploaded, `compressed_size` is the size of the object at the
|
|
destination. A blob that was already there can have a different size from the
|
|
local copy. That happens when it was written with another compression level or
|
|
other recipients.
|
|
|
|
### `blob_chunks`
|
|
|
|
The chunks that each blob holds.
|
|
|
|
- `blob_id`: the blob. References `blobs.id`.
|
|
- `chunk_hash`: the chunk. References `chunks.chunk_hash`.
|
|
- `offset`: the byte offset of the chunk within the uncompressed blob.
|
|
- `length`: the length of the chunk in bytes.
|
|
|
|
The primary key is (`blob_id`, `chunk_hash`). `idx_blob_chunks_chunk_hash`
|
|
indexes `chunk_hash`.
|
|
|
|
A restore finds the blob for each chunk through this table.
|
|
|
|
### `snapshots`
|
|
|
|
One row for each snapshot.
|
|
|
|
- `id`: the snapshot ID. Primary key.
|
|
- `hostname`: the hostname of the machine that made the backup.
|
|
- `vaultik_version`: the `vaultik` version that made it.
|
|
- `vaultik_git_revision`: the git commit of that version.
|
|
- `started_at`: when the backup started.
|
|
- `completed_at`: when the backup finished. It is `NULL` until then.
|
|
- `file_count`: the number of files and symlinks in the snapshot. Directories
|
|
are not counted.
|
|
- `chunk_count`: the number of chunks this snapshot stored that were not stored
|
|
before.
|
|
- `new_chunk_size`: the total size of those chunks before compression.
|
|
- `blob_count`: the number of blobs this snapshot created.
|
|
- `total_size`: the total size of all files in the snapshot.
|
|
- `uncompressed_chunk_size`: the total size, before compression, of the
|
|
distinct chunks the snapshot's files use.
|
|
- `blob_size`: the total compressed size of the blobs the snapshot uses.
|
|
- `blob_uncompressed_size`: the total uncompressed size of those blobs.
|
|
- `compression_ratio`: the compression ratio of those blobs.
|
|
- `compression_level`: the compression level the backup used.
|
|
- `upload_bytes`: the bytes uploaded during the backup.
|
|
- `upload_duration_ms`: the time spent uploading, in milliseconds.
|
|
|
|
The snapshot ID has the form `<hostname>_<snapshot-name>_<timestamp>`. An
|
|
example is `server1_home_2025-06-01T12:00:00Z`. It has three parts:
|
|
|
|
- the hostname, up to its first `.`
|
|
- the snapshot name
|
|
- the start time, in UTC
|
|
|
|
`snapshot list` shows `uncompressed_chunk_size` as `UNCOMPRESSED SIZE` and
|
|
`new_chunk_size` as `NEW CHUNK SIZE`.
|
|
|
|
A snapshot with `completed_at` set to `NULL` is unfinished. `snapshot list`
|
|
does not show it. The next backup deletes it, and so does `prune`.
|
|
|
|
### `snapshot_files`
|
|
|
|
The files in each snapshot.
|
|
|
|
- `snapshot_id`: the snapshot. References `snapshots.id`.
|
|
- `file_id`: the file. References `files.id`.
|
|
|
|
The primary key is (`snapshot_id`, `file_id`). `idx_snapshot_files_file_id`
|
|
indexes `file_id`.
|
|
|
|
### `snapshot_blobs`
|
|
|
|
The blobs that each snapshot uses.
|
|
|
|
- `snapshot_id`: the snapshot. References `snapshots.id`.
|
|
- `blob_id`: the blob. References `blobs.id`.
|
|
- `blob_hash`: a copy of the blob's hash.
|
|
|
|
The primary key is (`snapshot_id`, `blob_id`). `idx_snapshot_blobs_blob_id`
|
|
indexes `blob_id`.
|
|
|
|
The manifest is built from this table.
|
|
|
|
### `snapshot_mount_points`
|
|
|
|
The mount points that a snapshot's backup found.
|
|
|
|
- `snapshot_id`: the snapshot. References `snapshots.id`.
|
|
- `path`: the absolute path of the directory.
|
|
|
|
The primary key is (`snapshot_id`, `path`).
|
|
|
|
A directory is recorded here when all of these hold:
|
|
|
|
- It is at or below a configured path.
|
|
- The backup found an entry in it that is not excluded.
|
|
- It is on a different filesystem from its parent directory.
|
|
|
|
The device number is read after the directory has been listed. Listing an
|
|
automount point mounts its volume, so the number is that of the volume. An
|
|
automount point whose volume cannot be mounted is not recorded, because the
|
|
backup finds no entry in it.
|
|
|
|
`snapshot create --prune` reads this table. It skips the prune when an earlier
|
|
snapshot of the same name recorded a mount point that the new backup did not
|
|
find. That happens when the volume is not mounted.
|
|
|
|
### `uploads`
|
|
|
|
One row for each blob that a backup uploaded.
|
|
|
|
- `blob_hash`: the blob. References `blobs.blob_hash`. Primary key.
|
|
- `snapshot_id`: the snapshot whose backup uploaded it. References
|
|
`snapshots.id`.
|
|
- `uploaded_at`: when the upload started. It is written as a date and time, not
|
|
as seconds.
|
|
- `size`: the size of the uploaded blob.
|
|
- `duration_ms`: the duration of the upload, in milliseconds.
|
|
|
|
`idx_uploads_snapshot_id` indexes `snapshot_id`.
|
|
|
|
A blob that was already at the destination gets no row here.
|
|
|
|
### `local_meta`
|
|
|
|
Settings that belong to this copy of the index.
|
|
|
|
- `key`: the name of the setting. Primary key.
|
|
- `value`: its value.
|
|
|
|
The only key is `storage_url`. It holds the destination that the index was
|
|
first used with. A command that changes data refuses to run when the config
|
|
names a different destination.
|
|
|
|
### `schema_migrations`
|
|
|
|
The migrations that have run.
|
|
|
|
- `version`: the number of the migration. Primary key.
|
|
- `applied_at`: when it ran. It is written as a date and time, not as seconds.
|
|
|
|
## How commands use the tables
|
|
|
|
### Backup
|
|
|
|
A backup works on the tables in this order:
|
|
|
|
- It deletes unfinished snapshots, and the rows that no snapshot uses.
|
|
- It creates a row in `snapshots`, with `completed_at` set to `NULL`.
|
|
- It deletes the `blobs` rows that were never uploaded.
|
|
- It walks each path and compares every entry with its row in `files`.
|
|
- It links each unchanged file to the snapshot in `snapshot_files`.
|
|
- It records each mount point in `snapshot_mount_points`.
|
|
- It reads each changed file and stores it. See below.
|
|
- It fills `snapshot_blobs` with every blob the snapshot uses.
|
|
- It stores the snapshot's totals in `snapshots`.
|
|
- It uploads the snapshot's metadata.
|
|
- It sets `completed_at`.
|
|
|
|
A file counts as changed when its row in `files` differs from the file on disk
|
|
in any of these:
|
|
|
|
- size
|
|
- modification time
|
|
- mode
|
|
- owner or group
|
|
- link target
|
|
|
|
A file is also read again when it lists a chunk that no uploaded blob holds.
|
|
|
|
Storing a changed file writes these rows:
|
|
|
|
- A row in `blobs`, when packing of a blob starts.
|
|
- When the blob is closed, in one transaction:
|
|
- a row in `chunks` for each new chunk in the blob
|
|
- the blob's rows in `blob_chunks`
|
|
- the blob's `blob_hash`, sizes and `finished_ts`
|
|
- When the blob is stored at the destination, in one transaction:
|
|
- `uploaded_ts` and `compressed_size` in `blobs`
|
|
- a row in `snapshot_blobs`
|
|
- a row in `uploads`, unless the blob was already at the destination
|
|
- The row in `files`, with its `file_chunks`, `chunk_files` and
|
|
`snapshot_files` rows.
|
|
|
|
The row in `files` is written last. It is written only after every blob that
|
|
holds the file's new chunks is stored.
|
|
|
|
### Snapshot metadata
|
|
|
|
The metadata of a snapshot is made from a copy of the index:
|
|
|
|
- The index is copied into a temporary directory with `VACUUM INTO`.
|
|
- Every row that does not belong to this snapshot is deleted from the copy.
|
|
- `VACUUM` rewrites the copy, so that no deleted row is left in the file.
|
|
- The copy is compressed with zstd and encrypted with age.
|
|
- The manifest is built from the copy's `snapshot_blobs` table.
|
|
- The copy is uploaded as `metadata/<remote-key>/db.zst.age`.
|
|
- The manifest is uploaded as `metadata/<remote-key>/manifest.json.zst`.
|
|
|
|
The copy is made through SQLite, not by copying the file. The index runs in WAL
|
|
mode, so rows that are already committed can still be in the `-wal` file beside
|
|
it.
|
|
|
|
### Restore
|
|
|
|
A restore does not use the local index.
|
|
|
|
- It downloads and decrypts `db.zst.age` into a temporary directory.
|
|
- It opens that file read-only. It applies no migration to it.
|
|
- It reads the files to restore from `files`.
|
|
- It finds each file's chunks in `file_chunks`.
|
|
- It finds the blob and offset of each chunk in `blob_chunks`.
|
|
|
|
It does not read the manifest.
|
|
|
|
### Prune
|
|
|
|
`prune` changes the local index in two ways:
|
|
|
|
- It deletes each snapshot whose directory is gone from the destination.
|
|
- It deletes unfinished snapshots, and the rows that no snapshot uses.
|
|
|
|
The second step removes unused rows in this order:
|
|
|
|
- files that are in no snapshot
|
|
- blobs that are in no snapshot
|
|
- `blob_chunks` rows whose chunk is gone
|
|
- chunks that no file uses
|
|
|
|
`prune` then deletes unused data at the destination. That step reads the
|
|
manifests, not the local index. It reads the local index only to keep a
|
|
directory under `metadata/` whose snapshot is still recorded there.
|
|
|
|
## Code
|
|
|
|
Each table has a repository type in `internal/database`:
|
|
|
|
- `FileRepository`
|
|
- `ChunkRepository`
|
|
- `FileChunkRepository`
|
|
- `ChunkFileRepository`
|
|
- `BlobRepository`
|
|
- `BlobChunkRepository`
|
|
- `SnapshotRepository`
|
|
- `UploadRepository`
|
|
- `LocalMetaRepository`
|
|
|
|
`Repositories` holds one of each.
|
|
|
|
`Repositories.WithTx` runs a function in one transaction:
|
|
|
|
```go
|
|
err := repos.WithTx(ctx, func(ctx context.Context, tx *sql.Tx) error {
|
|
// several repository calls that use tx
|
|
})
|
|
```
|
|
|
|
The transaction commits when the function returns no error. Otherwise it is
|
|
rolled back.
|
|
|
|
## SQLite settings
|
|
|
|
- The index runs in WAL mode. A command that only reads, such as
|
|
`snapshot list`, can read the index while a backup writes to it.
|
|
- A connection waits up to 10 seconds for a lock before it fails.
|
|
- Foreign keys are enforced.
|
|
- A process opens one connection, so all its access to the index is in turn.
|
|
- Queries pass their values as parameters.
|