Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,13 @@ Inspect a database file:
cargo run --release -- inspect ./data.ftwdb
```

Verify or snapshot a directory store:
Verify, snapshot, restore, or salvage a directory store:

```sh
cargo run --release -- check-store ./energy.ftwdb
cargo run --release -- backup ./energy.ftwdb ./backups/energy-2026-07-21.ftwdb
cargo run --release -- restore ./backups/energy-2026-07-21.ftwdb ./restored-energy.ftwdb
cargo run --release -- salvage ./damaged-energy.ftwdb ./salvaged-energy.ftwdb
```

## Design documents
Expand All @@ -71,7 +72,7 @@ cargo run --release -- restore ./backups/energy-2026-07-21.ftwdb ./restored-ener
- [Storage format v1](docs/format.md)
- [Immutable segment format](docs/segment-format.md)
- [Persistent rollups and retention](docs/rollups.md)
- [Integrity checks, backup, and restore](docs/operations.md)
- [Integrity checks, backup, restore, and salvage](docs/operations.md)
- [OSS database research](docs/research.md)
- [Benchmark protocol](docs/benchmarking.md)
- [Deterministic energy workload](docs/workload.md)
Expand Down
5 changes: 4 additions & 1 deletion bench/sd-card-emulator/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,10 @@ checked-in real-installation fixture and rejects a
changed active-log checksum or recovered FTWDB watermark. After recovery, it
backs up the checked store outside the emulated card, restores that backup to a
new store on the card, and compares checked counts, snapshot CRCs, and active
log SHA-256 values.
log SHA-256 values. It then adds a seven-byte short tail to an off-card copy,
salvages it to another new store on the card, and requires a `partial` result,
seven discarded bytes, 889,978 points, 89 commits, equal snapshot CRCs, and the
clean raw-log SHA-256 value.

```sh
mkdir -p bench-results/sd-emulator
Expand Down
26 changes: 26 additions & 0 deletions bench/sd-card-emulator/linux-smoke.sh
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,18 @@ test "$before_hash" = "$after_hash"
sha256sum "$out/restore-backup/active.wlog" >"$out/restore-backup-sha.txt"
sha256sum "$mount_dir/restored-database/active.wlog" >"$out/restore-target-sha.txt"

# Keep the damaged source off the emulated card. A short tail must yield a
# verified partial store on the card without changing the clean raw prefix.
cp -a "$out/restore-backup" "$out/salvage-source"
printf 'partial' >>"$out/salvage-source/active.wlog"
sync
"$ftw" salvage "$out/salvage-source" "$mount_dir/salvaged-database" \
>"$out/salvage.json"
"$ftw" check-store "$mount_dir/salvaged-database" \
>"$out/salvage-check.json"
sha256sum "$mount_dir/salvaged-database/active.wlog" \
>"$out/salvage-target-sha.txt"

json_u64() {
local file=$1
local field=$2
Expand Down Expand Up @@ -103,16 +115,30 @@ test "$(json_string "$out/restore.json" source_snapshot_crc32)" = \
"$(json_string "$out/restore.json" destination_snapshot_crc32)"
test "$(cut -d' ' -f1 "$out/restore-backup-sha.txt")" = \
"$(cut -d' ' -f1 "$out/restore-target-sha.txt")"
test "$(json_string "$out/salvage.json" status)" = partial
test "$(json_string "$out/salvage.json" stop_reason)" = incomplete-frame-header
test "$(json_u64 "$out/salvage.json" discarded_bytes)" = 7
test "$(json_u64 "$out/salvage.json" recovered_points)" = 889978
test "$(json_u64 "$out/salvage.json" recovered_commits)" = 89
test "$(json_u64 "$out/salvage-check.json" raw_points)" = 889978
test "$(json_u64 "$out/salvage-check.json" raw_commits)" = 89
test "$(json_string "$out/salvage.json" source_prefix_crc32)" = \
"$(json_string "$out/salvage.json" destination_snapshot_crc32)"
test "$(cut -d' ' -f1 "$out/restore-backup-sha.txt")" = \
"$(cut -d' ' -f1 "$out/salvage-target-sha.txt")"

linux_nbd_finish
trap - EXIT

printf 'linux_nbd_smoke=passed\n'
printf 'restore_drill=passed\n'
printf 'salvage_drill=passed\n'
printf 'fsck_exit=%d\n' "$fsck_exit"
printf 'active_log_sha256=%s\n' "$after_hash"
cat "$out/load.json"
cat "$out/check-after.json"
cat "$out/verification.json"
cat "$out/restore.json"
cat "$out/restore-check.json"
cat "$out/salvage.json"
cat "$out/salvage-check.json"
79 changes: 72 additions & 7 deletions docs/operations.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Integrity checks, backup, and restore
# Integrity checks, backup, restore, and salvage

## Integrity check

Expand Down Expand Up @@ -109,7 +109,70 @@ cryptographic authenticity check.

Restore does not repair a damaged backup and does not infer data past a corrupt
frame. Use a separate target, keep the source backup, and run `ftw check-store`
on the result. Salvage will use a separate command and safety review.
on the result.

## Strict salvage

`ftw salvage <damaged-store> <absent-target>` copies the longest valid raw-log
prefix into a new store. The command exits with code 2 for missing or extra
arguments. A valid database header followed by a damaged frame produces a
verified `partial` result and exit code 0. Header, source-open, lock,
source-race, stage-check, or publication errors use exit code 1. The command
never changes the source. It has no replace option, and it leaves any existing
target, including an empty directory or dangling symlink, unchanged.

Salvage opens the source directory without following a symlink, then opens
only `active.wlog` relative to that directory with no-follow and nonblocking
flags. Both paths must keep the same file identity during the run, and
`active.wlog` must be a regular file. The command takes a shared lock on that
file. It does not read a manifest or rollup, so damaged, stale, and orphan
derived files do not affect the recovered raw prefix.

The scanner first requires a valid FTWDB-v1 database header. It then validates
each frame in order: bounds, kind and version, header and payload CRC, payload
duplicate commit IDs, decode, catalog and transaction rules, and point limits.
It stops before the first failed frame. It never searches for a later `WBAT`
marker, and it never copies frames after a failure. This means that salvage
cannot recover sound data that happens to follow damaged data. A complete last
frame with a bad CRC counts as damage; salvage cannot prove that an interrupted
write caused it.

The fixed `stop_reason` values are:

- `clean-eof`;
- `incomplete-frame-header` and `incomplete-frame-payload`;
- `invalid-frame-magic`, `unsupported-frame-version`, and
`frame-header-checksum-mismatch`;
- `invalid-legacy-frame-size`, `transaction-frame-too-large`,
`identified-transaction-too-short`, and `unknown-frame-kind`;
- `payload-checksum-mismatch`, `duplicate-commit-id`,
`invalid-transaction`, `transaction-point-count-too-large`, and
`invalid-catalog-transaction`.

After a valid database header, each listed frame or transaction fault yields a
`partial` result, including a fault in the first frame. A header-only source is
`clean`; a bad first frame can produce a checked header-only target with zero
commits and points. An invalid or unsupported database header is fatal and
publishes no target.

The new store contains the header and validated frames as `active.wlog`, plus
empty `manifests` and `rollups` directories. Salvage uses the same hidden-stage,
file sync, directory sync, identity rollback, and atomic no-clobber publication
as restore. It opens both the stage and target read-only and runs the full store
check with no recovery. The shared stage lock stays held through publication,
the target check, and any rollback.

The source-prefix CRC32 uses the restore snapshot domain and one relative path,
`active.wlog`. It covers that path length and bytes, the recovered prefix
length, and the exact prefix bytes. The destination snapshot covers the same
path and bytes. Salvage compares the values before and after publication. CRC32
detects many accidental changes but does not prove authenticity.

On success, `ftwdb-salvage-v1` reports `status`, `source_bytes`,
`recovered_prefix_bytes`, `discarded_bytes`, `stop_offset`, `stop_reason`,
`recovered_commits`, `recovered_points`, `source_prefix_crc32`, and
`destination_snapshot_crc32`. `status` is `clean` only at a checked frame
boundary at end of file; otherwise it is `partial`.

## Sync and full-disk checks

Expand All @@ -132,14 +195,16 @@ the M4 physical SD-card power-cut release gate.
`tests/cli.rs` runs the built `ftw` binary as a subprocess. It fixes the usage
contract at exit code 2 with usage text on standard error, while data, file,
and store errors use exit code 1. The test covers the generated workload,
sanitized real fixture, TSBS IoT, integrity check, log inspection, backup, and
strict restore paths. It parses each promised JSON record and checks its main
counts and status fields.
sanitized real fixture, TSBS IoT, integrity check, log inspection, backup,
strict restore, and salvage paths. It parses each promised JSON record and
checks its main counts and status fields.

The test also snapshots every path and every file byte in a store before and
after both `check-store` and `inspect`. Separate missing-path checks prove that
neither command creates its input. The backup check covers linked and copied
file counts plus the hard-link fallback count on a normal local filesystem.
The restore check covers JSON, exact bytes, counts, checksum equality,
corruption refusal, and no-clobber behavior. Salvage and physical SD-card tests
remain M4 work.
corruption refusal, and no-clobber behavior. The salvage checks cover clean and
partial results, every frame and transaction stop class, no resync, source
identity, special files, concurrent no-clobber publication, rollback, and the
sanitized fixture. Physical SD-card tests remain an M4 release gate.
12 changes: 8 additions & 4 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,13 @@ integrity checks, inspection, backup and restore JSON, exact restored bytes,
and byte-for-byte read-only behavior. The sanitized fixture restore fixes the
expected result at 889,978 points, 89 commits, and snapshot CRC32 `9ff1a95b`.
The Linux NBD power-loss run now follows recovery with an off-card backup and a
verified restore to a new target on the emulated card. Together with the
verified restore to a new target on the emulated card. Strict salvage now reads
only the raw log, stops at the first invalid frame without resync, ignores all
derived files, and publishes a checked prefix through the same atomic
no-clobber path. The sanitized fixture fixes clean salvage at 889,978 points,
89 commits, and snapshot CRC32 `9ff1a95b`; the NBD run also checks a seven-byte
torn suffix and a verified partial target. Together with the
process-lock, sync-failure, `/dev/full`, and NBD tests, this closes the
robustness gaps tracked in issue #17. Tests on the target board and SD cards,
power cuts during commits, soak runs, remote backup policy, corruption salvage,
and the remaining result-verified adapters stay open and are required for the
M4 exit.
power cuts during commits, soak runs, remote backup policy, and the remaining
result-verified adapters stay open and are required for the M4 exit.
59 changes: 54 additions & 5 deletions src/bin/ftw.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
use flate2::read::GzDecoder;
use ftwdb::{
BackupReport, Config, Database, Durability, EnergyWorkload, Error, RestoreReport,
RollupResolution, Store, Transaction, WorkloadConfig, gauge_bucket_checksum, load_real_fixture,
load_tsbs_iot,
RollupResolution, SalvageReport, Store, Transaction, WorkloadConfig, gauge_bucket_checksum,
load_real_fixture, load_tsbs_iot,
};
use std::env;
use std::fs::File;
Expand Down Expand Up @@ -42,6 +42,7 @@ fn main() -> ExitCode {
Some("check-store") => check_store(&arguments[2..]),
Some("backup") => backup(&arguments[2..]),
Some("restore") => restore(&arguments[2..]),
Some("salvage") => salvage(&arguments[2..]),
Some("generate") => generate(&arguments[2..]),
Some("bench-ftwdb") => bench_ftwdb(&arguments[2..]),
Some("bench-real-fixture") => bench_real_fixture(&arguments[2..]),
Expand Down Expand Up @@ -318,6 +319,33 @@ fn restore_report_json(report: &RestoreReport) -> String {
)
}

fn salvage(arguments: &[String]) -> CliResult<()> {
if arguments.len() != 2 {
return Err(usage_error(
"salvage requires a damaged store and absent target directory",
));
}
let report = Store::salvage_from(&arguments[0], &arguments[1])?;
println!("{}", salvage_report_json(&report));
Ok(())
}

fn salvage_report_json(report: &SalvageReport) -> String {
format!(
"{{\"format\":\"ftwdb-salvage-v1\",\"status\":\"{}\",\"source_bytes\":{},\"recovered_prefix_bytes\":{},\"discarded_bytes\":{},\"stop_offset\":{},\"stop_reason\":\"{}\",\"recovered_commits\":{},\"recovered_points\":{},\"source_prefix_crc32\":\"{:08x}\",\"destination_snapshot_crc32\":\"{:08x}\"}}",
report.status,
report.source_bytes,
report.recovered_prefix_bytes,
report.discarded_bytes,
report.stop_offset,
report.stop_reason,
report.recovered_commits,
report.recovered_points,
report.source_prefix_crc32,
report.destination_snapshot_crc32
)
}

fn backup_report_json(report: &BackupReport) -> String {
let fallback_error_kinds = report
.hard_link_fallback_error_kinds
Expand Down Expand Up @@ -574,14 +602,14 @@ fn runtime_invalid(reason: impl Into<String>) -> CliError {

fn usage(program: &str) {
eprintln!(
"usage:\n {program} inspect <database-file>\n {program} check-store <store-directory>\n {program} backup <store-directory> <absent-destination>\n {program} restore <backup-directory> <absent-target>\n {program} generate <output-directory> [--seed N] [--sites N] [--days N] [--cadence-seconds N] [--start-micros N]\n {program} bench-ftwdb <workload-directory> <empty-database-directory> [--durability always|manual] [--batch-points N]\n {program} bench-real-fixture <points.csv.gz> <empty-database-directory> [--durability always|manual|every-bytes:N] [--batch-points N]\n {program} bench-tsbs-iot <influx-line-file|-> <empty-database-directory> [--durability always|manual|every-bytes:N] [--batch-rows N]"
"usage:\n {program} inspect <database-file>\n {program} check-store <store-directory>\n {program} backup <store-directory> <absent-destination>\n {program} restore <backup-directory> <absent-target>\n {program} salvage <damaged-store> <absent-target>\n {program} generate <output-directory> [--seed N] [--sites N] [--days N] [--cadence-seconds N] [--start-micros N]\n {program} bench-ftwdb <workload-directory> <empty-database-directory> [--durability always|manual] [--batch-points N]\n {program} bench-real-fixture <points.csv.gz> <empty-database-directory> [--durability always|manual|every-bytes:N] [--batch-points N]\n {program} bench-tsbs-iot <influx-line-file|-> <empty-database-directory> [--durability always|manual|every-bytes:N] [--batch-rows N]"
);
}

#[cfg(test)]
mod tests {
use super::{backup_report_json, restore_report_json};
use ftwdb::{BackupReport, RestoreReport};
use super::{backup_report_json, restore_report_json, salvage_report_json};
use ftwdb::{BackupReport, RestoreReport, SalvageReport, SalvageStatus, SalvageStopReason};

#[test]
fn backup_json_uses_fixed_error_kind_names() {
Expand Down Expand Up @@ -618,4 +646,25 @@ mod tests {
"{\"format\":\"ftwdb-restore-v1\",\"files\":4,\"bytes\":123,\"manifest_generation\":7,\"raw_commits\":8,\"raw_points\":9,\"source_snapshot_crc32\":\"0123abcd\",\"destination_snapshot_crc32\":\"0123abcd\"}"
);
}

#[test]
fn salvage_json_has_fixed_status_reason_checksums_and_counts() {
let report = SalvageReport {
status: SalvageStatus::Partial,
source_bytes: 130,
recovered_prefix_bytes: 123,
discarded_bytes: 7,
stop_offset: 123,
stop_reason: SalvageStopReason::IncompleteFrameHeader,
recovered_commits: 8,
recovered_points: 9,
source_prefix_crc32: 0x0123_abcd,
destination_snapshot_crc32: 0x0123_abcd,
};

assert_eq!(
salvage_report_json(&report),
"{\"format\":\"ftwdb-salvage-v1\",\"status\":\"partial\",\"source_bytes\":130,\"recovered_prefix_bytes\":123,\"discarded_bytes\":7,\"stop_offset\":123,\"stop_reason\":\"incomplete-frame-header\",\"recovered_commits\":8,\"recovered_points\":9,\"source_prefix_crc32\":\"0123abcd\",\"destination_snapshot_crc32\":\"0123abcd\"}"
);
}
}
8 changes: 8 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ pub enum Error {
path: PathBuf,
reason: String,
},
SourceChanged {
path: PathBuf,
},
}

impl fmt::Display for Error {
Expand Down Expand Up @@ -75,6 +78,11 @@ impl fmt::Display for Error {
"snapshot publication at {} could not be rolled back cleanly: {reason}",
path.display()
),
Self::SourceChanged { path } => write!(
f,
"source file {} changed while it was being checked",
path.display()
),
}
}
}
Expand Down
7 changes: 5 additions & 2 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,13 @@ pub use real_fixture::{RealFixtureLoadReport, load_real_fixture};
pub use rollup::{CalendarGaugeRollup, FixedGaugeRollup, GaugeBucket};
pub use rollup_segment::{RollupSegment, RollupSegmentStats};
pub use segment::{Segment, SegmentStats};
pub use storage::{Commit, Config, Database, Durability, PlanOutcome, Point, RecoveredTail, Stats};
pub use storage::{
Commit, Config, Database, Durability, PlanOutcome, Point, RecoveredTail, SalvageStopReason,
Stats,
};
pub use store::{
BackupReport, IntegrityReport, MaintenanceReport, RestoreReport, RetentionGate, RollupQuery,
RollupSource, Store,
RollupSource, SalvageReport, SalvageStatus, Store,
};
pub use transaction::Transaction;
pub use tsbs::{TsbsIotLoadReport, load_tsbs_iot};
Expand Down
Loading
Loading